From 612e617d6ce385628157e7be2ccff7ab131ba83a Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sun, 20 Sep 2026 15:23:32 +0900 Subject: [PATCH] fix: Site.tsx lost every quotation mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the testimonial quotes from the markup into `content: open-quote` fixed them for eight languages on screen and silently broke the React export, which reads the DOM and so cannot see a mark that only exists in a stylesheet. What came out was a component with no quotes at all and a `quoted` class pointing at a rule that does not ship with it — while the file's own header promises no dependency beyond React. The export now resolves generated content and writes it in as text: the quote keywords through the element's computed `quotes` pair, string literals as written, anything else (counters, attr, images) left alone. `quoted` joins `blk-in` among the class names stripped on the way out, for the same reason — both name rules the file does not carry. The marks go on the same line as the text they wrap. JSX collapses the newline between two pieces of text into a space, so the obvious version renders `「 like this 」`, which is wrong in every language that does not ask for a space and invisible unless you compare the strings. Verified by compiling the exported file with esbuild and rendering it with react-dom/server, then comparing the blockquote against what the page shows. German, French, Chinese and English all round-trip exactly, spaces included. The seven new tests supply pseudo-element styles jsdom will not compute, and all six mutations of the new code turn them red. --- README.md | 2 + docs/README.de.md | 2 + docs/README.es.md | 2 + docs/README.fr.md | 2 + docs/README.ja.md | 2 + docs/README.ko.md | 2 + docs/README.pt.md | 2 + docs/README.zh.md | 2 + lib/export-tsx.test.ts | 87 +++++++++++++++++++++++++++++++++++++++++- lib/export-tsx.ts | 59 ++++++++++++++++++++++++++-- 10 files changed, 157 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0562a64..6766656 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ Three formats, all derived from **the same rendered result**. There is no second One flat component, no dependency beyond React. Colours, fonts and radii all live in a single style object on the outermost element, so retheming means editing one place. Tailwind class names are preserved. +Anything the page draws with CSS rather than markup has to come along as text, or it vanishes from a file that ships no stylesheet. Quotation marks moved into `content: open-quote` at one point and the export lost every one of them, keeping only the class name that pointed at a rule which does not ship. They are resolved and written into the JSX now, flush against the text — JSX turns the newline between two pieces of text into a space, and `「 like this 」` is wrong in every language that does not ask for one. + Component splitting is deliberately skipped — a generated file you can read top to bottom and cut apart yourself beats a structure you have to reverse-engineer first. Verification is a real `tsc --noEmit --strict --jsx react-jsx` run, **judged by exit code**. That is how the first version turned out not to compile: CSS custom properties are not valid in `React.CSSProperties`. It now emits an `as CSSProperties` cast. diff --git a/docs/README.de.md b/docs/README.de.md index f2f64cd..c903e5d 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -187,6 +187,8 @@ Drei Formate, alle aus **demselben gerenderten Ergebnis**. Es gibt keine zweite Eine einzige flache Komponente, außer React ohne Abhängigkeiten. Farben, Schriften und Radien stehen alle in einem einzigen Style-Objekt am äußersten Element — ein Themenwechsel ist also eine Änderung an genau einer Stelle. Tailwind-Klassennamen bleiben erhalten. +Alles, was die Seite mit CSS statt mit Markup zeichnet, muss als Text mitkommen, sonst verschwindet es aus einer Datei, die kein Stylesheet mitbringt. Die Anführungszeichen wanderten irgendwann in `content: open-quote`, und der Export verlor sie alle — übrig blieb nur der Klassenname, der auf eine Regel zeigte, die nicht mitgeliefert wird. Sie werden jetzt aufgelöst und in das JSX geschrieben, direkt am Text: JSX macht aus dem Zeilenumbruch zwischen zwei Textstücken ein Leerzeichen, und `« so »` ist in jeder Sprache falsch, die keines verlangt. + Auf eine Aufteilung in Komponenten wird bewusst verzichtet: Eine generierte Datei, die man von oben nach unten lesen und selbst zerlegen kann, ist mehr wert als eine Struktur, die man erst rückwärts verstehen muss. Die Prüfung ist ein echter Lauf von `tsc --noEmit --strict --jsx react-jsx`, **bewertet nach dem Exit-Code**. So kam heraus, dass die erste Fassung nicht kompilierte: CSS-Custom-Properties sind in `React.CSSProperties` nicht zulässig. Jetzt wird ein `as CSSProperties` ausgegeben. diff --git a/docs/README.es.md b/docs/README.es.md index 422243f..f5a4c3b 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -186,6 +186,8 @@ Tres formatos, todos derivados **del mismo resultado renderizado**. No hay una s Un único componente plano, sin más dependencia que React. Colores, tipografías y radios viven todos en un solo objeto de estilo del elemento más externo, así que cambiar de tema es editar un solo sitio. Se conservan las clases de Tailwind. +Todo lo que la página dibuja con CSS en lugar de con marcado tiene que viajar como texto, o desaparece de un archivo que no lleva hoja de estilos. Las comillas pasaron en algún momento a `content: open-quote` y la exportación las perdió todas, conservando solo el nombre de clase que apuntaba a una regla que no viaja con el archivo. Ahora se resuelven y se escriben en el JSX, pegadas al texto: JSX convierte en espacio el salto de línea entre dos fragmentos de texto, y `« así »` está mal en todo idioma que no lo pida. + La división en componentes se omite a propósito: un archivo generado que puedes leer de arriba abajo y trocear tú mismo vale más que una estructura que primero hay que descifrar. La verificación es una ejecución real de `tsc --noEmit --strict --jsx react-jsx`, **juzgada por el código de salida**. Así se descubrió que la primera versión no compilaba: las propiedades personalizadas de CSS no son válidas en `React.CSSProperties`. Ahora emite un `as CSSProperties`. diff --git a/docs/README.fr.md b/docs/README.fr.md index b4500c9..6aa72ee 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -187,6 +187,8 @@ Trois formats, tous issus **du même rendu**. Il n'existe pas de seconde copie d Un seul composant plat, sans autre dépendance que React. Couleurs, polices et rayons vivent tous dans un unique objet de style sur l'élément le plus extérieur : changer de thème revient à éditer un seul endroit. Les classes Tailwind sont conservées. +Tout ce que la page dessine en CSS plutôt qu'en balisage doit partir avec elle sous forme de texte, sinon cela disparaît d'un fichier qui n'embarque aucune feuille de style. Les guillemets sont passés à un moment dans `content: open-quote` et l'export les a tous perdus, ne gardant que le nom de classe qui désignait une règle absente du fichier. Ils sont maintenant résolus et écrits dans le JSX, collés au texte : JSX transforme en espace le saut de ligne entre deux morceaux de texte, et `「 comme ceci 」` est faux dans toute langue qui n'en demande pas. + Le découpage en composants est volontairement omis : un fichier généré qu'on peut lire de haut en bas et découper soi-même vaut mieux qu'une structure qu'il faut d'abord décrypter. La vérification est une véritable exécution de `tsc --noEmit --strict --jsx react-jsx`, **jugée sur le code de sortie**. C'est ainsi qu'on a découvert que la première version ne compilait pas : les propriétés personnalisées CSS ne sont pas valides dans `React.CSSProperties`. Le fichier émet désormais un `as CSSProperties`. diff --git a/docs/README.ja.md b/docs/README.ja.md index 12ed2f6..0025d32 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -188,6 +188,8 @@ A small bakery in Brooklyn → en locale 平坦なコンポーネント一つ、React 以外の依存はゼロ。色・書体・角丸はすべて最も外側の style オブジェクト一箇所にまとまっているので、着せ替えはそこだけ触ります。Tailwind のクラス名はそのまま残ります。 +ページがマークアップではなく CSS で描いているものは、テキストとして一緒に持ち出さないと、スタイルシートを同梱しないファイルからは消えます。引用符が一度 `content: open-quote` に移ったとき、書き出しはそれを全部失い、同梱されない規則を指すクラス名だけが残りました。いまは解決してテキストとして JSX に書き込み、しかも本文に密着させています——JSX は二つのテキストの間の改行を空白に変えるので、`「 このように 」` は空白を求めないすべての言語で誤りだからです。 + コンポーネント分割は意図的に行っていません——生成されたファイルは、上から下まで読んで自分で切り分けられる方が、先に構造を読み解かされるより良いからです。 検証は実際に `tsc --noEmit --strict --jsx react-jsx` を走らせ、**終了コードで判定**しています。初版がコンパイルできなかったのもこれで分かりました。CSS カスタムプロパティは `React.CSSProperties` では不正なので、現在は `as CSSProperties` を付けて出力します。 diff --git a/docs/README.ko.md b/docs/README.ko.md index 2a70ebf..d495a94 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -188,6 +188,8 @@ A small bakery in Brooklyn → en locale 평평한 컴포넌트 하나, React 외 의존성 0. 색·글꼴·모서리 반경은 모두 최외곽 style 객체 한 곳에 모여 있어서 리테마는 그 한 곳만 고치면 됩니다. Tailwind 클래스명은 그대로 보존됩니다. +페이지가 마크업이 아니라 CSS로 그리는 것은 텍스트로 함께 가져가지 않으면, 스타일시트를 동봉하지 않는 파일에서는 사라집니다. 따옴표가 한때 `content: open-quote`로 옮겨갔을 때 내보내기는 그것을 모두 잃었고, 동봉되지 않는 규칙을 가리키는 클래스 이름만 남았습니다. 지금은 해석해서 JSX에 텍스트로 써 넣으며, 본문에 붙여 씁니다 — JSX는 두 텍스트 사이의 줄바꿈을 공백으로 바꾸고, `「 이렇게 」` 는 공백을 원하지 않는 모든 언어에서 틀리기 때문입니다. + 컴포넌트 분리는 일부러 하지 않았습니다 — 생성된 파일은 위에서 아래로 읽고 직접 잘라낼 수 있는 편이, 먼저 구조를 역추적해야 하는 것보다 낫기 때문입니다. 검증은 실제로 `tsc --noEmit --strict --jsx react-jsx` 를 돌리고 **종료 코드로 판정**합니다. 초판이 컴파일되지 않는다는 사실도 이렇게 드러났습니다. CSS 사용자 정의 속성은 `React.CSSProperties` 에서 유효하지 않아, 현재는 `as CSSProperties` 캐스트를 붙여 출력합니다. diff --git a/docs/README.pt.md b/docs/README.pt.md index b35c45c..1456f7a 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -186,6 +186,8 @@ Três formatos, todos derivados **do mesmo resultado renderizado**. Não existe Um único componente plano, sem dependência além do React. Cores, fontes e raios ficam todos em um único objeto de estilo no elemento mais externo, então trocar de tema é editar um lugar só. Os nomes de classe do Tailwind são preservados. +Tudo o que a página desenha com CSS em vez de marcação precisa ir junto como texto, senão desaparece de um arquivo que não leva folha de estilo. As aspas passaram em algum momento para `content: open-quote` e a exportação perdeu todas elas, guardando apenas o nome de classe que apontava para uma regra que não vai junto. Agora são resolvidas e escritas no JSX, coladas ao texto: o JSX transforma em espaço a quebra de linha entre dois trechos de texto, e `« assim »` está errado em todo idioma que não peça um. + A divisão em componentes foi deixada de fora de propósito: um arquivo gerado que dá para ler de cima a baixo e recortar você mesmo vale mais do que uma estrutura que precisa ser decifrada antes. A verificação é uma execução real de `tsc --noEmit --strict --jsx react-jsx`, **julgada pelo código de saída**. Foi assim que se descobriu que a primeira versão não compilava: propriedades customizadas de CSS não são válidas em `React.CSSProperties`. Agora sai com um `as CSSProperties`. diff --git a/docs/README.zh.md b/docs/README.zh.md index b8832a7..7809082 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -188,6 +188,8 @@ A small bakery in Brooklyn → en locale 一个扁平组件,除 React 外零依赖。主题色、字体、圆角全在最外层那一个 style 对象里,换皮就改那一处。Tailwind 类名保留。 +页面上凡是用 CSS 而不是标记画出来的东西,都得作为文本一起带走,否则在一个不附带样式表的文件里就会消失。引号一度被挪进 `content: open-quote`,导出于是把它们全丢了,只留下一个指向不随行规则的类名。现在它们会被解析出来写进 JSX,并且紧贴文本——JSX 会把两段文本之间的换行变成一个空格,而 `「 这样 」` 在任何不要求空格的语言里都是错的。 + 刻意不做组件拆分——生成出来的文件,能从头读到尾自己拆,好过一个得先反推的结构。 验证方式是真跑 `tsc --noEmit --strict --jsx react-jsx` 并**按退出码判定**。第一版就是这么发现 CSS 自定义属性在 `React.CSSProperties` 里不合法的,现在带 `as CSSProperties` 断言。 diff --git a/lib/export-tsx.test.ts b/lib/export-tsx.test.ts index dd48560..a9158d6 100644 --- a/lib/export-tsx.test.ts +++ b/lib/export-tsx.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { buildReactSource } from "./export-tsx"; function element(html: string): Element { @@ -72,3 +72,88 @@ describe("buildReactSource", () => { expect(source).toContain("我开了家咖啡店"); }); }); + +/** + * Quotation marks moved from the markup into CSS, and the export lost them + * without anything noticing: the class it kept referred to a rule that does + * not ship with the file. These bake the generated content back in. + */ +describe("CSS generated content", () => { + const real = globalThis.getComputedStyle; + afterEach(() => { + globalThis.getComputedStyle = real; + }); + + /** jsdom resolves no pseudo-element content, so the values are supplied here. */ + function withPseudo(styles: Record) { + globalThis.getComputedStyle = ((el: Element, pseudo?: string | null) => { + const key = pseudo ? `${(el as HTMLElement).tagName.toLowerCase()}${pseudo}` : ""; + const own = styles[key] ?? {}; + return { content: own.content ?? "none", quotes: own.quotes ?? "auto" } as CSSStyleDeclaration; + }) as typeof globalThis.getComputedStyle; + } + + it("resolves open-quote and close-quote through the quotes pair", () => { + withPseudo({ + "blockquote::before": { content: "open-quote", quotes: '"「" "」"' }, + "blockquote::after": { content: "close-quote", quotes: '"「" "」"' }, + }); + const source = buildReactSource(element(`
很好喝
`), "t"); + expect(source).toContain("「很好喝」"); + }); + + it("keeps the marks flush against the text", () => { + // JSX turns the newline between two pieces of text into a space, which + // would render as 「 很好喝 」 — wrong in every language that does not + // want one, and invisible unless you compare the strings. + withPseudo({ + "blockquote::before": { content: "open-quote", quotes: '"「" "」"' }, + "blockquote::after": { content: "close-quote", quotes: '"「" "」"' }, + }); + const source = buildReactSource(element(`
很好喝
`), "t"); + expect(source).not.toContain("「\n"); + expect(source).not.toMatch(/「\s/); + expect(source).not.toMatch(/\s」/); + }); + + it("carries a mark that is itself spaced, like the French guillemets", () => { + withPseudo({ + "blockquote::before": { content: "open-quote", quotes: '"« " " »"' }, + "blockquote::after": { content: "close-quote", quotes: '"« " " »"' }, + }); + const source = buildReactSource(element(`
Excellent
`), "t"); + expect(source).toContain("« Excellent »"); + }); + + it("takes a string literal as written", () => { + withPseudo({ "span::before": { content: '"→ "' } }); + const source = buildReactSource(element(`next`), "t"); + expect(source).toContain("→ next"); + }); + + it("adds nothing when there is no generated content", () => { + withPseudo({}); + const source = buildReactSource(element(`
plain
`), "t"); + expect(source).toContain("plain"); + expect(source).not.toMatch(/[「」«»]/); + }); + + it("ignores a quote keyword with no pair to resolve it", () => { + // `quotes: auto` is the browser's own default; there is nothing to read. + withPseudo({ "blockquote::before": { content: "open-quote", quotes: "auto" } }); + const source = buildReactSource(element(`
plain
`), "t"); + expect(source).toContain("plain"); + expect(source).not.toMatch(/open-quote/); + }); + + it("drops the class that named a rule the file does not carry", () => { + withPseudo({}); + const source = buildReactSource( + element(`
x
`), + "t", + ); + expect(source).toContain('className="text-sm"'); + expect(source).not.toContain("quoted"); + expect(source).not.toContain("blk-in"); + }); +}); diff --git a/lib/export-tsx.ts b/lib/export-tsx.ts index 9f72c58..c74c094 100644 --- a/lib/export-tsx.ts +++ b/lib/export-tsx.ts @@ -81,6 +81,32 @@ function escapeText(text: string): string { : text.replace(//g, ">"); } +/** + * CSS generated content, resolved to the text it renders as. + * + * The exported file is meant to stand on its own, with none of our stylesheet + * behind it, so anything that exists only as `::before { content: open-quote }` + * would simply disappear — which is what happened to every testimonial's + * quotation marks the moment they moved from the markup into CSS. Baking them + * in keeps the component looking like the page it came from. + */ +function generatedText(element: Element, pseudo: "::before" | "::after"): string { + const style = getComputedStyle(element, pseudo); + const content = style.content; + if (!content || content === "none" || content === "normal") return ""; + + if (content === "open-quote" || content === "close-quote") { + // `quotes` computes to a pair of quoted strings, e.g. `"„" "“"`. + const marks = [...style.quotes.matchAll(/"([^"]*)"/g)].map((m) => m[1]!); + if (marks.length < 2) return ""; + return content === "open-quote" ? marks[0]! : marks[1]!; + } + + // A string literal; anything else (counters, attr(), images) is left alone. + const literal = content.match(/^"([\s\S]*)"$/); + return literal ? literal[1]! : ""; +} + function serialize(node: Node, depth: number): string { const pad = " ".repeat(depth); @@ -106,7 +132,12 @@ function serialize(node: Node, depth: number): string { continue; } if (attr.name === "class") { - const cleaned = attr.value.replace(/\bblk-in\b/g, "").replace(/\s+/g, " ").trim(); + // `blk-in` names a keyframe and `quoted` a quote style, and neither rule + // ships with the component — the marks are baked in as text below. + const cleaned = attr.value + .replace(/\b(?:blk-in|quoted)\b/g, "") + .replace(/\s+/g, " ") + .trim(); if (cleaned) attributes.push(`className=${JSON.stringify(cleaned)}`); continue; } @@ -123,9 +154,29 @@ function serialize(node: Node, depth: number): string { if (VOID_TAGS.has(tag)) return `${pad}<${tag}${attrText} />`; - const children = Array.from(element.childNodes) - .map((child) => serialize(child, depth + 1)) - .filter(Boolean); + const inner = " ".repeat(depth + 1); + const before = generatedText(element, "::before"); + const after = generatedText(element, "::after"); + const childNodes = Array.from(element.childNodes); + + let children: string[]; + if ((before || after) && childNodes.every((n) => n.nodeType === Node.TEXT_NODE)) { + // JSX collapses the newline between two pieces of text into a space, which + // would leave one inside every quotation mark — `「 text 」` rather than + // `「text」`. Emitting the whole run on one line keeps it exact. + const text = childNodes + .map((n) => (n.textContent ?? "").replace(/\s+/g, " ")) + .join("") + .trim(); + const joined = `${before}${text}${after}`; + children = joined ? [`${inner}${escapeText(joined)}`] : []; + } else { + children = [ + ...(before ? [`${inner}${escapeText(before)}`] : []), + ...childNodes.map((child) => serialize(child, depth + 1)), + ...(after ? [`${inner}${escapeText(after)}`] : []), + ].filter(Boolean); + } if (children.length === 0) return `${pad}<${tag}${attrText} />`; return `${pad}<${tag}${attrText}>\n${children.join("\n")}\n${pad}`;