isorite 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/core.js +175 -14
  2. package/dist/main.js +1217 -52
  3. package/notes/algorithms/the-quadtree/subdivision.svg +15 -0
  4. package/notes/algorithms/the-quadtree.md +2 -0
  5. package/package.json +2 -1
  6. package/plugin/server.ts +1 -1
  7. package/web/_app/immutable/assets/{0.CTbO0owi.css → 0.DcsDj0O1.css} +1 -1
  8. package/web/_app/immutable/assets/5.DbDKXCoO.css +1 -0
  9. package/web/_app/immutable/assets/NoteEditor.S3q5lmKq.css +1 -0
  10. package/web/_app/immutable/assets/drawn.CHbcoujQ.css +1 -0
  11. package/web/_app/immutable/chunks/{8wVGBFSs.js → -2Meesge.js} +3 -3
  12. package/web/_app/immutable/chunks/{BL3mLym4.js → 9z49435t.js} +1 -1
  13. package/web/_app/immutable/chunks/{DMAYXnpq.js → BA8Hpxhg.js} +1 -1
  14. package/web/_app/immutable/chunks/{CKSRTWxH.js → BAclEN0O.js} +1 -1
  15. package/web/_app/immutable/chunks/{DECNRCf2.js → BLmF1B2i.js} +1 -1
  16. package/web/_app/immutable/chunks/{CiVjR8gq.js → BTclWEfx.js} +1 -1
  17. package/web/_app/immutable/chunks/{Cp_JbuGh.js → BWVMfAMh.js} +1 -1
  18. package/web/_app/immutable/chunks/{D4fLEcOQ.js → BXAOBcPb.js} +1 -1
  19. package/web/_app/immutable/chunks/BYSDTLqw.js +1 -0
  20. package/web/_app/immutable/chunks/{DDuWClBR.js → BgT6ZdzK.js} +1 -1
  21. package/web/_app/immutable/chunks/{CO2Y5y64.js → Bq2GBAJ0.js} +1 -1
  22. package/web/_app/immutable/chunks/BqrMZ9MX.js +1 -0
  23. package/web/_app/immutable/chunks/{BN6xhG8e.js → Bt7VOT5H.js} +1 -1
  24. package/web/_app/immutable/chunks/{BGbXxNFa.js → BuUzVkG2.js} +1 -1
  25. package/web/_app/immutable/chunks/{Brxvm_b1.js → By4S_gy5.js} +1 -1
  26. package/web/_app/immutable/chunks/{CRIvE37b.js → BymwHy7o.js} +1 -1
  27. package/web/_app/immutable/chunks/{BrqJKzQU.js → CD6xPodA.js} +1 -1
  28. package/web/_app/immutable/chunks/{BXF2XoRC.js → CMeq8te_.js} +1 -1
  29. package/web/_app/immutable/chunks/CODXTnUW.js +523 -0
  30. package/web/_app/immutable/chunks/{CEufJyoj.js → CQd9D8Nc.js} +1 -1
  31. package/web/_app/immutable/chunks/{BrGmYbJT.js → CT_Ry4q8.js} +1 -1
  32. package/web/_app/immutable/chunks/{CQadubS9.js → C_amgxV1.js} +1 -1
  33. package/web/_app/immutable/chunks/{GRrQoDGJ.js → CfL6O_ia.js} +1 -1
  34. package/web/_app/immutable/chunks/{spNKdrPy.js → CjtKPNlv.js} +1 -1
  35. package/web/_app/immutable/chunks/{DxLkKbK2.js → Cl5zRP_n.js} +1 -1
  36. package/web/_app/immutable/chunks/{CETSRtnV.js → CyFLqlvP.js} +1 -1
  37. package/web/_app/immutable/chunks/{Bq5hCPe1.js → Czz9qOh8.js} +1 -1
  38. package/web/_app/immutable/chunks/{BguXRgvc.js → D3-rqfsM.js} +1 -1
  39. package/web/_app/immutable/chunks/{bcQIrid8.js → D5XlJBy8.js} +1 -1
  40. package/web/_app/immutable/chunks/{TuFg0KqZ.js → D5vocsTl.js} +1 -1
  41. package/web/_app/immutable/chunks/{CTscJvp6.js → D95RKsvN.js} +1 -1
  42. package/web/_app/immutable/chunks/{BzI3yqBQ.js → DBC2xjOo.js} +1 -1
  43. package/web/_app/immutable/chunks/DDVefAkE.js +7 -0
  44. package/web/_app/immutable/chunks/{BsLNwr5A.js → DF89zyH-.js} +1 -1
  45. package/web/_app/immutable/chunks/DGmSCGD3.js +1 -0
  46. package/web/_app/immutable/chunks/{DacMdKjW.js → DNjLZcwq.js} +1 -1
  47. package/web/_app/immutable/chunks/{C-dfcq9W.js → DRX-TQzj.js} +1 -1
  48. package/web/_app/immutable/chunks/{BunrC_8P.js → DX4bJJO0.js} +1 -1
  49. package/web/_app/immutable/chunks/{EQVRMnZU.js → DXK7PB4g.js} +1 -1
  50. package/web/_app/immutable/chunks/{mbLqADPr.js → DXTK-eyo.js} +1 -1
  51. package/web/_app/immutable/chunks/{DII7jNnq.js → DaulBP5z.js} +1 -1
  52. package/web/_app/immutable/chunks/{wCTXGvXm.js → DbNETcPs.js} +1 -1
  53. package/web/_app/immutable/chunks/Db_gdTNI.js +1 -0
  54. package/web/_app/immutable/chunks/{CX-xckzu.js → DfCZPwp1.js} +1 -1
  55. package/web/_app/immutable/chunks/{DKNOMI4Y.js → Dh1lBxfx.js} +1 -1
  56. package/web/_app/immutable/chunks/DktGnzDy.js +151 -0
  57. package/web/_app/immutable/chunks/{BgczuZKA.js → DkxkvOjx.js} +1 -1
  58. package/web/_app/immutable/chunks/{0M1khDs2.js → DlXrOzlh.js} +1 -1
  59. package/web/_app/immutable/chunks/{B85D_PlV.js → Dpa4Syyy.js} +1 -1
  60. package/web/_app/immutable/chunks/DshHzxgH.js +1 -0
  61. package/web/_app/immutable/chunks/DuLysVkb.js +1 -0
  62. package/web/_app/immutable/chunks/{CdY1YAit.js → EMo1IeTW.js} +1 -1
  63. package/web/_app/immutable/chunks/F7HCRtmu.js +1 -0
  64. package/web/_app/immutable/chunks/Iqr2CudW.js +3 -0
  65. package/web/_app/immutable/chunks/{CoyQYage.js → RFxekmX-.js} +1 -1
  66. package/web/_app/immutable/chunks/{vk5Wkr31.js → RlApGeew.js} +1 -1
  67. package/web/_app/immutable/chunks/{CLVUDRRk.js → Xm0YTnxV.js} +1 -1
  68. package/web/_app/immutable/chunks/{CZ8wTb_M.js → gPtCGHiy.js} +1 -1
  69. package/web/_app/immutable/chunks/{B3aPQAYq.js → mLFVM17H.js} +1 -1
  70. package/web/_app/immutable/chunks/{D4SKbj9K.js → oWM_kgYq.js} +1 -1
  71. package/web/_app/immutable/chunks/{wj7G6pU5.js → rghE0CVz.js} +1 -1
  72. package/web/_app/immutable/chunks/{B9YLdnR_.js → tUSDgbpy.js} +1 -1
  73. package/web/_app/immutable/chunks/{DTdlqvEb.js → vfDvz3qP.js} +1 -1
  74. package/web/_app/immutable/chunks/{BRKS-CCX.js → whhiiiga.js} +1 -1
  75. package/web/_app/immutable/chunks/wxBqjpRy.js +1 -0
  76. package/web/_app/immutable/entry/app.wp4GfiBT.js +2 -0
  77. package/web/_app/immutable/entry/start.PPXyd7_0.js +1 -0
  78. package/web/_app/immutable/nodes/0.JerS9yOA.js +26 -0
  79. package/web/_app/immutable/nodes/1.C-RxYNx3.js +1 -0
  80. package/web/_app/immutable/nodes/2.Bbkruoyi.js +1 -0
  81. package/web/_app/immutable/nodes/3.BkpGKa9k.js +1 -0
  82. package/web/_app/immutable/nodes/4.Bj25nsxG.js +2 -0
  83. package/web/_app/immutable/nodes/5.D9sfzyBX.js +9 -0
  84. package/web/_app/immutable/nodes/6.Di1re0aR.js +1 -0
  85. package/web/_app/immutable/nodes/7.MWEn-qPC.js +1 -0
  86. package/web/_app/version.json +1 -1
  87. package/web/index.html +18 -17
  88. package/web/kit/chart.js +55 -0
  89. package/web/kit/isorite.css +32 -0
  90. package/web/kit/kit.css +80 -0
  91. package/web/kit/kit.js +543 -0
  92. package/web/kit/licenses/d3.txt +13 -0
  93. package/web/kit/licenses/interval-tree-1d.txt +22 -0
  94. package/web/kit/licenses/isoformat.txt +13 -0
  95. package/web/kit/licenses/katex.txt +21 -0
  96. package/web/kit/licenses/observablehq-plot.txt +13 -0
  97. package/web/kit/licenses/three.txt +21 -0
  98. package/web/kit/math.css +1 -0
  99. package/web/kit/math.js +257 -0
  100. package/web/kit/model.js +4236 -0
  101. package/web/kit/plain.css +16 -0
  102. package/web/_app/immutable/assets/5.TsqJFEFF.css +0 -1
  103. package/web/_app/immutable/assets/NoteEditor.8wWtZK_A.css +0 -1
  104. package/web/_app/immutable/chunks/CBWiKnLz.js +0 -1
  105. package/web/_app/immutable/chunks/CHbKgHy3.js +0 -1
  106. package/web/_app/immutable/chunks/CJ1dhP0p.js +0 -1
  107. package/web/_app/immutable/chunks/CLkY0zVi.js +0 -1
  108. package/web/_app/immutable/chunks/D-aFnd2T.js +0 -1
  109. package/web/_app/immutable/chunks/DRQEUdad.js +0 -1
  110. package/web/_app/immutable/chunks/DZYaqCwi.js +0 -1
  111. package/web/_app/immutable/chunks/D_19S_Iu.js +0 -1
  112. package/web/_app/immutable/chunks/DcgxJ8bF.js +0 -673
  113. package/web/_app/immutable/chunks/Ddgejj-u.js +0 -1
  114. package/web/_app/immutable/chunks/nMarIA7J.js +0 -3
  115. package/web/_app/immutable/chunks/vUsuwDSf.js +0 -6
  116. package/web/_app/immutable/entry/app.CswuY3sh.js +0 -2
  117. package/web/_app/immutable/entry/start.D8ZvSyBD.js +0 -1
  118. package/web/_app/immutable/nodes/0.DBoa3NAQ.js +0 -26
  119. package/web/_app/immutable/nodes/1.DrwFerb8.js +0 -1
  120. package/web/_app/immutable/nodes/2.DPmbppXV.js +0 -1
  121. package/web/_app/immutable/nodes/3.BW7AbIrS.js +0 -1
  122. package/web/_app/immutable/nodes/4.B5HAyMKs.js +0 -2
  123. package/web/_app/immutable/nodes/5.BvScSoCM.js +0 -5
  124. package/web/_app/immutable/nodes/6.Bna6qY2G.js +0 -1
  125. package/web/_app/immutable/nodes/7.BxQaF3lD.js +0 -1
package/dist/main.js CHANGED
@@ -18,7 +18,7 @@ import { Hono } from "hono";
18
18
  import { streamSSE } from "hono/streaming";
19
19
  import { z } from "zod";
20
20
  import { OpenCode } from "@opencode/client";
21
- import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
21
+ import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
22
22
  import { createInterface } from "node:readline/promises";
23
23
  import { createServer } from "node:net";
24
24
  import { isSea } from "node:sea";
@@ -43,6 +43,342 @@ var __exportAll = (all, no_symbols) => {
43
43
  return target;
44
44
  };
45
45
  //#endregion
