@dogsbay/ssg 0.2.0-beta.131 → 0.2.0-beta.133

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.
@@ -1,31 +1,31 @@
1
1
  Third-party software in @dogsbay/ssg (dist/package/)
2
2
 
3
3
  ========================================================================
4
- @dogsbay/elements@0.2.0-beta.131 (MIT)
4
+ @dogsbay/elements@0.2.0-beta.133 (MIT)
5
5
  https://github.com/dogsbay/dogsbay.git
6
6
 
7
7
  Licensed under MIT; the package ships no licence file.
8
8
 
9
9
  ========================================================================
10
- @dogsbay/primitives@0.2.0-beta.131 (MIT)
10
+ @dogsbay/primitives@0.2.0-beta.133 (MIT)
11
11
  https://github.com/dogsbay/dogsbay.git
12
12
 
13
13
  Licensed under MIT; the package ships no licence file.
14
14
 
15
15
  ========================================================================
16
- @dogsbay/render-html@0.2.0-beta.131 (MIT)
16
+ @dogsbay/render-html@0.2.0-beta.133 (MIT)
17
17
  https://github.com/dogsbay/dogsbay.git
18
18
 
19
19
  Licensed under MIT; the package ships no licence file.
20
20
 
21
21
  ========================================================================
22
- @dogsbay/search@0.2.0-beta.131 (MIT)
22
+ @dogsbay/search@0.2.0-beta.133 (MIT)
23
23
  https://github.com/dogsbay/dogsbay.git
24
24
 
25
25
  Licensed under MIT; the package ships no licence file.
26
26
 
27
27
  ========================================================================
28
- @dogsbay/ui@0.2.0-beta.131 (MIT)
28
+ @dogsbay/ui@0.2.0-beta.133 (MIT)
29
29
  https://github.com/dogsbay/dogsbay.git
30
30
 
31
31
  Licensed under MIT; the package ships no licence file.
@@ -462,6 +462,12 @@ function sourceNav(v, path, from) {
462
462
  if (!/\.(ya?ml|json)$/i.test(p)) throw new ConfigError(`content.sources (${path}): nav must be a .topicmap.yml topic map or a .yml or .json nav file; got ${JSON.stringify(v)}`);
463
463
  return p;
464
464
  }
465
+ function projectRoots(cfg) {
466
+ const roots = cfg.sources.map((s) => s.path === "." ? "" : s.path);
467
+ if (roots.includes("")) return [""];
468
+ const navDirs = cfg.sources.flatMap((s) => s.nav ? [s.nav.includes("/") ? s.nav.slice(0, s.nav.lastIndexOf("/")) : s.nav] : []);
469
+ return [.../* @__PURE__ */ new Set([...roots, cfg.themeDir, ...cfg.css, ...navDirs])].filter(Boolean);
470
+ }
465
471
  function sourceRoot(s) {
466
472
  return s.path === "." ? "" : s.path.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
467
473
  }
@@ -548,8 +554,8 @@ function slugFromPath(p) {
548
554
  if (s.endsWith("/index")) s = s.slice(0, -"/index".length);
549
555
  return s;
550
556
  }
