Skip to content
Source
Sign inSign up
Open History to see the last commit

generate-highlights.mts

189 lines7.4 KB

1#!/usr/bin/env node
2​
3// Hermes has no WebAssembly, so wasm2c supplies C for T3Highlights.
4// Token and language IDs are an ABI; generate native code and JS tables from the same version.
5// After bumping @pierre/highlights:
6// WASM2C=/path/to/wabt-1.0.42/bin/wasm2c vp run --filter @t3tools/mobile generate:highlights
7​
8import * as NodeChildProcess from "node:child_process";
9import * as NodeCrypto from "node:crypto";
10import * as NodeFS from "node:fs";
11import * as NodeModule from "node:module";
12import * as NodeOS from "node:os";
13import * as NodePath from "node:path";
14import * as NodeURL from "node:url";
15​
16const WASM2C_VERSION = "1.0.42";
17const THEMES = { light: "pierre-light", dark: "pierre-dark" } as const;
18​
19const mobileRoot = NodePath.resolve(import.meta.dirname, "..");
20const moduleRoot = NodePath.join(mobileRoot, "modules/t3-highlights");
21const generatedDataPath = NodePath.join(mobileRoot, "src/lib/highlights.generated.ts");
22​
23interface ThemeStyle {
24 readonly color?: string;
25 readonly italic?: boolean;
26 readonly weight?: number;
28​
29interface PreparedTheme {
30 readonly fg: string;
31 readonly styles: ReadonlyArray<ThemeStyle | null>;
33​
34function resolveHighlightsPackage() {
35 const require = NodeModule.createRequire(NodePath.join(mobileRoot, "package.json"));
36 const root = NodePath.dirname(require.resolve("@pierre/highlights/package.json"));
37 const { version } = JSON.parse(NodeFS.readFileSync(NodePath.join(root, "package.json"), "utf8"));
38 const wasm = NodeFS.readFileSync(NodePath.join(root, "dist/highlights.wasm"));
39 return {
40 root,
41 version: version as string,
42 wasmSha256: NodeCrypto.createHash("sha256").update(wasm).digest("hex"),
43 };
45​
46function importDist<T>(root: string, file: string): Promise<T> {
47 // The package exports neither its language table nor its theme resolver.
48 return import(NodeURL.pathToFileURL(NodePath.join(root, "dist", file)).href);
50​
51function generatedBanner(source: ReturnType<typeof resolveHighlightsPackage>): string {
52 return `Generated by apps/mobile/scripts/generate-highlights.mts from @pierre/highlights ${source.version} (highlights.wasm sha256 ${source.wasmSha256}). Do not edit.`;
54​
55export async function computeHighlightsData() {
56 const source = resolveHighlightsPackage();
57 const { default: languageIds } = await importDist<{ default: Record<string, number> }>(
58 source.root,
59 "languages.js",
60 );
61 const { prepareTheme } = await importDist<{ prepareTheme: (theme: unknown) => PreparedTheme }>(
62 source.root,
63 "theme.js",
64 );
65​
66 const themeStyles: Record<string, Array<readonly [string, number]>> = {};
67 for (const [scheme, name] of Object.entries(THEMES)) {
68 const { default: theme } = await importDist<{ default: unknown }>(
69 source.root,
70 `themes/${name}.js`,
71 );
72 const { fg, styles } = prepareTheme(theme);
73 // Mirrors `lineRecordsToTokens`: unstyled tokens take the theme foreground.
74 themeStyles[scheme] = styles.map((style, tokenId) => {
75 const color = style?.color ?? fg;
76 if (!/^#[0-9a-f]{6}([0-9a-f]{2})?$/iu.test(color)) {
77 throw new Error(`${name} token ${tokenId} has a color React Native cannot draw: ${color}`);
78 }
79 const fontStyle = (style?.italic === true ? 1 : 0) | ((style?.weight ?? 0) >= 600 ? 2 : 0);
80 return [color, fontStyle] as const;
81 });
82 }
83​
84 return { source, languageIds, themeStyles };
86​
87function renderHighlightsData(data: Awaited<ReturnType<typeof computeHighlightsData>>): string {
88 return [
89 `// ${generatedBanner(data.source)}`,
90 "",
91 `export const HIGHLIGHTS_LANGUAGE_IDS: Readonly<Record<string, number>> = ${JSON.stringify(data.languageIds)};`,
92 "",
93 "/** `[color, fontStyle]` by token ID. fontStyle bits: italic 1, bold 2. */",
94 `export const HIGHLIGHTS_THEME_STYLES: Readonly<Record<"light" | "dark", ReadonlyArray<readonly [string, number]>>> = ${JSON.stringify(data.themeStyles)};`,
95 "",
96 ].join("\n");
98​
99// The lexers ask the host whether a non-ASCII code point can start or continue
100// an identifier. These are the classes the JavaScript glue tests with regexes.
101function renderIdRanges(): string {
102 const classes = [
103 ["id_start", /^\p{ID_Start}$/u],
104 ["id_continue", /^[‌‍\p{ID_Continue}]$/u],
105 ] as const;
106 let out = `/* Unicode ${process.versions.unicode} identifier classes. */\n`;
107 for (const [name, pattern] of classes) {
108 const ranges: Array<[number, number]> = [];
109 let start = -1;
110 for (let codePoint = 0; codePoint <= 0x110000; codePoint += 1) {
111 const member =
112 codePoint < 0x110000 &&
113 (codePoint < 0xd800 || codePoint > 0xdfff) &&
114 pattern.test(String.fromCodePoint(codePoint));
115 if (member && start < 0) start = codePoint;
116 if (!member && start >= 0) {
117 ranges.push([start, codePoint - 1]);
118 start = -1;
119 }
120 }
121 out += `static const uint32_t ${name}_ranges[][2] = {\n`;
122 for (let index = 0; index < ranges.length; index += 4) {
123 const row = ranges.slice(index, index + 4);
124 out += ` ${row.map(([from, to]) => `{0x${from.toString(16)}, 0x${to.toString(16)}}`).join(", ")},\n`;
125 }
126 out += "};\n";
127 }
128 return out;
130​
131function resolveWasm2c(): { readonly binary: string; readonly prefix: string } {
132 const binary = process.env.WASM2C ?? "wasm2c";
133 const version = NodeChildProcess.execFileSync(binary, ["--version"], { encoding: "utf8" }).trim();
134 if (version !== WASM2C_VERSION) {
135 throw new Error(`Expected wasm2c ${WASM2C_VERSION}, found ${version}. Set WASM2C.`);
136 }
137 const resolved = NodeFS.realpathSync(
138 NodePath.isAbsolute(binary)
139 ? binary
140 : NodeChildProcess.execFileSync("which", [binary], { encoding: "utf8" }).trim(),
141 );
142 return { binary, prefix: NodePath.resolve(NodePath.dirname(resolved), "..") };
144​
145async function generate() {
146 const source = resolveHighlightsPackage();
147 const wasm2c = resolveWasm2c();
148 const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-highlights-"));
149 try {
150 NodeChildProcess.execFileSync(wasm2c.binary, [
151 NodePath.join(source.root, "dist/highlights.wasm"),
152 "-n",
153 "highlights",
154 "-o",
155 NodePath.join(scratch, "highlights.c"),
156 ]);
157 const banner = `/* ${generatedBanner(source)} */\n`;
158 for (const file of ["highlights.c", "highlights.h"]) {
159 const contents = NodeFS.readFileSync(NodePath.join(scratch, file), "utf8");
160 NodeFS.writeFileSync(NodePath.join(moduleRoot, "Vendor/highlights", file), banner + contents);
161 }
162 } finally {
163 NodeFS.rmSync(scratch, { recursive: true, force: true });
164 }
165​
166 // The runtime must come from the same wabt release as the generated code.
167 const runtime = [
168 ["include/wasm-rt.h", "wasm-rt.h"],
169 ["share/wabt/wasm2c/wasm-rt-impl.h", "wasm-rt-impl.h"],
170 ["share/wabt/wasm2c/wasm-rt-impl.c", "wasm-rt-impl.c"],
171 ["share/wabt/wasm2c/wasm-rt-impl-tableops.inc", "wasm-rt-impl-tableops.inc"],
172 ["share/wabt/wasm2c/wasm-rt-mem-impl.c", "wasm-rt-mem-impl.c"],
173 ["share/wabt/wasm2c/wasm-rt-mem-impl-helper.inc", "wasm-rt-mem-impl-helper.inc"],
174 ] as const;
175 for (const [from, to] of runtime) {
176 NodeFS.copyFileSync(
177 NodePath.join(wasm2c.prefix, from),
178 NodePath.join(moduleRoot, "Vendor/wasm2c", to),
179 );
180 }
181​
182 NodeFS.writeFileSync(NodePath.join(moduleRoot, "cpp/generated/id_ranges.inc"), renderIdRanges());
183 NodeFS.writeFileSync(generatedDataPath, renderHighlightsData(await computeHighlightsData()));
184 NodeChildProcess.execFileSync("vp", ["fmt", generatedDataPath], { stdio: "inherit" });
186​
187if (import.meta.main) {
188 await generate();