|
| 1 | +import {promises as fs} from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | + |
| 4 | +const PAGES_ROOT = path.resolve('src/pages'); |
| 5 | +const PLUGIN_PATH = path.resolve('src/plugins/rehype-content-figures.ts'); |
| 6 | +const FENCE_PATTERN = /^(`{3,})([^\s`]*)[ \t]+([^\n]+)$/gm; |
| 7 | +const EXPLICIT_META_PATTERN = /(?:^|\s)(?:filename|file|title)=/i; |
| 8 | +const FILENAME_PATTERN = /^(?!.*\s)(?:(?:.+\/)?[^/]+\.[^/]+|(?:.+\/)?(?:Dockerfile|Jenkinsfile|Makefile|Procfile))$/iu; |
| 9 | + |
| 10 | +const replaceRequired = (source, search, replacement, description) => { |
| 11 | + const next = source.replace(search, replacement); |
| 12 | + |
| 13 | + if (next === source) { |
| 14 | + throw new Error(`Could not update ${description}`); |
| 15 | + } |
| 16 | + |
| 17 | + return next; |
| 18 | +}; |
| 19 | + |
| 20 | +const walk = async (directory) => { |
| 21 | + const entries = await fs.readdir(directory, {withFileTypes: true}); |
| 22 | + const files = []; |
| 23 | + |
| 24 | + for (const entry of entries) { |
| 25 | + const location = path.join(directory, entry.name); |
| 26 | + |
| 27 | + if (entry.isDirectory()) { |
| 28 | + files.push(...(await walk(location))); |
| 29 | + } else if (entry.isFile() && entry.name.endsWith('.md')) { |
| 30 | + files.push(location); |
| 31 | + } |
| 32 | + } |
| 33 | + |
| 34 | + return files; |
| 35 | +}; |
| 36 | + |
| 37 | +const escapeMetaValue = (value) => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); |
| 38 | + |
| 39 | +let migratedFences = 0; |
| 40 | +const markdownFiles = await walk(PAGES_ROOT); |
| 41 | + |
| 42 | +for (const file of markdownFiles) { |
| 43 | + const source = await fs.readFile(file, 'utf8'); |
| 44 | + const migrated = source.replace(FENCE_PATTERN, (line, fence, language, rawMeta) => { |
| 45 | + const meta = rawMeta.trim(); |
| 46 | + |
| 47 | + if (!language || EXPLICIT_META_PATTERN.test(meta)) { |
| 48 | + return line; |
| 49 | + } |
| 50 | + |
| 51 | + migratedFences += 1; |
| 52 | + const key = FILENAME_PATTERN.test(meta) ? 'filename' : 'title'; |
| 53 | + |
| 54 | + return `${fence}${language} ${key}="${escapeMetaValue(meta)}"`; |
| 55 | + }); |
| 56 | + |
| 57 | + if (/<fieldset>\s*<legend>[^<]+<\/legend>[\s\S]*?```/u.test(migrated)) { |
| 58 | + throw new Error(`Decorative fieldset remains in ${path.relative(process.cwd(), file)}`); |
| 59 | + } |
| 60 | + |
| 61 | + if (/code-example-group/u.test(migrated)) { |
| 62 | + throw new Error(`Temporary code example group remains in ${path.relative(process.cwd(), file)}`); |
| 63 | + } |
| 64 | + |
| 65 | + if (migrated !== source) { |
| 66 | + await fs.writeFile(file, migrated); |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +let plugin = await fs.readFile(PLUGIN_PATH, 'utf8'); |
| 71 | + |
| 72 | +plugin = replaceRequired( |
| 73 | + plugin, |
| 74 | + ' readonly label?: string;', |
| 75 | + ' readonly title?: string;', |
| 76 | + 'CodeMeta title field', |
| 77 | +); |
| 78 | + |
| 79 | +plugin = replaceRequired( |
| 80 | + plugin, |
| 81 | + /const isFilename = \(value: string\): boolean =>\n\s*!\/\\s\/\.test\(value\) && \/\(\?:\^\|\\\/\)\[\\w@\.\-\]\+\(\?:\\\.\[\\w\-\]\+\)\+\$\/u\.test\(value\);/u, |
| 82 | + `const isFilename = (value: string): boolean =>\n /^(?!.*\\s)(?:(?:.+\\/)?[^/]+\\.[^/]+|(?:.+\\/)?(?:Dockerfile|Jenkinsfile|Makefile|Procfile))$/iu.test(\n value,\n );`, |
| 83 | + 'filename detection', |
| 84 | +); |
| 85 | + |
| 86 | +plugin = replaceRequired( |
| 87 | + plugin, |
| 88 | + /const getExplicitCodeMeta = \(pre: Element, code: Element\): CodeMeta => \{[\s\S]*?\n\};\n\nconst getCommentCodeMeta/u, |
| 89 | + `const getExplicitCodeMeta = (pre: Element, code: Element): CodeMeta => {\n const propertyFilename = pre.properties.dataFilename ?? code.properties.dataFilename;\n const meta = getCodeMetaString(code);\n const filenameMatch = meta.match(\n /(?:^|\\s)(?:filename|file)=(?:\"([^\"]+)\"|'([^']+)'|([^\\s]+))/i,\n );\n const titleMatch = meta.match(\n /(?:^|\\s)title=(?:\"([^\"]+)\"|'([^']+)'|([^\\s]+))/i,\n );\n const matchedFilename = filenameMatch?.slice(1).find(Boolean)?.trim();\n const matchedTitle = titleMatch?.slice(1).find(Boolean)?.trim();\n const filename =\n typeof propertyFilename === 'string' && propertyFilename.trim()\n ? propertyFilename.trim()\n : matchedFilename;\n const remainingMeta = [filenameMatch?.[0], titleMatch?.[0]]\n .filter((value): value is string => Boolean(value))\n .reduce((value, match) => value.replace(match, ' '), meta)\n .replace(/\\s+/g, ' ')\n .trim();\n const plainMeta = unwrapQuotes(remainingMeta);\n const fallbackFilename = !filename && isFilename(plainMeta) ? plainMeta : undefined;\n const title = matchedTitle ?? (plainMeta && !fallbackFilename ? plainMeta : undefined);\n\n return {\n filename: filename ?? fallbackFilename,\n title,\n };\n};\n\nconst getCommentCodeMeta`, |
| 90 | + 'explicit filename and title parsing', |
| 91 | +); |
| 92 | + |
| 93 | +plugin = replaceRequired( |
| 94 | + plugin, |
| 95 | + ` const label =\n separatorIndex >= 0 ? comment.slice(0, separatorIndex).trim() : undefined;\n\n return {\n filename: candidate,\n label: label || undefined,\n };`, |
| 96 | + ` const title =\n separatorIndex >= 0 ? comment.slice(0, separatorIndex).trim() : undefined;\n\n return {\n filename: candidate,\n title: title || undefined,\n };`, |
| 97 | + 'comment metadata title', |
| 98 | +); |
| 99 | + |
| 100 | +plugin = replaceRequired( |
| 101 | + plugin, |
| 102 | + ' return explicit.filename || explicit.label ? explicit : getCommentCodeMeta(code);', |
| 103 | + ' return explicit.filename || explicit.title ? explicit : getCommentCodeMeta(code);', |
| 104 | + 'explicit metadata fallback', |
| 105 | +); |
| 106 | + |
| 107 | +plugin = replaceRequired( |
| 108 | + plugin, |
| 109 | + ' const {filename, label} = code ? getCodeMeta(pre, code) : {};', |
| 110 | + ' const {filename, title} = code ? getCodeMeta(pre, code) : {};', |
| 111 | + 'code figure metadata destructuring', |
| 112 | +); |
| 113 | + |
| 114 | +plugin = replaceRequired( |
| 115 | + plugin, |
| 116 | + ` filename && label\n ? \`\${label} · \${filename}\`\n : (filename ?? label ?? languageLabel);`, |
| 117 | + ` filename && title\n ? \`\${title} · \${filename}\`\n : (filename ?? title ?? languageLabel);`, |
| 118 | + 'code figure caption', |
| 119 | +); |
| 120 | + |
| 121 | +await fs.writeFile(PLUGIN_PATH, plugin); |
| 122 | +console.log(`Migrated ${migratedFences} code fences to explicit metadata.`); |
0 commit comments