551
- function parseNavYaml(text, labelFor, baseDir = "") {
552
- const data = loadYaml(text);
557
+ function parseNavYaml(text, labelFor, baseDir = "", load2 = loadYaml) {
558
+ const data = load2(text);
553
559
  if (!Array.isArray(data)) return [];
554
560
  return data.flatMap((e) => yamlEntry(e, labelFor, baseDir, false));
555
561
  }
@@ -1066,31 +1072,31 @@ var CLIENT_CANDIDATES = ['""', '"&"', '"\\\\$&")).join("|")})', '"\\\\$&")}"', '
1066
1072
  var SITE_NOTICES = `Third-party software in this site's _dogsbay/ files (dogsbay.js, site.css) and pages
1067
1073
 
1068
1074
  ========================================================================
1069
- @dogsbay/elements@0.2.0-beta.131 (MIT)
1075
+ @dogsbay/elements@0.2.0-beta.133 (MIT)
1070
1076
  https://github.com/dogsbay/dogsbay.git
1071
1077
 
1072
1078
  Licensed under MIT; the package ships no licence file.
1073
1079
 
1074
1080
  ========================================================================
1075
- @dogsbay/primitives@0.2.0-beta.131 (MIT)
1081
+ @dogsbay/primitives@0.2.0-beta.133 (MIT)
1076
1082
  https://github.com/dogsbay/dogsbay.git
1077
1083
 
1078
1084
  Licensed under MIT; the package ships no licence file.
1079
1085
 
1080
1086
  ========================================================================
1081
- @dogsbay/render-html@0.2.0-beta.131 (MIT)
1087
+ @dogsbay/render-html@0.2.0-beta.133 (MIT)
1082
1088
  https://github.com/dogsbay/dogsbay.git
1083
1089
 
1084
1090
  Licensed under MIT; the package ships no licence file.
1085
1091
 
1086
1092
  ========================================================================
1087
- @dogsbay/search@0.2.0-beta.131 (MIT)
1093
+ @dogsbay/search@0.2.0-beta.133 (MIT)
1088
1094
  https://github.com/dogsbay/dogsbay.git
1089
1095
 
1090
1096
  Licensed under MIT; the package ships no licence file.
1091
1097
 
1092
1098
  ========================================================================
1093
- @dogsbay/ui@0.2.0-beta.131 (MIT)
1099
+ @dogsbay/ui@0.2.0-beta.133 (MIT)
1094
1100
  https://github.com/dogsbay/dogsbay.git
1095
1101
 
1096
1102
  Licensed under MIT; the package ships no licence file.
@@ -2164,7 +2170,7 @@ var ThemeTemplates = class {
2164
2170
  };
2165
2171
 
2166
2172
  // src/core/preprocess.ts
2167
- import { render } from "@dogsbay/minja/browser";
2173
+ import { render, RenderLimitError } from "@dogsbay/minja/browser";
2168
2174
  import {
2169
2175
  maskMinjaInCode,
2170
2176
  unmaskMinjaInCode,
@@ -2194,11 +2200,59 @@ var MapLoader = class {
2194
2200
  throw new Error(`include not found: ${path}${basePath ? ` (from ${basePath})` : ""}`);
2195
2201
  }
2196
2202
  };
2203
+ var TemplateBudget = class {
2204
+ constructor(renderMs, buildMs, now2 = () => Date.now()) {
2205
+ this.renderMs = renderMs;
2206
+ this.buildMs = buildMs;
2207
+ this.now = now2;
2208
+ }
2209
+ spentMs = 0;
2210
+ active = 0;
2211
+ since = 0;
2212
+ /** Renders not run because the budget was spent, by what they were for. */
2213
+ skipped = { pages: 0, titles: 0 };
2214
+ get spent() {
2215
+ return this.spentMs + (this.active ? this.now() - this.since : 0);
2216
+ }
2217
+ /** The time the next render may take; 0 when the budget is spent. */
2218
+ next() {
2219
+ return Math.max(0, Math.min(this.renderMs, this.buildMs - this.spent));
2220
+ }
2221
+ start() {
2222
+ if (this.active++ === 0) this.since = this.now();
2223
+ }
2224
+ end() {
2225
+ if (--this.active === 0) this.spentMs += this.now() - this.since;
2226
+ }
2227
+ reset() {
2228
+ this.spentMs = 0;
2229
+ this.since = this.now();
2230
+ this.skipped = { pages: 0, titles: 0 };
2231
+ }
2232
+ /** The problem to report when renders were skipped since the last reset, else undefined. */
2233
+ problem() {
2234
+ const { pages, titles } = this.skipped;
2235
+ if (!pages && !titles) return void 0;
2236
+ const what = [pages && `${pages} page(s)`, titles && `${titles} title(s)`].filter(Boolean).join(" and ");
2237
+ return `[template] the template time budget (${this.buildMs / 1e3} s) ran out: ${what} shown without their templates rendered`;
2238
+ }
2239
+ };
2240
+ function templateBudget(untrusted, given) {
2241
+ return new TemplateBudget(given?.renderMs ?? (untrusted ? 2e3 : 1e4), given?.buildMs ?? (untrusted ? 3e4 : 6e5));
2242
+ }
2197
2243
  function needsTemplating(text) {
2198
2244
  return text.includes("{{") || text.includes("{%");
2199
2245
  }
2200
- async function preprocess(text, path, attributes, files, undefinedRefs = "preserve") {
2246
+ async function preprocess(text, path, attributes, files, undefinedRefs = "preserve", run = {}) {
2201
2247
  if (!needsTemplating(text)) return { text };
2248
+ const budget = run.budget;
2249
+ const timeout = budget ? budget.next() : 1e4;
2250
+ const skip = () => {
2251
+ if (run.kind !== "copy") budget.skipped[run.kind === "title" ? "titles" : "pages"]++;
2252
+ return { text, skipped: true };
2253
+ };
2254
+ if (budget && timeout <= 0) return skip();
2255
+ budget?.start();
2202
2256
  try {
2203
2257
  const out = await render(mask(text), {
2204
2258
  context: withUnderscoreAliases(attributes),
@@ -2207,11 +2261,15 @@ async function preprocess(text, path, attributes, files, undefinedRefs = "preser
2207
2261
  hyphenToUnderscore: true,
2208
2262
  basePath: dirname(path),
2209
2263
  loader: new MapLoader(files),
2210
- timeout: 1e4
2264
+ timeout,
2265
+ ...run.parseCache !== void 0 ? { parseCache: run.parseCache } : {}
2211
2266
  });
2212
2267
  return { text: stripFenceMinjaMarkers(stripInlineMinjaMarkers(unmask(out))) };
2213
2268
  } catch (err) {
2269
+ if (budget && timeout < budget.renderMs && err instanceof RenderLimitError && budget.next() <= 0) return skip();
2214
2270
  return { text, error: err instanceof Error ? err.message : String(err) };
2271
+ } finally {
2272
+ budget?.end();
2215
2273
  }
2216
2274
  }
2217
2275
  function withUnderscoreAliases(attrs2) {
@@ -2223,6 +2281,84 @@ function withUnderscoreAliases(attrs2) {
2223
2281
  return out;
2224
2282
  }
2225
2283
 
2284
+ // src/core/build.ts
2285
+ import { ParseCache, DEFAULT_PARSE_CACHE_BYTES } from "@dogsbay/minja/browser";
2286
+
2287
+ // src/core/scan-cache.ts
2288
+ var ScanCache = class {
2289
+ entries = /* @__PURE__ */ new Map();
2290
+ /** Project path of a file read while resolving titles → the pages whose entries read it. */
2291
+ readers = /* @__PURE__ */ new Map();
2292
+ /** The last nav YAML loaded, by its text: an unchanged nav.yml is not parsed again. */
2293
+ navText;
2294
+ navData;
2295
+ /** Resolved topic maps, with what they were resolved with and the file map's value of every file read. */
2296
+ maps = /* @__PURE__ */ new Map();
2297
+ /** The page's entry when it was made from this content, else a new empty one for it. */
2298
+ entry(path, content) {
2299
+ const had = this.entries.get(path);
2300
+ if (had && had.content === content) return had;
2301
+ if (had) this.dropDeps(path, had);
2302
+ const e = { content };
2303
+ this.entries.set(path, e);
2304
+ return e;
2305
+ }
2306
+ /** Records that a page's title or description read a file, so a change to that file drops them. */
2307
+ read(page, file) {
2308
+ const e = this.entries.get(page);
2309
+ if (!e) return;
2310
+ (e.deps ??= /* @__PURE__ */ new Set()).add(file);
2311
+ let r = this.readers.get(file);
2312
+ if (!r) this.readers.set(file, r = /* @__PURE__ */ new Set());
2313
+ r.add(page);
2314
+ }
2315
+ /** A file changed, was added or was removed: its own entry goes, and the titles that read it. */
2316
+ changed(path) {
2317
+ const own = this.entries.get(path);
2318
+ if (own) {
2319
+ this.dropDeps(path, own);
2320
+ this.entries.delete(path);
2321
+ }
2322
+ const readers = this.readers.get(path);
2323
+ if (!readers) return;
2324
+ this.readers.delete(path);
2325
+ for (const page of readers) {
2326
+ const e = this.entries.get(page);
2327
+ if (!e) continue;
2328
+ this.dropDeps(page, e);
2329
+ delete e.title;
2330
+ delete e.description;
2331
+ delete e.resolvedWith;
2332
+ }
2333
+ }
2334
+ /** `load(text)`, or the value it gave for the same text last time (the caller must not change it). */
2335
+ yaml(text, load2) {
2336
+ if (this.navText !== text) {
2337
+ this.navData = load2(text);
2338
+ this.navText = text;
2339
+ }
2340
+ return this.navData;
2341
+ }
2342
+ /** A topic map resolved with `key` (its variables), if no file it read has changed since. */
2343
+ topicMap(path, key, files) {
2344
+ const m = this.maps.get(path);
2345
+ if (!m || m.key !== key) return void 0;
2346
+ for (const [p, c] of m.reads) if (files.get(p) !== c) return void 0;
2347
+ return m.nav;
2348
+ }
2349
+ /** Keeps a resolved topic map (not one with errors: the build stops on those) and the files it read. */
2350
+ keepTopicMap(path, key, reads, nav) {
2351
+ if (!nav.errors.length) this.maps.set(path, { key, reads, nav });
2352
+ }
2353
+ get size() {
2354
+ return this.entries.size;
2355
+ }
2356
+ dropDeps(page, e) {
2357
+ for (const f of e.deps ?? []) this.readers.get(f)?.delete(page);
2358
+ e.deps = void 0;
2359
+ }
2360
+ };
2361
+
2226
2362
  // src/core/build.ts
2227
2363
  import { TemplatePages } from "@dogsbay/format-dogsbay-md/browser";
2228
2364
  import { extractText, SearchIndexBuilder } from "@dogsbay/search/index";
@@ -2896,6 +3032,16 @@ function fmKey(src) {
2896
3032
  return detach(`${m ? m[0] : ""}
2897
3033
  ${firstH1(src) ?? ""}`);
2898
3034
  }
3035
+ function detachValue(v) {
3036
+ if (typeof v === "string") return detach(v);
3037
+ if (Array.isArray(v)) return v.map(detachValue);
3038
+ if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
3039
+ const o = {};
3040
+ for (const [k, x] of Object.entries(v)) o[detach(k)] = detachValue(x);
3041
+ return o;
3042
+ }
3043
+ return v;
3044
+ }
2899
3045
  function frontMatterCopy(fm) {
2900
3046
  try {
2901
3047
  const copy = JSON.parse(JSON.stringify(fm));
@@ -2942,7 +3088,7 @@ async function build(input, hooks = {}) {
2942
3088
  phase.init = now() - t0;
2943
3089
  let pages = 0;
2944
3090
  let assets = 0;
2945
- const state = { redirects: [], navCache: navCache(), written: /* @__PURE__ */ new Set(), stubs: [], pageOut: /* @__PURE__ */ new Map(), sitemaps: /* @__PURE__ */ new Map(), supplied: /* @__PURE__ */ new Set(), usedMath: false, diagrams: diagramStore(input, cfg) };
3091
+ const state = { redirects: [], navCache: navCache(), written: /* @__PURE__ */ new Set(), stubs: [], pageOut: /* @__PURE__ */ new Map(), sitemaps: /* @__PURE__ */ new Map(), supplied: /* @__PURE__ */ new Set(), usedMath: false, diagrams: diagramStore(input, cfg), scan: hooks.keep || hooks.lazy ? new ScanCache() : void 0, parseCache: input.parseCacheBytes === 0 ? false : new ParseCache(input.parseCacheBytes ?? DEFAULT_PARSE_CACHE_BYTES), templateBudget: templateBudget(input.untrusted === true, input.templateMs) };
2946
3092
  for (const r of cfg.redirects) {
2947
3093
  const from = sitePath(r.from, cfg.basePath);
2948
3094
  if (!from) {
@@ -3013,12 +3159,14 @@ async function build(input, hooks = {}) {
3013
3159
  }
3014
3160
  }
3015
3161
  state.stubs = await writeTranslationStubs(cfg, allUnits, units, translations, state, emit);
3162
+ const spent = state.templateBudget.problem();
3163
+ if (spent) report("template", spent);
3016
3164
  return {
3017
3165
  files: out,
3018
3166
  warnings,
3019
3167
  problems,
3020
3168
  config: cfg,
3021
- stats: { pages, assets, units: units.length, ms: Math.round(now() - t0), msByPhase: roundAll(phase), highlightCache: highlighter.cacheStats() }
3169
+ stats: { pages, assets, units: units.length, ms: Math.round(now() - t0), msByPhase: roundAll(phase), highlightCache: highlighter.cacheStats(), ...state.parseCache ? { parseCache: state.parseCache.stats() } : {} }
3022
3170
  };
3023
3171
  }
3024
3172
  function selectUnits(allUnits, names) {
@@ -3116,9 +3264,14 @@ async function buildUnit(unit, allUnits, cfg, input, highlighter, emit, report,
3116
3264
  const ns = named.length ? namespaceOf(src) : "";
3117
3265
  return ns ? (p) => p === ns || p.startsWith(ns + "/") ? p : `${ns}/${p}` : void 0;
3118
3266
  };
3119
- const includesFor = (src) => {
3267
+ const includesFor = (src, read2) => {
3120
3268
  const at = scoped(src);
3121
- return at ? (p) => readInclude(at(p)) : readInclude;
3269
+ if (!read2) return at ? (p) => readInclude(at(p)) : readInclude;
3270
+ return (p) => {
3271
+ const q = at ? at(p) : p;
3272
+ read2(abs(q));
3273
+ return readInclude(q);
3274
+ };
3122
3275
  };
3123
3276
  const namespaceOf = (p) => named.find((m) => p === m.namespace || p.startsWith(m.namespace + "/"))?.namespace ?? "";
3124
3277
  const globalPrefixes = new Set(cfg.taxonomies.map((t2) => t2.path.split("/")[0]).filter(Boolean));
@@ -3156,6 +3309,7 @@ async function buildUnit(unit, allUnits, cfg, input, highlighter, emit, report,
3156
3309
  return c == null ? void 0 : readText(c);
3157
3310
  };
3158
3311
  const includedPaths = /* @__PURE__ */ new Set();
3312
+ const includedProject = /* @__PURE__ */ new Set();
3159
3313
  const readInclude = async (p) => {
3160
3314
  if (mode.keep || mode.lazy) includedPaths.add(p);
3161
3315
  const hit = cache.get(p);
@@ -3168,7 +3322,8 @@ async function buildUnit(unit, allUnits, cfg, input, highlighter, emit, report,
3168
3322
  };
3169
3323
  const resolvers = [openApiResolver(read, input.openApiChars), autodocUnresolved, ...input.resolvers?.(unit) ?? []];
3170
3324
  const hasContent = (p) => !buildOnly(p) && input.files.has(abs(p));
3171
- const resolveTitle = async (title, src, path, pageFm) => {
3325
+ const templateRun = { parseCache: state.parseCache, budget: state.templateBudget, kind: "page" };
3326
+ const resolveTitle = async (title, src, path, pageFm, read2) => {
3172
3327
  if (!isTemplated(title)) return title;
3173
3328
  if (pageFm && templatesOf(path).isTemplatePage(pageFm)) {
3174
3329
  return (await templatesOf(path).renderField(title, path, pageFm)).trim();
@@ -3178,7 +3333,7 @@ async function buildUnit(unit, allUnits, cfg, input, highlighter, emit, report,
3178
3333
  const preamble = head.split("\n").filter((l) => /^\s*\{%-?\s*(include|set)\b/.test(l)).join("\n");
3179
3334
  const marker = "DBTITLEMARKER";
3180
3335
  const r = await preprocess(`${preamble}
3181
- ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.undefinedRefs);
3336
+ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path, read2), cfg.undefinedRefs, { parseCache: state.parseCache, budget: state.templateBudget, kind: "title" });
3182
3337
  const m = r.text.split(marker);
