fix(studio): Studio's own text reads without em or en dashes (#5414)

* fix(studio): text in Studio reads without em or en dashes

* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable

* test(studio): the dash check test covers every entity form

* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too

* fix(producer): the gate check stops early on the engine's new fallback wording
This commit is contained in:
Miguel Ángel
2026-10-10 19:08:30 +00:00
committed by GitHub
parent f2b81b2415
commit 9825614d91
136 changed files with 549 additions and 358 deletions
+94
View File
@@ -0,0 +1,94 @@
// Text a person reads in Studio carries no em or en dash: use a colon, comma, period or parentheses.
// Covers Studio and every package whose messages it shows; the CLI's terminal-only output is out.
// Reads the TypeScript AST, so strings, template text and JSX text count and comments never do.
import { readFileSync, readdirSync } from "node:fs";
import { extname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const ROOT = join(import.meta.dirname, "..");
const PACKAGES = [
"core",
"engine",
"lint",
"parsers",
"player",
"producer",
"sdk",
"shader-transitions",
"studio",
"studio-server",
];
// JSX keeps HTML entities raw in the AST, and React renders them as dashes.
const DASH = /[\u2013\u2014]|&[mn]dash;|&#821[12];|&#x201[34];/i;
// Dashes no person sees in Studio, each matched as narrowly as the text allows.
const EXEMPT = [
// A comment in injected runtime JS, copied byte for byte into producer goldens and catalog payloads.
{
file: "packages/core/src/compiler/compositionScoping.ts",
contains: "Swallow \u2014 the scoped root",
},
// The empty cell of .media/index.md, an agent file kept identical to the media-use copies.
{ file: "packages/core/src/figma/mediaIndex.ts", equals: "\u2014" },
];
const isExempt = (filename, text) =>
EXEMPT.some(
(rule) =>
rule.file === filename &&
(rule.equals === undefined ? text.includes(rule.contains) : text === rule.equals),
);
const TEXT_KINDS = new Set([
ts.SyntaxKind.StringLiteral,
ts.SyntaxKind.NoSubstitutionTemplateLiteral,
ts.SyntaxKind.TemplateHead,
ts.SyntaxKind.TemplateMiddle,
ts.SyntaxKind.TemplateTail,
ts.SyntaxKind.JsxText,
]);
export function listDashedText(source, filename = "source.tsx") {
const kind = extname(filename) === ".tsx" ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, kind);
const issues = [];
function visit(node) {
if (TEXT_KINDS.has(node.kind) && DASH.test(node.text) && !isExempt(filename, node.text)) {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
issues.push(`${filename}:${line + 1} ${node.text.trim().slice(0, 100)}`);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return issues;
}
export const isShippedSource = (path) =>
/\.tsx?$/.test(path) && !/\.d\.ts$|\.(test|spec)\.tsx?$/.test(path);
export function checkStudioCopyDashes(root = ROOT) {
return PACKAGES.flatMap((name) => {
const sourceRoot = join(root, "packages", name, "src");
return readdirSync(sourceRoot, { recursive: true })
.filter(isShippedSource)
.sort()
.flatMap((path) =>
listDashedText(
readFileSync(join(sourceRoot, path), "utf8"),
relative(root, join(sourceRoot, path)),
),
);
});
}
function main() {
const issues = checkStudioCopyDashes();
if (issues.length > 0) {
console.error("Studio text with an em or en dash (use a colon, comma, period or parentheses):");
issues.forEach((issue) => console.error(`- ${issue}`));
console.error(`${issues.length} hit(s).`);
process.exitCode = 1;
return;
}
console.log("Studio copy verified: no em or en dash in shipped text.");
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
+105
View File
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import {
checkStudioCopyDashes,
isShippedSource,
listDashedText,
} from "./check-studio-copy-dashes.mjs";
const lines = (source) => listDashedText(source, "x.tsx").map((issue) => issue.split(" ")[0]);
describe("Studio copy dash checker", () => {
it("reports dashes in every string, template and JSX text form, literal or escaped", () => {
const source = [
'const a = "One \\u2014 two";',
"const b = `plain \\u2013 template`;",
"const c = `\\u2014 ${a}`;",
"const d = `${a} \\u2014 ${b} x`;",
"const e = `${a} x \\u2014`;",
'const f = "Literal \u2014 too";',
"const g = () => <p>Range 1\u20132</p>;",
"const h = () => <p>One &mdash; two</p>;",
'const i = () => <p title="One &#8211; two" />;',
"const j = () => <p>One &ndash; two</p>;",
"const l = () => <p>One &#8212; two</p>;",
'const k = () => <p title="One &#X2014; two" />;',
].join("\n");
assert.deepEqual(lines(source), [
"x.tsx:1",
"x.tsx:2",
"x.tsx:3",
"x.tsx:4",
"x.tsx:5",
"x.tsx:6",
"x.tsx:7",
"x.tsx:8",
"x.tsx:9",
"x.tsx:10",
"x.tsx:11",
"x.tsx:12",
]);
});
it("ignores comments, regexes, hyphens and shortcut glyphs", () => {
const source = [
"// A comment \u2014 never copy",
"/* block \u2013 comment */",
"const d = /[\u2013\u2014]/;",
'const f = "A plain hyphen - is fine, \u2325-click too";',
].join("\n");
assert.deepEqual(lines(source), []);
});
it("exempts only the named strings in the named files", () => {
const media = 'const a = "\u2014";\nconst b = "a \u2014 b";';
assert.deepEqual(
listDashedText(media, "packages/core/src/figma/mediaIndex.ts").map((i) => i.split(" ")[0]),
["packages/core/src/figma/mediaIndex.ts:2"],
);
assert.equal(listDashedText(media, "packages/core/src/other.ts").length, 2);
const scoping = 'const s = `/* Swallow \u2014 the scoped root */`;\nconst t = "a \u2014 b";';
assert.deepEqual(
listDashedText(scoping, "packages/core/src/compiler/compositionScoping.ts").map(
(i) => i.split(" ")[0],
),
["packages/core/src/compiler/compositionScoping.ts:2"],
);
});
it("checks shipped .ts and .tsx files, not tests or declarations", () => {
assert.deepEqual(
["a.ts", "b.tsx", "c.test.ts", "d.spec.tsx", "e.d.ts", "f.css", "g.js"].filter(
isShippedSource,
),
["a.ts", "b.tsx"],
);
});
it("scans Studio and every package whose text Studio shows, but not the CLI", () => {
const shown = [
"core",
"engine",
"lint",
"parsers",
"player",
"producer",
"sdk",
"shader-transitions",
"studio",
"studio-server",
];
const root = mkdtempSync(join(tmpdir(), "studio-dashes-"));
for (const name of [...shown, "cli"]) {
const src = join(root, "packages", name, "src");
mkdirSync(src, { recursive: true });
writeFileSync(join(src, "a.ts"), 'export const a = "x \u2014 y";');
writeFileSync(join(src, "a.test.ts"), 'const b = "x \u2014 y";');
}
const scanned = checkStudioCopyDashes(root).map((issue) => issue.split("/")[1]);
rmSync(root, { recursive: true });
assert.deepEqual(scanned.sort(), shown);
});
});