/** @import { Node } from 'esrap/languages/ts' */ /** @import * as ESTree from 'estree' */ /** @import { AST } from 'svelte/compiler' */ // @ts-check import process from 'node:process'; import fs from 'node:fs'; import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import * as esrap from 'esrap'; import ts from 'esrap/languages/ts'; const DIR = '../../documentation/docs/98-reference/.generated'; const watch = process.argv.includes('-w'); function run() { /** @type {Record>} */ const messages = {}; const seen = new Set(); fs.rmSync(DIR, { force: true, recursive: true }); fs.mkdirSync(DIR); for (const category of fs.readdirSync('messages')) { if (category.startsWith('.')) continue; messages[category] = {}; for (const file of fs.readdirSync(`messages/${category}`)) { if (!file.endsWith('.md')) continue; const markdown = fs .readFileSync(`messages/${category}/${file}`, 'utf-8') .replace(/\r\n/g, '\n'); const sorted = []; for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) { const [_, code, text] = match; if (seen.has(code)) { throw new Error(`Duplicate message code ${category}/${code}`); } sorted.push({ code, _ }); const sections = text.trim().split('\n\n'); const details = []; while (!sections[sections.length - 1].startsWith('> ')) { details.unshift(/** @type {string} */ (sections.pop())); } if (sections.length === 0) { throw new Error('No message text'); } seen.add(code); messages[category][code] = { messages: sections.map((section) => section.replace(/^> /gm, '').replace(/^>\n/gm, '\n')), details: details.join('\n\n') }; } sorted.sort((a, b) => (a.code < b.code ? -1 : 1)); fs.writeFileSync( `messages/${category}/${file}`, sorted.map((x) => x._.trim()).join('\n\n') + '\n' ); } fs.writeFileSync( `${DIR}/${category}.md`, '\n\n' + Object.entries(messages[category]) .map(([code, { messages, details }]) => { const chunks = [ `### ${code}`, ...messages.map((message) => '```\n' + message + '\n```') ]; if (details) { chunks.push(details); } return chunks.join('\n\n'); }) .sort() .join('\n\n') + '\n' ); } /** * @param {string} name * @param {string} dest */ function transform(name, dest) { const source = fs .readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8') .replace(/\r\n/g, '\n'); /** @type {AST.JSComment[]} */ const comments = []; let ast = /** @type {ESTree.Node} */ ( /** @type {unknown} */ ( acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true, onComment: comments }) ) ); comments.forEach((comment) => { if (comment.type === 'Block') { comment.value = comment.value.replace(/^\t+/gm, ''); } }); ast = walk(ast, null, { Identifier(node, context) { if (node.name === 'CODES') { /** @type {ESTree.ArrayExpression} */ const array = { type: 'ArrayExpression', elements: Object.keys(messages[name]).map((code) => ({ type: 'Literal', value: code })) }; return array; } } }); const body = /** @type {ESTree.Program} */ (ast).body; const category = messages[name]; // find the `export function CODE` node const index = body.findIndex((node) => { if ( node.type === 'ExportNamedDeclaration' && node.declaration && node.declaration.type === 'FunctionDeclaration' ) { return node.declaration.id.name === 'CODE'; } }); if (index === -1) throw new Error(`missing export function CODE in ${name}.js`); const template_node = body[index]; body.splice(index, 1); const jsdoc = /** @type {AST.JSComment} */ ( comments.findLast((comment) => comment.start < /** @type {number} */ (template_node.start)) ); const printed = esrap.print( /** @type {Node} */ (ast), ts({ comments: comments.filter((comment) => comment !== jsdoc) }) ); for (const code in category) { const { messages } = category[code]; /** @type {string[]} */ const vars = []; const group = messages.map((text, i) => { for (const match of text.matchAll(/%(\w+)%/g)) { const name = match[1]; if (!vars.includes(name)) { vars.push(match[1]); } } return { text, vars: vars.slice() }; }); /** @type {ESTree.Expression} */ let message = { type: 'Literal', value: '' }; let prev_vars; for (let i = 0; i < group.length; i += 1) { const { text, vars } = group[i]; if (vars.length === 0) { message = { type: 'Literal', value: text }; prev_vars = vars; continue; } const parts = text.split(/(%\w+%)/); /** @type {ESTree.Expression[]} */ const expressions = []; /** @type {ESTree.TemplateElement[]} */ const quasis = []; for (let i = 0; i < parts.length; i += 1) { const part = parts[i]; if (i % 2 === 0) { const str = part.replace(/(`|\${)/g, '\\$1'); quasis.push({ type: 'TemplateElement', value: { raw: str, cooked: str }, tail: i === parts.length - 1 }); } else { expressions.push({ type: 'Identifier', name: part.slice(1, -1) }); } } /** @type {ESTree.Expression} */ const expression = { type: 'TemplateLiteral', expressions, quasis }; if (prev_vars) { if (vars.length === prev_vars.length) { throw new Error('Message overloads must have new parameters'); } message = { type: 'ConditionalExpression', test: { type: 'Identifier', name: vars[prev_vars.length] }, consequent: expression, alternate: message }; } else { message = expression; } prev_vars = vars; } const clone = /** @type {ESTree.Statement} */ ( walk(/** @type {ESTree.Node} */ (template_node), null, { FunctionDeclaration(node, context) { if (node.id.name !== 'CODE') return; const params = []; for (const param of node.params) { if (param.type === 'Identifier' && param.name === 'PARAMETER') { params.push(...vars.map((name) => ({ type: 'Identifier', name }))); } else { params.push(param); } } return /** @type {ESTree.FunctionDeclaration} */ ({ .../** @type {ESTree.FunctionDeclaration} */ (context.next()), params, id: { ...node.id, name: code } }); }, TemplateLiteral(node, context) { /** @type {ESTree.TemplateElement} */ let quasi = { type: 'TemplateElement', value: { ...node.quasis[0].value }, tail: node.quasis[0].tail }; /** @type {ESTree.TemplateLiteral} */ let out = { type: 'TemplateLiteral', quasis: [quasi], expressions: [] }; for (let i = 0; i < node.expressions.length; i += 1) { const q = structuredClone(node.quasis[i + 1]); const e = node.expressions[i]; if (e.type === 'Literal' && e.value === 'CODE') { quasi.value.raw += code + q.value.raw; continue; } if (e.type === 'Identifier' && e.name === 'MESSAGE') { if (message.type === 'Literal') { const str = /** @type {string} */ (message.value).replace(/(`|\${)/g, '\\$1'); quasi.value.raw += str + q.value.raw; continue; } if (message.type === 'TemplateLiteral') { const m = structuredClone(message); quasi.value.raw += m.quasis[0].value.raw; out.quasis.push(...m.quasis.slice(1)); out.expressions.push(...m.expressions); quasi = m.quasis[m.quasis.length - 1]; quasi.value.raw += q.value.raw; continue; } } out.quasis.push((quasi = q)); out.expressions.push(/** @type {ESTree.Expression} */ (context.visit(e))); } return out; }, Literal(node) { if (node.value === 'CODE') { return { type: 'Literal', value: code }; } }, Identifier(node) { if (node.name !== 'MESSAGE') return; return message; } }) ); const jsdoc_clone = { ...jsdoc, value: /** @type {string} */ (jsdoc.value) .split('\n') .map((line) => { if (line === ' * MESSAGE') { return messages[messages.length - 1] .split('\n') .map((line) => ` * ${line}`) .join('\n'); } if (line.includes('PARAMETER')) { return vars .map((name, i) => { const optional = i >= group[0].vars.length; return optional ? ` * @param {string | undefined | null} [${name}]` : ` * @param {string} ${name}`; }) .join('\n'); } return line; }) .filter((x) => x !== '') .join('\n') }; const block = esrap.print( // @ts-expect-error some bullshit /** @type {ESTree.Program} */ ({ ...ast, body: [clone] }), ts({ comments: [jsdoc_clone] }) ).code; printed.code += `\n\n${block}`; body.push(clone); } fs.writeFileSync( dest, `/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` + printed.code, 'utf-8' ); } transform('compile-errors', 'src/compiler/errors.js'); transform('compile-warnings', 'src/compiler/warnings.js'); transform('client-warnings', 'src/internal/client/warnings.js'); transform('client-errors', 'src/internal/client/errors.js'); transform('server-errors', 'src/internal/server/errors.js'); transform('shared-errors', 'src/internal/shared/errors.js'); transform('shared-warnings', 'src/internal/shared/warnings.js'); } if (watch) { let running = false; let timeout; fs.watch('messages', { recursive: true }, (type, file) => { if (running) { timeout ??= setTimeout(() => { running = false; timeout = null; }); } else { running = true; // eslint-disable-next-line no-console console.log('Regenerating messages...'); run(); } }); } run();