3183
3338
  return (m.length >= 3 ? m[1] : title).trim();
3184
3339
  };
@@ -3186,13 +3341,23 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3186
3341
  for (const m of unit.members) {
3187
3342
  const path = m.source.nav;
3188
3343
  if (!path || !TOPIC_MAP.test(path) || topicMaps.has(path)) continue;
3344
+ const variables = withUnderscoreAliases({ ...cfg.attributes, ...m.source.attributes ?? {} });
3345
+ const key = state.scan ? JSON.stringify([sourceRoot(m.source), variables]) : "";
3346
+ const kept = state.scan?.topicMap(path, key, input.files);
3347
+ if (kept) {
3348
+ topicMaps.set(path, kept);
3349
+ continue;
3350
+ }
3189
3351
  const { topicMapError, topicMapNav } = await import("./chunks/topicmap-PY3WDCSI.mjs");
3352
+ const reads = /* @__PURE__ */ new Map();
3190
3353
  const projectText = async (p) => {
3191
3354
  const c = input.files.get(p);
3355
+ reads.set(p, c);
3192
3356
  return c == null ? void 0 : readText(c);
3193
3357
  };
3194
- const got = await topicMapNav(path, sourceRoot(m.source), projectText, withUnderscoreAliases({ ...cfg.attributes, ...m.source.attributes ?? {} }));
3358
+ const got = await topicMapNav(path, sourceRoot(m.source), projectText, variables);
3195
3359
  if (got.errors.length) throw topicMapError(path, got.errors);
3360
+ state.scan?.keepTopicMap(path, key, reads, got);
3196
3361
  topicMaps.set(path, got);
3197
3362
  }
3198
3363
  const navInputs = /* @__PURE__ */ new Set([...unit.members.flatMap((m) => m.source.nav ? [m.source.nav] : []), ...[...topicMaps.values()].flatMap((t2) => t2.maps)]);
@@ -3213,6 +3378,13 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3213
3378
  }
3214
3379
  return a;
