Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_typstproject.mjs

30.2 KiB, 1 run

created by r2519314175:775, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_typstproject.mjs — a book is a project, and every file it names must arrive.
2//
3// The author tried to compile a 17-chapter book by the Doc panel's Compile button and
4// by asking his daimon, and both failed on line one with
5//
6// failed to load file (access denied), hints: cannot read file outside of project
7// root, you can adjust the project root with the --root argument
8//
9// Three defects, one feature. The compiler was handed EXACTLY ONE FILE and its shadow
10// filesystem was wiped before every compile, so an `#import` could never resolve; the
11// message it failed with described a setting that does not exist here, so the daimon
12// reading it concluded the book's images were out of bounds and spent the hour hunting
13// assets that were never the problem; and when the gatherer was built to fix both, IT
14// WAS NEVER WIRED IN. `Tool::TypstCompile` went on reading one file and handing the
15// string to the single-source door for a whole release, so the user's next attempt
16// failed the same way, the daimon concluded the compiler "can only handle self-contained
17// single files", and told him to attach a folder that was already in the workspace.
18//
19// That is why every project claim below is now made THROUGH THE MODEL'S TOOL, by wire
20// name, with the arguments a model sends. The first version of this file called the
21// gatherer directly and passed twenty-four checks while production compiled one string.
22// A check that does not go through the door the user goes through is checking a room
23// nobody enters.
24//
25// This file asserts the properties that had to become true, and it asserts most of them
26// by TAKING A FILE AWAY. Compiling the whole fixture proves only that something worked;
27// withholding each file in turn and demanding that the refusal NAME THE FILE is what
28// proves the project is really being gathered and the message is really being composed
29// from what this side knows.
30//
31// Run it red before you believe it green:
32//
33// node dev/verify_typstproject.mjs # the code as it stands
34// TYPST_BROKEN=1 node dev/verify_typstproject.mjs
35//
36// `TYPST_BROKEN=1` reads the one file and compiles it through
37// `window.DaimondTypst.compile(text)`. That is not a simulation of the old code: it is
38// what `Tool::TypstCompile` did, line for line, until seq 117, and it is still the right
39// door for a source with no project behind it. Every project claim below must fail
40// under it. A check that stays green there is checking nothing.
41import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
42import { fileURLToPath } from 'node:url';
43import { dirname, join as pjoin } from 'node:path';
44import { open, shot } from './harness.mjs';
45
46const HERE = dirname(fileURLToPath(import.meta.url));
47const FIX = pjoin(HERE, 'fixtures', 'typstproj');
48const BROKEN = process.env.TYPST_BROKEN === '1';
49
50// The author's own book, which is the external oracle for everything below: nothing in
51// it was written to make a test pass. Skipped, loudly, on a machine that has not got it.
52const BOOK = process.env.DAIMOND_BOOK || `${process.env.HOME}/usr/books/elearnity`;
53const BOOK_MAIN = 'CheapThinking/thinking.typ';
54
55const ok = [], bad = [], skipped = [];
56const check = (name, pass, detail) => {
57 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
58 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
59};
60const skip = (name, why) => {
61 skipped.push(name);
62 console.log(' skip ' + name + ' — ' + why);
63};
64
65// The fixture, read once on this side. `text` files go in as strings so a failure
66// message can quote them; the font goes in as bytes.
67const FILES = [
68 ['books/proj/main.typ', 'text'],
69 ['books/proj/template.typ', 'text'],
70 ['books/proj/parts/util.typ', 'text'],
71 ['books/proj/chap01.typ', 'text'],
72 ['books/shared/glo.typ', 'text'],
73 ['books/assets/mark.svg', 'text'],
74 ['books/assets/fonts/Radley-Regular.ttf', 'bytes'],
75];
76const CONTENT = {};
77for (const [rel, kind] of FILES) {
78 CONTENT[rel] = kind === 'text'
79 ? readFileSync(pjoin(FIX, rel), 'utf8')
80 : Array.from(readFileSync(pjoin(FIX, rel)));
81}
82const SOLO = readFileSync(pjoin(FIX, 'solo/main.typ'), 'utf8');
83
84const s = await open({ name: 'typstproject' });
85const p = s.page;
86await p.waitForTimeout(1500);
87
88// Every claim below is about an account that HOLDS the typesetting pack, which is the
89// condition under which "it compiles" was ever true. Stated and asserted rather than
90// inherited from a fresh profile that happened to be told nothing.
91const held = await p.evaluate(async () => {
92 const mod = await import('../pkg/oxedyne_daimond.js');
93 mod.set_locked_packs('');
94 return mod.tool_locked('typst_compile');
95});
96check('the account these claims are made about holds the typesetting pack',
97 held === false, `typst_compile locked: ${held}`);
98
99/// Seed a private workspace with `files`, then compile `main` in it AS THE MODEL DOES:
100/// by calling the tool `typst_compile` by wire name with a `path` argument.
101///
102/// Each variant gets its own account namespace, so withholding a file in one cannot
103/// leave a stale copy behind for the next -- a shared workspace is exactly how a
104/// withheld-file check would go green for the wrong reason.
105///
106/// The PDF is read back out of the workspace rather than taken from a return value,
107/// because writing it there is part of what the tool promises.
108async function compileIn(tag, files, main, broken) {
109 return await p.evaluate(async ({ tag, files, main, broken }) => {
110 const mod = await import('../pkg/oxedyne_daimond.js');
111 mod.set_account_ns('d~tp_' + tag);
112 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
113 for (const f of files) {
114 if (typeof f[1] === 'string') {
115 await app.run_tool('file_write', JSON.stringify({ path: f[0], content: f[1] }));
116 } else {
117 await app.write_bytes(f[0], new Uint8Array(f[1]));
118 }
119 }
120 let said = '';
121 if (broken) {
122 // `Tool::TypstCompile` as it stood until seq 117, verbatim: read the one file,
123 // hand the driver the string.
124 const text = await app.run_tool('file_read', JSON.stringify({ path: main }));
125 const out = await window.DaimondTypst.compile(text);
126 said = (out && out.error) ? 'Error: ' + out.error : '';
127 if (out && out.pdf) await app.write_bytes(main.replace(/\.typ$/i, '.pdf'), out.pdf);
128 } else {
129 said = await app.run_tool('typst_compile', JSON.stringify({ path: main }));
130 }
131 let pdf = null;
132 if (said.indexOf('Error:') !== 0) {
133 try { pdf = await mod.read_bytes(main.replace(/\.typ$/i, '.pdf'), 0, 40000000); }
134 catch (e) { pdf = null; }
135 }
136 mod.set_account_ns('');
137 return {
138 said: said,
139 error: said.indexOf('Error:') === 0 ? said.slice(6).trim() : '',
140 bytes: pdf ? pdf.length : 0,
141 magic: pdf ? String.fromCharCode.apply(null, Array.from(pdf.slice(0, 5))) : '',
142 // A PDF names every font it embeds in its descriptors, so this is a direct
143 // question about which family was actually drawn with -- not about which
144 // one the source asked for.
145 radley: pdf ? new TextDecoder('latin1').decode(pdf).indexOf('Radley') >= 0 : false,
146 };
147 }, { tag, files, main, broken });
148}
149
150/// The same, by the Doc panel's door, which returns a structured refusal.
151async function panelIn(tag, files, main) {
152 return await p.evaluate(async ({ tag, files, main }) => {
153 const mod = await import('../pkg/oxedyne_daimond.js');
154 mod.set_account_ns('d~tpp_' + tag);
155 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
156 for (const f of files) {
157 if (typeof f[1] === 'string') {
158 await app.run_tool('file_write', JSON.stringify({ path: f[0], content: f[1] }));
159 } else {
160 await app.write_bytes(f[0], new Uint8Array(f[1]));
161 }
162 }
163 const out = await mod.typst_compile_project(main);
164 mod.set_account_ns('');
165 return {
166 error: (out && out.error) || '',
167 bytes: out && out.pdf ? out.pdf.length : 0,
168 remedy: (out && out.remedy) ? { action: out.remedy.action, path: out.remedy.path, line: out.remedy.line } : null,
169 };
170 }, { tag, files, main });
171}
172
173const all = () => FILES.map(([rel]) => [rel, CONTENT[rel]]);
174const without = (drop) => FILES.filter(([rel]) => rel !== drop).map(([rel]) => [rel, CONTENT[rel]]);
175const MAIN = 'books/proj/main.typ';
176
177// ── 1. The whole project, through the tool the model calls ──────────────────
178const whole = await compileIn('whole', all(), MAIN, BROKEN);
179check('the MODEL\'S TOOL compiles a multi-file project to a real PDF',
180 whole.error === '' && whole.magic === '%PDF-' && whole.bytes > 1000,
181 whole.error ? whole.error.slice(0, 260) : `${whole.bytes} bytes, magic "${whole.magic}"`);
182
183// The root is the claim underneath that one. `main.typ` imports `../shared/glo.typ`,
184// so a compile that succeeds can only have put the root at `books/` -- one level ABOVE
185// the directory the compiled file sits in, which is what `typst --root` is passed on the
186// author's command line and what his attachment got wrong.
187check('the project root climbs above the compiled file when an import climbs',
188 whole.error === '' && whole.bytes > 1000,
189 whole.error ? whole.error.slice(0, 200) : 'compiled with ../shared/glo.typ resolved');
190
191// ── 2. The font is the one the source asked for ─────────────────────────────
192check('the family the source names is the family embedded in the PDF',
193 whole.radley === true,
194 `Radley in the PDF: ${whole.radley}`);
195
196// ── 3. Withhold each file, and demand the refusal name it ───────────────────
197//
198// The old message named nothing at all: it said "cannot read file outside of project
199// root" whatever was missing, which is why one wrong sentence produced one wrong
200// diagnosis. These assert the PROPERTY -- the name of the thing that is absent appears
201// in the reason -- and never a fixed string, so rewording the explanation cannot make
202// them lie.
203const WITHHELD = [
204 ['books/proj/template.typ', 'template.typ', 'a sibling import'],
205 ['books/proj/parts/util.typ', 'parts/util.typ', 'a sub-directory import'],
206 ['books/shared/glo.typ', '../shared/glo.typ', 'an import that climbs'],
207 ['books/assets/mark.svg', '/assets/mark.svg', 'an absolute asset path'],
208 ['books/proj/chap01.typ', 'chap01.typ', 'a chapter include'],
209];
210//
211// Each one carries TWO conditions beyond "it failed and said the name", because on the
212// single-file compiler the first import fails whatever is missing -- so "withholding
213// template.typ names template.typ" would go green on the very bug this file exists to
214// catch. The refusal must therefore also name NOTHING THAT WAS SUPPLIED, and the whole
215// project must have compiled when nothing was withheld. Neither is true of a compiler
216// that only ever sees one file, and neither depends on a word of the wording.
217for (const [drop, named, what] of WITHHELD) {
218 const tag = drop.replace(/[^a-z0-9]/gi, '');
219 const r = await compileIn(tag, without(drop), MAIN, BROKEN);
220 const others = WITHHELD.map(w => w[1]).filter(n => n !== named && named.indexOf(n) < 0);
221 const wrong = others.filter(n => r.error.indexOf(n) >= 0);
222 check(`withholding ${what} fails, and the reason names "${named}" and nothing present`,
223 whole.error === '' && r.bytes === 0 && r.error.indexOf(named) >= 0 && wrong.length === 0,
224 r.bytes ? `it compiled anyway (${r.bytes} bytes)`
225 : (wrong.length ? `it blamed a file that was supplied: ${wrong.join(', ')}`
226 : r.error.slice(0, 200)));
227 // And it must not lead with the sentence that misled the author: typst's own words
228 // are kept, but underneath, after an explanation of what actually happened here.
229 const said = r.error.indexOf('typst said:');
230 const root = r.error.indexOf('--root argument');
231 check(` and does not lead with typst's "--root" hint`,
232 r.bytes === 0 && (root < 0 || (said >= 0 && root > said)),
233 `explanation at ${said}, typst's hint at ${root}`);
234}
235
236// ── 4. The root the refusal quotes ──────────────────────────────────────────
237const noAsset = await compileIn('rootquote', without('books/assets/mark.svg'), MAIN, BROKEN);
238check('a refusal says where the project root was put',
239 noAsset.bytes === 0 && /root was put at books\b/.test(noAsset.error),
240 noAsset.error.slice(0, 200));
241// And where it LOOKED for the picture, which is not the root: the search runs from the
242// compiled file's own folder outward. Naming only the root sent the author looking in
243// the wrong folder for something that was never expected to be there.
244check(' and names the folders a root-relative name was looked for in',
245 noAsset.bytes === 0 && /books\/proj/.test(noAsset.error),
246 noAsset.error.slice(0, 260));
247
248// ── 5. Fonts: refuse rather than approximate ────────────────────────────────
249//
250// This is a correctness claim, not a nicety. The control below shows what silence
251// looks like: the same document, with no Radley anywhere, compiles happily through the
252// single-file door and returns a PDF nobody would question. Line breaks, last lines
253// and page count all come from the metrics of whatever got substituted, so a book
254// proofread against that PDF is a book proofread against a document that will never
255// print. Nothing in typst says a word about it -- measured: this build reports
256// diagnostics only when a compile FAILS, and a missing family does not fail.
257const noFont = await compileIn('nofont', without('books/assets/fonts/Radley-Regular.ttf'), MAIN, BROKEN);
258check('a project whose font cannot be loaded is refused, not approximated',
259 noFont.bytes === 0 && /Radley/.test(noFont.error),
260 noFont.bytes ? `it compiled in a substitute font (${noFont.bytes} bytes)` : noFont.error.slice(0, 220));
261check(' and the refusal says where to put the font',
262 noFont.bytes === 0 && /fonts/.test(noFont.error),
263 noFont.error.slice(0, 200));
264
265// The control goes STRAIGHT TO THE VENDORED COMPILER, underneath both doors, because
266// that is where the silence lives. Same source, same fonts, nothing in the way: a PDF
267// comes back, no diagnostic comes with it, and the family the source asked for is not
268// the family in the file. This is the behaviour the two refusals above exist to stop,
269// and if it ever changes -- if typst starts warning -- this check going red is how we
270// find out that the refusals could be replaced by something better.
271const silent = await p.evaluate(async () => {
272 const VENDOR = new URL('/vendor/typst/', location.href);
273 const mod = await import(new URL('typst_ts_web_compiler.mjs', VENDOR).href);
274 await mod.default(new URL('typst_ts_web_compiler_bg.wasm', VENDOR));
275 const b = new mod.TypstCompilerBuilder();
276 b.set_dummy_access_model();
277 for (const n of ['LibertinusSerif-Regular.otf', 'LibertinusSerif-Bold.otf',
278 'LibertinusSerif-Italic.otf', 'LibertinusSerif-BoldItalic.otf', 'NewCMMath-Regular.otf']) {
279 const r = await fetch(new URL('fonts/' + n, VENDOR));
280 await b.add_raw_font(new Uint8Array(await r.arrayBuffer()));
281 }
282 const c = await b.build();
283 c.reset_shadow();
284 c.add_source('/m.typ', '#set text(font: "Radley")\n= Hello\n\nBody text.\n');
285 const ret = c.compile('/m.typ', undefined, 'pdf', 3);
286 const pdf = ret instanceof Uint8Array ? ret : (ret && (ret.result || ret.artifact));
287 return {
288 bytes: pdf ? pdf.length : 0,
289 diag: ret && ret.diagnostics ? JSON.stringify(ret.diagnostics).length : 0,
290 radley: pdf ? new TextDecoder('latin1').decode(pdf).indexOf('Radley') >= 0 : false,
291 };
292});
293check('CONTROL: typst itself substitutes a missing font in silence — a PDF, no warning, wrong face',
294 silent.bytes > 1000 && silent.diag === 0 && silent.radley === false,
295 `${silent.bytes} bytes, ${silent.diag} bytes of diagnostics, Radley embedded: ${silent.radley}`);
296
297// And the single-file door refuses the same document, because a lone source can never
298// bring a font with it -- there is nothing it could be satisfied by.
299const oneFile = await p.evaluate(async () => {
300 const r = await window.DaimondTypst.compile('#set text(font: "Radley")\n= Hello\n\nBody text.\n');
301 return { bytes: r && r.pdf ? r.pdf.length : 0, error: (r && r.error) || '' };
302});
303check('the single-file door refuses a font it could never have',
304 oneFile.bytes === 0 && /Radley/.test(oneFile.error),
305 oneFile.bytes ? `it compiled in a substitute (${oneFile.bytes} bytes)` : oneFile.error.slice(0, 200));
306
307// ── 5b. A registry package is a fourth kind of failure ──────────────────────
308//
309// The four ways a compile can fail need four different actions from whoever reads the
310// message, and telling them apart is the whole point of composing it here. A missing
311// file wants finding; a path above the root wants a different folder attached; a font
312// wants a font file; and a `@preview/…` package the bundle does not carry wants NONE OF
313// THOSE -- it comes from Typst Universe over a network this compiler does not have, and
314// no rearrangement of files will ever satisfy it. In the session that prompted this work
315// the daimon spent several turns hunting assets and then rewrote the author's book source
316// to work around a message that never said which of the four it was.
317//
318// THE FIXTURE MUST NAME A PACKAGE THIS BUILD DOES NOT CARRY. It used to be
319// `@preview/cetz:0.3.4`, which was the right choice while every `@` import was refused;
320// five packs now travel inside the bundle (`www/assets/typst/packs/`) and cetz is one of
321// them, so that document COMPILES and the check was measuring the vendoring rather than
322// the refusal. `@preview/nonesuch:1.0.0` exists in no registry and in no pack, which is
323// the condition the message is for. `verify_typstpack` is what covers the carried side.
324const MISSING_PKG = '@preview/nonesuch:1.0.0';
325const pkg = await compileIn('pkg',
326 [['pkgproj/main.typ', `#import "${MISSING_PKG}"\n\n= Drawing\n`]],
327 'pkgproj/main.typ', BROKEN);
328check('a registry package this build does not carry is refused as a package, not as a missing file',
329 whole.error === '' && pkg.bytes === 0
330 && pkg.error.indexOf(MISSING_PKG) >= 0
331 && /registry/i.test(pkg.error) && /network/i.test(pkg.error),
332 pkg.bytes ? `it compiled anyway (${pkg.bytes} bytes)` : pkg.error.slice(0, 240));
333check(' and says plainly that moving files will not fix it',
334 whole.error === '' && pkg.bytes === 0 && /not a (missing file|path problem)/i.test(pkg.error),
335 pkg.error.slice(0, 200));
336
337// ── 6. Reaching above the marked folder, and what to DO about it ────────────
338//
339// The author's attachment was one level too deep, and "cannot read file outside of
340// project root" is a terrible way to be told so. This asks for the fix by name -- and
341// then asks for it as an ACTION, because prose is what the daimon got wrong: it read a
342// correct refusal, concluded the compiler could not do imports at all, and told the
343// user to attach a folder that was already open and named in the same sentence.
344const solo = await compileIn('solo', [['solo/main.typ', SOLO]], 'solo/main.typ', BROKEN);
345check('a project that reaches above the marked folder says to mark the one above it',
346 // "mark", not "above": the fixture's own import is literally `../../above.typ`, so a
347 // test for the word "above" passes on a message that merely quotes the line -- which
348 // is exactly what the single-source refusal does.
349 solo.bytes === 0 && /\bmark\b/i.test(solo.error) && solo.error.indexOf('../../above.typ') >= 0,
350 solo.bytes ? `it compiled anyway (${solo.bytes} bytes)` : solo.error.slice(0, 240));
351check(' and carries a machine-readable remedy, not only advice',
352 solo.bytes === 0 && /\bREMEDY mark-above folder=\S+ because=\S+ needs=\S+/.test(solo.error),
353 solo.error.slice(-160));
354// The same refusal by the panel's door arrives STRUCTURED, so the page can draw it as a
355// button instead of asking the reader to translate a paragraph into a click.
356const soloPanel = BROKEN ? null : await panelIn('solo', [['solo/main.typ', SOLO]], 'solo/main.typ');
357if (soloPanel) {
358 check(' and the panel gets it as a field it can draw as a button',
359 soloPanel.bytes === 0 && soloPanel.remedy !== null
360 && soloPanel.remedy.action === 'mark-above' && soloPanel.remedy.path === 'solo',
361 JSON.stringify(soloPanel.remedy));
362} else {
363 skip(' and the panel gets it as a field it can draw as a button', 'the panel door is not the broken path');
364}
365
366// ── 7. The walk is bounded, and says so rather than compiling a part ────────
367//
368// A cap that truncates is worse than no cap: the PDF comes out, a chapter is missing,
369// and nobody is told. So the claim is not "it stops" but "it stops WITHOUT producing
370// a PDF, and names the limit". The control immediately after is a project of the same
371// shape, comfortably under the cap, which must still compile -- otherwise this check
372// would pass just as well on a compiler that had stopped working altogether.
373function chain(n) {
374 const files = [];
375 let main = '';
376 for (let i = 0; i < n; i++) {
377 files.push([`cap/f${i}.typ`, `#let v${i}() = [${i}]\n`]);
378 main += `#import "f${i}.typ": v${i}\n`;
379 }
380 files.push(['cap/main.typ', main + '= Capped\n']);
381 return files;
382}
383const over = await compileIn('capover', chain(520), 'cap/main.typ', BROKEN);
384check('past the file cap the compile refuses and names the limit',
385 over.bytes === 0 && /\b500\b/.test(over.error),
386 over.bytes ? `it compiled a partial project (${over.bytes} bytes)` : over.error.slice(0, 200));
387const under = await compileIn('capunder', chain(60), 'cap/main.typ', BROKEN);
388check('CONTROL: the same shape under the cap still compiles',
389 under.error === '' && under.bytes > 1000,
390 under.error ? under.error.slice(0, 200) : `${under.bytes} bytes`);
391
392// ── 8. The root is not the search path ──────────────────────────────────────
393//
394// The second fault, and the one that made the author's book uncompilable BOTH ways
395// round. His book imports `../style/…`, so the root must sit above the book; his fonts
396// and his root-relative pictures live INSIDE the book. While the font and asset search
397// hung off the computed root, the arrangement that made the imports resolve was the
398// arrangement that lost every font, and the compile was refused for a missing family
399// that was sitting in the project all along.
400//
401// Same fixture, re-laid out: `inner/book/` reaching up to `inner/shared/`, with the
402// pictures and fonts inside the book rather than beside the shared folder. The root
403// must come out at `inner` for the import, and `"/assets/mark.svg"` and the fonts must
404// still be found at `inner/book/assets/…`.
405const inner = (drop) => FILES.filter(([rel]) => rel !== drop).map(([rel]) => [
406 rel.startsWith('books/assets/') ? 'inner/book/assets/' + rel.slice('books/assets/'.length)
407 : rel.startsWith('books/proj/') ? 'inner/book/' + rel.slice('books/proj/'.length)
408 : 'inner/shared/' + rel.slice('books/shared/'.length),
409 CONTENT[rel],
410]);
411const nest = await compileIn('inner', inner(''), 'inner/book/main.typ', BROKEN);
412check('a book that carries its own assets and fonts compiles with the root ABOVE it',
413 nest.error === '' && nest.magic === '%PDF-' && nest.bytes > 1000,
414 nest.error ? nest.error.slice(0, 300) : `${nest.bytes} bytes`);
415check(' and the font came from the book\'s own folder, not from the root',
416 nest.radley === true, `Radley in the PDF: ${nest.radley}`);
417// The picture is the other half of the same claim, and it is asserted by absence: take
418// it away and the compile must fail naming it, so its presence above cannot be an
419// accident of a compiler that ignores pictures.
420const nestNoPic = await compileIn('innernopic', inner('books/assets/mark.svg'), 'inner/book/main.typ', BROKEN);
421check(' and a root-relative picture inside the book is really being gathered',
422 nest.error === '' && nestNoPic.bytes === 0 && nestNoPic.error.indexOf('/assets/mark.svg') >= 0,
423 nestNoPic.bytes ? `it compiled without the picture (${nestNoPic.bytes} bytes)` : nestNoPic.error.slice(0, 200));
424
425// ── 9. A project that declares its own root is taken at its word ────────────
426//
427// `typst.toml` is typst's own manifest, and where a project has one it says where the
428// project starts. The closure here sits entirely inside `tt/book`, so the root would
429// otherwise be inferred as `tt/book`; the manifest one level up moves it, which is
430// visible in the folder a refusal quotes.
431const MANIFEST = [
432 ['tt/typst.toml', '[package]\nname = "demo"\nversion = "0.1.0"\nentrypoint = "book/main.typ"\n'],
433 ['tt/book/main.typ', '#import "part.typ": bit\n\n= Declared\n\n#bit\n\n#image("/assets/mark.svg", width: 20pt)\n'],
434 ['tt/book/part.typ', '#let bit = [a part]\n'],
435];
436const manifest = await compileIn('manifest', MANIFEST, 'tt/book/main.typ', BROKEN);
437check('a typst.toml above the file declares the root, and the refusal quotes it',
438 manifest.bytes === 0 && /root was put at tt\b/.test(manifest.error),
439 manifest.error.slice(0, 220));
440
441// ── 10. The author's own book ───────────────────────────────────────────────
442//
443// Everything above is a fixture written by the same hand that wrote the code, which
444// proves consistency and not correctness. This is the oracle: 63 sources, 24 fonts and
445// three pictures, none of them arranged for this file, laid out the two ways the author
446// might mark the folder in.
447if (!existsSync(pjoin(BOOK, BOOK_MAIN))) {
448 skip('the author\'s real book gathers', `no book at ${BOOK}`);
449} else {
450 const lits = (t) => Array.from(t.matchAll(/"([^"\n]*)"/g)).map(m => m[1]);
451 const seen = new Set([BOOK_MAIN]);
452 const q = [BOOK_MAIN];
453 const pics = new Set();
454 while (q.length) {
455 const f = q.pop();
456 const dir = f.indexOf('/') >= 0 ? f.slice(0, f.lastIndexOf('/')) : '';
457 let text; try { text = readFileSync(pjoin(BOOK, f), 'utf8'); } catch { continue; }
458 for (const l of lits(text)) {
459 if (l.startsWith('@')) continue;
460 const e = (l.split('/').pop().split('.').pop() || '').toLowerCase();
461 if (e === 'typ') {
462 if (l.startsWith('/')) continue;
463 const segs = (dir ? dir + '/' + l : l).split('/');
464 const out = [];
465 let bad = false;
466 for (const sg of segs) {
467 if (sg === '' || sg === '.') continue;
468 if (sg === '..') { if (!out.length) { bad = true; break; } out.pop(); continue; }
469 out.push(sg);
470 }
471 const path = out.join('/');
472 if (bad || seen.has(path) || !existsSync(pjoin(BOOK, path))) continue;
473 seen.add(path); q.push(path);
474 } else if (['svg', 'png', 'jpg', 'jpeg', 'webp'].includes(e) && l.startsWith('/')) {
475 pics.add(l.slice(1));
476 }
477 }
478 }
479 const fontFiles = [];
480 (function walk(d) {
481 for (const n of readdirSync(pjoin(BOOK, d))) {
482 const rel = d + '/' + n;
483 if (statSync(pjoin(BOOK, rel)).isDirectory()) walk(rel);
484 else if (/\.(ttf|otf|ttc)$/i.test(n)) fontFiles.push(rel);
485 }
486 })('assets/fonts');
487
488 // The book's sources, VERBATIM -- all 63 files, a glossary, an index, 17 chapters.
489 //
490 // Until the packs were vendored this could not be true: `book_template.typ` imports
491 // `@preview/cetz` and `@preview/cetz-plot`, and chapter two's figures are drawn with
492 // cetz, so every `@preview/…` line was stripped and the figures replaced by grey
493 // rectangles -- which meant this check compiled a book nobody wrote. cetz, cetz-plot
494 // and their closure now travel inside the bundle, so the author's own source is what
495 // goes in, drawings included. Reinstating a stub or an import filter here would put
496 // the fiction back; if a `@preview/…` import stops this compile, the answer is to
497 // vendor the pack (www/assets/typst/refresh.sh), not to edit it out.
498 const bookSrc = [...seen].map(rel => [rel, readFileSync(pjoin(BOOK, rel), 'utf8')]);
499 const bookBin = [...pics, ...fontFiles].map(rel => [rel, Array.from(readFileSync(pjoin(BOOK, rel)))]);
500 console.log(` .. the real book: ${bookSrc.length} sources, ${pics.size} pictures, ${fontFiles.length} fonts`);
501
502 // (a) The folder above the book is marked in -- the arrangement that makes the
503 // `../style/…` imports resolve -- and the book's own `assets` is what a copy of
504 // it looks like when the symlink has not been followed: inside the book, one
505 // level below the root the imports force. This is the shape that lost every
506 // font, and it is where the author's book actually failed.
507 const inBook = bookSrc.concat(bookBin.map(([rel, b]) => ['CheapThinking/' + rel, b]));
508 const real = await compileIn('realbook', inBook, BOOK_MAIN, BROKEN);
509 check('THE REAL BOOK compiles with the root above it and its assets inside it',
510 real.error === '' && real.magic === '%PDF-' && real.bytes > 20000,
511 real.error ? real.error.slice(0, 400) : `${real.bytes} bytes`);
512 check(' and it is set in the family the book asks for',
513 real.radley === true, `Radley in the PDF: ${real.radley}`);
514
515 // (b) The same book with the shared tree's assets where the disk really keeps them,
516 // beside the book rather than inside it. Both must work, because which one the
517 // user gets depends on whether a symlink was followed by whatever copied it.
518 const beside = bookSrc.concat(bookBin);
519 const real2 = await compileIn('realbook2', beside, BOOK_MAIN, BROKEN);
520 check(' and again with the shared assets beside the book instead of inside it',
521 real2.error === '' && real2.bytes > 20000,
522 real2.error ? real2.error.slice(0, 300) : `${real2.bytes} bytes`);
523
524 // (c) The book folder marked in on its own, which is the mistake anybody would make.
525 // There is no reading above a marked folder -- the mark IS the permission -- so
526 // the only honest answer is a refusal that names the import that reaches out and
527 // carries the action to take.
528 const onlyBook = inBook
529 .filter(([rel]) => rel.startsWith('CheapThinking/'))
530 .map(([rel, c]) => [rel.slice('CheapThinking/'.length), c]);
531 const tooDeep = await compileIn('realbookdeep', onlyBook, 'thinking.typ', BROKEN);
532 check(' and marking only the book folder is refused by name, with the remedy attached',
533 tooDeep.bytes === 0
534 && tooDeep.error.indexOf('../style/glossary_index.typ') >= 0
535 && /REMEDY mark-above/.test(tooDeep.error),
536 tooDeep.error.slice(0, 300));
537}
538
539await shot(s, 'typstproject');
540const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e));
541console.log('\nconsole errors:', errs.slice(0, 4));
542await s.close();
543
544console.log(`\n${ok.length} passed, ${bad.length} failed, ${skipped.length} skipped`
545 + (BROKEN ? ' (TYPST_BROKEN=1)' : ''));
546if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
547process.exit(bad.length ? 1 : 0);