Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` を付けて出力します。
Expand Down
2 changes: 2 additions & 0 deletions docs/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 캐스트를 붙여 출력합니다.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.pt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions docs/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 断言。
Expand Down
87 changes: 86 additions & 1 deletion lib/export-tsx.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, { content?: string; quotes?: string }>) {
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(`<blockquote>很好喝</blockquote>`), "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(`<blockquote>很好喝</blockquote>`), "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(`<blockquote>Excellent</blockquote>`), "t");
expect(source).toContain("« Excellent »");
});

it("takes a string literal as written", () => {
withPseudo({ "span::before": { content: '"→ "' } });
const source = buildReactSource(element(`<span>next</span>`), "t");
expect(source).toContain("→ next");
});

it("adds nothing when there is no generated content", () => {
withPseudo({});
const source = buildReactSource(element(`<blockquote>plain</blockquote>`), "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(`<blockquote>plain</blockquote>`), "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(`<blockquote class="quoted blk-in text-sm">x</blockquote>`),
"t",
);
expect(source).toContain('className="text-sm"');
expect(source).not.toContain("quoted");
expect(source).not.toContain("blk-in");
});
});
59 changes: 55 additions & 4 deletions lib/export-tsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,32 @@ function escapeText(text: string): string {
: text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

/**
* 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);

Expand All @@ -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;
}
Expand All @@ -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}</${tag}>`;
Expand Down
Loading