chore: watch for messages changes in dev generate script (#15950)

* chore: watch for messages changes in dev generate script

* no need for this to be async

* tweak

* guard

* only create one timeout

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/15956/head
Paolo Ricciuti 4 months ago committed by GitHub
parent 21bf947ca8
commit b7b393d50f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -136,7 +136,7 @@
],
"scripts": {
"build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js",
"dev": "node scripts/process-messages && rollup -cw",
"dev": "node scripts/process-messages -w & rollup -cw",
"check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc",
"check:watch": "tsc --watch",
"generate:version": "node ./scripts/generate-version.js",

@ -1,409 +1,441 @@
// @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';
/** @type {Record<string, Record<string, { messages: string[], details: string | null }>>} */
const messages = {};
const seen = new Set();
const DIR = '../../documentation/docs/98-reference/.generated';
fs.rmSync(DIR, { force: true, recursive: true });
fs.mkdirSync(DIR);
for (const category of fs.readdirSync('messages')) {
if (category.startsWith('.')) continue;
const watch = process.argv.includes('-w');
messages[category] = {};
function run() {
/** @type {Record<string, Record<string, { messages: string[], details: string | null }>>} */
const messages = {};
const seen = new Set();
for (const file of fs.readdirSync(`messages/${category}`)) {
if (!file.endsWith('.md')) continue;
fs.rmSync(DIR, { force: true, recursive: true });
fs.mkdirSync(DIR);
const markdown = fs
.readFileSync(`messages/${category}/${file}`, 'utf-8')
.replace(/\r\n/g, '\n');
for (const category of fs.readdirSync('messages')) {
if (category.startsWith('.')) continue;
const sorted = [];
messages[category] = {};
for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) {
const [_, code, text] = match;
for (const file of fs.readdirSync(`messages/${category}`)) {
if (!file.endsWith('.md')) continue;
if (seen.has(code)) {
throw new Error(`Duplicate message code ${category}/${code}`);
}
const markdown = fs
.readFileSync(`messages/${category}/${file}`, 'utf-8')
.replace(/\r\n/g, '\n');
sorted.push({ code, _ });
const sorted = [];
const sections = text.trim().split('\n\n');
const details = [];
for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) {
const [_, code, text] = match;
while (!sections[sections.length - 1].startsWith('> ')) {
details.unshift(/** @type {string} */ (sections.pop()));
}
if (seen.has(code)) {
throw new Error(`Duplicate message code ${category}/${code}`);
}
sorted.push({ code, _ });
if (sections.length === 0) {
throw new Error('No message text');
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')
};
}
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'
);
}
sorted.sort((a, b) => (a.code < b.code ? -1 : 1));
fs.writeFileSync(
`messages/${category}/${file}`,
sorted.map((x) => x._.trim()).join('\n\n') + '\n'
`${DIR}/${category}.md`,
'<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->\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'
);
}
fs.writeFileSync(
`${DIR}/${category}.md`,
'<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->\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 {Array<{
* type: string;
* value: string;
* start: number;
* end: number
* }>}
* @param {string} name
* @param {string} dest
*/
const comments = [];
let ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
onComment: (block, value, start, end) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && source[a - 1] !== '\n') a -= 1;
let b = a;
while (/[ \t]/.test(source[b])) b += 1;
const indentation = source.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}
comments.push({ type: block ? 'Block' : 'Line', value, start, end });
}
});
function transform(name, dest) {
const source = fs
.readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8')
.replace(/\r\n/g, '\n');
ast = walk(ast, null, {
_(node, { next }) {
let comment;
/**
* @type {Array<{
* type: string;
* value: string;
* start: number;
* end: number
* }>}
*/
const comments = [];
let ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
onComment: (block, value, start, end) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && source[a - 1] !== '\n') a -= 1;
let b = a;
while (/[ \t]/.test(source[b])) b += 1;
const indentation = source.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}
while (comments[0] && comments[0].start < node.start) {
comment = comments.shift();
// @ts-expect-error
(node.leadingComments ||= []).push(comment);
comments.push({ type: block ? 'Block' : 'Line', value, start, end });
}
});
next();
if (comments[0]) {
const slice = source.slice(node.end, comments[0].start);
ast = walk(ast, null, {
_(node, { next }) {
let comment;
if (/^[,) \t]*$/.test(slice)) {
while (comments[0] && comments[0].start < node.start) {
comment = comments.shift();
// @ts-expect-error
node.trailingComments = [comments.shift()];
(node.leadingComments ||= []).push(comment);
}
}
},
// @ts-expect-error
Identifier(node, context) {
if (node.name === 'CODES') {
return {
type: 'ArrayExpression',
elements: Object.keys(messages[name]).map((code) => ({
type: 'Literal',
value: code
}))
};
}
}
});
if (comments.length > 0) {
// @ts-expect-error
(ast.trailingComments ||= []).push(...comments);
}
const category = messages[name];
// find the `export function CODE` node
const index = ast.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 = ast.body[index];
ast.body.splice(index, 1);
next();
for (const code in category) {
const { messages } = category[code];
/** @type {string[]} */
const vars = [];
if (comments[0]) {
const slice = source.slice(node.end, comments[0].start);
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]);
if (/^[,) \t]*$/.test(slice)) {
// @ts-expect-error
node.trailingComments = [comments.shift()];
}
}
},
// @ts-expect-error
Identifier(node, context) {
if (node.name === 'CODES') {
return {
type: 'ArrayExpression',
elements: Object.keys(messages[name]).map((code) => ({
type: 'Literal',
value: code
}))
};
}
}
return {
text,
vars: vars.slice()
};
});
/** @type {import('estree').Expression} */
let message = { type: 'Literal', value: '' };
let prev_vars;
if (comments.length > 0) {
// @ts-expect-error
(ast.trailingComments ||= []).push(...comments);
}
for (let i = 0; i < group.length; i += 1) {
const { text, vars } = group[i];
const category = messages[name];
if (vars.length === 0) {
message = {
type: 'Literal',
value: text
};
prev_vars = vars;
continue;
// find the `export function CODE` node
const index = ast.body.findIndex((node) => {
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'FunctionDeclaration'
) {
return node.declaration.id.name === 'CODE';
}
});
const parts = text.split(/(%\w+%)/);
if (index === -1) throw new Error(`missing export function CODE in ${name}.js`);
/** @type {import('estree').Expression[]} */
const expressions = [];
const template_node = ast.body[index];
ast.body.splice(index, 1);
/** @type {import('estree').TemplateElement[]} */
const quasis = [];
for (const code in category) {
const { messages } = category[code];
/** @type {string[]} */
const vars = [];
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)
});
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 {import('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');
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;
}
message = {
type: 'ConditionalExpression',
test: {
type: 'Identifier',
name: vars[prev_vars.length]
},
consequent: expression,
alternate: message
};
} else {
message = expression;
}
const parts = text.split(/(%\w+%)/);
prev_vars = vars;
}
/** @type {import('estree').Expression[]} */
const expressions = [];
const clone = walk(/** @type {import('estree').Node} */ (template_node), null, {
// @ts-expect-error Block is a block comment, which is not recognised
Block(node, context) {
if (!node.value.includes('PARAMETER')) return;
const value = /** @type {string} */ (node.value)
.split('\n')
.map((line) => {
if (line === ' * MESSAGE') {
return messages[messages.length - 1]
.split('\n')
.map((line) => ` * ${line}`)
.join('\n');
}
/** @type {import('estree').TemplateElement[]} */
const quasis = [];
if (line.includes('PARAMETER')) {
return vars
.map((name, i) => {
const optional = i >= group[0].vars.length;
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)
});
}
}
return optional
? ` * @param {string | undefined | null} [${name}]`
: ` * @param {string} ${name}`;
})
.join('\n');
}
/** @type {import('estree').Expression} */
const expression = {
type: 'TemplateLiteral',
expressions,
quasis
};
return line;
})
.filter((x) => x !== '')
.join('\n');
if (prev_vars) {
if (vars.length === prev_vars.length) {
throw new Error('Message overloads must have new parameters');
}
if (value !== node.value) {
return { ...node, value };
message = {
type: 'ConditionalExpression',
test: {
type: 'Identifier',
name: vars[prev_vars.length]
},
consequent: expression,
alternate: message
};
} else {
message = expression;
}
},
FunctionDeclaration(node, context) {
if (node.id.name !== 'CODE') return;
const params = [];
prev_vars = vars;
}
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);
const clone = walk(/** @type {import('estree').Node} */ (template_node), null, {
// @ts-expect-error Block is a block comment, which is not recognised
Block(node, context) {
if (!node.value.includes('PARAMETER')) return;
const value = /** @type {string} */ (node.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');
if (value !== node.value) {
return { ...node, value };
}
}
},
FunctionDeclaration(node, context) {
if (node.id.name !== 'CODE') return;
const params = [];
return /** @type {import('estree').FunctionDeclaration} */ ({
.../** @type {import('estree').FunctionDeclaration} */ (context.next()),
params,
id: {
...node.id,
name: code
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);
}
}
});
},
TemplateLiteral(node, context) {
/** @type {import('estree').TemplateElement} */
let quasi = {
type: 'TemplateElement',
value: {
...node.quasis[0].value
},
tail: node.quasis[0].tail
};
/** @type {import('estree').TemplateLiteral} */
let out = {
type: 'TemplateLiteral',
quasis: [quasi],
expressions: []
};
return /** @type {import('estree').FunctionDeclaration} */ ({
.../** @type {import('estree').FunctionDeclaration} */ (context.next()),
params,
id: {
...node.id,
name: code
}
});
},
TemplateLiteral(node, context) {
/** @type {import('estree').TemplateElement} */
let quasi = {
type: 'TemplateElement',
value: {
...node.quasis[0].value
},
tail: node.quasis[0].tail
};
for (let i = 0; i < node.expressions.length; i += 1) {
const q = structuredClone(node.quasis[i + 1]);
const e = node.expressions[i];
/** @type {import('estree').TemplateLiteral} */
let out = {
type: 'TemplateLiteral',
quasis: [quasi],
expressions: []
};
if (e.type === 'Literal' && e.value === 'CODE') {
quasi.value.raw += code + q.value.raw;
continue;
}
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 === 'Identifier' && e.name === 'MESSAGE') {
if (message.type === 'Literal') {
const str = /** @type {string} */ (message.value).replace(/(`|\${)/g, '\\$1');
quasi.value.raw += str + q.value.raw;
if (e.type === 'Literal' && e.value === 'CODE') {
quasi.value.raw += code + 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;
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 {import('estree').Expression} */ (context.visit(e)));
}
out.quasis.push((quasi = q));
out.expressions.push(/** @type {import('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;
}
});
return out;
},
Literal(node) {
if (node.value === 'CODE') {
return {
type: 'Literal',
value: code
};
}
},
Identifier(node) {
if (node.name !== 'MESSAGE') return;
return message;
}
});
// @ts-expect-error
ast.body.push(clone);
}
const module = esrap.print(ast);
// @ts-expect-error
ast.body.push(clone);
fs.writeFileSync(
dest,
`/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` +
module.code,
'utf-8'
);
}
const module = esrap.print(ast);
transform('compile-errors', 'src/compiler/errors.js');
transform('compile-warnings', 'src/compiler/warnings.js');
fs.writeFileSync(
dest,
`/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` +
module.code,
'utf-8'
);
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');
}
transform('compile-errors', 'src/compiler/errors.js');
transform('compile-warnings', 'src/compiler/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();
}
});
}
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');
run();

Loading…
Cancel
Save