46
+ //#region packages/kit/src/assemble.ts
47
+ function meta(html, name) {
48
+ for (const m of html.matchAll(/<meta\b[^>]*>/gi)) {
49
+ const tag = m[0];
50
+ if ((tag.match(/\bname\s*=\s*["']([^"']*)["']/i)?.[1])?.toLowerCase() === name) return unescape(tag.match(/\bcontent\s*=\s*"([^"]*)"|\bcontent\s*=\s*'([^']*)'/i)?.slice(1).find((x) => x !== void 0) ?? "");
51
+ }
52
+ return null;
53
+ }
54
+ function manifestOf(html) {
55
+ return {
56
+ title: unescape(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? ""),
57
+ description: meta(html, "description") ?? "",
58
+ author: meta(html, "author") ?? "",
59
+ date: meta(html, "date") ?? "",
60
+ kit: meta(html, "isorite:kit"),
61
+ uses: (meta(html, "isorite:uses") ?? "").split(/[\s,]+/).filter(Boolean)
62
+ };
63
+ }
64
+ /** The artifact with its manifest as given (write_artifact, plan-m23 §3.5): its title, description, author, date, kit
65
+ * and uses set in its head, put in where missing, so they're always right whatever the file said. A file with no
66
+ * head or no document around it is given one. */
67
+ function withManifest(html, m) {
68
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
69
+ let doc = html.trim();
70
+ if (!/<html\b/i.test(doc)) doc = /<head\b/i.test(doc) ? `<!doctype html>\n<html lang="en">\n${doc}\n</html>` : `<!doctype html>\n<html lang="en">\n<head>\n</head>\n<body>\n${doc.replace(/<\/?body\b[^>]*>/gi, "")}\n</body>\n</html>`;
71
+ if (!/<head\b/i.test(doc)) doc = doc.replace(/<html\b[^>]*>/i, (h) => `${h}\n<head>\n</head>`);
72
+ if (!/^<!doctype/i.test(doc)) doc = `<!doctype html>\n${doc}`;
73
+ const metas = [
74
+ ["description", m.description],
75
+ ["author", m.author],
76
+ ["date", m.date],
77
+ ["isorite:kit", m.kit ?? void 0],
78
+ ["isorite:uses", m.uses?.join(" ")]
79
+ ];
80
+ for (const [name, value] of metas) {
81
+ if (value === void 0) continue;
82
+ const tag = `<meta name="${name}" content="${esc(value)}">`, re = new RegExp(`<meta\\b[^>]*\\bname\\s*=\\s*["']${name.replace(":", "\\:")}["'][^>]*>`, "i");
83
+ doc = re.test(doc) ? doc.replace(re, () => tag) : doc.replace(/<head\b[^>]*>/i, (h) => `${h}\n${tag}`);
84
+ }
85
+ if (m.title !== void 0) {
86
+ const t = `<title>${esc(m.title)}</title>`;
87
+ doc = /<title\b[^>]*>[\s\S]*?<\/title>/i.test(doc) ? doc.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, () => t) : doc.replace(/<head\b[^>]*>/i, (h) => `${h}\n${t}`);
88
+ }
89
+ if (!/<meta\b[^>]*charset/i.test(doc)) doc = doc.replace(/<head\b[^>]*>/i, (h) => `${h}\n<meta charset="utf-8">`);
90
+ return doc.endsWith("\n") ? doc : doc + "\n";
91
+ }
92
+ /** The kit's parts an artifact uses beyond the core: Plot with a chart, three.js with 3D. */
93
+ function partsUsed(html) {
94
+ const parts = [];
95
+ if (/<iso-chart[\s>]|\bPlot\.\w/.test(html)) parts.push("chart");
96
+ if (/<iso-model[\s>]|<iso-chart3d[\s>]|\bTHREE\.\w/.test(html)) parts.push("model");
97
+ const text = html.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, "");
98
+ if (/<iso-math[\s>]|\biso\.math\(|\$\$[\s\S]+?\$\$|\\\(|\$(?=\S)[^$\n]+?(?<=\S)\$(?!\d)/.test(text) || /\biso\.math\(/.test(html)) parts.push("math");
99
+ return parts;
100
+ }
101
+ /** The artifact as one document: the CSP, the kit's themes, components and the parts it uses, inlined at the top of
102
+ * its head (any kit <link> or <script src> it wrote is taken out), with its data too for a standalone export. */
103
+ function assemble(html, kit, opts = {}) {
104
+ const look = opts.look ?? "isorite", ground = opts.ground ?? "lapis";
105
+ const body = html.replace(/<link\b[^>]*\bhref\s*=\s*["'](?:\.\/)?kit\/[^"']*["'][^>]*>\s*/gi, "").replace(/<script\b[^>]*\bsrc\s*=\s*["'](?:\.\/)?kit\/[^"']*["'][^>]*>\s*<\/script>\s*/gi, "");
106
+ const data = Object.entries(opts.data ?? {}).map(([p, v]) => typeof v === "string" ? `<script type="text/plain" data-path="${attr(p)}">${safe(v)}<\/script>` : `<script type="application/octet-stream" data-encoding="base64" data-path="${attr(p)}">${base64(v)}<\/script>`);
107
+ const parts = partsUsed(body).map((p) => `${p === "math" ? `<style>\n${safe(kit["math.css"])}\n</style>\n` : ""}<script>\n${safe(kit[`${p}.js`])}\n<\/script>`);
108
+ const head = [
109
+ CSP,
110
+ `<style data-look="isorite">\n${safe(kit["isorite.css"])}\n</style>`,
111
+ `<style data-look="plain">\n${safe(kit["plain.css"])}\n</style>`,
112
+ `<style>\n${safe(kit["kit.css"])}\n</style>`,
113
+ ...data,
114
+ `<script>\n${safe(kit["kit.js"])}\n<\/script>`,
115
+ ...parts
116
+ ].join("\n");
117
+ return (/<head\b[^>]*>/i.test(body) ? body.replace(/<head\b[^>]*>/i, (h) => `${h}\n${head}\n`) : `<!doctype html><html><head><meta charset="utf-8">\n${head}\n</head><body>${body}</body></html>`).replace(/<html\b([^>]*)>/i, (_, a) => `<html${a.replace(/\s+data-(?:look|ground)\s*=\s*("[^"]*"|'[^']*'|\S+)/gi, "")} data-look="${look}" data-ground="${ground}">`);
118
+ }
119
+ function base64(bytes) {
120
+ let s = "";
121
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
122
+ return btoa(s);
123
+ }
124
+ var KIT_FILES, unescape, CSP, safe, attr;
125
+ var init_assemble = __esmMin((() => {
126
+ KIT_FILES = [
127
+ "kit.js",
128
+ "chart.js",
129
+ "model.js",
130
+ "math.js",
131
+ "math.css",
132
+ "isorite.css",
133
+ "plain.css",
134
+ "kit.css"
135
+ ];
136
+ unescape = (s) => s.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
137
+ CSP = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; connect-src 'none'">`;
138
+ safe = (t) => t.replace(/<\/(script|style)/gi, "<\\/$1");
139
+ attr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
140
+ }));
141
+ //#endregion
142
+ //#region packages/kit/src/rules.ts
143
+ /** The files an artifact reads, from its markup and script: data="…", an iso-model's src, iso.data/text/bytes("…"). */
144
+ function readsOf(html) {
145
+ const own = html.replace(/<!--[\s\S]*?-->/g, ""), reads = /* @__PURE__ */ new Set();
146
+ for (const r of own.matchAll(/iso\.(?:data|text|bytes)\(\s*["'`]([^"'`$]+)["'`]/g)) reads.add(r[1]);
147
+ for (const t of own.matchAll(TAG)) {
148
+ const a = attrsOf(t[2]);
149
+ (a.data ?? "").split(/[\s,]+/).filter(Boolean).forEach((f) => reads.add(f));
150
+ if (t[1].toLowerCase() === "iso-model" && a.src) reads.add(a.src);
151
+ }
152
+ return [...reads];
153
+ }
154
+ function checkArtifact(html, ctx = {}) {
155
+ const out = [], m = manifestOf(html);
156
+ const err = (rule, message) => out.push({
157
+ rule,
158
+ level: "error",
159
+ message
160
+ });
161
+ const warn = (rule, message) => out.push({
162
+ rule,
163
+ level: "warning",
164
+ message
165
+ });
166
+ const own = html.replace(/<!--[\s\S]*?-->/g, "");
167
+ if (m.kit === null) err(1, "no <meta name=\"isorite:kit\" content=\"1\">: without it isorite gives it no kit");
168
+ if (!m.title) err(1, "no <title>");
169
+ if (!m.description) err(1, "no <meta name=\"description\" content=\"…\">: one sentence on what it shows");
170
+ const reads = /* @__PURE__ */ new Set();
171
+ for (const r of own.matchAll(/iso\.(?:data|text|bytes)\(\s*["'`]([^"'`$]+)["'`]/g)) reads.add(r[1]);
172
+ const files = (a) => (a.data ?? "").split(/[\s,]+/).filter(Boolean);
173
+ for (const t of own.matchAll(TAG)) {
174
+ const a = attrsOf(t[2]);
175
+ files(a).forEach((f) => reads.add(f));
176
+ if (t[1].toLowerCase() === "iso-model" && a.src) reads.add(a.src);
177
+ }
178
+ for (const r of reads) if (!m.uses.includes(r)) err(2, `it reads ${r}, which isn't in <meta name="isorite:uses">`);
179
+ if (ctx.files) {
180
+ for (const u of m.uses) if (!ctx.files.includes(u)) err(2, `${u} is in isorite:uses but there's no such file beside it${ctx.files.length ? ` (there are: ${ctx.files.join(", ")})` : ""}`);
181
+ }
182
+ if (ctx.uses && [...ctx.uses].sort().join() !== [...m.uses].sort().join()) err(2, `isorite:uses (${m.uses.join(" ") || "none"}) and the uses given (${ctx.uses.join(" ") || "none"}) differ`);
183
+ if (ctx.columns) for (const t of own.matchAll(TAG)) {
184
+ const a = attrsOf(t[2]), fs = files(a), known = fs.map((f) => ctx.columns[f]);
185
+ if (!fs.length || known.some((k) => !k)) continue;
186
+ const have = [...new Set(known.flat()), ...fs.length > 1 ? ["file"] : []];
187
+ for (const k of COLUMN_ATTRS) for (const c of (a[k] ?? "").split(/[\s,]+/).filter(Boolean)) {
188
+ const name = c.replace(/^abs\((.+)\)$/, "$1");
189
+ if (k === "at" && t[1].toLowerCase() !== "iso-chart3d" && !have.includes(name)) continue;
190
+ if (!have.includes(name)) err(3, `<${t[1]} data="${a.data}" ${k}="${a[k]}">: ${a.data} has no column "${name}" (it has ${have.join(", ")})`);
191
+ }
192
+ }
193
+ const code = own.replace(/<meta\b[^>]*>/gi, "").replace(/\btone\s*=\s*"[^"]*"/g, "");
194
+ const colour = code.match(/#[0-9a-fA-F]{3,8}\b(?![-\w])|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(\s*[\d.]/i) ?? code.match(/\b(?:fill|stroke|fillStyle|strokeStyle|color|background(?:-color)?|shadowColor|border(?:-color)?)\s*[:=]\s*["'`]?(?:white|black|red|blue|green|gray|grey|orange|yellow|purple|pink|navy|teal|silver)\b/i);
195
+ if (colour) err(4, `a colour of its own (${colour[0].trim().slice(0, 40)}): use the theme's, var(--lattice) in CSS or iso.tokens().lattice in script`);
196
+ const font = code.match(/font-family\s*:\s*[^;"}]*|\bfont\s*[:=]\s*["'`]?[^;"'`}]*(?<![.\w-])(?:sans-serif|serif|monospace|Arial|Helvetica|system-ui|Georgia|Times|Courier|Inter|Roboto)\b/i);
197
+ if (font && !/var\(--font|\$\{/.test(font[0])) err(4, `a font of its own (${font[0].slice(0, 50)}): use var(--font-ui), var(--font-text) or iso.tokens().ui`);
198
+ if (/border-radius\s*:/i.test(code)) err(4, "border-radius: the theme decides corners");
199
+ const urls = code.replace(/http:\/\/www\.w3\.org\/[\w/.-]+/g, "").match(/\bhttps?:\/\/[^\s"'`)<]+/);
200
+ if (urls) err(5, `a web address (${urls[0].slice(0, 60)}): an artifact reaches nothing outside the file`);
201
+ if (/<script\b[^>]*\bsrc\s*=/i.test(code.replace(/<script\b[^>]*\bsrc\s*=\s*["'](?:\.\/)?kit\/[^"']*["'][^>]*>/gi, ""))) err(5, "a <script src>: everything is in the file, and the kit is given");
202
+ if (/<link\b[^>]*\bhref\s*=\s*["'](?!(?:\.\/)?kit\/)/i.test(code)) err(5, "a <link href>: everything is in the file, and the kit is given");
203
+ if (/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|importScripts)\s*\(|\bimport\s*\(/.test(code)) err(5, "fetch, XMLHttpRequest or import(): read files with iso.data(path)");
204
+ if (html.length > 2e5) err(6, `${Math.round(html.length / 1024)} kB, over 200 kB: keep data in a data file, not in the page`);
205
+ const directive = own.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]*>/g, " ").match(/\b(?:drag(?:ging)? to|click (?:here|to|on)|tap to|scroll to|hover (?:to|over)|use the slider|press (?:the|play)|try (?:dragging|moving|clicking))\b[^.]*/i);
206
+ if (directive) warn(7, `text that explains how to use it ("${directive[0].trim().slice(0, 60)}"): say what a thing is, not what to do with it`);
207
+ return out;
208
+ }
209
+ var COLUMN_ATTRS, TAG, attrsOf;
210
+ var init_rules = __esmMin((() => {
211
+ init_assemble();
212
+ COLUMN_ATTRS = [
213
+ "x",
214
+ "y",
215
+ "z",
216
+ "by",
217
+ "column",
218
+ "columns",
219
+ "at"
220
+ ];
221
+ TAG = /<(iso-[\w-]+)\b([^>]*)>/gi;
222
+ attrsOf = (s) => Object.fromEntries([...s.matchAll(/([\w-]+)\s*=\s*"([^"]*)"/g)].map((m) => [m[1].toLowerCase(), m[2]]));
223
+ }));
224
+ //#endregion
225
+ //#region packages/kit/src/data.ts
226
+ /** One line of delimited text into fields; quotes may hold the delimiter, newlines and doubled quotes. */
227
+ function* records(text, sep) {
228
+ let field = "", row = [], quoted = false;
229
+ for (let i = 0; i < text.length; i++) {
230
+ const c = text[i];
231
+ if (quoted) {
232
+ if (c === "\"") {
233
+ if (text[i + 1] === "\"") {
234
+ field += "\"";
235
+ i++;
236
+ } else quoted = false;
237
+ } else field += c;
238
+ } else if (c === "\"" && field === "") quoted = true;
239
+ else if (c === sep) {
240
+ row.push(field);
241
+ field = "";
242
+ } else if (c === "\n" || c === "\r") {
243
+ if (c === "\r" && text[i + 1] === "\n") i++;
244
+ row.push(field);
245
+ field = "";
246
+ if (row.length > 1 || row[0] !== "") yield row;
247
+ row = [];
248
+ } else field += c;
249
+ }
250
+ row.push(field);
251
+ if (row.length > 1 || row[0] !== "") yield row;
252
+ }
253
+ function parseDelimited(text, sep = ",") {
254
+ const it = records(text.replace(/^/, ""), sep), head = it.next();
255
+ if (head.done) return [];
256
+ const cols = head.value.map((c) => c.trim());
257
+ const out = [];
258
+ for (const r of it) out.push(Object.fromEntries(cols.map((c, i) => [c, value(r[i] ?? "")])));
259
+ return out;
260
+ }
261
+ function parseData(path, text) {
262
+ const ext = path.toLowerCase().split(".").pop();
263
+ if (ext === "json") {
264
+ const j = JSON.parse(text);
265
+ if (Array.isArray(j)) return j;
266
+ const inner = j && typeof j === "object" ? Object.values(j).find(Array.isArray) : null;
267
+ if (inner) return inner;
268
+ throw new Error(`${path}: not an array of rows`);
269
+ }
270
+ if (ext === "jsonl" || ext === "ndjson") return text.split(/\r?\n/).filter((l) => l.trim()).map((l) => JSON.parse(l));
271
+ if (ext === "tsv") return parseDelimited(text, " ");
272
+ if (ext === "csv") return parseDelimited(text, ",");
273
+ throw new Error(`${path}: the kit reads CSV, TSV, JSON and JSON lines`);
274
+ }
275
+ /** Every column any row has, in the order they first appear. */
276
+ function columnsOf(rows) {
277
+ const seen = /* @__PURE__ */ new Set();
278
+ for (const r of rows.slice(0, 1e3)) for (const k of Object.keys(r)) seen.add(k);
279
+ return [...seen];
280
+ }
281
+ /** What read_data tells Iso: enough to build on a file without reading it. */
282
+ function shapeOf(path, rows, head = 5) {
283
+ return {
284
+ path,
285
+ rows: rows.length,
286
+ head: rows.slice(0, head),
287
+ columns: columnsOf(rows).map((name) => {
288
+ const vs = rows.map((r) => r[name]), present = vs.filter((v) => v !== null && v !== void 0 && v !== ""), empty = vs.length - present.length;
289
+ if (!present.length) return {
290
+ name,
291
+ type: "empty",
292
+ empty
293
+ };
294
+ if (present.every((v) => typeof v === "number")) {
295
+ let min = Infinity, max = -Infinity, sum = 0;
296
+ for (const v of present) {
297
+ if (v < min) min = v;
298
+ if (v > max) max = v;
299
+ sum += v;
300
+ }
301
+ return {
302
+ name,
303
+ type: "number",
304
+ min,
305
+ max,
306
+ mean: sum / present.length,
307
+ empty
308
+ };
309
+ }
310
+ if (present.every((v) => typeof v === "boolean")) return {
311
+ name,
312
+ type: "boolean",
313
+ empty
314
+ };
315
+ const distinct = [...new Set(present.map(String))];
316
+ return {
317
+ name,
318
+ type: "text",
319
+ distinct: distinct.length,
320
+ values: distinct.slice(0, 12),
321
+ empty
322
+ };
323
+ })
324
+ };
325
+ }
326
+ /** The shape as text, the way Iso reads it. */
327
+ function describeShape(s) {
328
+ const g = (x) => Number.isInteger(x) ? String(x) : String(+x.toPrecision(4));
329
+ const lines = [`${s.path}: ${s.rows} rows, ${s.columns.length} columns`];
330
+ for (const c of s.columns) {
331
+ const empty = c.empty ? `, ${c.empty} empty` : "";
332
+ if (c.type === "number") lines.push(`- ${c.name}: number, ${g(c.min)} to ${g(c.max)} (mean ${g(c.mean)})${empty}`);
333
+ else if (c.type === "text") lines.push(`- ${c.name}: text, ${c.distinct} distinct: ${c.values.join(", ")}${c.distinct > c.values.length ? ", …" : ""}${empty}`);
334
+ else lines.push(`- ${c.name}: ${c.type}${empty}`);
335
+ }
336
+ const cols = s.columns.map((c) => c.name);
337
+ lines.push("First rows:", cols.join(","), ...s.head.map((r) => cols.map((c) => r[c] ?? "").join(",")));
338
+ return lines.join("\n");
339
+ }
340
+ var NUMBER, value;
341
+ var init_data = __esmMin((() => {
342
+ NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
343
+ value = (s) => s === "" ? null : NUMBER.test(s.trim()) ? +s : s;
344
+ }));
345
+ //#endregion
346
+ //#region packages/kit/src/context.gen.ts
347
+ var REFERENCE, RECIPES;
348
+ var init_context_gen = __esmMin((() => {
349
+ REFERENCE = "# The kit (isorite kit 1)\n\nAn artifact is one HTML file: markup, perhaps a script, and a manifest in its `<head>`. isorite gives it the kit:\nthe look (every colour, font and corner), the elements below, and `window.iso`. Write what it shows and does, never\nhow it looks: no colour, font family or border-radius of your own.\n\n## The file\n\n```html\n<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Title</title>\n<meta name=\"description\" content=\"One sentence: what it shows, and what can be done with it.\">\n<meta name=\"isorite:kit\" content=\"1\">\n<meta name=\"isorite:uses\" content=\"runs.csv other.csv\">\n<style>/* layout only, if needed */</style>\n</head>\n<body>\n<iso-page>\n <header><h1>Title</h1><p>The question it answers, in a sentence or two.</p></header>\n <iso-grid>\n <iso-panel class=\"wide\"><h2>Name <span>a short reading</span></h2> … </iso-panel>\n <iso-panel><h2>…</h2> … </iso-panel>\n </iso-grid>\n</iso-page>\n</body>\n</html>\n```\n\n`write_artifact` fills in the title, description and manifest from its arguments, so they are always right.\n\n## Layout\n\n- `<iso-grid>`: panels in two columns (`style=\"--cols: 3\"` for three); `<iso-panel class=\"wide\">` spans the row.\n- `<iso-panel>`: its first child is `<h2>`; a `<span>` in the `h2` is a soft aside. A `<p>` in a panel is a\n caption: what the panel shows, in a sentence.\n- `<iso-row>`: controls side by side. `<iso-stats>`: big numbers side by side.\n\n## Data, with no script\n\nEvery element with `data=\"file.csv\"` reads it itself. Several files are one table:\n`data=\"euler.csv leapfrog.csv\"` adds a column `file` (`euler`, `leapfrog`) to compare them with `by=\"file\"`.\n`where=\"integrator=leapfrog\"` keeps some rows; `where=\"t<=$t\"` compares with a control's value. `abs(col)` is a\ncolumn's size.\n\n- `<iso-chart data=\"f.csv\" x=\"t\" y=\"energy\" by=\"body\">`: a chart.\n - `type`: `line` (default), `dot`, `bar`, `area`, `step`.\n - `y` may name several columns: `y=\"kinetic potential total\"`, each its own line.\n - `by`: a column whose values are the series.\n - `x-scale`, `y-scale`: `linear` or `log`, or `$name` to follow a control (`y-scale=\"$scale\"`).\n - `x-label`, `y-label`: the axis with its unit (`\"time (s)\"`). `height` in pixels (260).\n - `at=\"t\"`: follows the control `t`. A rule where x is its value; or, when the data has a column `t` and x is\n another column (a path in x and y over time), a dot on each series at the row whose `t` is nearest it.\n- `<iso-table data=\"f.csv\" columns=\"a b c\" limit=\"50\">`: the rows.\n- `<iso-stat data=\"f.csv\" column=\"err\" of=\"max\" label=\"worst error\" tone=\"gold\">`: one number. `of`: `min`, `max`,\n `mean`, `sum`, `count`, `first`, `last`. Tones: `gold`, `blue`, `green`, `rose`. Without `data`, set it from a\n script: `el.value = 0.0023`.\n- `<iso-chart3d data=\"f.csv\" x=\"x\" y=\"y\" z=\"z\" by=\"body\" at=\"t\">`: points or paths in 3D, turned by dragging.\n- `<iso-model src=\"part.glb\">`: a 3D model (glTF, GLB, STL, OBJ), turned by dragging. List it in isorite:uses.\n\n## Text and maths\n\n- A `<p>` in a panel is its caption. Maths is written as in a note: `$E = \\tfrac12 m v^2$` inline, `$$…$$` on a\n line of its own; the kit sets it. `<iso-math>\\frac{a}{b}</iso-math>` is a formula alone.\n- Never name the theme's colours in text (\"the lattice line\"): a chart's legend says which series is which.\n\n## Controls\n\nEach writes its value to `iso.state[name]`; elements that name it (`at=\"t\"`, `$t`) follow it.\n\n- `<iso-slider name=\"dt\" label=\"time step\" min=\"0.001\" max=\"0.05\" step=\"0.001\" value=\"0.01\" digits=\"3\">`\n- `<iso-select name=\"scale\" label=\"scale\" options=\"linear log\">`: one of several.\n- `<iso-toggle name=\"softened\" label=\"softened\" value=\"true\">`: on or off.\n- `<iso-play name=\"t\" min=\"0\" max=\"12\" speed=\"1.5\">`: plays `iso.state.t` from min to max, and pauses.\n- `<iso-note href=\"physics/softening\">softening</iso-note>`: opens a note in isorite.\n\n## Script, when markup isn't enough\n\nFor a simulation or anything computed. Put it at the end of the body.\n\n- `const rows = await iso.data(\"f.csv\")`: the rows as objects, numbers as numbers.\n- `iso.state.name`, `iso.watch((name) => …)`: the shared state; `watch` runs after any change.\n- `iso.loop((dt, t) => …)`: runs every frame (dt in seconds), paused when hidden. Returns a stop function.\n- `<iso-canvas height=\"320\" id=\"sim\">` and `document.getElementById(\"sim\").draw = (ctx, w, h, t) => { … }`: draw on\n a canvas sized to its panel; call `.redraw()` after a change. `t` is the theme: `t.text`, `t.soft`, `t.faint`,\n `t.line`, `t.lattice` (blue), `t.growth` (gold), `t.malachite` (green), `t.rose`, `t.series[i]`, `t.ui` (the font).\n- A chart of what the script computes: `chart.rows = [{ t: 0, energy: -1.3 }, …]` on an `<iso-chart>` with no\n `data`.\n- `iso.format(x)`: a number to read (3 figures, 2.3×10⁻³). `iso.tokens()`: the theme, as `t` above.\n- In CSS: `var(--text)`, `var(--text-soft)`, `var(--text-faint)`, `var(--line)`, `var(--lattice)`, `var(--growth)`,\n `var(--malachite)`, `var(--rose)`, `var(--font-ui)`, `var(--font-text)`.\n\n## Rules (write_artifact checks them, and refuses a file that breaks one)\n\n1. Every file read (`data=\"…\"`, `src=\"…\"`, `iso.data(\"…\")`) is in `uses`, and exists: call `read_data` first.\n2. Every column named is in its file, spelt as `read_data` gives it.\n3. No colour, font family or border-radius of its own: no `#hex`, `rgb(…)`, `white`, `Arial`. Use the tokens.\n4. No network: no `http…` address, no `<script src>`, no `fetch`. The kit is given; everything else is in the file.\n5. Under 200 kB: data stays in data files.\n6. No text that tells how to use it (\"drag to rotate\", \"click here\"): a label says what a thing is.\n\n## What good is\n\nEvery panel answers a question about the data, with a caption saying what to see. Numbers are read out. Axes say\nwhat they measure, with units. Controls change what is drawn at once. Nothing is decoration.\n";
350
+ RECIPES = {
351
+ "dashboard": {
352
+ "about": "a dashboard of one data file: numbers, charts and a table, in markup only",
353
+ "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>The pendulum's period</title>\n<meta name=\"description\" content=\"Forty timed swings at eight lengths: how the period grows with length, how close it comes to 2π√(L/g), and how much the trials scatter.\">\n<meta name=\"isorite:kit\" content=\"1\">\n<meta name=\"isorite:uses\" content=\"pendulum.csv\">\n</head>\n<body>\n<!-- Recipe 1: a dashboard of one data file, in markup only. Change: the file, the numbers, the charts' columns, the\n captions. Every column named here must be one read_data gave. -->\n<iso-page>\n <header>\n <h1>The pendulum's period</h1>\n <p>Does a longer pendulum swing slower, and by as much as theory says?</p>\n </header>\n <iso-grid>\n <iso-panel class=\"wide\">\n <h2>In numbers <span>40 swings</span></h2>\n <iso-stats>\n <iso-stat data=\"pendulum.csv\" column=\"period_s\" of=\"min\" label=\"shortest period (s)\" tone=\"blue\"></iso-stat>\n <iso-stat data=\"pendulum.csv\" column=\"period_s\" of=\"max\" label=\"longest period (s)\" tone=\"gold\"></iso-stat>\n <iso-stat data=\"pendulum.csv\" column=\"temp_c\" of=\"mean\" label=\"mean temperature (°C)\"></iso-stat>\n <iso-stat data=\"pendulum.csv\" column=\"trial\" of=\"count\" label=\"swings timed\"></iso-stat>\n </iso-stats>\n </iso-panel>\n <iso-panel>\n <h2>Period against length</h2>\n <iso-chart data=\"pendulum.csv\" type=\"dot\" x=\"length_m\" y=\"period_s\" x-label=\"length (m)\" y-label=\"period (s)\"></iso-chart>\n <p>The period rises with length, more slowly as the pendulum gets longer: a square root.</p>\n </iso-panel>\n <iso-panel>\n <h2>On log axes <span>slope ½ is √L</span></h2>\n <iso-chart data=\"pendulum.csv\" type=\"dot\" x=\"length_m\" y=\"period_s\" x-scale=\"log\" y-scale=\"log\" x-label=\"length (m)\" y-label=\"period (s)\"></iso-chart>\n <p>A straight line here is a power law; its slope, near one half, is the exponent.</p>\n </iso-panel>\n <iso-panel class=\"wide\">\n <h2>Each trial <span>the scatter is the stopwatch</span></h2>\n <iso-chart data=\"pendulum.csv\" type=\"line\" x=\"length_m\" y=\"period_s\" by=\"trial\" height=\"200\" x-label=\"length (m)\" y-label=\"period (s)\"></iso-chart>\n </iso-panel>\n <iso-panel class=\"wide\">\n <h2>The measurements</h2>\n <iso-table data=\"pendulum.csv\" columns=\"length_m trial period_s temp_c\" limit=\"40\"></iso-table>\n </iso-panel>\n </iso-grid>\n</iso-page>\n</body>\n</html>\n"
354
+ },
355
+ "compare": {
356
+ "about": "two data files with the same columns, compared: together on one chart, and side by side",
357
+ "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>A lid against an open mug</title>\n<meta name=\"description\" content=\"Two mugs of the same coffee cooling for an hour, one with a lid: their temperature together, the gap between them, and where each stands at a chosen minute.\">\n<meta name=\"isorite:kit\" content=\"1\">\n<meta name=\"isorite:uses\" content=\"mug-open.csv mug-lid.csv\">\n</head>\n<body>\n<!-- Recipe 2: comparing two data files with the same columns. data=\"a.csv b.csv\" reads both as one table with a\n column `file` (\"mug-open\", \"mug-lid\"), so by=\"file\" draws one series each and where=\"file=…\" picks one.\n Change: the files, the columns, the captions. -->\n<iso-page>\n <header>\n <h1>A lid against an open mug</h1>\n <p>How much longer does a lid keep coffee hot?</p>\n </header>\n <iso-grid>\n <iso-panel class=\"wide\">\n <h2>At one minute</h2>\n <iso-row>\n <iso-slider name=\"t\" label=\"minute\" min=\"0\" max=\"60\" step=\"2\" value=\"20\" digits=\"0\"></iso-slider>\n <iso-select name=\"scale\" label=\"temperature scale\" options=\"linear log\"></iso-select>\n </iso-row>\n <iso-stats>\n <iso-stat data=\"mug-open.csv\" column=\"temp_c\" of=\"last\" where=\"t_min<=$t\" label=\"open (°C)\" tone=\"blue\"></iso-stat>\n <iso-stat data=\"mug-lid.csv\" column=\"temp_c\" of=\"last\" where=\"t_min<=$t\" label=\"with a lid (°C)\" tone=\"gold\"></iso-stat>\n <iso-stat data=\"mug-open.csv\" column=\"temp_c\" of=\"last\" label=\"open after the hour (°C)\"></iso-stat>\n <iso-stat data=\"mug-lid.csv\" column=\"temp_c\" of=\"last\" label=\"lid after the hour (°C)\"></iso-stat>\n </iso-stats>\n </iso-panel>\n <iso-panel class=\"wide\">\n <h2>Both, cooling</h2>\n <iso-chart data=\"mug-open.csv mug-lid.csv\" x=\"t_min\" y=\"temp_c\" by=\"file\" at=\"t\" y-scale=\"$scale\" x-label=\"time (min)\" y-label=\"temperature (°C)\" height=\"300\"></iso-chart>\n <p>Both fall fast at first and slower as they near the room's 21 °C; the lid halves the early loss.</p>\n </iso-panel>\n <iso-panel>\n <h2>Open</h2>\n <iso-table data=\"mug-open.csv\" limit=\"12\"></iso-table>\n </iso-panel>\n <iso-panel>\n <h2>With a lid</h2>\n <iso-table data=\"mug-lid.csv\" limit=\"12\"></iso-table>\n </iso-panel>\n </iso-grid>\n</iso-page>\n</body>\n</html>\n"
358
+ },
359
+ "simulation": {
360
+ "about": "a simulation with parameters, play and reset, drawn on a canvas, with a live chart (a script)",
361
+ "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>A damped spring</title>\n<meta name=\"description\" content=\"A mass on a spring, integrated live: change the stiffness and the damping and see the motion, the energy draining away, and the period against theory's.\">\n<meta name=\"isorite:kit\" content=\"1\">\n<meta name=\"isorite:uses\" content=\"\">\n<style>\n /* Layout only. */\n .readout { margin-top: 12px; }\n</style>\n</head>\n<body>\n<!-- Recipe 3: a simulation with parameters and play. The parts to change are marked CHANGE: the state (what the\n model is), step() (how it moves), draw() (what's on the canvas), and what's measured. The rest stays. -->\n<iso-page>\n <header>\n <h1>A damped spring</h1>\n <p>How do stiffness and damping shape an oscillation?</p>\n </header>\n <iso-grid>\n <iso-panel class=\"wide\">\n <h2>The model</h2>\n <iso-row>\n <iso-slider name=\"k\" label=\"stiffness k (N/m)\" min=\"1\" max=\"40\" step=\"0.5\" value=\"10\" digits=\"1\"></iso-slider>\n <iso-slider name=\"c\" label=\"damping c (kg/s)\" min=\"0\" max=\"2\" step=\"0.05\" value=\"0.3\" digits=\"2\"></iso-slider>\n </iso-row>\n <iso-row>\n <iso-toggle name=\"playing\" label=\"play\" value=\"true\"></iso-toggle>\n <button type=\"button\" class=\"iso-button\" id=\"reset\">reset</button>\n </iso-row>\n <iso-stats class=\"readout\">\n <iso-stat id=\"time\" label=\"time (s)\"></iso-stat>\n <iso-stat id=\"energy\" label=\"energy left\" tone=\"gold\"></iso-stat>\n <iso-stat id=\"period\" label=\"period, theory (s)\" tone=\"blue\"></iso-stat>\n </iso-stats>\n </iso-panel>\n <iso-panel>\n <h2>The mass</h2>\n <iso-canvas id=\"scene\" height=\"300\"></iso-canvas>\n </iso-panel>\n <iso-panel>\n <h2>Energy <span>a share of the start</span></h2>\n <iso-chart id=\"chart\" x=\"t\" y=\"energy\" x-label=\"time (s)\" y-label=\"energy left\" height=\"300\"></iso-chart>\n <p>With no damping the energy stays; damping drains it, faster the larger c is.</p>\n </iso-panel>\n </iso-grid>\n</iso-page>\n<script>\n // CHANGE: the model's state and its start.\n const m = 1;\n let s, history;\n function reset() { s = { t: 0, x: 1, v: 0 }; history = []; }\n reset();\n const energy = () => 0.5 * m * s.v * s.v + 0.5 * iso.state.k * s.x * s.x;\n let e0 = energy();\n\n // CHANGE: one step of the model (here semi-implicit Euler, small steps).\n function step(dt) {\n const a = (-iso.state.k * s.x - iso.state.c * s.v) / m;\n s.v += a * dt;\n s.x += s.v * dt;\n s.t += dt;\n }\n\n // CHANGE: what's drawn. t is the theme: t.line, t.text, t.soft, t.faint, t.lattice, t.growth, t.ui.\n const scene = document.getElementById(\"scene\");\n scene.draw = (ctx, w, h, t) => {\n const cx = w / 2, y0 = 24, rest = h * 0.5, y = rest + s.x * h * 0.3;\n ctx.strokeStyle = t.line; ctx.lineWidth = 1;\n ctx.beginPath(); ctx.moveTo(cx - 60, y0); ctx.lineTo(cx + 60, y0); ctx.stroke();\n ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(cx - 80, rest); ctx.lineTo(cx + 80, rest); ctx.stroke(); ctx.setLineDash([]);\n // The spring, as a zigzag from the ceiling to the mass.\n ctx.strokeStyle = t.soft; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cx, y0);\n for (let i = 1; i < 16; i++) ctx.lineTo(cx + (i % 2 ? 12 : -12), y0 + (y - 20 - y0) * i / 16);\n ctx.lineTo(cx, y - 20); ctx.stroke();\n ctx.fillStyle = t.lattice; ctx.fillRect(cx - 20, y - 20, 40, 40);\n ctx.fillStyle = t.faint; ctx.font = `11px ${t.ui}`; ctx.fillText(\"rest\", cx + 84, rest + 4);\n };\n\n // Measured, and shown: the readouts and the chart, a few times a second.\n const chart = document.getElementById(\"chart\");\n let since = 0;\n function show() {\n document.getElementById(\"time\").value = s.t.toFixed(1);\n document.getElementById(\"energy\").value = iso.format(energy() / e0);\n document.getElementById(\"period\").value = iso.format(2 * Math.PI * Math.sqrt(m / iso.state.k));\n chart.rows = history;\n }\n\n iso.loop((dt) => {\n if (!iso.state.playing) return;\n for (let i = 0; i < 20; i++) step(dt / 20);\n scene.redraw();\n since += dt;\n if (since > 0.1) { since = 0; history.push({ t: +s.t.toFixed(2), energy: energy() / e0 }); if (history.length > 600) history.shift(); show(); }\n });\n iso.watch((name) => { if (name === \"k\") { e0 = energy(); } show(); scene.redraw(); });\n document.getElementById(\"reset\").addEventListener(\"click\", () => { reset(); e0 = energy(); show(); scene.redraw(); });\n show();\n<\/script>\n</body>\n</html>\n"
362
+ },
363
+ "explorable": {
364
+ "about": "an explorable explanation: inputs, numbers derived from them, and linked views (a script)",
365
+ "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Why a fast wheel turns backwards</title>\n<meta name=\"description\" content=\"Sample a sine too slowly and a slower one appears: choose the signal's frequency and the sampling rate, and see the samples, the wave they seem to come from, and where every frequency lands.\">\n<meta name=\"isorite:kit\" content=\"1\">\n<meta name=\"isorite:uses\" content=\"\">\n</head>\n<body>\n<!-- Recipe 4: an explorable explanation. An input, numbers derived from it, and linked views that all follow the same\n state; captions say what to see. CHANGE: the inputs (sliders), derive() (the numbers), the drawing, and the\n chart's rows. Keep: every view redrawn from iso.state in one place, update(). -->\n<iso-page>\n <header>\n <h1>Why a fast wheel turns backwards</h1>\n <p>Sampled too slowly, a signal is indistinguishable from a slower one. Which one, and when?</p>\n </header>\n <iso-grid>\n <iso-panel class=\"wide\">\n <h2>The signal and the sampling</h2>\n <iso-row>\n <iso-slider name=\"f\" label=\"signal (Hz)\" min=\"0.2\" max=\"12\" step=\"0.1\" value=\"7\" digits=\"1\"></iso-slider>\n <iso-slider name=\"fs\" label=\"samples per second\" min=\"2\" max=\"24\" step=\"1\" value=\"8\" digits=\"0\"></iso-slider>\n </iso-row>\n <iso-stats>\n <iso-stat id=\"nyquist\" label=\"Nyquist limit (Hz)\" tone=\"blue\"></iso-stat>\n <iso-stat id=\"apparent\" label=\"what the samples show (Hz)\" tone=\"gold\"></iso-stat>\n <iso-stat id=\"verdict\" label=\"faithful?\"></iso-stat>\n </iso-stats>\n </iso-panel>\n <iso-panel class=\"wide\">\n <h2>One second <span>the signal, its samples, and the slower wave through them</span></h2>\n <iso-canvas id=\"wave\" height=\"260\"></iso-canvas>\n <p>Gold dots are the samples. Above the Nyquist limit the blue wave, slower, passes through every one of them as\n well as the true signal does: from the samples alone, the two can't be told apart.</p>\n </iso-panel>\n <iso-panel class=\"wide\">\n <h2>Where every frequency lands</h2>\n <iso-chart id=\"fold\" x=\"signal\" y=\"apparent\" at=\"f\" x-label=\"signal (Hz)\" y-label=\"frequency the samples show (Hz)\" height=\"220\"></iso-chart>\n <p>The frequency seen climbs with the signal's up to half the sampling rate, then folds back down:\n $f_\\text{seen} = |f - f_s \\operatorname{round}(f / f_s)|$. See <iso-note href=\"signals/sampling\">sampling</iso-note>.</p>\n </iso-panel>\n </iso-grid>\n</iso-page>\n<script>\n // CHANGE: the numbers derived from the inputs.\n function derive(f, fs) {\n const nyquist = fs / 2;\n const folded = Math.abs(f - fs * Math.round(f / fs));\n return { nyquist, apparent: folded, faithful: f < nyquist };\n }\n\n // CHANGE: the drawing. t is the theme (t.line, t.soft, t.faint, t.lattice, t.growth, t.ui).\n const wave = document.getElementById(\"wave\");\n wave.draw = (ctx, w, h, t) => {\n const { f, fs } = iso.state, d = derive(f, fs), mid = h / 2, amp = h * 0.38;\n const X = (s) => 40 + s * (w - 60), Y = (v) => mid - v * amp;\n ctx.strokeStyle = t.line; ctx.lineWidth = 1;\n ctx.beginPath(); ctx.moveTo(X(0), mid); ctx.lineTo(X(1), mid); ctx.stroke();\n const curve = (freq, colour, width) => {\n ctx.strokeStyle = colour; ctx.lineWidth = width; ctx.beginPath();\n for (let i = 0; i <= 600; i++) { const s = i / 600, v = Math.sin(2 * Math.PI * freq * s); i ? ctx.lineTo(X(s), Y(v)) : ctx.moveTo(X(s), Y(v)); }\n ctx.stroke();\n };\n curve(f, t.soft, 1);\n // The slower wave through the same samples: the signal less the nearest whole number of sampling rates.\n if (!d.faithful) curve(f - fs * Math.round(f / fs), t.lattice, 2);\n ctx.fillStyle = t.growth;\n for (let k = 0; k <= fs; k++) { const s = k / fs; ctx.beginPath(); ctx.arc(X(s), Y(Math.sin(2 * Math.PI * f * s)), 4, 0, 2 * Math.PI); ctx.fill(); }\n ctx.fillStyle = t.faint; ctx.font = `11px ${t.ui}`; ctx.fillText(\"0 s\", X(0) - 8, h - 6); ctx.fillText(\"1 s\", X(1) - 8, h - 6);\n };\n\n // One place redraws every view from the state.\n const fold = document.getElementById(\"fold\");\n function update() {\n const { f, fs } = iso.state, d = derive(f, fs);\n document.getElementById(\"nyquist\").value = iso.format(d.nyquist);\n document.getElementById(\"apparent\").value = iso.format(d.apparent);\n document.getElementById(\"verdict\").value = d.faithful ? \"yes\" : \"no: aliased\";\n // CHANGE: the chart's rows, computed from the state.\n const rows = [];\n for (let s = 0; s <= 12; s += 0.05) rows.push({ signal: +s.toFixed(2), apparent: derive(s, fs).apparent });\n fold.rows = rows;\n wave.redraw();\n }\n iso.watch(update);\n update();\n<\/script>\n</body>\n</html>\n"
366
+ },
367
+ "viewer": {
368
+ "about": "a 3D model's viewer (glTF, GLB, STL, OBJ), with what it is beside it",
369
+ "html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>The corner bracket</title>\n<meta name=\"description\" content=\"The printed corner bracket in 3D, with its dimensions and the notes on how it was designed and tested.\">\n<meta name=\"isorite:kit\" content=\"1\">\n<meta name=\"isorite:uses\" content=\"bracket.stl\">\n</head>\n<body>\n<!-- Recipe 5: a 3D model's viewer, with what it is beside it. iso-model shows glTF, GLB, STL and OBJ; list the file\n in isorite:uses. CHANGE: the model's file, the numbers, the notes it links to. -->\n<iso-page>\n <header>\n <h1>The corner bracket</h1>\n <p>The part as printed: an L of two plates, 6 cm by 5 cm.</p>\n </header>\n <iso-grid style=\"--cols: 3\">\n <iso-panel class=\"wide\">\n <h2>The part <span>bracket.stl</span></h2>\n <iso-model src=\"bracket.stl\" height=\"380\"></iso-model>\n </iso-panel>\n <iso-panel>\n <h2>Size</h2>\n <iso-stats>\n <iso-stat label=\"long arm (mm)\" value=\"60\" tone=\"blue\"></iso-stat>\n <iso-stat label=\"short arm (mm)\" value=\"50\"></iso-stat>\n <iso-stat label=\"thickness (mm)\" value=\"10\"></iso-stat>\n </iso-stats>\n </iso-panel>\n <iso-panel>\n <h2>Printed</h2>\n <iso-stats>\n <iso-stat label=\"infill\" value=\"40%\"></iso-stat>\n <iso-stat label=\"mass (g)\" value=\"31\" tone=\"gold\"></iso-stat>\n </iso-stats>\n </iso-panel>\n <iso-panel>\n <h2>Notes</h2>\n <p><iso-note href=\"design/bracket-loads\">The loads it takes</iso-note>, and\n <iso-note href=\"design/print-settings\">how it was printed</iso-note>.</p>\n </iso-panel>\n </iso-grid>\n</iso-page>\n</body>\n</html>\n"
370
+ }
371
+ };
372
+ }));
373
+ //#endregion
374
+ //#region packages/kit/src/index.ts
375
+ var init_src$2 = __esmMin((() => {
376
+ init_assemble();
377
+ init_rules();
378
+ init_data();
379
+ init_context_gen();
380
+ }));
381
+ //#endregion
46
382
  //#region packages/core/src/text.ts
47
383
  /** Compare by code point, as Python sorts strings. */
48
384
  function cmp(a, b) {
@@ -142,7 +478,7 @@ function normpath(p) {
142
478
  }
143
479
  return "/".repeat(initial) + out.join("/") || ".";
144
480
  }
145
- /** SHA-256 as hex, synchronous and dependency-free, so the core runs anywhere. */
481
+ /** SHA-256 as hex, synchronous and dependency-free, so the core runs anywhere: of text (as UTF-8), or of bytes. */
146
482
  function sha256(message) {
147
483
  const K = new Uint32Array([
148
484
  1116352408,
@@ -210,7 +546,7 @@ function sha256(message) {
210
546
  3204031479,
211
547
  3329325298
212
548
  ]);
213
- const data = new TextEncoder().encode(message);
549
+ const data = typeof message === "string" ? new TextEncoder().encode(message) : message;
214
550
  const len = data.length;
215
551
  const padded = new Uint8Array(len + 9 + 63 >> 6 << 6);
216
552
  padded.set(data);
@@ -377,7 +713,7 @@ function linkTexts(body) {
377
713
  return out;
378
714
  }
379
715
  /** Every link and image in a body, in order: where it points, its title, and whether it is an image
380
- * (`![…](…)`, which for an artifact or a picture means "shown here", an embed). */
716
+ * (`![…](…)`, which for an artifact or a picture means "shown here", an embed), with an image's alt text. */
381
717
  function citeRefs(body) {
382
718
  const { tokens } = parse$1(body);
383
719
  const out = [];
@@ -391,7 +727,8 @@ function citeRefs(body) {
391
727
  else if (c.type === "image") out.push({
392
728
  href: c.attrGet("src") ?? "",
393
729
  title: c.attrGet("title"),
394
- embed: true
730
+ embed: true,
731
+ alt: c.content
395
732
  });
396
733
  }
397
734
  return out;
@@ -446,6 +783,94 @@ var init_markdown = __esmMin((() => {
446
783
  SPACES = /[\s_]+/gu;
447
784
  }));
448
785
  //#endregion
786
+ //#region packages/core/src/picture.ts
787
+ /** Width and height in pixels, or null. */
788
+ function pictureSize(bytes, type) {
789
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), n = bytes.length;
790
+ const at = (i, s) => [...s].every((c, k) => bytes[i + k] === c.charCodeAt(0));
791
+ const ok = (w, h) => w > 0 && h > 0 ? {
792
+ width: w,
793
+ height: h
794
+ } : null;
795
+ try {
796
+ if (type === "image/png" && n >= 24 && at(12, "IHDR")) return ok(v.getUint32(16), v.getUint32(20));
797
+ if (type === "image/gif" && n >= 10 && at(0, "GIF")) return ok(v.getUint16(6, true), v.getUint16(8, true));
798
+ if (type === "image/jpeg") {
799
+ for (let i = 2; i + 9 < n;) {
800
+ if (bytes[i] !== 255) return null;
801
+ const m = bytes[i + 1];
802
+ if (m >= 192 && m <= 207 && m !== 196 && m !== 200 && m !== 204) return ok(v.getUint16(i + 7), v.getUint16(i + 5));
803
+ i += 2 + v.getUint16(i + 2);
804
+ }
805
+ return null;
806
+ }
807
+ if (type === "image/webp" && n >= 30 && at(0, "RIFF") && at(8, "WEBP")) {
808
+ if (at(12, "VP8 ")) return ok(v.getUint16(26, true) & 16383, v.getUint16(28, true) & 16383);
809
+ if (at(12, "VP8L")) {
810
+ const b = v.getUint32(21, true);
811
+ return ok((b & 16383) + 1, (b >> 14 & 16383) + 1);
812
+ }
813
+ if (at(12, "VP8X")) return ok(1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16), 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16));
814
+ return null;
815
+ }
816
+ if (type === "image/svg+xml") {
817
+ const head = new TextDecoder().decode(bytes.subarray(0, 4096)), tag = /<svg\b[^>]*>/i.exec(head)?.[0] ?? "";
818
+ const attr = (name) => new RegExp(`\\s${name}\\s*=\\s*["']\\s*([\\d.]+)\\s*(px)?\\s*["']`, "i").exec(tag);
819
+ const w = attr("width"), h = attr("height");
820
+ if (w && h) return ok(Math.round(Number(w[1])), Math.round(Number(h[1])));
821
+ const box = /\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(tag);
822
+ return box ? ok(Math.round(Number(box[1])), Math.round(Number(box[2]))) : null;
823
+ }
824
+ } catch {}
825
+ return null;
826
+ }
827
+ var TYPES$1, pictureType, OTHER, fileType;
828
+ var init_picture = __esmMin((() => {
829
+ TYPES$1 = {
830
+ png: "image/png",
831
+ jpg: "image/jpeg",
832
+ jpeg: "image/jpeg",
833
+ gif: "image/gif",
834
+ svg: "image/svg+xml",
835
+ webp: "image/webp",
836
+ avif: "image/avif"
837
+ };
838
+ pictureType = (path) => {
839
+ const dot = path.lastIndexOf(".");
840
+ return dot < 0 || path.lastIndexOf("/") > dot ? null : TYPES$1[path.slice(dot + 1).toLowerCase()] ?? null;
841
+ };
842
+ OTHER = {
843
+ html: "text/html",
844
+ htm: "text/html",
845
+ csv: "text/csv",
846
+ tsv: "text/tab-separated-values",
847
+ json: "application/json",
848
+ jsonl: "application/jsonl",
849
+ ndjson: "application/x-ndjson",
850
+ txt: "text/plain",
851
+ tex: "text/plain",
852
+ pdf: "application/pdf",
853
+ glb: "model/gltf-binary",
854
+ gltf: "model/gltf+json",
855
+ stl: "model/stl",
856
+ obj: "model/obj",
857
+ zip: "application/zip",
858
+ parquet: "application/vnd.apache.parquet",
859
+ arrow: "application/vnd.apache.arrow.file",
860
+ npy: "application/octet-stream",
861
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
862
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
863
+ epub: "application/epub+zip",
864
+ odt: "application/vnd.oasis.opendocument.text"
865
+ };
866
+ fileType = (path) => {
867
+ const dot = path.lastIndexOf(".");
868
+ if (dot < 0 || path.lastIndexOf("/") > dot) return null;
869
+ const ext = path.slice(dot + 1).toLowerCase();
870
+ return ext === "md" ? null : pictureType(path) ?? OTHER[ext] ?? "application/octet-stream";
871
+ };
872
+ }));
873
+ //#endregion
449
874
  //#region packages/core/src/kinds.ts
450
875
  function kindOf(type) {
451
876
  const t = type.trim().toLowerCase();
@@ -518,11 +943,25 @@ function outwardLinks(body) {
518
943
  }
519
944
  seen.add(href);
520
945
  }
946
+ for (const { href, embed, alt } of citeRefs(body)) {
947
+ if (!embed || seen.has(href)) continue;
948
+ const c = codeTarget(href);
949
+ if (!c || !pictureType(c.path)) continue;
950
+ out.push({
951
+ kind: "code",
952
+ href,
953
+ text: alt || c.path.split("/").at(-1),
954
+ path: c.path,
955
+ picture: true
956
+ });
957
+ seen.add(href);
958
+ }
521
959
  return out;
522
960
  }
523
961
  var ABOUT, DONE, EMBED;
524
962
  var init_kinds = __esmMin((() => {
525
963
  init_markdown();
964
+ init_picture();
526
965
  init_text();
527
966
  ABOUT = {
528
967
  question: "about",
@@ -540,16 +979,24 @@ var init_kinds = __esmMin((() => {
540
979
  function comparePaths(a, b) {
541
980
  return cmpTuple(a.split("/"), b.split("/"));
542
981
  }
543
- var AGENTS_FILE, ARTIFACT, IMAGE, citeKind, isEntry, Concept, Directory, SCHEME, LOG_DATE, Bundle, oneLine, escape;
982
+ var AGENTS_FILE, KINDS$2, citeKind, isEntry, Concept, Directory, SCHEME, LOG_DATE, Bundle, oneLine, escape;
544
983
  var init_bundle = __esmMin((() => {
545
984
  init_frontmatter();
546
985
  init_markdown();
547
986
  init_kinds();
548
987
  init_text();
549
988
  AGENTS_FILE = "AGENTS.md";
550
- ARTIFACT = /\.html?$/i;
551
- IMAGE = /\.(png|jpe?g|gif|svg|webp|avif)$/i;
552
- citeKind = (path) => ARTIFACT.test(path) ? "artifact" : IMAGE.test(path) ? "image" : null;
989
+ KINDS$2 = [
990
+ ["artifact", /\.html?$/i],
991
+ ["image", /\.(png|jpe?g|gif|svg|webp|avif)$/i],
992
+ ["data", /\.(csv|tsv|json|jsonl|ndjson|parquet|arrow|feather|npy|npz|xlsx|xls|h5|hdf5|nc)$/i],
993
+ ["document", /\.(pdf|docx?|odt|rtf|epub|pptx?|odp|txt|tex)$/i]
994
+ ];
995
+ citeKind = (path) => {
996
+ const name = basename$1(path);
997
+ if (/\.md$/i.test(name) || !/\.[^.]+$/.test(name.replace(/^\./, ""))) return null;
998
+ return KINDS$2.find(([, re]) => re.test(name))?.[0] ?? "misc";
999
+ };
553
1000
  isEntry = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
554
1001
  Concept = class {
555
1002
  id;
@@ -725,7 +1172,7 @@ var init_bundle = __esmMin((() => {
725
1172
  concept.links = links;
726
1173
  concept.outward = outwardLinks(concept.body);
727
1174
  for (const link of links) if (link.broken) this.issue(concept.path, "warning", "broken-link", `broken link to ${link.target}`);
728
- for (const { href, title, embed } of citeRefs(concept.body)) {
1175
+ for (const { href, title, embed, alt } of citeRefs(concept.body)) {
729
1176
  const path = this.filePath(concept, href), kind = path === null ? null : citeKind(path);
730
1177
  if (path === null || kind === null) continue;
731
1178
  const t = strip((title ?? "").toLowerCase());
@@ -735,10 +1182,11 @@ var init_bundle = __esmMin((() => {
735
1182
  embed,
736
1183
  broken: !this.files.has(path),
737
1184
  rel: rating(title),
738
- align: t === "left" ? "left" : t === "center" || t === "centre" ? "center" : null
1185
+ align: t === "left" ? "left" : t === "center" || t === "centre" ? "center" : null,
1186
+ ...embed && alt ? { alt } : {}
739
1187
  };
740
1188
  concept.cites.push(cite);
741
- if (cite.broken) this.issue(concept.path, "warning", "broken-link", `broken link to ${path}`);
1189
+ if (cite.broken) this.issue(concept.path, "warning", "broken-link", kind === "image" ? `picture not in the notes: ${path}` : `broken link to ${path}`);
742
1190
  }
743
1191
  }
744
1192
  const byName = /* @__PURE__ */ new Map();
@@ -1809,13 +2257,60 @@ function mapModel(base) {
1809
2257
  s
1810
2258
  ]);
1811
2259
  }
2260
+ const holdsFiles = /* @__PURE__ */ new Set();
2261
+ const parentOf = (p) => p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
2262
+ for (const f of base.files) holdsFiles.add(parentOf(f));
2263
+ const pictureFolder = (d) => {
2264
+ const dd = base.directories.get(d);
2265
+ const here = [...base.files].filter((f) => parentOf(f) === d);
2266
+ return !!d && (!dd || !dd.concepts.length && !dd.children.length) && here.length > 0 && here.every((f) => citeKind(f) === "image");
2267
+ };
2268
+ const refOf = (f) => citeKind(f) === "image" && pictureFolder(parentOf(f)) ? parentOf(f) + "/" : f;
2269
+ const fileEdges = /* @__PURE__ */ new Map();
2270
+ for (const c of base.concepts.values()) for (const x of c.cites) {
2271
+ if (x.broken || !base.files.has(x.target)) continue;
2272
+ const ref = refOf(x.target);
2273
+ if (!fileEdges.has(c.id)) fileEdges.set(c.id, /* @__PURE__ */ new Set());
2274
+ fileEdges.get(c.id).add(ref);
2275
+ }
2276
+ const rankOf = /* @__PURE__ */ new Map();
2277
+ for (const [from, refs] of fileEdges) for (const ref of refs) {
2278
+ edges.push([
2279
+ from,
2280
+ ref,
2281
+ STRENGTH.uses
2282
+ ]);
2283
+ rankOf.set(ref, Math.max(rankOf.get(ref) ?? 0, order.get(from)?.depth ?? 0));
2284
+ }
2285
+ const filesIn = /* @__PURE__ */ new Map();
2286
+ for (const f of [...base.files].sort()) {
2287
+ const of = citeKind(f), ref = of ? refOf(f) : null;
2288
+ if (!of || !ref) continue;
2289
+ const at = ref.endsWith("/") ? parentOf(ref.slice(0, -1)) : parentOf(f);
2290
+ const list = filesIn.get(at) ?? [];
2291
+ if (!list.some((n) => n.ref === ref)) list.push({
2292
+ kind: "concept",
2293
+ id: "f:" + ref,
2294
+ ref,
2295
+ weight: 1,
2296
+ rank: rankOf.get(ref) ?? 0,
2297
+ of
2298
+ });
2299
+ filesIn.set(at, list);
2300
+ }
1812
2301
  const dir = (id) => {
1813
2302
  const d = base.directories.get(id);
1814
- return {
2303
+ if (id && pictureFolder(id)) return null;
2304
+ const children = [
2305
+ ...[...d.children].sort().map(dir).filter((c) => c !== null),
2306
+ ...[...d.concepts].sort().map(note),
2307
+ ...filesIn.get(id) ?? []
2308
+ ];
2309
+ return id && !children.length && (holdsFiles.has(id) || d.children.length) ? null : {
1815
2310
  kind: "dir",
1816
2311
  id: "d:" + id,
1817
2312
  ref: id,
1818
- children: [...[...d.children].sort().map(dir), ...[...d.concepts].sort().map(note)]
2313
+ children
1819
2314
  };
1820
2315
  };
1821
2316
  return {
@@ -1824,12 +2319,14 @@ function mapModel(base) {
1824
2319
  };
1825
2320
  }
1826
2321
  var init_map = __esmMin((() => {
2322
+ init_bundle();
1827
2323
  init_graph();
1828
2324
  }));
1829
2325
  //#endregion
1830
2326
  //#region packages/core/src/index.ts
1831
2327
  var init_src$1 = __esmMin((() => {
1832
2328
  init_bundle();
2329
+ init_picture();
1833
2330
  init_frontmatter();
1834
2331
  init_source();
1835
2332
  init_markdown();
@@ -1847,6 +2344,7 @@ var init_src$1 = __esmMin((() => {
1847
2344
  }));
1848
2345
  //#endregion
1849
2346
  //#region packages/core/src/node.ts
2347
+ init_src$2();
1850
2348
  init_src$1();
1851
2349
  init_bundle();
1852
2350
  init_text();
@@ -1884,8 +2382,9 @@ function loadBundle(root) {
1884
2382
  }
1885
2383
  //#endregion
1886
2384
  //#region packages/engine/src/api.ts
1887
- var EngineError, Conflict;
2385
+ var MAX_FILE_BYTES, EngineError, Conflict;
1888
2386
  var init_api = __esmMin((() => {
2387
+ MAX_FILE_BYTES = 2e7;
1889
2388
  EngineError = class extends Error {
1890
2389
  status;
1891
2390
  constructor(message, status = 400) {
@@ -2032,7 +2531,8 @@ async function completeOnce(http, p, key, call, heard) {
2032
2531
  ...call.tools?.length ? {
2033
2532
  tools: call.tools,
2034
2533
  tool_choice: call.toolChoice ?? "auto"
2035
- } : {}
2534
+ } : {},
2535
+ ...call.effort && p.name === OPENROUTER.name ? { reasoning: { effort: call.effort } } : {}
2036
2536
  });
2037
2537
  let res;
2038
2538
  try {
@@ -2290,13 +2790,14 @@ async function rounds(o) {
2290
2790
  let firstIn = 0;
2291
2791
  for (let round = 0, calls = 1;; round++, calls++) {
2292
2792
  let first = true;
2293
- const ask = (maxTokens) => complete(o.http, o.provider, o.key, {
2793
+ const ask = (maxTokens, effort) => complete(o.http, o.provider, o.key, {
2294
2794
  model,
2295
2795
  messages,
2296
2796
  maxTokens,
2297
2797
  tools: most ? o.lookup.defs() : [],
2298
2798
  toolChoice: round < most ? "auto" : "none",
2299
2799
  signal: o.signal,
2800
+ ...effort ? { effort } : {},
2300
2801
  onText: (piece) => {
2301
2802
  o.onText(piece, first && round > 0);
2302
2803
  first = false;
@@ -2313,10 +2814,14 @@ async function rounds(o) {
2313
2814
  add(r);
2314
2815
  if (round === 0) firstIn = r.usage.tokensIn;
2315
2816
  if (empty(r)) {
2316
- r = await ask(o.maxTokens * 3);
2817
+ r = await ask(o.maxTokens * 3, "low");
2317
2818
  add(r);
2318
2819
  if (empty(r)) throw new ModelError(`${model} used its whole output budget (${Math.max(o.maxTokens * 3, o.provider.maxTokens ?? 0)} tokens) thinking, and wrote nothing. Try a lower tier, or raise max_tokens under [provider] in config.toml.`, 502, { kind: "cut" });
2319
2820
  }
2821
+ if (!r.text.trim() && !r.calls.length && !r.cut) {
2822
+ r = await ask(o.maxTokens);
2823
+ add(r);
2824
+ }
2320
2825
  if (!r.calls.length || round >= most) return {
2321
2826
  text: r.text,
2322
2827
  usage,
@@ -2699,6 +3204,9 @@ something at one place in the note they have open.`;
2699
3204
  is drawn as the diagram. Draw one where a picture of how things stand to
2700
3205
  each other helps, or when asked; not by habit. Maths in a label goes
2701
3206
  between $$ and $$.
3207
+ - A picture is shown as ![what it shows](its/path.png). You can't see it: what
3208
+ you know of it is the words in the brackets. Keep a picture's line as it is
3209
+ when you rewrite around it, and never make up a picture's path.
2702
3210
  - Ordinary Markdown otherwise. No HTML.`;
2703
3211
  FORMAT_RULES = FORMAT;
2704
3212
  LOOKUP = (rounds, meaning) => `# Looking things up
@@ -2807,7 +3315,7 @@ var init_lookup = __esmMin((() => {
2807
3315
  ...str$1,
2808
3316
  description: "The note's id or path: basics/entropy or /basics/entropy.md."
2809
3317
  } }, ["id"]),
2810
- fn$1("read_note", "Read one section of a note, by its heading, or the whole note when no section is named. Prefer a section.", {
3318
+ fn$1("read_note", "Read one section of a note, by its heading, or the whole note when no section is named. Prefer a section. A picture in it is ![what it shows](path): you have its words, not the picture.", {
2811
3319
  id: str$1,
2812
3320
  section: {
2813
3321
  ...str$1,
@@ -3229,11 +3737,12 @@ function turnMessages(a) {
3229
3737
  PRACTICE,
3230
3738
  LEARNING,
3231
3739
  MODE_RULES[a.mode],
3740
+ ...a.mode === "write" ? [ARTIFACTS] : [],
3232
3741
  ASKING(a.mode === "write"),
3233
3742
  ...checking ? [CHECKING] : [],
3234
3743
  ...understood.length ? [UNDERSTANDING] : [],
3235
3744
  FORMAT_RULES,
3236
- LOOKING(ROUNDS[a.tier], a.meaning, a.lookup)
3745
+ LOOKING(a.mode === "write" ? Math.max(ROUNDS[a.tier], 6) : ROUNDS[a.tier], a.meaning, a.lookup)
3237
3746
  ].join("\n\n");
3238
3747
  const briefed = a.briefings?.length ? `# Earlier in this conversation, summed up\n\n${a.briefings.join("\n\n---\n\n")}` : "";
3239
3748
  const talk = a.earlier.map((t) => turnExchange(t).text).join("\n\n---\n\n");
@@ -3312,7 +3821,7 @@ function turnMessages(a) {
3312
3821
  ]
3313
3822
  };
3314
3823
  }
3315
- var MODES, ITEMS, CONVERSATIONS_DIR, conversationPath, WHO, ISORITE, PRACTICE, LEARNING, ASK, WRITE, UNDERSTANDING, ASKING, CHECKING, MODE_RULES, LOOKING, COMPACT$1, str, fn, POINT_TO_NOTE, EDIT_NOTE$1, WRITE_NOTE$1, MOVE_NOTE, DELETE_NOTE, WRITE_UNDERSTANDING, ASK_QUESTION, OFFER_TEXT, MODE_TOOLS;
3824
+ var MODES, ITEMS, CONVERSATIONS_DIR, conversationPath, WHO, ISORITE, PRACTICE, LEARNING, ASK, WRITE, UNDERSTANDING, ARTIFACTS, ASKING, CHECKING, MODE_RULES, LOOKING, COMPACT$1, str, fn, POINT_TO_NOTE, EDIT_NOTE$1, WRITE_NOTE$1, MOVE_NOTE, DELETE_NOTE, WRITE_UNDERSTANDING, ASK_QUESTION, READ_DATA, ARTIFACT_RECIPE, WRITE_ARTIFACT, READ_ARTIFACT, OFFER_TEXT, MODE_TOOLS;
3316
3825
  var init_turn = __esmMin((() => {
3317
3826
  init_iso();
3318
3827
  init_loop();
@@ -3350,6 +3859,11 @@ what you change is shown to them there as you do it.`;
3350
3859
  problem note is practice that \`uses\` notes. Theirs alone to mark:
3351
3860
  \`resolved\` or \`solved\`, with the day. \`[[!name]]\` alone on a line
3352
3861
  embeds one in a note.
3862
+ - A problem of several steps is several problem notes, each linked to the one
3863
+ before it as "requires", in a folder of their own when they belong
3864
+ together. A problem has no steps inside it.
3865
+ - Files beside the notes that are not notes (data, documents, pictures,
3866
+ artifacts) are linked as any note is: [runs](/simulation/runs.csv).
3353
3867
  - Every change you make is marked as yours, in the note's history, and they
3354
3868
  can undo it.
3355
3869
 
@@ -3435,6 +3949,22 @@ far, from its Understanding note.
3435
3949
  linked: what they wrote in a note, a question of theirs open or resolved,
3436
3950
  a problem solved or not. Say nothing the evidence does not show.
3437
3951
  - Never invent a note, a link or a quotation.`;
3952
+ ARTIFACTS = `# Artifacts
3953
+
3954
+ An artifact is an HTML page beside their notes that isorite shows working: a
3955
+ dashboard of their data, a comparison, a simulation, an explorable
3956
+ explanation, a 3D model's viewer. Make one when they ask for something to see
3957
+ or try; not for what an answer or a note says as well.
3958
+
3959
+ 1. artifact_recipe with the recipe nearest what they asked: dashboard,
3960
+ compare, simulation, explorable or viewer. It gives a complete working
3961
+ artifact and the kit's reference. Start from the recipe and change what it
3962
+ marks; keep the rest.
3963
+ 2. read_data for every data file it will read. Use the columns as given.
3964
+ 3. write_artifact with the whole file. It is checked and run. If it reports a
3965
+ problem, fix that and write the whole file again.
3966
+ 4. Say in a sentence what it shows, linked: [what it shows](/folder/name.html).
3967
+ Link it from the note it belongs to if they want it there.`;
3438
3968
  ASKING = (write) => `# Asking back, and their own answers
3439
3969
 
3440
3970
  - Where a question to them would do more than an answer (what is unclear in
@@ -3582,6 +4112,50 @@ was not said.`;
3582
4112
  "text",
3583
4113
  "about"
3584
4114
  ]);
4115
+ READ_DATA = fn("read_data", "A data file's shape: its columns with their types and ranges, the row count and the first rows. Read it before writing anything that uses the file, so the column names are real.", { path: {
4116
+ ...str,
4117
+ description: "The file's path in the base: folder/name.csv."
4118
+ } }, ["path"]);
4119
+ ARTIFACT_RECIPE = fn("artifact_recipe", "A recipe for an artifact: a complete working one to start from, the kit's reference, and the rules write_artifact checks. dashboard: numbers, charts and a table of one data file. compare: two data files side by side. simulation: a model with parameters and play. explorable: inputs, derived numbers and linked views. viewer: a 3D model.", { name: {
4120
+ type: "string",
4121
+ enum: [
4122
+ "dashboard",
4123
+ "compare",
4124
+ "simulation",
4125
+ "explorable",
4126
+ "viewer"
4127
+ ]
4128
+ } }, ["name"]);
4129
+ WRITE_ARTIFACT = fn("write_artifact", "Write an artifact: a whole HTML file on the kit. The title, description and uses given here are written into it. It is checked, and run where it can be; the result says what to fix. Nothing is written while a rule is broken.", {
4130
+ path: {
4131
+ ...str,
4132
+ description: "Where: folder/name.html, beside the notes it belongs to."
4133
+ },
4134
+ title: str,
4135
+ description: {
4136
+ ...str,
4137
+ description: "One sentence: what it shows, and what can be done with it."
4138
+ },
4139
+ uses: {
4140
+ type: "array",
4141
+ items: str,
4142
+ description: "The data or model files it reads, relative to it (runs.csv). Those its markup and script name are added."
4143
+ },
4144
+ html: {
4145
+ ...str,
4146
+ description: "The whole file."
4147
+ }
4148
+ }, [
4149
+ "path",
4150
+ "title",
4151
+ "description",
4152
+ "uses",
4153
+ "html"
4154
+ ]);
4155
+ READ_ARTIFACT = fn("read_artifact", "An artifact's whole file, to change it and write it again.", { path: {
4156
+ ...str,
4157
+ description: "folder/name.html"
4158
+ } }, ["path"]);
3585
4159
  OFFER_TEXT = fn("offer_text", "Offer them their own answers, lightly joined, to insert at the end of a note as their writing. Only their words.", {
3586
4160
  id: {
3587
4161
  ...str,
@@ -3596,7 +4170,8 @@ was not said.`;
3596
4170
  ask: [
3597
4171
  POINT_TO_NOTE,
3598
4172
  ASK_QUESTION,
3599
- OFFER_TEXT
4173
+ OFFER_TEXT,
4174
+ READ_DATA
3600
4175
  ],
3601
4176
  write: [
3602
4177
  EDIT_NOTE$1,
@@ -3605,7 +4180,11 @@ was not said.`;
3605
4180
  DELETE_NOTE,
3606
4181
  POINT_TO_NOTE,
3607
4182
  ASK_QUESTION,
3608
- OFFER_TEXT
4183
+ OFFER_TEXT,
4184
+ READ_DATA,
4185
+ ARTIFACT_RECIPE,
4186
+ WRITE_ARTIFACT,
4187
+ READ_ARTIFACT
3609
4188
  ]
3610
4189
  };
3611
4190
  }));
@@ -3810,6 +4389,11 @@ function checkNoteId(id) {
3810
4389
  if (!id || id.length > 400 || parts.some((p) => !p || BAD_PART.test(p))) throw new EngineError(`not a note's id: ${JSON.stringify(id)}`);
3811
4390
  return id;
3812
4391
  }
4392
+ /** A file's path in the base as Iso gives one: no leading slash, no "..", nothing hidden. */
4393
+ function filePathOf(raw) {
4394
+ const p = String(raw ?? "").trim().replace(/^\/+/, "");
4395
+ return p && !p.split("/").some((x) => !x || x === "." || x === ".." || x.startsWith(".")) ? p : null;
4396
+ }
3813
4397
  async function walk(files, root) {
3814
4398
  const out = [];
3815
4399
  const at = (path) => root && path ? `${root}/${path}` : root || path;
@@ -3847,7 +4431,7 @@ function keepDone(before, next) {
3847
4431
  return next;
3848
4432
  }
3849
4433
  }
3850
- var SIGN_IN_MS, fromBase, REPLY_TOKENS, NOTE_TOKENS, DRAFT_PREFIX, DRAFT_DIR, isDraftId, bare, inBase, BAD_PART, TURN_ID, escapeRe, MAX_SENT, BRIEF, NOTE_ARG, MARKED, EDIT_NOTE, WRITE_NOTE, Engine;
4434
+ var SIGN_IN_MS, fromBase, REPLY_TOKENS, NOTE_TOKENS, ARTIFACT_TOKENS, DRAFT_PREFIX, DRAFT_DIR, isDraftId, bare, inBase, BAD_PART, TURN_ID, escapeRe, MAX_SENT, BRIEF, NOTE_ARG, MARKED, EDIT_NOTE, WRITE_NOTE, Engine;
3851
4435
  var init_engine = __esmMin((() => {
3852
4436
  init_src$1();
3853
4437
  init_api();
@@ -3863,6 +4447,7 @@ var init_engine = __esmMin((() => {
3863
4447
  fromBase = (base, text) => `(From the base ${base}: name its notes ${base}:<id>, or give base "${base}".)\n${text}`;
3864
4448
  REPLY_TOKENS = 2500;
3865
4449
  NOTE_TOKENS = 6e3;
4450
+ ARTIFACT_TOKENS = 32e3;
3866
4451
  DRAFT_PREFIX = "@draft/";
3867
4452
  DRAFT_DIR = "drafts";
3868
4453
  isDraftId = (id) => id.startsWith(DRAFT_PREFIX);
@@ -4030,23 +4615,91 @@ var init_engine = __esmMin((() => {
4030
4615
  const notes = {}, broken = await this.brokenCode();
4031
4616
  for (const c of this.bundle.concepts.values()) {
4032
4617
  const bad = broken.get(c.id) ?? [];
4033
- const links = c.outward.map((o) => ({
4618
+ const links = c.outward.filter((o) => !(o.kind === "code" && o.picture)).map((o) => ({
4034
4619
  kind: o.kind,
4035
4620
  href: o.href,
4036
4621
  text: o.text,
4037
4622
  ...bad.includes(o.href) ? { broken: true } : {}
4038
4623
  }));
4624
+ const pictures = [], shown = /* @__PURE__ */ new Set();
4625
+ for (const p of c.cites) {
4626
+ if (p.kind !== "image" || !p.embed || shown.has(p.target)) continue;
4627
+ shown.add(p.target);
4628
+ pictures.push({
4629
+ href: p.target,
4630
+ path: p.target,
4631
+ from: "notes",
4632
+ text: p.alt || p.target.split("/").at(-1),
4633
+ ...p.broken ? { broken: true } : {}
4634
+ });
4635
+ }
4636
+ for (const o of c.outward) {
4637
+ if (o.kind !== "code" || !o.picture) continue;
4638
+ pictures.push({
4639
+ href: o.href,
4640
+ path: o.path,
4641
+ from: "code",
4642
+ text: o.text,
4643
+ ...bad.includes(o.href) ? { broken: true } : {}
4644
+ });
4645
+ }
4039
4646
  notes[c.id] = {
4040
4647
  title: c.title,
4041
4648
  tokens: estimate(c.body.trim()),
4042
4649
  ...c.kind !== "note" ? { kind: c.kind } : {},
4043
4650
  ...c.done ? { done: c.done } : {},
4044
- ...links.length ? { links } : {}
4651
+ ...links.length ? { links } : {},
4652
+ ...pictures.length ? { pictures } : {}
4045
4653
  };
4046
4654
  }
4655
+ const model = mapModel(this.bundle), files = {};
4656
+ const items = [];
4657
+ const walk = (d) => {
4658
+ for (const c of d.children) if (c.kind === "dir") walk(c);
4659
+ else if (c.id.startsWith("f:")) items.push(c.ref);
4660
+ };
4661
+ walk(model.root);
4662
+ const all = new Set(items);
4663
+ for (const ref of items) {
4664
+ if (ref.endsWith("/")) {
4665
+ const count = [...this.bundle.files].filter((f) => f.startsWith(ref) && !f.slice(ref.length).includes("/")).length;
4666
+ files[ref] = {
4667
+ title: `${ref.slice(0, -1).split("/").at(-1)} · ${count} ${count === 1 ? "picture" : "pictures"}`,
4668
+ kind: "image",
4669
+ count
4670
+ };
4671
+ continue;
4672
+ }
4673
+ const kind = citeKind(ref), name = ref.split("/").at(-1);
4674
+ if (kind !== "artifact") {
4675
+ files[ref] = {
4676
+ title: name,
4677
+ kind
4678
+ };
4679
+ continue;
4680
+ }
4681
+ const html = await this.opts.notes.read(ref).catch(() => null), m = html ? manifestOf(html) : null;
4682
+ files[ref] = {
4683
+ title: m?.title || name,
4684
+ kind
4685
+ };
4686
+ const dir = ref.split("/").slice(0, -1);
4687
+ for (const u of m?.uses ?? []) {
4688
+ const parts = [...dir];
4689
+ for (const p of u.split("/")) if (p === "..") parts.pop();
4690
+ else if (p && p !== ".") parts.push(p);
4691
+ const to = parts.join("/");
4692
+ if (all.has(to) && !model.edges.some(([a, b]) => a === ref && b === to)) model.edges.push([
4693
+ ref,
4694
+ to,
4695
+ STRENGTH.uses
4696
+ ]);
4697
+ }
4698
+ }
4047
4699
  return {
4048
- model: mapModel(this.bundle),
4049
- notes
4700
+ model,
4701
+ notes,
4702
+ ...items.length ? { files } : {}
4050
4703
  };
4051
4704
  }
4052
4705
  /** The base's settings, from `.isorite/settings.toml`. */
@@ -4400,7 +5053,7 @@ var init_engine = __esmMin((() => {
4400
5053
  }
4401
5054
  async deleteNote(id, base, who = "you") {
4402
5055
  if (!inBase(id)) throw new EngineError("only a note of the base is deleted here");
4403
- const notes = this.opts.notes, path = `${checkNoteId(id)}.md`;
5056
+ const { files: notes, path } = this.where(id);
4404
5057
  await this.one(async () => {
4405
5058
  const now = await notes.read(path);
4406
5059
  if (now === null) throw new Conflict(`${id} is gone`, null);
@@ -4438,8 +5091,113 @@ var init_engine = __esmMin((() => {
4438
5091
  this.queue = run.catch(() => void 0);
4439
5092
  return run;
4440
5093
  }
5094
+ /** A file in the notes that isn't a note (M23: any kind; M22 gave pictures only), or a picture in the base's
5095
+ * repository. Within its folder, and (in the notes) never in a hidden folder such as `.isorite/`. */
5096
+ async file(path, from = "notes") {
5097
+ const type = from === "code" ? pictureType(path) : fileType(path);
5098
+ if (!type) return null;
5099
+ let files, at;
5100
+ if (from === "code") {
5101
+ const target = codeTarget(`code:${path}`), where = (await this.settingsOfBase()).repository;
5102
+ files = target && where ? this.opts.repository?.(where) ?? null : null;
5103
+ at = target?.path ?? "";
5104
+ } else {
5105
+ at = path.replace(/^\/+/, "");
5106
+ files = at.split("/").some((p) => !p || p === "." || p === ".." || p.startsWith(".")) ? null : this.opts.notes;
5107
+ }
5108
+ const bytes = files ? await files.readBytes(at) : null;
5109
+ if (!bytes) return null;
5110
+ if (bytes.length > 2e7) throw new EngineError(`${path} is larger than isorite gives (${MAX_FILE_BYTES / 1e6} MB)`, 413);
5111
+ return {
5112
+ bytes,
5113
+ type,
5114
+ version: sha256(bytes).slice(0, 16)
5115
+ };
5116
+ }
5117
+ /** What was read of each picture, kept while its size and time are the same. */
5118
+ pictureCache = /* @__PURE__ */ new Map();
5119
+ /** Every picture in the notes (M22), found afresh: one put in the folder by hand since the base was opened is
5120
+ * among them, and from then on in the bundle too, so a link to it isn't broken. A hidden folder is never looked in. */
5121
+ async pictures() {
5122
+ const found = [];
5123
+ const visit = async (dir) => {
5124
+ for (const f of await this.opts.notes.list(dir)) {
5125
+ if (f.name.startsWith(".")) continue;
5126
+ const path = dir ? `${dir}/${f.name}` : f.name;
5127
+ if (f.dir) await visit(path);
5128
+ else if (pictureType(f.name)) found.push({
5129
+ path,
5130
+ ...f.size === void 0 ? {} : { size: f.size },
5131
+ ...f.mtime === void 0 ? {} : { mtime: f.mtime }
5132
+ });
5133
+ }
5134
+ };
5135
+ await visit("");
5136
+ const out = [], seen = /* @__PURE__ */ new Set();
5137
+ for (const f of found) {
5138
+ seen.add(f.path);
5139
+ const kept = this.pictureCache.get(f.path);
5140
+ if (kept && f.size !== void 0 && kept.size === f.size && kept.mtime === f.mtime) {
5141
+ out.push(kept.info);
5142
+ continue;
5143
+ }
5144
+ const bytes = f.size !== void 0 && f.size > 2e7 ? null : await this.opts.notes.readBytes(f.path);
5145
+ if (!bytes) continue;
5146
+ const px = pictureSize(bytes, pictureType(f.path));
5147
+ const info = {
5148
+ path: f.path,
5149
+ bytes: bytes.length,
5150
+ width: px?.width ?? null,
5151
+ height: px?.height ?? null,
5152
+ sha256: sha256(bytes)
5153
+ };
5154
+ this.pictureCache.set(f.path, {
5155
+ ...f.size === void 0 ? {} : { size: f.size },
5156
+ ...f.mtime === void 0 ? {} : { mtime: f.mtime },
5157
+ info
5158
+ });
5159
+ out.push(info);
5160
+ }
5161
+ for (const path of this.pictureCache.keys()) if (!seen.has(path)) this.pictureCache.delete(path);
5162
+ let changed = false;
5163
+ for (const path of seen) if (!this.files.has(path)) {
5164
+ this.files.set(path, { path });
5165
+ changed = true;
5166
+ }
5167
+ for (const path of [...this.files.keys()]) if (pictureType(path) && !seen.has(path)) {
5168
+ this.files.delete(path);
5169
+ changed = true;
5170
+ }
5171
+ if (changed) this.built = this.index = null;
5172
+ return out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
5173
+ }
5174
+ /** Put a picture in the notes (M22). The path must be a picture's, out of any hidden folder, and free: a file
5175
+ * that's there is never replaced. Not journaled: a picture has no history, and stays when its link goes. */
5176
+ savePicture(path, bytes) {
5177
+ return this.one(async () => {
5178
+ const type = pictureType(path), parts = path.split("/");
5179
+ if (!type || parts.some((p) => !p || p === "." || p === ".." || p.startsWith(".")) || /[\\\x00-\x1f]/.test(path)) throw new EngineError(`not a picture's path: ${path}`, 400);
5180
+ if (bytes.length > 2e7) throw new EngineError(`the picture is larger than isorite takes (${MAX_FILE_BYTES / 1e6} MB)`, 413);
5181
+ if (await this.opts.notes.readBytes(path) !== null) throw new Conflict(`${path} is there already`, null);
5182
+ await this.opts.notes.writeBytes(path, bytes);
5183
+ this.files.set(path, { path });
5184
+ this.built = this.index = null;
5185
+ const px = pictureSize(bytes, type);
5186
+ return {
5187
+ path,
5188
+ bytes: bytes.length,
5189
+ width: px?.width ?? null,
5190
+ height: px?.height ?? null,
5191
+ sha256: sha256(bytes)
5192
+ };
5193
+ });
5194
+ }
4441
5195
  /** Where a note's file is: drafts and Understanding notes are among isorite's own files. */
4442
5196
  where(id) {
5197
+ if (inBase(id) && citeKind(id) === "artifact") return {
5198
+ files: this.opts.notes,
5199
+ path: checkNoteId(id)
5200
+ };
4443
5201
  if (!inBase(id)) return {
4444
5202
  files: this.opts.own,
4445
5203
  path: `${isDraftId(id) ? DRAFT_DIR : UNDERSTANDING_DIR}/${checkNoteId(bare(id))}.md`
@@ -5802,9 +6560,9 @@ var init_engine = __esmMin((() => {
5802
6560
  tier: req.tier,
5803
6561
  messages,
5804
6562
  lookup: tools,
5805
- maxTokens: REPLY_TOKENS * 2,
5806
6563
  signal: abort.signal,
5807
- most: ROUNDS[req.tier],
6564
+ maxTokens: mode === "write" ? ARTIFACT_TOKENS : REPLY_TOKENS * 2,
6565
+ most: mode === "write" ? Math.max(ROUNDS[req.tier], 6) : ROUNDS[req.tier],
5808
6566
  onText: (delta, again) => this.publish({
5809
6567
  type: "iso-text",
5810
6568
  request: turn,
@@ -6115,8 +6873,101 @@ var init_engine = __esmMin((() => {
6115
6873
  });
6116
6874
  return `Your reading of ${title(id)} is written.`;
6117
6875
  }
6876
+ if (name === "read_data") {
6877
+ const path = filePathOf(args.path);
6878
+ if (!path) return `${String(args.path ?? "")} is not a file's path in their base.`;
6879
+ const got = await this.file(path).catch(() => null);
6880
+ if (!got) return `There is no file ${path}.${this.nearFiles(path)}`;
6881
+ try {
6882
+ return describeShape(shapeOf(path, parseData(path, new TextDecoder().decode(got.bytes))));
6883
+ } catch (e) {
6884
+ return `${path} could not be read as data: ${e.message}`;
6885
+ }
6886
+ }
6887
+ if (name === "artifact_recipe") {
6888
+ const r = RECIPES[String(args.name ?? "")];
6889
+ if (!r) return `There's no recipe ${String(args.name ?? "")}: there are ${Object.keys(RECIPES).join(", ")}.`;
6890
+ return `${REFERENCE}\n\n# The recipe: ${String(args.name)}\n\n${r.about}. A complete working artifact; change what it marks, keep the rest.\n\n\`\`\`html\n${r.html}\`\`\``;
6891
+ }
6892
+ if (name === "read_artifact") {
6893
+ const path = filePathOf(args.path);
6894
+ if (!path || citeKind(path) !== "artifact") return `${String(args.path ?? "")} is not an artifact's path (folder/name.html).`;
6895
+ const got = await this.opts.notes.read(path);
6896
+ return got === null ? `There is no artifact ${path}.` : got;
6897
+ }
6898
+ if (name === "write_artifact") return this.writeArtifact(args, t);
6118
6899
  return `${name} cannot be used yet.`;
6119
6900
  }
6901
+ /** Files in the same folder as a path, to say which there are when one isn't. */
6902
+ nearFiles(path) {
6903
+ const dir = path.split("/").slice(0, -1).join("/"), there = [...this.files.keys()].filter((f) => !f.endsWith(".md") && f.split("/").slice(0, -1).join("/") === dir);
6904
+ return there.length ? ` In ${dir || "the top folder"} there are: ${there.map((f) => f.split("/").at(-1)).join(", ")}.` : "";
6905
+ }
6906
+ /** write_artifact (M23, plan-m23 §3.5, §3.7): the manifest written as given, the rules checked before anything is
6907
+ * written, then written and journalled as a note is, and run where it can be, with all it reports said back. */
6908
+ async writeArtifact(args, t) {
6909
+ const path = filePathOf(args.path);
6910
+ if (!path || citeKind(path) !== "artifact" || !inBase(path)) return `${String(args.path ?? "")} is not an artifact's path: give folder/name.html.`;
6911
+ const raw = String(args.html ?? "");
6912
+ if (!raw.trim()) return "Give the whole file, in html.";
6913
+ const given = (Array.isArray(args.uses) ? args.uses : typeof args.uses === "string" ? args.uses.split(/[\s,]+/) : []).map((u) => String(u));
6914
+ const uses = [...new Set([
6915
+ ...given,
6916
+ ...manifestOf(raw).uses,
6917
+ ...readsOf(raw)
6918
+ ].map((u) => u.trim().replace(/^\.\//, "")).filter(Boolean))];
6919
+ const title = String(args.title ?? "").trim() || manifestOf(raw).title || path.split("/").at(-1).replace(/\.html?$/, "").replace(/-/g, " ");
6920
+ const html = withManifest(raw, {
6921
+ title,
6922
+ description: String(args.description ?? "").trim() || manifestOf(raw).description,
6923
+ author: "Iso",
6924
+ date: new Date(this.opts.clock.now()).toISOString().slice(0, 10),
6925
+ kit: "1",
6926
+ uses
6927
+ });
6928
+ const dir = path.split("/").slice(0, -1);
6929
+ const at = (rel) => {
6930
+ const parts = [...dir];
6931
+ for (const p of rel.split("/")) if (p === "..") parts.pop();
6932
+ else if (p && p !== ".") parts.push(p);
6933
+ return parts.join("/");
6934
+ };
6935
+ const files = [], columns = {};
6936
+ for (const u of uses) {
6937
+ const got = await this.file(at(u)).catch(() => null);
6938
+ if (!got) continue;
6939
+ files.push(u);
6940
+ if (citeKind(u) === "data") try {
6941
+ columns[u] = columnsOf(parseData(u, new TextDecoder().decode(got.bytes)));
6942
+ } catch {}
6943
+ }
6944
+ const broken = checkArtifact(html, {
6945
+ files,
6946
+ columns
6947
+ }), errors = broken.filter((b) => b.level === "error");
6948
+ const list = (bs) => bs.map((b) => `- ${b.message}`).join("\n");
6949
+ if (errors.length) return `Not written: ${errors.length === 1 ? "a rule is" : "rules are"} broken.\n${list(errors)}\nFix ${errors.length === 1 ? "it" : "them"} and call write_artifact again with the whole file.`;
6950
+ const was = await this.opts.notes.read(path);
6951
+ const saved = await this.one(() => this.put(path, html, was === null ? null : hashText(was), "edit", { asked: t.asked }, t.who));
6952
+ if (saved.entry) await t.item({
6953
+ kind: "change",
6954
+ note: path,
6955
+ entry: saved.entry,
6956
+ why: was === null ? "create" : "edit",
6957
+ title,
6958
+ added: html.split("\n").length,
6959
+ removed: was?.split("\n").length ?? 0
6960
+ });
6961
+ const warnings = broken.filter((b) => b.level === "warning");
6962
+ const run = await this.opts.renderArtifact?.(path, html).catch(() => null) ?? null;
6963
+ const problems = [
6964
+ ...run && !run.rendered ? ["it didn't start: the kit never said it was ready"] : [],
6965
+ ...(run?.errors ?? []).map((e) => `it reported: ${e}`),
6966
+ ...run && run.rendered && run.drawn === 0 ? ["it drew nothing: no chart, canvas or 3D view has anything in it"] : [],
6967
+ ...warnings.map((w) => w.message)
6968
+ ];
6969
+ return `${was === null ? "Written" : "Written anew"}: ${path}. ${run ? "It was run, with every control touched" : "It couldn't be run here (no browser), so only its rules were checked"}${problems.length ? `, and:\n${problems.map((p) => `- ${p}`).join("\n")}\nFix these and call write_artifact again with the whole file.` : run ? ", with no error." : "."}`;
6970
+ }
6120
6971
  /** A note's Understanding note (M10): Iso's reading and your scratchpad. */
6121
6972
  async understanding(note) {
6122
6973
  if (!inBase(checkNoteId(note))) throw new EngineError("only a note of the base has an Understanding note");
@@ -6491,6 +7342,8 @@ var init_ports = __esmMin((() => {
6491
7342
  systemClock = { now: () => Date.now() };
6492
7343
  MemoryFiles = class {
6493
7344
  files = /* @__PURE__ */ new Map();
7345
+ /** Files held as bytes (pictures), apart from text. */
7346
+ bytes = /* @__PURE__ */ new Map();
6494
7347
  constructor(files = {}) {
6495
7348
  for (const [path, text] of Object.entries(files)) this.files.set(path, text);
6496
7349
  }
@@ -6503,22 +7356,42 @@ var init_ports = __esmMin((() => {
6503
7356
  async append(path, text) {
6504
7357
  this.files.set(path, (this.files.get(path) ?? "") + text);
6505
7358
  }
7359
+ async readBytes(path) {
7360
+ const b = this.bytes.get(path), t = this.files.get(path);
7361
+ return b ?? (t === void 0 ? null : new TextEncoder().encode(t));
7362
+ }
7363
+ async writeBytes(path, bytes) {
7364
+ this.files.delete(path);
7365
+ this.bytes.set(path, bytes);
7366
+ }
6506
7367
  async remove(path) {
6507
7368
  this.files.delete(path);
7369
+ this.bytes.delete(path);
6508
7370
  }
6509
7371
  async list(dir) {
6510
7372
  const prefix = dir ? dir + "/" : "";
6511
7373
  const found = /* @__PURE__ */ new Map();
6512
- for (const path of this.files.keys()) {
7374
+ for (const path of [...this.files.keys(), ...this.bytes.keys()]) {
6513
7375
  if (!path.startsWith(prefix)) continue;
6514
7376
  const rest = path.slice(prefix.length), cut = rest.indexOf("/");
6515
- if (cut < 0) found.set(rest, false);
6516
- else found.set(rest.slice(0, cut), true);
7377
+ if (cut >= 0) {
7378
+ found.set(rest.slice(0, cut), {
7379
+ name: rest.slice(0, cut),
7380
+ dir: true
7381
+ });
7382
+ continue;
7383
+ }
7384
+ const b = this.bytes.get(path);
7385
+ found.set(rest, b ? {
7386
+ name: rest,
7387
+ dir: false,
7388
+ size: b.length
7389
+ } : {
7390
+ name: rest,
7391
+ dir: false
7392
+ });
6517
7393
  }
6518
- return [...found].map(([name, isDir]) => ({
6519
- name,
6520
- dir: isDir
6521
- }));
7394
+ return [...found.values()];
6522
7395
  }
6523
7396
  };
6524
7397
  MemorySecrets = class {
@@ -7978,6 +8851,7 @@ var Terminal = class {
7978
8851
  };
7979
8852
  //#endregion
7980
8853
  //#region packages/server/src/http.ts
8854
+ init_src$1();
7981
8855
  init_src();
7982
8856
  const LOOPBACK = /* @__PURE__ */ new Set([
7983
8857
  "localhost",
@@ -7999,6 +8873,8 @@ const TYPES = {
7999
8873
  ".map": "application/json",
8000
8874
  ".txt": "text/plain; charset=utf-8"
8001
8875
  };
8876
+ /** What a picture may do when opened on its own: be drawn, and nothing else (no script, no fetch, no form). */
8877
+ const PICTURE_CSP = "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'";
8002
8878
  const Edit = z.object({
8003
8879
  base: z.string().max(64),
8004
8880
  text: z.string()
@@ -8221,6 +9097,42 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes, terminal
8221
9097
  app.get("/api/map", (c) => answer(c, () => api.map()));
8222
9098
  app.get("/api/timeline", (c) => answer(c, () => api.timeline()));
8223
9099
  app.get("/api/search", (c) => answer(c, () => api.search(c.req.query("q") ?? "")));
9100
+ app.get("/api/pictures", (c) => answer(c, () => api.pictures()));
9101
+ app.post("/api/file", async (c) => {
9102
+ let origin = "";
9103
+ try {
9104
+ origin = new URL(c.req.header("origin") ?? "").host;
9105
+ } catch {}
9106
+ if (!c.req.header("origin") || origin !== c.req.header("host")) return refuse(c, 403, "cross-origin request refused");
9107
+ if (c.req.header("x-isorite-token") !== token) return refuse(c, 403, "bad token");
9108
+ const path = c.req.query("path") ?? "", type = pictureType(path);
9109
+ if (!type || (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase() !== type) return refuse(c, 415, "a picture, of the type its name says");
9110
+ if (Number(c.req.header("content-length") ?? 0) > 2e7) return refuse(c, 413, "picture too large");
9111
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
9112
+ if (bytes.length > 2e7) return refuse(c, 413, "picture too large");
9113
+ return answer(c, () => api.savePicture(path, bytes));
9114
+ });
9115
+ app.get("/api/file", async (c) => {
9116
+ const code = c.req.query("code"), path = code ?? c.req.query("path") ?? "";
9117
+ let found;
9118
+ try {
9119
+ found = await api.file(path, code === void 0 ? "notes" : "code");
9120
+ } catch (e) {
9121
+ if (e instanceof EngineError) return refuse(c, e.status, e.message);
9122
+ throw e;
9123
+ }
9124
+ if (!found) return refuse(c, 404, `no file at ${path}`);
9125
+ const etag = `"${found.version}"`;
9126
+ const headers = {
9127
+ "Content-Type": found.type,
9128
+ "Cache-Control": "no-cache",
9129
+ ETag: etag,
9130
+ "X-Content-Type-Options": "nosniff",
9131
+ "Content-Security-Policy": PICTURE_CSP
9132
+ };
9133
+ if (c.req.header("if-none-match") === etag) return c.body(null, 304, headers);
9134
+ return c.body(found.bytes, 200, headers);
9135
+ });
8224
9136
  app.put("/api/note", async (c) => {
8225
9137
  const refused = writeRefused(c);
8226
9138
  if (refused) return refused;
@@ -8555,12 +9467,26 @@ var NodeFiles = class {
8555
9467
  throw e;
8556
9468
  }
8557
9469
  }
8558
- /** Written beside the file and renamed over it, so a reader never sees half a note. */
9470
+ async readBytes(path) {
9471
+ try {
9472
+ return new Uint8Array(await readFile(this.at(path)));
9473
+ } catch (e) {
9474
+ if (gone(e)) return null;
9475
+ throw e;
9476
+ }
9477
+ }
8559
9478
  async write(path, text) {
9479
+ await this.replace(path, text);
9480
+ }
9481
+ async writeBytes(path, bytes) {
9482
+ await this.replace(path, bytes);
9483
+ }
9484
+ /** Written beside the file and renamed over it, so a reader never sees half a note. */
9485
+ async replace(path, data) {
8560
9486
  const full = this.at(path), beside = `${full}.${process.pid}.${this.n++}.tmp`;
8561
9487
  await mkdir(dirname(full), { recursive: true });
8562
9488
  try {
8563
- await writeFile(beside, text, "utf8");
9489
+ await writeFile(beside, data, typeof data === "string" ? "utf8" : void 0);
8564
9490
  await rename(beside, full);
8565
9491
  } catch (e) {
8566
9492
  await rm(beside, { force: true });
@@ -8574,10 +9500,27 @@ var NodeFiles = class {
8574
9500
  }
8575
9501
  async list(dir) {
8576
9502
  try {
8577
- return (await readdir(this.at(dir), { withFileTypes: true })).filter((e) => e.isDirectory() || e.isFile()).map((e) => ({
8578
- name: e.name,
8579
- dir: e.isDirectory()
8580
- })).sort((a, b) => a.name < b.name ? -1 : 1);
9503
+ const at = this.at(dir), entries = await readdir(at, { withFileTypes: true });
9504
+ return (await Promise.all(entries.filter((e) => e.isDirectory() || e.isFile()).map(async (e) => {
9505
+ if (e.isDirectory()) return {
9506
+ name: e.name,
9507
+ dir: true
9508
+ };
9509
+ if (e.name.endsWith(".md")) return {
9510
+ name: e.name,
9511
+ dir: false
9512
+ };
9513
+ const s = await stat(resolve(at, e.name)).catch(() => null);
9514
+ return s ? {
9515
+ name: e.name,
9516
+ dir: false,
9517
+ size: s.size,
9518
+ mtime: s.mtimeMs
9519
+ } : {
9520
+ name: e.name,
9521
+ dir: false
9522
+ };
9523
+ }))).sort((a, b) => a.name < b.name ? -1 : 1);
8581
9524
  } catch (e) {
8582
9525
  if (gone(e)) return [];
8583
9526
  throw e;
@@ -8836,7 +9779,7 @@ var Setup = class {
8836
9779
  };
8837
9780
  //#endregion
8838
9781
  //#region packages/server/src/version.ts
8839
- const VERSION = "0.9.1";
9782
+ const VERSION = "0.11.0";
8840
9783
  //#endregion
8841
9784
  //#region packages/server/src/git.ts
8842
9785
  /** The top of the repository `folder` is in, or null: the nearest folder holding `.git`, a directory or, in a
@@ -8890,9 +9833,155 @@ function openStudyBase(path) {
8890
9833
  };
8891
9834
  }
8892
9835
  //#endregion
9836
+ //#region packages/server/src/render.ts
9837
+ /** A Chromium on this machine: ISORITE_BROWSER, Playwright's own, or a browser installed in the usual places. */
9838
+ function findBrowser(env = process.env) {
9839
+ if (env.ISORITE_BROWSER && existsSync(env.ISORITE_BROWSER)) return env.ISORITE_BROWSER;
9840
+ const cache = env.PLAYWRIGHT_BROWSERS_PATH ?? (process.platform === "darwin" ? join(homedir(), "Library", "Caches", "ms-playwright") : process.platform === "win32" ? join(env.LOCALAPPDATA ?? "", "ms-playwright") : join(homedir(), ".cache", "ms-playwright"));
9841
+ const found = [];
9842
+ try {
9843
+ for (const d of readdirSync(cache).filter((d) => /^chromium(_headless_shell)?-\d+$/.test(d)).sort().reverse()) for (const p of [
9844
+ "chrome-linux/headless_shell",
9845
+ "chrome-headless-shell-linux64/chrome-headless-shell",
9846
+ "chrome-linux/chrome",
9847
+ "chrome-linux64/chrome",
9848
+ "chrome-mac/headless_shell",
9849
+ "chrome-headless-shell-mac-arm64/chrome-headless-shell",
9850
+ "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
9851
+ "chrome-win/headless_shell.exe",
9852
+ "chrome-headless-shell-win64/chrome-headless-shell.exe",
9853
+ "chrome-win/chrome.exe"
9854
+ ]) found.push(join(cache, d, p));
9855
+ } catch {}
9856
+ found.push("/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/snap/bin/chromium", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", join(env["PROGRAMFILES"] ?? "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"), join(env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Microsoft", "Edge", "Application", "msedge.exe"));
9857
+ return found.find((p) => existsSync(p)) ?? null;
9858
+ }
9859
+ /** The host: a sandboxed frame given the document, and the bridge, answering files through window.isoRead. */
9860
+ const HOST = `(doc, ground) => {
9861
+ const f = document.getElementById("f");
9862
+ window.said = []; window.ready = false;
9863
+ const tell = (m, t = []) => f.contentWindow.postMessage({ isorite: 0, ...m }, "*", t);
9864
+ addEventListener("message", async (e) => {
9865
+ const m = e.data;
9866
+ if (e.source !== f.contentWindow || !m || m.isorite !== 0) return;
9867
+ if (m.type === "ready") { window.ready = true; tell({ type: "ground", ground }); tell({ type: "look", look: "isorite" }); }
9868
+ else if (m.type === "file" || m.type === "bytes") {
9869
+ const got = await window.isoRead(m.path);
9870
+ if (got.error) tell({ type: "reply", id: m.id, error: got.error });
9871
+ else if (m.type === "bytes") { const buf = got.b64 !== undefined ? Uint8Array.from(atob(got.b64), (c) => c.charCodeAt(0)).buffer : new TextEncoder().encode(got.text).buffer; tell({ type: "reply", id: m.id, bytes: buf }, [buf]); }
9872
+ else tell({ type: "reply", id: m.id, text: got.text ?? atob(got.b64) });
9873
+ }
9874
+ else if (m.type === "height") f.style.height = Math.min(8000, Math.ceil(m.height) + 4) + "px";
9875
+ else if (m.type === "error") window.said.push(String(m.message));
9876
+ });
9877
+ f.srcdoc = doc;
9878
+ }`;
9879
+ /** What it drew: SVG marks, a table's cells, and 100 for each canvas with anything on it (a WebGL one, if it has a size). */
9880
+ const DRAWN = `(() => {
9881
+ let n = document.querySelectorAll("svg path, svg line, svg circle, svg rect, svg polyline, svg polygon, svg text, iso-table td").length;
9882
+ for (const c of document.querySelectorAll("canvas")) {
9883
+ try {
9884
+ const g = c.getContext("2d");
9885
+ if (g) { const d = g.getImageData(0, 0, c.width, c.height).data; if (d.some((v, i) => v !== d[i % 4])) n += 100; }
9886
+ else n += 100;
9887
+ } catch { n += 100; }
9888
+ }
9889
+ return n;
9890
+ })()`;
9891
+ /** Every control touched: sliders to three quarters, choices to their second, buttons pressed, a drag on a canvas. */
9892
+ const TOUCH = `(async () => {
9893
+ for (const r of document.querySelectorAll("input[type=range]")) { r.value = String(+r.min + (+r.max - +r.min) * 0.75); r.dispatchEvent(new Event("input", { bubbles: true })); r.dispatchEvent(new Event("change", { bubbles: true })); }
9894
+ for (const s of document.querySelectorAll("select")) if (s.options.length > 1) { s.selectedIndex = 1; s.dispatchEvent(new Event("change", { bubbles: true })); }
9895
+ for (const b of document.querySelectorAll("button, input[type=checkbox]")) if (!b.closest("iso-note")) b.click();
9896
+ await new Promise((r) => setTimeout(r, 400));
9897
+ const c = document.querySelector("canvas");
9898
+ if (c) {
9899
+ const r = c.getBoundingClientRect(), at = (t, x) => c.dispatchEvent(new PointerEvent(t, { clientX: r.left + x, clientY: r.top + r.height / 2, bubbles: true, pointerId: 1 }));
9900
+ at("pointerdown", r.width / 2); at("pointermove", r.width / 2 + 60); at("pointerup", r.width / 2 + 60);
9901
+ c.dispatchEvent(new MouseEvent("click", { clientX: r.left + r.width / 3, clientY: r.top + r.height / 3, bubbles: true }));
9902
+ }
9903
+ })()`;
9904
+ let launched = null;
9905
+ async function browser(path) {
9906
+ launched ??= (async () => {
9907
+ const { chromium } = await import("playwright-core");
9908
+ return chromium.launch({
9909
+ executablePath: path,
9910
+ args: [
9911
+ "--use-gl=angle",
9912
+ "--use-angle=swiftshader",
9913
+ "--enable-unsafe-swiftshader"
9914
+ ]
9915
+ });
9916
+ })().catch((e) => {
9917
+ launched = null;
9918
+ throw e;
9919
+ });
9920
+ return launched;
9921
+ }
9922
+ /** Render an assembled artifact (packages/kit's assemble) unseen, answer its bridge from `read` for the files in
9923
+ * `uses`, touch its controls, and say what happened. */
9924
+ async function renderArtifact(doc, uses, read, opts) {
9925
+ const b = await browser(opts.executable), width = opts.width ?? 1100;
9926
+ const page = await b.newPage({ viewport: {
9927
+ width,
9928
+ height: 900
9929
+ } });
9930
+ const errors = [];
9931
+ page.on("console", (m) => {
9932
+ if (m.type?.() === "error") errors.push(m.text());
9933
+ });
9934
+ page.on("pageerror", (e) => errors.push(e.message ?? String(e)));
9935
+ const work = (async () => {
9936
+ await page.exposeFunction("isoRead", async (rel) => {
9937
+ if (!uses.includes(rel)) return { error: `${rel} is not in the artifact's manifest (isorite:uses)` };
9938
+ const got = await read(rel);
9939
+ if (got === null) return { error: `${rel}: no such file beside the artifact` };
9940
+ return typeof got === "string" ? { text: got } : { b64: Buffer.from(got).toString("base64") };
9941
+ });
9942
+ await page.setContent(`<!doctype html><html><body style="margin:0;background:${opts.ground === "quartz" ? "#F2F0EA" : "#07101C"}"><iframe id="f" sandbox="allow-scripts" style="display:block;width:${width}px;height:900px;border:0"></iframe></body></html>`);
9943
+ await page.evaluate(`(${HOST})(${JSON.stringify(doc)}, ${JSON.stringify(opts.ground ?? "lapis")})`);
9944
+ await page.waitForTimeout(2e3);
9945
+ const ready = await page.evaluate("window.ready");
9946
+ const frame = page.frames().find((fr) => fr !== page.mainFrame());
9947
+ const height = await page.evaluate("document.getElementById(\"f\").offsetHeight");
9948
+ const drawn = frame ? await frame.evaluate(DRAWN) : 0;
9949
+ let screenshot;
9950
+ if (opts.screenshot) {
9951
+ await page.setViewportSize({
9952
+ width,
9953
+ height: Math.max(400, Math.min(height, 4e3))
9954
+ });
9955
+ screenshot = await page.screenshot({ fullPage: true });
9956
+ }
9957
+ if (frame) await frame.evaluate(TOUCH);
9958
+ await page.waitForTimeout(600);
9959
+ const said = await page.evaluate("window.said");
9960
+ return {
9961
+ rendered: ready,
9962
+ errors: [.../* @__PURE__ */ new Set([...said, ...errors])].slice(0, 12),
9963
+ drawn,
9964
+ height,
9965
+ ...screenshot ? { screenshot } : {}
9966
+ };
9967
+ })();
9968
+ const timeout = new Promise((resolve) => setTimeout(() => resolve({
9969
+ rendered: false,
9970
+ errors: ["it didn't finish starting within the time allowed: a loop that never ends?"],
9971
+ drawn: 0,
9972
+ height: 0
9973
+ }), opts.timeout ?? 2e4));
9974
+ try {
9975
+ return await Promise.race([work, timeout]);
9976
+ } finally {
9977
+ await page.close().catch(() => void 0);
9978
+ }
9979
+ }
9980
+ //#endregion
8893
9981
  //#region packages/server/src/serve.ts
8894
9982
  init_src$1();
8895
9983
  init_src();
9984
+ init_src$2();
8896
9985
  /** The built app: in a checkout, where npm run build puts it; installed from npm, in the package's web/. */
8897
9986
  const WEB_DIR = [join(dirname(fileURLToPath(import.meta.url)), "..", "web"), join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "app", "build")].find((dir) => existsSync(join(dir, "index.html"))) ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "app", "build");
8898
9987
  const moveIt = (study) => `${study} is a study base, which this isorite no longer reads (format 1). Move it into its notes with: isorite migrate ${study}`;
@@ -8950,9 +10039,28 @@ async function openEngine({ notes }, device = deviceId(), config = configPath(),
8950
10039
  name: `vectors-${createHash("sha256").update(notes).digest("hex").slice(0, 16)}.json`
8951
10040
  }
8952
10041
  } : {},
8953
- ...extras.elsewhere ? { elsewhere: extras.elsewhere } : {}
10042
+ ...extras.elsewhere ? { elsewhere: extras.elsewhere } : {},
10043
+ renderArtifact: (path, html) => runArtifact(notes, path, html)
8954
10044
  });
8955
10045
  }
10046
+ /** Run an artifact unseen: null when there's no browser, or no built kit, to run it with. */
10047
+ async function runArtifact(notes, path, html) {
10048
+ const executable = findBrowser(), kitDir = join(WEB_DIR, "kit");
10049
+ if (!executable || !existsSync(join(kitDir, "kit.js"))) return null;
10050
+ const kit = Object.fromEntries(KIT_FILES.map((f) => [f, readFileSync(join(kitDir, f), "utf8")]));
10051
+ const dir = dirname(resolve(notes, path)), uses = manifestOf(html).uses;
10052
+ const read = async (rel) => {
10053
+ const at = resolve(dir, rel);
10054
+ if (!at.startsWith(resolve(notes) + sep) || !existsSync(at)) return null;
10055
+ return /\.(csv|tsv|json|jsonl|ndjson|txt|gltf|obj)$/i.test(at) ? readFileSync(at, "utf8") : new Uint8Array(readFileSync(at));
10056
+ };
10057
+ const got = await renderArtifact(assemble(html, kit), uses, read, { executable });
10058
+ return {
10059
+ rendered: got.rendered,
10060
+ errors: got.errors,
10061
+ drawn: got.drawn
10062
+ };
10063
+ }
8956
10064
  /** The guide's finder, as a provider: no key, no sign-in, and a name that says what it is. */
8957
10065
  const { auth: _auth, ...openrouter } = OPENROUTER;
8958
10066
  const FINDER = {
@@ -9683,6 +10791,8 @@ async function doctor(folder, o = {}) {
9683
10791
  say("browser pages", asking.length ? "fail" : "ok", asking.length ? `${asking.join(" and ")} asked for a password (401, Basic): that is not isorite's server; stop it, or give isorite another port (--port <n>)` : `isorite's pages at ${url}/ ask for no password. ${hint}`);
9684
10792
  }
9685
10793
  if (env.SSH_CONNECTION) say("browser", "ok", `over SSH: on your own machine, ssh -L ${port}:localhost:${port} <this host>, then http://localhost:${port}/`);
10794
+ const chrome = findBrowser(env);
10795
+ say("artifacts", chrome ? "ok" : "warn", chrome ? `run unseen when Iso writes one, in ${chrome}` : "no Chromium found to run them unseen, so only their rules are checked when Iso writes one; ISORITE_BROWSER names one (Chrome, Chromium, Edge)");
9686
10796
  let model = null;
9687
10797
  if (env.ISORITE_EMBED === "off") say("search by meaning", "skip", "switched off (ISORITE_EMBED=off)");
9688
10798
  else {
@@ -11079,6 +12189,19 @@ const finderHttp = (pace = 0) => scriptedHttp(finderAnswer, pace);
11079
12189
  //#region packages/guide/src/node.ts
11080
12190
  /** In a checkout, the package's notes/; in the npm package, where this is bundled into dist/, the guide/ beside it. */
11081
12191
  const GUIDE_DIR = [join(import.meta.dirname, "..", "guide"), join(import.meta.dirname, "..", "notes")].find((d) => existsSync(join(d, "start-here.md"))) ?? join(import.meta.dirname, "..", "notes");
12192
+ /** The guide's files that aren't notes, by path, as bytes: its pictures (M22), and its artifacts and their data (M23). */
12193
+ function loadGuidePictures(dir = GUIDE_DIR) {
12194
+ const out = {};
12195
+ const visit = (at) => {
12196
+ for (const name of readdirSync(at)) {
12197
+ const path = join(at, name);
12198
+ if (statSync(path).isDirectory()) visit(path);
12199
+ else if (/\.(png|jpe?g|gif|svg|webp|avif|html|csv|tsv|json|jsonl|stl|glb|gltf|obj|pdf)$/i.test(name)) out[relative(dir, path).split(sep).join("/")] = new Uint8Array(readFileSync(path));
12200
+ }
12201
+ };
12202
+ visit(dir);
12203
+ return out;
12204
+ }
11082
12205
  /** The guide's files as they stand in the package: its notes and its AGENTS.md, by path. */
11083
12206
  function loadGuide(dir = GUIDE_DIR) {
11084
12207
  const out = {};
@@ -11106,7 +12229,7 @@ function writeGuide(folder) {
11106
12229
  fresh: false
11107
12230
  };
11108
12231
  if (existsSync(at) && readdirSync(at).some((f) => f !== ".isorite")) throw new StudyError(`${at} has files in it that are not the guide: name another folder, isorite guide <folder>`);
11109
- for (const [path, text] of Object.entries(loadGuide())) {
12232
+ for (const [path, text] of [...Object.entries(loadGuide()), ...Object.entries(loadGuidePictures())]) {
11110
12233
  mkdirSync(dirname(join(at, path)), { recursive: true });
11111
12234
  writeFileSync(join(at, path), text);
11112
12235
  }
@@ -11155,8 +12278,21 @@ async function writeExample(folder, now = Date.now()) {
11155
12278
  mkdirSync(dirname(at), { recursive: true });
11156
12279
  writeFileSync(at, text);
11157
12280
  }
12281
+ copyPictures(NOTES_DIR, notes);
11158
12282
  return { notes };
11159
12283
  }
12284
+ /** The pictures among a folder's notes (M22), copied as they are: they have no history. */
12285
+ function copyPictures(from, to, dir = from) {
12286
+ for (const name of readdirSync(dir)) {
12287
+ const path = join(dir, name);
12288
+ if (statSync(path).isDirectory()) copyPictures(from, to, path);
12289
+ else if (citeKind(name) === "image") {
12290
+ const at = join(to, relative(from, path));
12291
+ mkdirSync(dirname(at), { recursive: true });
12292
+ copyFileSync(path, at);
12293
+ }
12294
+ }
12295
+ }
11160
12296
  const GEOMETRY_DIR = join(import.meta.dirname, "..", "geometry");
11161
12297
  /** The geometry example's notes, and the code beside them, as they stand today, by path. */
11162
12298
  function loadGeometry(dir = GEOMETRY_DIR) {
@@ -11282,12 +12418,14 @@ function bases(args, { config = configPath(), out = console.log } = {}) {
11282
12418
  }
11283
12419
  //#endregion
11284
12420
  //#region packages/server/src/main.ts
12421
+ init_src$2();
11285
12422
  init_src$1();
11286
12423
  init_model();
11287
12424
  const USAGE = `isorite: an agentic harness for understanding
11288
12425
 
11289
12426
  isorite new <folder> start a knowledge base: the folder, empty, with isorite's .isorite/
11290
- isorite check <notes> check a folder of notes: frontmatter, links, cycles
12427
+ isorite check <notes> [--unused] check a folder of notes: frontmatter, links, cycles; --unused lists
12428
+ the pictures no note shows
11291
12429
  isorite serve <notes> [--port <n>] [--allow-host <name>]
11292
12430
  open it in the browser, on this machine only
11293
12431
  isorite migrate <study base> [--merge] move a study base (format 1) into its notes' .isorite/; exercises
@@ -11324,7 +12462,7 @@ const USAGE = `isorite: an agentic harness for understanding
11324
12462
  the browser; run again, as you left it. Iso is your model, or with
11325
12463
  none set up, the guide's finder (also: isorite --tutorial)
11326
12464
  `;
11327
- function check(notes, out = console.log) {
12465
+ function check(notes, out = console.log, unused = false) {
11328
12466
  const root = resolve(notes);
11329
12467
  if (!existsSync(root) || !statSync(root).isDirectory()) {
11330
12468
  out(`no folder at ${root}`);
@@ -11333,14 +12471,41 @@ function check(notes, out = console.log) {
11333
12471
  const bundle = loadBundle(root), issues = bundle.lint(), cycles = bundle.requiresCycles();
11334
12472
  for (const i of issues) out(`${i.level.padEnd(7)} ${i.path}: ${i.message}`);
11335
12473
  for (const c of cycles) out(`warning requires cycle: ${c.join(" -> ")}`);
11336
- const errors = issues.filter((i) => i.level === "error").length;
11337
- out(`${bundle.concepts.size} notes in ${bundle.directories.size} folders; ${errors} errors, ${issues.length - errors} warnings, ${cycles.length} requires cycles`);
12474
+ let artifactErrors = 0, artifactWarnings = 0;
12475
+ for (const f of [...bundle.files].filter((x) => citeKind(x) === "artifact").sort()) {
12476
+ const html = readFileSync(join(root, f), "utf8");
12477
+ if (manifestOf(html).kit === null) continue;
12478
+ const dir = dirname(join(root, f)), files = manifestOf(html).uses.filter((u) => existsSync(join(dir, u)));
12479
+ const columns = Object.fromEntries(files.filter((u) => citeKind(u) === "data").flatMap((u) => {
12480
+ try {
12481
+ return [[u, columnsOf(parseData(u, readFileSync(join(dir, u), "utf8")))]];
12482
+ } catch {
12483
+ return [];
12484
+ }
12485
+ }));
12486
+ for (const b of checkArtifact(html, {
12487
+ files,
12488
+ columns
12489
+ })) {
12490
+ out(`${b.level.padEnd(7)} ${f}: ${b.message}`);
12491
+ if (b.level === "error") artifactErrors++;
12492
+ else artifactWarnings++;
12493
+ }
12494
+ }
12495
+ const errors = issues.filter((i) => i.level === "error").length + artifactErrors;
12496
+ out(`${bundle.concepts.size} notes in ${bundle.directories.size} folders; ${errors} errors, ${issues.length - issues.filter((i) => i.level === "error").length + artifactWarnings} warnings, ${cycles.length} requires cycles`);
12497
+ if (unused) {
12498
+ const cited = new Set([...bundle.concepts.values()].flatMap((c) => c.cites.map((x) => x.target)));
12499
+ const idle = [...bundle.files].filter((f) => citeKind(f) === "image" && !cited.has(f)).sort();
12500
+ for (const f of idle) out(`unused ${f}`);
12501
+ out(idle.length ? `${idle.length} ${idle.length === 1 ? "picture" : "pictures"} no note shows` : "every picture is shown by a note");
12502
+ }
11338
12503
  return errors ? 1 : 0;
11339
12504
  }
11340
12505
  function main(argv) {
11341
12506
  const [command, ...rest] = argv;
11342
12507
  try {
11343
- if (command === "check" && rest[0]) return check(rest[0]);
12508
+ if (command === "check" && rest.find((r) => !r.startsWith("--"))) return check(rest.find((r) => !r.startsWith("--")), console.log, rest.includes("--unused"));
11344
12509
  if (command === "new" && rest[0]) {
11345
12510
  const made = newBase(rest[0]);
11346
12511
  console.log(`${made}: a knowledge base, empty. Open it in the browser, or beside your code in the terminal:\n isorite serve ${made}\n cd <your code> && isorite term ${made}`);