3215
3380
  };
3381
+ const resolvedKeys = /* @__PURE__ */ new Map();
3382
+ const resolvedKey = (p) => {
3383
+ const key = named.length ? namespaceOf(p) : "";
3384
+ let k = resolvedKeys.get(key);
3385
+ if (k === void 0) resolvedKeys.set(key, k = JSON.stringify([label, key, sourceOf(p).templates ?? null, cfg.name, cfg.undefinedRefs, attributesOf(p)]));
3386
+ return k;
3387
+ };
3216
3388
  const templatesBy = /* @__PURE__ */ new Map();
3217
3389
  const templatesOf = (p) => {
3218
3390
  const key = named.length ? namespaceOf(p) : "";
@@ -3239,11 +3411,17 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3239
3411
  if (/\.(md|markdown)$/i.test(p)) {
3240
3412
  if (excluded(p)) continue;
3241
3413
  const slug = slugFromPath(p);
3242
- const src = await readText(c);
3414
+ const memo2 = state.scan?.entry(abs(p), c);
3415
+ let text;
3416
+ const source = async () => text ??= await readText(c);
3243
3417
  let fm = {};
3244
- try {
3245
- fm = splitFrontmatter2(src).data ?? {};
3246
- } catch {
3418
+ if (memo2?.fm) fm = memo2.fm;
3419
+ else {
3420
+ try {
3421
+ fm = splitFrontmatter2(await source()).data ?? {};
3422
+ } catch {
3423
+ }
3424
+ if (memo2) memo2.fm = detachValue(fm);
3247
3425
  }
3248
3426
  if (fm.status === "draft" && !input.includeDrafts) continue;
3249
3427
  if (templatesOf(p).isPartial(fm, p)) continue;
@@ -3279,20 +3457,35 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3279
3457
  warn("duplicate-page", `two sources map to the page "${slug || "/"}"; keeping ${srcOf(infos.get(slug))}, ignoring ${abs(p)}`, { file: abs(p) });
3280
3458
  continue;
3281
3459
  }
3460
+ const resolvedWith = memo2 ? resolvedKey(p) : void 0;
3461
+ let title;
3462
+ let description;
3463
+ if (memo2 && memo2.title !== void 0 && memo2.resolvedWith === resolvedWith) {
3464
+ title = memo2.title;
3465
+ description = memo2.description;
3466
+ for (const f of memo2.deps ?? []) includedProject.add(f);
3467
+ } else {
3468
+ const keep = memo2 && !templatesOf(p).isTemplatePage(fm);
3469
+ const reads = keep ? (f) => state.scan.read(abs(p), f) : void 0;
3470
+ const skippedBefore = state.templateBudget.skipped.titles;
3471
+ title = detach(await resolveTitle(str(fm.title) ?? firstH1(await source()) ?? humanize2(slug.split("/").pop() || cfg.name), await source(), p, fm, reads));
3472
+ description = fm.description != null ? detach(await resolveTitle(String(fm.description), await source(), p, fm, reads)) : void 0;
3473
+ if (keep && state.templateBudget.skipped.titles === skippedBefore) Object.assign(memo2, { title, description, resolvedWith });
3474
+ }
3282
3475
  infos.set(slug, {
3283
3476
  slug,
3284
3477
  source: p,
3285
3478
  out: outFileFor(slug, isIndexFile),
3286
- title: detach(await resolveTitle(str(fm.title) ?? firstH1(src) ?? humanize2(slug.split("/").pop() || cfg.name), src, p, fm)),
3479
+ title,
3287
3480
  order: typeof fm.order === "number" ? fm.order : typeof fm.weight === "number" ? fm.weight : void 0,
3288
3481
  draft: fm.draft === true || fm.status === "draft",
3289
- description: fm.description != null ? detach(await resolveTitle(String(fm.description), src, p, fm)) : void 0,
3482
+ description,
3290
3483
  data: frontMatterCopy(fm)
3291
3484
  });
3292
- if (diagrams) for (const code of fencedDiagrams(src)) diagrams.note(detach(code), p);
3485
+ if (diagrams) for (const code of memo2 ? memo2.diagrams ??= fencedDiagrams(await source()).map(detach) : fencedDiagrams(await source())) diagrams.note(detach(code), p);
3293
3486
  taxonomies?.add(slug, fm);
3294
3487
  blog?.add(slug, infos.get(slug).title, fm);
3295
- if (mode.keep || mode.lazy) frontMatter.set(p, fmKey(src));
3488
+ if (mode.keep || mode.lazy) frontMatter.set(p, memo2 ? memo2.fmKey ??= fmKey(await source()) : fmKey(await source()));
3296
3489
  } else if (!SKIP_FILES.has(memberRel(p)) && copiedPath(p)) {
3297
3490
  const verdict = await assetVerdict(p, c, input.untrusted === true, sanitizing);
3298
3491
  if (verdict === "untrusted") untrustedFiles.push(p);
@@ -3353,6 +3546,7 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3353
3546
  if (strays.length) warn("openapi", `${strays.length} page(s) are in the folder of the OpenAPI source "${ns}", and are left out of the nav: ${strays.slice(0, 5).map(srcOf).join(", ")}${strays.length > 5 ? ", \u2026" : ""}`);
3354
3547
  }
3355
3548
  const labelFor = (slug) => infos.get(slug)?.title;
3549
+ const navLoad = state.scan ? (text) => state.scan.yaml(text, loadYaml) : void 0;
3356
3550
  const memberNav = async (ns) => {
3357
3551
  const dir2 = ns ? ns + "/" : "";
3358
3552
  const member = unit.members.find((m) => m.namespace === ns);
@@ -3364,7 +3558,7 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3364
3558
  if (!file) return { nav: fromPages() };
3365
3559
  try {
3366
3560
  const text = await read(file);
3367
- return { nav: file.endsWith(".json") ? prefixNav(parseNavJson(text, (slug) => labelFor(ns ? prefixSlug([ns], slug) : slug)), ns) : parseNavYaml(text, labelFor, ns), file, project: abs(file) };
3561
+ return { nav: file.endsWith(".json") ? prefixNav(parseNavJson(text, (slug) => labelFor(ns ? prefixSlug([ns], slug) : slug)), ns) : parseNavYaml(text, labelFor, ns, navLoad), file, project: abs(file) };
3368
3562
  } catch (e) {
3369
3563
  const mark = e.mark;
3370
3564
  warn("nav", `could not read ${file}: ${e.message}; using the directory layout`, {
@@ -3382,7 +3576,7 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3382
3576
  const c = input.files.get(path);
3383
3577
  const text = c == null ? void 0 : await readText(c);
3384
3578
  if (text === void 0) throw new ConfigError(`content.sources (${src.path}): its nav file ${path} does not exist`);
3385
- return { nav: path.endsWith(".json") ? prefixNav(parseNavJson(text, nsLabel), ns) : parseNavYaml(text, labelFor, ns), file: path, project: path, files: [path] };
3579
+ return { nav: path.endsWith(".json") ? prefixNav(parseNavJson(text, nsLabel), ns) : parseNavYaml(text, labelFor, ns, navLoad), file: path, project: path, files: [path] };
3386
3580
  };
3387
3581
  let nav = [];
3388
3582
  const navFiles = [];
@@ -3510,7 +3704,7 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3510
3704
  warn2("front-matter", `front matter does not parse${line ? ` (line ${line})` : ""}: ${reason}`, line);
3511
3705
  if (mode.diagnostics) diagnostics = [{ code: "front-matter", message: detach(`front matter does not parse: ${reason}`), ...line ? { line } : {} }];
3512
3706
  }
3513
- const pre = templatesOf(info.source).isTemplatePage(fm) ? { text: await templatesOf(info.source).render(bodySrc, info.source, fm, { firstLine: raw.slice(0, raw.length - bodySrc.length).split("\n").length }) } : await preprocess(bodySrc, info.source, attributesOf(info.source), includesFor(info.source), cfg.undefinedRefs);
3707
+ const pre = templatesOf(info.source).isTemplatePage(fm) ? { text: await templatesOf(info.source).render(bodySrc, info.source, fm, { firstLine: raw.slice(0, raw.length - bodySrc.length).split("\n").length }) } : await preprocess(bodySrc, info.source, attributesOf(info.source), includesFor(info.source), cfg.undefinedRefs, templateRun);
3514
3708
  if (pre.error) {
3515
3709
  warn2("template", `template error: ${pre.error}`);
3516
3710
  if (mode.diagnostics) (diagnostics ??= []).push({ code: "template", message: detach(`template error: ${pre.error}`) });
@@ -3857,7 +4051,7 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3857
4051
  body = sp.content;
3858
4052
  } catch {
3859
4053
  }
3860
- const text = templatesOf(info.source).isTemplatePage(pageFm) ? await templatesOf(info.source).render(body, info.source, pageFm) : (await preprocess(body, info.source, attributesOf(info.source), includesFor(info.source), cfg.undefinedRefs)).text;
4054
+ const text = templatesOf(info.source).isTemplatePage(pageFm) ? await templatesOf(info.source).render(body, info.source, pageFm) : (await preprocess(body, info.source, attributesOf(info.source), includesFor(info.source), cfg.undefinedRefs, { ...templateRun, kind: "copy" })).text;
3861
4055
  const ns = named.length ? namespaceOf(info.source) : "";
3862
4056
  if (!ns && !PLACEHOLDER_DIRECTIVE.test(text)) return text;
3863
4057
  const tree = dogsbayMdToTree(text).tree;
@@ -3920,7 +4114,7 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path), cfg.un
3920
4114
  },
3921
4115
  included(path) {
3922
4116
  const r = rel(path);
3923
- return r !== void 0 && includedPaths.has(r);
4117
+ return r !== void 0 && includedPaths.has(r) || includedProject.has(path);
3924
4118
  }
3925
4119
  };
