@zvndev/darkmown 2.4.1-next.7.ge25dbba → 2.5.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.
@@ -0,0 +1,321 @@
1
+ // ---------------------------------------------------------------------------
2
+ // TOOLS: outline(files, entry) and refs(files, entry, name)
3
+ //
4
+ // `outline` answers "what is in this page, and where", as a flat list of
5
+ // declarations with real line numbers. `refs` answers "if I change this symbol,
6
+ // what else moves", which is the query that turns a one-edit model into a
7
+ // multi-edit one.
8
+ //
9
+ // Both are built on the symbol table `compileFromMemory` now returns, which the
10
+ // compiler's own directive handlers record as they parse. There is no second
11
+ // parser here on purpose: a parser beside the compiler drifts, and the day it
12
+ // disagrees is the day a model is told something true about a file that will not
13
+ // compile.
14
+ //
15
+ // The one thing the compiler cannot cheaply supply is a line number for a `{ … }`
16
+ // binding in prose, because markdown is rendered a chunk at a time with no line
17
+ // index in scope. The compiler records those reads WITHOUT a line but WITH the
18
+ // exact expression text, and this module locates them by searching the source
19
+ // for that compiler-supplied string. That is a substring search for a known
20
+ // needle, not a parse: the compiler still decides what is true.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import { compileFromMemory } from "../compiler.js";
24
+ import { posixKey, shortPath } from "./validate.js";
25
+
26
+ /** Longest a `detail` may be before it is elided. A multi-line JSON seed is
27
+ * otherwise the widest thing in the outline and tells the model nothing. */
28
+ const DETAIL_MAX = 52;
29
+
30
+ /**
31
+ * @param {string} s
32
+ * @returns {string}
33
+ */
34
+ function short(s) {
35
+ const one = String(s ?? "")
36
+ .replace(/\s+/g, " ")
37
+ .trim();
38
+ return one.length > DETAIL_MAX ? `${one.slice(0, DETAIL_MAX - 1)}…` : one;
39
+ }
40
+
41
+ /**
42
+ * Map a symbol's absolute compile path back to the caller's own file key.
43
+ * The virtual root is an implementation detail of `compileFromMemory`; a model
44
+ * that never saw `/site/pages/` must not be handed it back.
45
+ * @param {string} abs
46
+ * @param {Record<string, string>} files
47
+ * @returns {string}
48
+ */
49
+ function fileKeyOf(abs, files) {
50
+ // Both sides go through `posixKey`: the compiler's half is platform-native
51
+ // (a Windows host resolves the virtual root to `C:\site\pages\…`) and the
52
+ // caller's half is always POSIX, so comparing them raw matches on one
53
+ // platform and silently fails on the other.
54
+ const want = posixKey(abs);
55
+ return Object.keys(files).find((k) => posixKey(k) === want) ?? want;
56
+ }
57
+
58
+ /**
59
+ * Resolve the line(s) a line-less read sits on, by finding the compiler's own
60
+ * expression text in the source.
61
+ *
62
+ * Matches only inside a `{ … }` binding, comparing the brace's whole trimmed
63
+ * contents to the expression. A bare substring search is not good enough and the
64
+ * failure is not theoretical: searching for `count` matched the heading "Click
65
+ * counter", its own `:state count = 0` declaration, and the `count++` inside a
66
+ * button, turning one real read into four. Anything a tool reports has to be
67
+ * something the model can act on.
68
+ *
69
+ * A binding repeated on several lines is several reads, so every match is
70
+ * returned rather than just the first.
71
+ *
72
+ * @param {string} source
73
+ * @param {string} expr
74
+ * @returns {number[]} 1-based lines.
75
+ */
76
+ function locate(source, expr) {
77
+ const needle = norm(expr);
78
+ if (!needle) return [];
79
+ /** @type {number[]} */
80
+ const out = [];
81
+ source.split("\n").forEach((line, i) => {
82
+ for (const m of line.matchAll(/\{([^{}]*)\}/g)) {
83
+ if (norm(m[1]) === needle) {
84
+ out.push(i + 1);
85
+ break;
86
+ }
87
+ }
88
+ });
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * Collapse whitespace so `{ cart | count }` and `{cart|count}` compare equal.
94
+ * @param {string} s
95
+ * @returns {string}
96
+ */
97
+ function norm(s) {
98
+ return String(s ?? "")
99
+ .replace(/\s+/g, "")
100
+ .trim();
101
+ }
102
+
103
+ /**
104
+ * Compile and return one symbol per source construct, with every line resolved.
105
+ *
106
+ * Two foldings happen here, both forced by how the compiler works rather than by
107
+ * taste. A statically-unrolled `@loop` recompiles its body once per row, so a
108
+ * directive inside it records N identical symbols at one `file:line`; and a prose
109
+ * read inside such a loop records N line-less reads. Both collapse to one.
110
+ *
111
+ * The `compiled` half is the compiler's own result, derived from the function
112
+ * rather than restated here: widening it to `object` compiled fine while these
113
+ * lived under bench/, and silently lost every property check the moment they
114
+ * became part of the package.
115
+ *
116
+ * @typedef {ReturnType<typeof compileFromMemory>} Compiled
117
+ * @typedef {import("../compiler/context.js").Symbol} Symbol
118
+ *
119
+ * @param {Record<string, string>} files
120
+ * @param {string} entry
121
+ * @returns {{ok: true, symbols: Symbol[], compiled: Compiled} | {ok: false, error: string}}
122
+ */
123
+ export function symbolsOf(files, entry) {
124
+ if (!files || !entry || !(entry in files)) {
125
+ return { ok: false, error: `no such file "${shortPath(entry)}" in this project` };
126
+ }
127
+ let compiled;
128
+ try {
129
+ compiled = compileFromMemory(files, entry);
130
+ } catch (err) {
131
+ return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err) };
132
+ }
133
+
134
+ const seen = new Set();
135
+ /** @type {Symbol[]} */
136
+ const symbols = [];
137
+ for (const sym of compiled.symbols ?? []) {
138
+ const file = fileKeyOf(sym.file ?? entry, files);
139
+ const lines = sym.line != null ? [sym.line] : locate(files[file] ?? "", sym.detail ?? "");
140
+ // A read whose expression cannot be found is still a real read; keep it with
141
+ // a null line rather than silently dropping a fact the compiler established.
142
+ for (const line of lines.length ? lines : [null]) {
143
+ const id = `${file}|${line}|${sym.kind}|${sym.name}|${sym.detail ?? ""}`;
144
+ if (seen.has(id)) continue;
145
+ seen.add(id);
146
+ symbols.push({ ...sym, file, line });
147
+ }
148
+ }
149
+ symbols.sort((a, b) =>
150
+ a.file === b.file ? (a.line ?? 1e9) - (b.line ?? 1e9) : a.file < b.file ? -1 : 1
151
+ );
152
+ return { ok: true, symbols, compiled };
153
+ }
154
+
155
+ /**
156
+ * TOOL ENTRY POINT: outline(files, entry)
157
+ *
158
+ * @param {Record<string, string>} files
159
+ * @param {string} entry
160
+ * @returns {{ok: boolean, text: string, data: object}}
161
+ */
162
+ export function outline(files, entry) {
163
+ const got = symbolsOf(files, entry);
164
+ if (!got.ok) return { ok: false, text: got.error, data: {} };
165
+
166
+ const byFile = new Map();
167
+ for (const s of got.symbols) {
168
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
169
+ byFile.get(s.file).push(s);
170
+ }
171
+ // The entry always leads, even when it declares nothing, so a model asking for
172
+ // an outline of an empty page gets an answer rather than silence.
173
+ if (!byFile.has(entry)) byFile.set(entry, []);
174
+
175
+ const out = [];
176
+ for (const [file, syms] of [...byFile.entries()].sort((a, b) =>
177
+ a[0] === entry ? -1 : b[0] === entry ? 1 : a[0] < b[0] ? -1 : 1
178
+ )) {
179
+ const lines = (files[file] ?? "").split("\n").length;
180
+ out.push(`${shortPath(file)} (${lines} lines)`);
181
+ if (!syms.length) {
182
+ out.push(" (no directives: plain markdown)");
183
+ continue;
184
+ }
185
+ for (const s of syms) {
186
+ // A block's span is printed as `:16-21`, because a model that is only
187
+ // told where a loop STARTS will replace its header and strand the closer.
188
+ const at = s.line == null ? "" : s.endLine ? `:${s.line}-${s.endLine}` : `:${s.line}`;
189
+ const tag = s.kind === "loop" && s.reactive ? "loop*" : s.kind;
190
+ out.push(` ${at.padEnd(8)} ${tag.padEnd(9)} ${short(s.detail ?? s.name)}`);
191
+ }
192
+ }
193
+ const reactive = got.compiled.assets?.runtime;
194
+ out.push("");
195
+ out.push(
196
+ `${reactive ? "reactive page (ships the runtime)" : "static page (ships zero JavaScript)"}. loop* = reactive loop.`
197
+ );
198
+
199
+ return {
200
+ ok: true,
201
+ text: out.join("\n"),
202
+ data: { symbols: got.symbols, runtime: Boolean(reactive) }
203
+ };
204
+ }
205
+
206
+ /** Symbol kinds that WRITE the state key they name. */
207
+ const WRITE_KINDS = new Set(["state", "store", "computed", "theme", "fetch", "action", "form"]);
208
+
209
+ /**
210
+ * TOOL ENTRY POINT: refs(files, entry, name)
211
+ *
212
+ * Every place a symbol is declared, written, or read, across the entry and
213
+ * everything it includes. This is the query behind impact expansion: "what else
214
+ * needs to change" is a lookup, not something a model should be recalling.
215
+ *
216
+ * @param {Record<string, string>} files
217
+ * @param {string} entry
218
+ * @param {string} [name] Symbol name, e.g. `cart`. Missing is reported, not thrown.
219
+ * @returns {{ok: boolean, text: string, data: object}}
220
+ */
221
+ export function refs(files, entry, name) {
222
+ const got = symbolsOf(files, entry);
223
+ if (!got.ok) return { ok: false, text: got.error, data: {} };
224
+ if (!name || typeof name !== "string") {
225
+ return { ok: false, text: 'refs needs a symbol name, e.g. refs("cart")', data: {} };
226
+ }
227
+
228
+ // A dotted read of `cart.total` is a reference to `cart`, and a loop over
229
+ // `products` targets it too. Match the head segment, not the whole string.
230
+ const head = (/** @type {string} */ s) => String(s ?? "").split(".")[0];
231
+ const hits = got.symbols.filter((s) => head(s.name) === name || head(s.target ?? "") === name);
232
+
233
+ if (!hits.length) {
234
+ const known = [...new Set(got.symbols.map((s) => head(s.name)))].filter(Boolean).sort();
235
+ return {
236
+ ok: true,
237
+ text: `"${name}" is not declared or referenced anywhere.\nThis page has: ${known.join(", ") || "nothing"}`,
238
+ data: { name, refs: [] }
239
+ };
240
+ }
241
+
242
+ const rows = hits.map((s) => ({
243
+ role:
244
+ s.kind === "read"
245
+ ? "read"
246
+ : s.kind === "loop"
247
+ ? "read"
248
+ : s.kind === "if"
249
+ ? "read"
250
+ : WRITE_KINDS.has(s.kind)
251
+ ? s.kind === "action"
252
+ ? "write"
253
+ : "declare"
254
+ : "read",
255
+ ...s
256
+ }));
257
+
258
+ const text = [
259
+ name,
260
+ ...rows.map(
261
+ (r) =>
262
+ ` ${r.role.padEnd(8)} ${short(r.detail ?? r.name).padEnd(DETAIL_MAX)} ${shortPath(r.file)}${r.line != null ? `:${r.line}` : ""}`
263
+ )
264
+ ].join("\n");
265
+
266
+ return { ok: true, text, data: { name, refs: rows } };
267
+ }
268
+
269
+ /**
270
+ * TOOL ENTRY POINT: deps(files, entry)
271
+ *
272
+ * What this page pulls in (includes, data files, collections) and, for any other
273
+ * page in the project, whether it pulls in the same thing. The reverse direction
274
+ * is what makes "I changed a partial, what breaks" answerable.
275
+ *
276
+ * @param {Record<string, string>} files
277
+ * @param {string} entry
278
+ * @returns {{ok: boolean, text: string, data: object}}
279
+ */
280
+ export function deps(files, entry) {
281
+ const got = symbolsOf(files, entry);
282
+ if (!got.ok) return { ok: false, text: got.error, data: {} };
283
+
284
+ const reads = [...(got.compiled.deps ?? [])]
285
+ .map((d) => fileKeyOf(d, files))
286
+ .filter((d) => d !== entry)
287
+ .sort();
288
+ const collections = [...(got.compiled.collectionsUsed ?? [])].sort();
289
+
290
+ // Reverse edges: every OTHER page that also reads this entry. Compiling each
291
+ // one is affordable because it is in memory, and a page that fails to compile
292
+ // is reported rather than skipped silently.
293
+ const usedBy = [];
294
+ const broken = [];
295
+ for (const other of Object.keys(files)) {
296
+ if (other === entry || !/\.(wd|md)$/.test(other)) continue;
297
+ const o = symbolsOf(files, other);
298
+ if (!o.ok) {
299
+ broken.push(other);
300
+ continue;
301
+ }
302
+ if ([...(o.compiled.deps ?? [])].map((d) => fileKeyOf(d, files)).includes(entry)) {
303
+ usedBy.push(other);
304
+ }
305
+ }
306
+
307
+ const out = [`${shortPath(entry)}`];
308
+ out.push(reads.length ? ` includes: ${reads.map(shortPath).join(", ")}` : " includes: nothing");
309
+ if (collections.length) out.push(` loops collections: ${collections.join(", ")}`);
310
+ out.push(
311
+ usedBy.length ? ` included by: ${usedBy.map(shortPath).join(", ")}` : " included by: nothing"
312
+ );
313
+ for (const b of broken)
314
+ out.push(` note: ${shortPath(b)} does not compile, so its includes are unknown`);
315
+
316
+ return {
317
+ ok: true,
318
+ text: out.join("\n"),
319
+ data: { entry, includes: reads, collections, usedBy, broken }
320
+ };
321
+ }
@@ -0,0 +1,81 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Reading a tool call out of a small model's reply.
3
+ //
4
+ // Its own module so it can be tested without a GPU. It first lived inside the
5
+ // runner, which meant importing the parser started a 50-task sweep — a thing
6
+ // worth not repeating.
7
+ //
8
+ // Deliberately forgiving about the wrapper and strict about the content. Small
9
+ // models fence their JSON about half the time and narrate around it the rest,
10
+ // and none of that is a capability failure worth scoring. What IS worth scoring
11
+ // is malformed or invented calls, so every rejection returns a reason the model
12
+ // can act on rather than throwing.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * @typedef {object} ToolCall
17
+ * @property {string} tool
18
+ * @property {object} args
19
+ */
20
+
21
+ /**
22
+ * Pull the first complete JSON object out of a reply.
23
+ *
24
+ * Scans with a brace counter that is string- and escape-aware, because the one
25
+ * thing a Darkmown edit reliably contains is `{ count }`, and a naive
26
+ * `indexOf("}")` truncates exactly the calls that matter most.
27
+ *
28
+ * @param {string} reply
29
+ * @returns {{ok: true, call: ToolCall} | {ok: false, error: string}}
30
+ */
31
+ export function parseToolCall(reply) {
32
+ const text = String(reply ?? "").replace(/```(?:json)?/g, "");
33
+ const start = text.indexOf("{");
34
+ if (start === -1) {
35
+ return {
36
+ ok: false,
37
+ error: 'no JSON object in that reply. Reply with exactly {"tool": "outline", "args": {}}'
38
+ };
39
+ }
40
+
41
+ let depth = 0;
42
+ let inString = false;
43
+ let escaped = false;
44
+ for (let i = start; i < text.length; i++) {
45
+ const c = text[i];
46
+ if (escaped) {
47
+ escaped = false;
48
+ continue;
49
+ }
50
+ if (c === "\\") {
51
+ escaped = true;
52
+ continue;
53
+ }
54
+ if (c === '"') {
55
+ inString = !inString;
56
+ continue;
57
+ }
58
+ if (inString) continue;
59
+ if (c === "{") depth++;
60
+ else if (c === "}") {
61
+ depth--;
62
+ if (depth !== 0) continue;
63
+ const slice = text.slice(start, i + 1);
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(slice);
67
+ } catch (err) {
68
+ const why = err instanceof Error ? err.message : String(err);
69
+ return { ok: false, error: `that is not valid JSON (${why})` };
70
+ }
71
+ if (typeof parsed?.tool !== "string") {
72
+ return { ok: false, error: 'that JSON object has no "tool" field' };
73
+ }
74
+ return {
75
+ ok: true,
76
+ call: { tool: parsed.tool, args: parsed.args ?? parsed.arguments ?? {} }
77
+ };
78
+ }
79
+ }
80
+ return { ok: false, error: "that JSON object is never closed" };
81
+ }
@@ -0,0 +1,169 @@
1
+ // ---------------------------------------------------------------------------
2
+ // TOOL: validate(files, entry)
3
+ //
4
+ // Compiles a whole virtual project with the REAL compiler and answers in the
5
+ // model's own vocabulary: either "it compiles, here is what it became", or one
6
+ // error with its code, its location, the corrective template, and a concrete
7
+ // example line.
8
+ //
9
+ // This is the tool the agent loop leans on hardest, and it is nearly free:
10
+ // `compileFromMemory` runs in process against an in-memory reader, no disk, so
11
+ // a model can afford to check after every edit rather than at the end.
12
+ //
13
+ // The error path deliberately surfaces `err.wd` (the structured companion the
14
+ // framework already attaches) instead of re-parsing the prose message. That is
15
+ // the same discipline as the outline tool: never re-derive what the compiler
16
+ // already knows.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import { compileFromMemory } from "../compiler.js";
20
+
21
+ /**
22
+ * Reduce any path the compiler hands back to the caller's own key form.
23
+ *
24
+ * The caller addresses files with POSIX keys (`site/pages/index.wd`), but
25
+ * `compileFromMemory` resolves them against its virtual root with
26
+ * `path.resolve`, which is PLATFORM-NATIVE: on Windows the same file comes back
27
+ * as `C:\site\pages\index.wd`. Comparing that to the caller's keys matches
28
+ * nothing, so every symbol gets filed under a path that is not in the project
29
+ * and every symbol-targeted edit fails. The virtual root is not a real
30
+ * filesystem and its separator is an accident of the host, so normalising here
31
+ * is the fix rather than a workaround.
32
+ *
33
+ * @param {string} p
34
+ * @returns {string}
35
+ */
36
+ export function posixKey(p) {
37
+ return String(p ?? "")
38
+ .replace(/\\/g, "/")
39
+ .replace(/^[A-Za-z]:/, "")
40
+ .replace(/^\/+/, "");
41
+ }
42
+
43
+ /**
44
+ * Shorten a virtual absolute path to what the author would call the file.
45
+ * The model never saw `/site/pages/`, so echoing it back is noise it may copy.
46
+ * @param {string} p
47
+ * @returns {string}
48
+ */
49
+ export function shortPath(p) {
50
+ if (!p) return "";
51
+ return posixKey(p)
52
+ .replace(/^site\/pages\//, "")
53
+ .replace(/^site\/_\//, "_/");
54
+ }
55
+
56
+ /** A virtual project path as it appears mid-sentence, either separator. */
57
+ const EMBEDDED_PATH = /(?:[A-Za-z]:)?[\\/]?site[\\/](?:pages|_)[\\/][^\s,)]*/g;
58
+
59
+ /**
60
+ * The same shortening applied to paths EMBEDDED in a sentence.
61
+ *
62
+ * `shortPath` only anchors at the start of a string, which covered the location
63
+ * this tool prints and missed the one place the model actually reads: the
64
+ * compiler's own message, which names the file mid-sentence ("Malformed @loop in
65
+ * /site/pages/index.wd:3"). A model that is shown the virtual root will use it,
66
+ * and the path it then writes resolves to nothing.
67
+ *
68
+ * Whole tokens are matched and rewritten rather than separators being replaced
69
+ * globally, so a Windows path is normalised without touching anything else in
70
+ * the sentence.
71
+ *
72
+ * @param {string} s
73
+ * @returns {string}
74
+ */
75
+ function shortenPaths(s) {
76
+ return String(s ?? "").replace(EMBEDDED_PATH, (match) => {
77
+ // A trailing `:3` line marker is part of the location, not the path.
78
+ const [, file, line] = /^(.*?)(:\d+)?$/.exec(match) ?? [];
79
+ return `${shortPath(file ?? match)}${line ?? ""}`;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * TOOL ENTRY POINT.
85
+ *
86
+ * @param {Record<string, string>} files Project-relative path → contents, e.g.
87
+ * `{"site/pages/index.wd": "---\ntitle: X\n---\n"}`.
88
+ * @param {string} entry Which file to compile, in the same key form.
89
+ * @returns {{ok: boolean, text: string, data: object}}
90
+ */
91
+ export function validate(files, entry) {
92
+ if (!files || typeof files !== "object") {
93
+ return { ok: false, text: "validate needs a files object", data: { code: "TOOL_ARGS" } };
94
+ }
95
+ if (!entry || !(entry in files)) {
96
+ return {
97
+ ok: false,
98
+ text: `no such file "${shortPath(entry)}". The project has: ${Object.keys(files).map(shortPath).join(", ")}`,
99
+ data: { code: "TOOL_ARGS" }
100
+ };
101
+ }
102
+
103
+ let compiled;
104
+ try {
105
+ compiled = compileFromMemory(files, entry);
106
+ } catch (err) {
107
+ // Every author-facing compile error carries the compiler's own structured
108
+ // payload on `err.wd` (see wdError in src/compiler/context.js). Naming that
109
+ // type rather than restating its fields is what keeps this in step when a
110
+ // field is added there.
111
+ const wd = /** @type {Partial<import("../compiler/context.js").WdErrorInfo>} */ (
112
+ /** @type {any} */ (err)?.wd ?? {}
113
+ );
114
+ // `file` is optional: a pure string->string pass (compileSkin) has no file
115
+ // to name, so fall back to the entry the caller asked about.
116
+ const where = wd.file
117
+ ? `${shortPath(wd.file)}${wd.line ? `:${wd.line}` : ""}`
118
+ : shortPath(entry);
119
+ // Strip the "[CODE] " prefix and the trailing "Use: …" the message already
120
+ // carries, because both are re-emitted below in a fixed position. A model
121
+ // copying from a single predictable shape beats one parsing prose.
122
+ const message = shortenPaths(
123
+ String(/** @type {any} */ (err)?.message ?? err)
124
+ .replace(/^\[[A-Z]{2}\d{3}\]\s*/, "")
125
+ .replace(/\s*Use:\s*[\s\S]*$/, "")
126
+ ).trim();
127
+
128
+ const lines = [`${wd.code ?? "ERROR"} at ${where}`, ` ${message}`];
129
+ if (wd.hint) lines.push(` use: ${shortenPaths(wd.hint)}`);
130
+ if (wd.example) lines.push(` e.g. ${shortenPaths(wd.example)}`);
131
+
132
+ return {
133
+ ok: false,
134
+ text: lines.join("\n"),
135
+ data: {
136
+ code: wd.code ?? null,
137
+ file: shortPath(wd.file ?? entry),
138
+ line: wd.line ?? null,
139
+ message,
140
+ hint: wd.hint ?? null,
141
+ example: wd.example ?? null
142
+ }
143
+ };
144
+ }
145
+
146
+ // A clean compile still has things worth telling the model, and one of them is
147
+ // load-bearing: whether the page went reactive. A brochure page that quietly
148
+ // starts shipping the runtime is a regression the framework cares about more
149
+ // than almost anything else, and it is invisible in the HTML.
150
+ const skins = Object.keys(compiled.assets?.skins ?? {}).length;
151
+ const facts = [
152
+ compiled.assets?.runtime ? "reactive (ships the runtime)" : "static (ships zero JavaScript)",
153
+ `${skins} skin${skins === 1 ? "" : "s"}`
154
+ ];
155
+ const lines = [`compiles. ${facts.join(", ")}.`];
156
+ for (const w of compiled.warnings ?? []) lines.push(` warning: ${w}`);
157
+
158
+ return {
159
+ ok: true,
160
+ text: lines.join("\n"),
161
+ data: {
162
+ runtime: Boolean(compiled.assets?.runtime),
163
+ skins,
164
+ warnings: [...(compiled.warnings ?? [])],
165
+ title: compiled.meta?.title ?? null,
166
+ html: compiled.html
167
+ }
168
+ };
169
+ }
@@ -118,6 +118,27 @@ export type EmittedPage = {
118
118
  assets: import("./compiler.js").Assets;
119
119
  warnings: string[];
120
120
  };
121
+ /**
122
+ * Site-wide metadata resolved from the home page's frontmatter.
123
+ */
124
+ export type SiteIdentity = {
125
+ /**
126
+ * Absolute origin without a trailing slash, or "".
127
+ */
128
+ siteUrl: string;
129
+ /**
130
+ * Site title (also the RSS channel title).
131
+ */
132
+ title: string;
133
+ /**
134
+ * Site description (also the RSS channel description).
135
+ */
136
+ description: string;
137
+ /**
138
+ * `"allow"` or `"deny"`, from `ai_crawlers:`.
139
+ */
140
+ aiCrawlers: string;
141
+ };
121
142
  export type Paths = import("./config.js").Paths;
122
143
  export type Assets = import("./compiler.js").Assets;
123
144
  export type Meta = import("./compiler.js").Meta;