the home site for me: also iteration 3 or 4 of my site
1#!/usr/bin/env bun
2
3import fs from 'fs';
4import path from 'path';
5import { glob } from 'glob';
6
7const contentDir = process.argv[2] || 'content';
8
9function splitByCodeBlocks(content: string): { text: string; isCode: boolean }[] {
10 const parts: { text: string; isCode: boolean }[] = [];
11 const codeBlockRegex = /^(```|~~~)/gm;
12
13 let lastIndex = 0;
14 let inCodeBlock = false;
15 let match;
16
17 codeBlockRegex.lastIndex = 0;
18
19 while ((match = codeBlockRegex.exec(content)) !== null) {
20 const segment = content.slice(lastIndex, match.index);
21 if (segment) {
22 parts.push({ text: segment, isCode: inCodeBlock });
23 }
24 inCodeBlock = !inCodeBlock;
25 lastIndex = match.index;
26 }
27
28 // Add remaining content
29 if (lastIndex < content.length) {
30 parts.push({ text: content.slice(lastIndex), isCode: inCodeBlock });
31 }
32
33 return parts;
34}
35
36function transformCallouts(content: string): string {
37 return content.replace(
38 /^> \[!(INFO|WARNING|WARN|DANGER|ERROR|TIP|HINT|NOTE)\]\n((?:> .*\n?)*)/gm,
39 (match, type, body) => {
40 const cleanBody = body.replace(/^> /gm, '').trim();
41 const normalizedType = type.toLowerCase() === 'warn' ? 'warning' :
42 type.toLowerCase() === 'error' ? 'danger' :
43 type.toLowerCase() === 'hint' ? 'tip' :
44 type.toLowerCase();
45 return `{% callout(type="${normalizedType}") %}\n${cleanBody}\n{% end %}\n`;
46 }
47 );
48}
49
50function transformImages(content: string): string {
51 // Transform multiple images: ![alt2](url2){attrs}
52 content = content.replace(
53 /!!(\[([^\]]*)\]\(([^)]+)\))+(?:\{([^}]+)\})?/g,
54 (match) => {
55 // Extract all [alt](url) pairs
56 const pairs = [...match.matchAll(/\[([^\]]*)\]\(([^)]+)\)/g)];
57 const urls = pairs.map(p => p[2]).join(', ');
58 const alts = pairs.map(p => p[1]).join(', ');
59
60 // Extract attrs if present
61 const attrsMatch = match.match(/\{([^}]+)\}$/);
62 const attrs = attrsMatch ? attrsMatch[1] : '';
63
64 const params: string[] = [`id="${urls}"`];
65
66 if (alts.trim()) {
67 params.push(`alt="${alts}"`);
68 }
69
70 if (attrs) {
71 const classes = attrs.match(/\.([a-zA-Z0-9_-]+)/g)?.map(c => c.slice(1)) || [];
72 if (classes.length) {
73 params.push(`class="${classes.join(' ')}"`);
74 }
75
76 const keyValueMatches = attrs.matchAll(/([a-zA-Z]+)=["']?([^"'\s}]+)["']?/g);
77 for (const [, key, value] of keyValueMatches) {
78 if (key !== 'class') {
79 params.push(`${key}="${value.replace(/["']/g, '')}"`);
80 }
81 }
82 }
83
84 return `{{ imgs(${params.join(', ')}) }}`;
85 }
86 );
87
88 // Transform single images: {attrs}
89 content = content.replace(
90 /!\[([^\]]*)\]\(([^)]+)\)(?:\{([^}]+)\})?/g,
91 (match, alt, url, attrs) => {
92 const params: string[] = [`id="${url}"`];
93
94 if (alt) {
95 params.push(`alt="${alt}"`);
96 }
97
98 if (attrs) {
99 const classes = attrs.match(/\.([a-zA-Z0-9_-]+)/g)?.map(c => c.slice(1)) || [];
100 if (classes.length) {
101 params.push(`class="${classes.join(' ')}"`);
102 }
103
104 const keyValueMatches = attrs.matchAll(/([a-zA-Z]+)=["']?([^"'\s}]+)["']?/g);
105 for (const [, key, value] of keyValueMatches) {
106 if (key !== 'class') {
107 params.push(`${key}="${value.replace(/["']/g, '')}"`);
108 }
109 }
110 }
111
112 return `{{ img(${params.join(', ')}) }}`;
113 }
114 );
115
116 return content;
117}
118
119function processFile(filePath: string): void {
120 let content = fs.readFileSync(filePath, 'utf8');
121 const originalContent = content;
122
123 // Split by code blocks and only transform non-code parts
124 const parts = splitByCodeBlocks(content);
125 content = parts.map(part => {
126 if (part.isCode) {
127 return part.text; // Don't transform code blocks
128 }
129 let text = part.text;
130 text = transformCallouts(text);
131 text = transformImages(text);
132 return text;
133 }).join('');
134
135 if (content !== originalContent) {
136 fs.writeFileSync(filePath, content);
137 }
138}
139
140const files = glob.sync(`${contentDir}/**/*.md`);
141files.forEach(processFile);
142