3926
4120
  async function emitGenerated() {
@@ -4450,6 +4644,7 @@ async function openDevSession(input, o = {}) {
4450
4644
  const rendering = /* @__PURE__ */ new Map();
4451
4645
  let ctx;
4452
4646
  let config;
4647
+ let parsedConfig;
4453
4648
  let taken = 0;
4454
4649
  let takenProblems = 0;
4455
4650
  let buildError;
@@ -4493,6 +4688,7 @@ async function openDevSession(input, o = {}) {
4493
4688
  } catch (e) {
4494
4689
  throw e instanceof ConfigError ? inConfig(e, name) : e;
4495
4690
  }
4691
+ parsedConfig = parsed;
4496
4692
  out = next;
4497
4693
  units = nextUnits;
4498
4694
  ctx = nextCtx;
@@ -4534,8 +4730,23 @@ async function openDevSession(input, o = {}) {
4534
4730
  const css = await (await siteCssBuilder(input))({ theme: config.theme, themeCss: ctx.theme.themeCss, extra: ctx.theme.extraCss, candidates: u.classes });
4535
4731
  out.set(`${u.unit.prefix ? u.unit.prefix + "/" : ""}_dogsbay/site.css`, css);
4536
4732
  };
4733
+ let requests = Promise.resolve();
4734
+ const budgeted = (work) => {
4735
+ const run = requests.then(async () => {
4736
+ const budget = ctx?.state.templateBudget;
4737
+ budget?.reset();
4738
+ try {
4739
+ return await work();
4740
+ } finally {
4741
+ const spent = ctx?.state.templateBudget === budget ? budget?.problem() : void 0;
4742
+ if (spent) ctx.report("template", spent);
4743
+ }
4744
+ });
4745
+ requests = run.catch(() => void 0);
4746
+ return run;
4747
+ };
4537
4748
  const renderStale = async (path, u) => {
4538
- await u.handle.render(path);
4749
+ await budgeted(() => u.handle.render(path));
4539
4750
  stale.delete(path);
4540
4751
  const html = out.get(path);
4541
4752
  if (typeof html === "string") {
@@ -4611,48 +4822,80 @@ async function openDevSession(input, o = {}) {
4611
4822
  },
4612
4823
  get,
4613
4824
  async change(paths) {
4825
+ const t0 = Date.now();
4826
+ const done = (r) => ({ ...r, ms: Date.now() - t0 });
4614
4827
  try {
4615
4828
  if (buildError !== void 0) {
4616
4829
  await full();
4617
4830
  buildError = void 0;
4618
4831
  buildProblem = void 0;
4619
- return { css: true };
4832
+ return done({ css: true, handled: "full" });
4620
4833
  }
4621
- return await changeParts(paths);
4834
+ return done(await budgeted(() => changeParts(paths)));
4622
4835
  } catch (e) {
4623
4836
  failed(e);
4624
- return { css: false };
4837
+ return done({ css: false, handled: "full" });
4625
4838
  }
4626
4839
  }
4627
4840
  };
4628
4841
  async function changeParts(paths) {
4629
4842
  const themeDir = config.themeDir ? config.themeDir.replace(/\/+$/, "") + "/" : void 0;
4630
4843
  const extraCss = new Set(config.css.map((c) => c.replace(/^\.\//, "")));
4631
- const navInputs = new Set([...units.values()].flatMap((u) => u.handle.navInputs));
4632
- const ownNav = (p) => TOPIC_MAP.test(p) || navInputs.has(p) || config.sources.some((s) => s.nav === p);
4633
- if (paths.some((p) => CONFIG_FILES.has(p) || themeDir && p.startsWith(themeDir) || extraCss.has(p) || ownNav(p))) {
4844
+ const lazy = /* @__PURE__ */ new Set();
4845
+ if (paths.some((p) => CONFIG_FILES.has(p))) {
4846
+ const next = await attributesOnly();
4847
+ if (!next) {
4848
+ await full();
4849
+ return { css: true, handled: "full" };
4850
+ }
4851
+ ctx.cfg = { ...ctx.cfg, attributes: next.attributes };
4852
+ config = { ...config, attributes: next.attributes };
4853
+ const same = JSON.stringify(next.attributes) === JSON.stringify(parsedConfig?.attributes);
4854
+ parsedConfig = next;
4855
+ if (!same) for (const prefix of units.keys()) lazy.add(prefix);
4856
+ }
4857
+ if (paths.some((p) => themeDir && p.startsWith(themeDir) || extraCss.has(p))) {
4634
4858
  await full();
4635
- return { css: true };
4859
+ return { css: true, handled: "full" };
4636
4860
  }
4637
- const lazy = /* @__PURE__ */ new Set();
4861
+ for (const p of paths) ctx.state.scan?.changed(p);
4862
+ let handled = lazy.size ? "unit" : "none";
4863
+ const mark = (h) => {
4864
+ const rank = ["none", "asset", "page", "unit", "full"];
4865
+ if (rank.indexOf(h) > rank.indexOf(handled)) handled = h;
4866
+ };
4638
4867
  for (const p of paths) {
4868
+ if (CONFIG_FILES.has(p)) continue;
4869
+ const readers = [...units.values()].filter((u2) => u2.handle.navInputs.includes(p) || u2.unit.members.some((m) => m.source.nav === p));
4870
+ if (readers.length) {
4871
+ for (const u2 of [...readers, unitOf(p)]) if (u2) lazy.add(u2.unit.prefix);
4872
+ mark("unit");
4873
+ continue;
4874
+ }
4875
+ if (TOPIC_MAP.test(p)) continue;
4639
4876
  const u = unitOf(p);
4640
4877
  if (!u) continue;
4641
4878
  const content = input.files.get(p);
4642
4879
  if (/\.(md|markdown)$/i.test(p)) {
4643
4880
  const page = u.handle.pageForSource(p);
4644
4881
  const text = content === void 0 ? void 0 : await textOf(content);
4645
- if (page && text !== void 0 && u.handle.sameFrontMatter(p, text) && !u.handle.included(p)) stale.add(page);
4646
- else {
4882
+ if (page && text !== void 0 && u.handle.sameFrontMatter(p, text) && !u.handle.included(p)) {
4883
+ stale.add(page);
4884
+ mark("page");
4885
+ } else {
4647
4886
  u.handle.forget(p);
4648
4887
  lazy.add(u.unit.prefix);
4888
+ mark("unit");
4649
4889
  }
4650
4890
  } else if (NAV_FILES2.test(p)) {
4651
4891
  lazy.add(u.unit.prefix);
4892
+ mark("unit");
4652
4893
  } else if (content === void 0 || /\.(md|markdown|ya?ml|json|txt|html?|csv)$/i.test(p)) {
4653
4894
  u.handle.forget(p);
4654
4895
  lazy.add(u.unit.prefix);
4896
+ mark("unit");
4655
4897
  } else {
4898
+ mark("asset");
4656
4899
  const at = memberPath(u.unit, p)?.path ?? p.slice(sourceRoot(u.unit.source).length);
4657
4900
  const key = u.unit.prefix ? `${u.unit.prefix}/${at}` : at;
4658
4901
  const sanitizing = (config.html ?? input.defaultHtml ?? "keep") === "sanitize";
@@ -4698,7 +4941,20 @@ async function openDevSession(input, o = {}) {
4698
4941
  (p) => stale.has(p)
4699
4942
  );
4700
4943
  }
4701
- return { css: false };
4944
+ return { css: false, handled };
4945
+ }
4946
+ async function attributesOnly() {
4947
+ const file = input.files.get("dogsbay.config.yml") ?? input.files.get("dogsbay.config.yaml");
4948
+ const text = file !== void 0 ? await textOf(file) : void 0;
4949
+ if (text === void 0 || !parsedConfig) return void 0;
4950
+ let next;
4951
+ try {
4952
+ next = parseConfig(text);
4953
+ } catch {
4954
+ return void 0;
4955
+ }
4956
+ const rest = (c) => JSON.stringify({ ...c, attributes: void 0 });
4957
+ return rest(next) === rest(parsedConfig) ? next : void 0;
4702
4958
  }
4703
4959
  async function emitInto(path, content) {
4704
4960
  if (START_ONLY.test(path)) return;
@@ -4818,5 +5074,6 @@ export {
4818
5074
  parseNavJson,
4819
5075
  parseNavYaml,
4820
5076
  peleRenderer,
4821
- prebuiltSiteCss
5077
+ prebuiltSiteCss,
5078
+ projectRoots
4822
5079
  };
@@ -20,6 +20,9 @@ import { type NavCache } from "../theme/regions.js";
20
20
  import { ThemeTemplates } from "../theme/page.js";
21
21
  import type { CssOptions } from "../theme/css-build.js";
22
22
  import { type FileRef, type TextRef, type TextStream } from "./detach.js";
23
+ import { type TemplateBudget } from "./preprocess.js";
24
+ import { ParseCache } from "@dogsbay/minja/browser";
25
+ import { ScanCache } from "./scan-cache.js";
23
26
  import { type RedirectInput, type RedirectRule } from "./site-files.js";
24
27
  import { DiagramStore, type DiagramRenderer, type DiagramRenderers } from "./diagrams.js";
25
28
  export type FileContent = string | Uint8Array | FileRef | TextRef;
@@ -90,6 +93,19 @@ export interface BuildInput {
90
93
  * 8–16 MB). The CLI passes 64M (up to about 128 MB), the Worker 4M.
91
94
  */
92
95
  highlightCacheChars?: number;
96
+ /**
97
+ * Estimated bytes of included templates' parses kept for reuse (minja's `ParseCache`; default
98
+ * 8 MB, which holds every reuse on OpenShift). 0 parses every include again.
99
+ */
100
+ parseCacheBytes?: number;
101
+ /**
102
+ * Template time: per render, and for the whole build (a dev session: for opening it, and for
103
+ * each change or page request). Defaults: 10 s and 10 minutes; an untrusted build 2 s and 30 s.
104
+ */
105
+ templateMs?: {
106
+ renderMs?: number;
107
+ buildMs?: number;
108
+ };
93
109
  /** Characters of rendered SVG kept for reuse across units (default 8M; a Worker passes less). */
94
110
  diagramCacheChars?: number;
95
111
  /**
@@ -189,6 +205,12 @@ export interface SiteState {
189
205
  written: Set<string>;
190
206
  /** The stubs for missing translations last written, so a dev session can replace them. */
191
207
  stubs: string[];
208
+ /** A dev session's: what pass 1 read from each page, kept between its rebuilds (src/core/scan-cache.ts). */
209
+ scan?: ScanCache;
210
+ /** Included templates' parses (false: none kept), kept as long as the state: a dev session's unit rebuilds keep it. */
211
+ parseCache: ParseCache | false;
212
+ /** Template time left for the build, or for a dev session's current request. */
213
+ templateBudget: TemplateBudget;
192
214
  }
193
215
  export interface BuildStats {
194
216
  pages: number;
@@ -203,6 +225,14 @@ export interface BuildStats {
203
225
  entries: number;
204
226
  chars: number;
205
227
  };
228
+ /** The parse cache for includes at the end of the build (minja's `ParseCache.stats()`). */
229
+ parseCache?: {
230
+ hits: number;
231
+ misses: number;
232
+ entries: number;
233
+ bytes: number;
234
+ maxBytes: number;
235
+ };
206
236
  }
207
237
  export interface BuildResult {
208
238
  files: Map<string, OutputContent>;
@@ -8,10 +8,14 @@
8
8
  * - otherwise a page added, removed or changed, a nav file, or a file of the
9
9
  * unit that is not a page (an include): the unit is built again without
10
10
  * its pages (`rebuildUnitLazy`), its old output is dropped, and all its
11
- * pages are stale;
11
+ * pages are stale. What pass 1 read from each unchanged page is kept
12
+ * between rebuilds (src/core/scan-cache.ts), so this is cheap;
13
+ * - a topic map, a map it includes or a source's `nav:` file: the units
14
+ * that read it are built again, as above;
12
15
  * - an asset: its entry is replaced;
13
- * - the config, theme or extra stylesheets: everything is built again, and
14
- * swapped in only if that build succeeds.
16
+ * - a config change to `attributes` only: every unit is built again, as above;
17
+ * - any other config change, the theme or extra stylesheets: everything is
18
+ * built again, and swapped in only if that build succeeds.
15
19
  *
16
20
  * A stale page is rendered when it is asked for, with the current nav and
17
21
  * page table, so nothing shown is out of date and no dependency map is
@@ -32,9 +36,7 @@ export interface DevSession {
32
36
  * already updated. It does not throw: a change that fails sets `buildError` (and `buildProblem`)
33
37
  * and resolves `{ css: false }`, and the last good site stays served.
34
38
  */
35
- change(paths: string[]): Promise<{
36
- css: boolean;
37
- }>;
39
+ change(paths: string[]): Promise<ChangeResult>;
38
40
  /** Warnings not handed out yet (the start's, then those of each change and page render). */
39
41
  takeWarnings(): string[];
40
42
  /**
@@ -62,6 +64,16 @@ export interface DevSession {
62
64
  /** `buildError` as data: a config that does not parse names the config file, with its line and column. */
63
65
  readonly buildProblem: BuildProblem | undefined;
64
66
  }
67
+ /** What `change` did: `css` when the stylesheet may have changed, how it handled the change, and how long it took. */
68
+ export interface ChangeResult {
69
+ css: boolean;
70
+ /**
71
+ * The most it rebuilt: `full` (everything, as at the start), `unit` (one or more units without
72
+ * their pages), `page` (pages marked stale), `asset` (files replaced), `none` (nothing it builds).
73
+ */
74
+ handled: "full" | "unit" | "page" | "asset" | "none";
75
+ ms: number;
76
+ }
65
77
  /**
66
78
  * By default every page is rendered at the start, as `build` does: search has its index and
67
79
  * every warning is reported up front. `lazy`: only pass 1 runs at the start and each page is
@@ -15,7 +15,8 @@ export interface NavNode {
15
15
  /** "topics/x.md" → "topics/x"; "index.md" → ""; "a/index.md" → "a". */
16
16
  export declare function slugFromPath(p: string): string;
17
17
  type LabelFor = (slug: string) => string | undefined;
18
- export declare function parseNavYaml(text: string, labelFor: LabelFor, baseDir?: string): NavNode[];
18
+ /** `load`: the YAML loader, which a dev session's scan cache wraps (it reads the result, never changes it). */
19
+ export declare function parseNavYaml(text: string, labelFor: LabelFor, baseDir?: string, load?: (text: string) => unknown): NavNode[];
19
20
  export declare function parseNavJson(text: string, labelFor: LabelFor): NavNode[];
20
21
  /** Derive a nav tree from page slugs: one section per directory, index pages as landing pages. */
21
22
  export declare function navFromPages(pages: {
@@ -1,6 +1,59 @@
1
+ /**
2
+ * Minja templating pass: `{{ var }}`, `{% if %}`, `{% include %}`.
3
+ * Runs on the raw page text before the markdown parser, the same way
4
+ * the Dogsbay CLI's site build does, but over an in-memory file map.
5
+ *
6
+ * Template syntax inside code (fences and inline spans) is masked so
7
+ * examples such as Helm's `{{ .Values }}` are never evaluated. `{#`
8
+ * (heading anchors like `{#intro}`) is masked globally because minja
9
+ * reads it as a comment opener.
10
+ */
11
+ import { type ParseCache } from "@dogsbay/minja/browser";
1
12
  export interface PreprocessResult {
2
13
  text: string;
3
14
  error?: string;
15
+ /** Not rendered: the build's template time budget was spent (counted in `TemplateBudget.skipped`). */
16
+ skipped?: boolean;
17
+ }
18
+ /**
19
+ * Template time for a whole build (plans/worker-scale.md stage 3): each render gets the shorter of
20
+ * its own limit and what is left, and once it is spent the rest are not rendered (counted, for one
21
+ * problem). Time is counted while at least one render is running, so renders the build prepares
22
+ * ahead in parallel are not counted twice. A dev session resets it for each request.
23
+ */
24
+ export declare class TemplateBudget {
25
+ readonly renderMs: number;
26
+ readonly buildMs: number;
27
+ private readonly now;
28
+ private spentMs;
29
+ private active;
30
+ private since;
31
+ /** Renders not run because the budget was spent, by what they were for. */
32
+ skipped: {
33
+ pages: number;
34
+ titles: number;
35
+ };
36
+ constructor(renderMs: number, buildMs: number, now?: () => number);
37
+ get spent(): number;
38
+ /** The time the next render may take; 0 when the budget is spent. */
39
+ next(): number;
40
+ start(): void;
41
+ end(): void;
42
+ reset(): void;
43
+ /** The problem to report when renders were skipped since the last reset, else undefined. */
44
+ problem(): string | undefined;
45
+ }
46
+ /** Per render: render limits for a build (untrusted: 2 s; else 10 s), and for the whole build (untrusted: 30 s; else 10 minutes). */
47
+ export declare function templateBudget(untrusted: boolean, given?: {
48
+ renderMs?: number;
49
+ buildMs?: number;
50
+ }): TemplateBudget;
51
+ /** What a build's renders share: the parse cache for includes, the time budget, and what the render is for. */
52
+ export interface TemplateRun {
53
+ parseCache?: ParseCache | false;
54
+ budget?: TemplateBudget;
55
+ /** What the render is for: a page's body, a title, or a copy of a body already counted (llms-full.txt), not counted again. */
56
+ kind?: "page" | "title" | "copy";
4
57
  }
5
58
  /** True when a page contains template syntax worth running minja over. */
6
59
  export declare function needsTemplating(text: string): boolean;
@@ -9,6 +62,6 @@ export declare function needsTemplating(text: string): boolean;
9
62
  * to the content root; `files` looks up other files by the same kind
10
63
  * of path. A template error leaves the page unchanged and is reported.
11
64
  */
12
- export declare function preprocess(text: string, path: string, attributes: Record<string, unknown>, files: (path: string) => string | undefined | Promise<string | undefined>, undefinedRefs?: "preserve" | "asciidoc-literal"): Promise<PreprocessResult>;
65
+ export declare function preprocess(text: string, path: string, attributes: Record<string, unknown>, files: (path: string) => string | undefined | Promise<string | undefined>, undefinedRefs?: "preserve" | "asciidoc-literal", run?: TemplateRun): Promise<PreprocessResult>;
13
66
  /** `product-title` is also reachable as `product_title`, because minja normalises hyphens on lookup. */
14
67
  export declare function withUnderscoreAliases(attrs: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What pass 1 of a unit's build reads from each page, kept between a dev session's rebuilds
3
+ * (plans/worker-scale.md, stage 4). Rebuilding a unit after a menu, title, include or new-page
4
+ * edit re-reads every page; on a large site most of that is resolving templated titles, which
5
+ * renders each page's preamble and its includes. A page's entry is used again while its file is
6
+ * the same content (the dev server and the playground replace a changed file's value in the file
7
+ * map), its title was resolved with the same attributes, and no file its title read has changed.
8
+ *
9
+ * Only a dev session builds with one (`BuildHooks.keep` or `lazy`): a build reads each page once.
10
+ */
11
+ import type { FileContent } from "./build.js";
12
+ import type { TopicMapNav } from "./topicmap.js";
13
+ export interface ScanEntry {
14
+ /** The file map's value the entry was made from: a different value is a different file. */
15
+ content: FileContent;
16
+ fm?: Record<string, unknown>;
17
+ /** The first H1 and the front matter block (`fmKey`), for telling a body edit from a page-table change. */
18
+ fmKey?: string;
19
+ /** Mermaid sources the page fences. */
20
+ diagrams?: string[];
21
+ /** The resolved title and description, and what they were resolved with. */
22
+ title?: string;
23
+ description?: string;
24
+ resolvedWith?: string;
25
+ /** Project paths the title and description read (includes found or looked for). */
26
+ deps?: Set<string>;
27
+ }
28
+ export declare class ScanCache {
29
+ private entries;
30
+ /** Project path of a file read while resolving titles → the pages whose entries read it. */
31
+ private readers;
32
+ /** The last nav YAML loaded, by its text: an unchanged nav.yml is not parsed again. */
33
+ private navText?;
34
+ private navData?;
35
+ /** Resolved topic maps, with what they were resolved with and the file map's value of every file read. */
36
+ private maps;
37
+ /** The page's entry when it was made from this content, else a new empty one for it. */
38
+ entry(path: string, content: FileContent): ScanEntry;
39
+ /** Records that a page's title or description read a file, so a change to that file drops them. */
40
+ read(page: string, file: string): void;
41
+ /** A file changed, was added or was removed: its own entry goes, and the titles that read it. */
42
+ changed(path: string): void;
43
+ /** `load(text)`, or the value it gave for the same text last time (the caller must not change it). */
44
+ yaml(text: string, load: (text: string) => unknown): unknown;
45
+ /** A topic map resolved with `key` (its variables), if no file it read has changed since. */
46
+ topicMap(path: string, key: string, files: Map<string, FileContent>): TopicMapNav | undefined;
47
+ /** Keeps a resolved topic map (not one with errors: the build stops on those) and the files it read. */
48
+ keepTopicMap(path: string, key: string, reads: Map<string, FileContent | undefined>, nav: TopicMapNav): void;
49
+ get size(): number;
50
+ private dropDeps;
51
+ }
@@ -20,8 +20,8 @@ export declare function openDevSession(input: BuildInput & {
20
20
  }, o?: DevOptions): Promise<DevSession>;
21
21
  export { prebuiltSiteCss };
22
22
  export type { BuildInput, BuildResult, FileContent, OutputContent, SinkContent, SiteCssBuilder, BuiltPage, HighlightPool, BuildProblem, PageDiagnostic } from "../core/build.js";
23
- export type { DevOptions, DevSession } from "../core/dev.js";
24
- export { parseConfig, ConfigError, type SiteConfig, type DiagramEngine } from "../core/config.js";
23
+ export type { ChangeResult, DevOptions, DevSession } from "../core/dev.js";
24
+ export { parseConfig, projectRoots, ConfigError, type SiteConfig, type DiagramEngine } from "../core/config.js";
25
25
  export type { DiagramRenderer, DiagramRenderers, DiagramRequest, DiagramResult } from "../core/diagrams.js";
26
26
  export { peleRenderer } from "../core/pele.js";
27
27
  export { parseNavYaml, parseNavJson, navFromPages, indexNav, type NavNode, type NavIndex } from "../core/nav.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/ssg",
3
- "version": "0.2.0-beta.131",
3
+ "version": "0.2.0-beta.133",
4
4
  "type": "module",
5
5
  "description": "Documentation static site generator for Dogsbay-MD that builds inside Cloudflare Workers and publishes sites that work offline",
6
6
  "license": "Apache-2.0",
@@ -47,38 +47,38 @@
47
47
  "tailwindcss": "^4.3.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "@dogsbay/elements": "0.2.0-beta.131",
51
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.131",
52
- "@dogsbay/format-openapi": "0.2.0-beta.131",
53
- "@dogsbay/format-topicmap": "0.2.0-beta.131",
54
- "@dogsbay/highlight": "0.2.0-beta.131",
55
- "@dogsbay/minja": "0.2.0-beta.131",
56
- "@dogsbay/primitives": "0.2.0-beta.131",
57
- "@dogsbay/render-html": "0.2.0-beta.131",
58
- "@dogsbay/search": "0.2.0-beta.131",
59
- "@dogsbay/serialize-core": "0.2.0-beta.131",
60
- "@dogsbay/site-check": "0.2.0-beta.131",
61
- "@dogsbay/site-files": "0.2.0-beta.131",
62
- "@dogsbay/types": "0.2.0-beta.131"
50
+ "@dogsbay/elements": "0.2.0-beta.133",
51
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.133",
52
+ "@dogsbay/format-openapi": "0.2.0-beta.133",
53
+ "@dogsbay/format-topicmap": "0.2.0-beta.133",
54
+ "@dogsbay/highlight": "0.2.0-beta.133",
55
+ "@dogsbay/minja": "0.2.0-beta.133",
56
+ "@dogsbay/primitives": "0.2.0-beta.133",
57
+ "@dogsbay/render-html": "0.2.0-beta.133",
58
+ "@dogsbay/search": "0.2.0-beta.133",
59
+ "@dogsbay/serialize-core": "0.2.0-beta.133",
60
+ "@dogsbay/site-check": "0.2.0-beta.133",
61
+ "@dogsbay/site-files": "0.2.0-beta.133",
62
+ "@dogsbay/types": "0.2.0-beta.133"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@axe-core/playwright": "^4.12.1",
66
66
  "@cloudflare/workers-types": "^5.20260921.1",
67
- "@dogsbay/elements": "0.2.0-beta.131",
68
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.131",
69
- "@dogsbay/format-openapi": "0.2.0-beta.131",
70
- "@dogsbay/format-topicmap": "0.2.0-beta.131",
71
- "@dogsbay/highlight": "0.2.0-beta.131",
72
- "@dogsbay/icons": "0.2.0-beta.131",
73
- "@dogsbay/minja": "0.2.0-beta.131",
74
- "@dogsbay/primitives": "0.2.0-beta.131",
75
- "@dogsbay/render-html": "0.2.0-beta.131",
76
- "@dogsbay/search": "0.2.0-beta.131",
77
- "@dogsbay/serialize-core": "0.2.0-beta.131",
78
- "@dogsbay/site-check": "0.2.0-beta.131",
79
- "@dogsbay/site-files": "0.2.0-beta.131",
80
- "@dogsbay/types": "0.2.0-beta.131",
81
- "@dogsbay/ui": "0.2.0-beta.131",
67
+ "@dogsbay/elements": "0.2.0-beta.133",
68
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.133",
69
+ "@dogsbay/format-openapi": "0.2.0-beta.133",
70
+ "@dogsbay/format-topicmap": "0.2.0-beta.133",
71
+ "@dogsbay/highlight": "0.2.0-beta.133",
72
+ "@dogsbay/icons": "0.2.0-beta.133",
73
+ "@dogsbay/minja": "0.2.0-beta.133",
74
+ "@dogsbay/primitives": "0.2.0-beta.133",
75
+ "@dogsbay/render-html": "0.2.0-beta.133",
76
+ "@dogsbay/search": "0.2.0-beta.133",
77
+ "@dogsbay/serialize-core": "0.2.0-beta.133",
78
+ "@dogsbay/site-check": "0.2.0-beta.133",
79
+ "@dogsbay/site-files": "0.2.0-beta.133",
80
+ "@dogsbay/types": "0.2.0-beta.133",
81
+ "@dogsbay/ui": "0.2.0-beta.133",
82
82
  "@mermanjs/web": "0.7.0",
83
83
  "@playwright/test": "1.61.1",
84
84
  "@types/node": "^22.10.0",