@staticforge/build 0.0.0-stage → 0.1.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/build.d.ts +75 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +692 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/cache.d.ts +64 -0
  9. package/dist/cache.d.ts.map +1 -0
  10. package/dist/cache.js +97 -0
  11. package/dist/cache.js.map +1 -0
  12. package/dist/code-assets.d.ts +36 -0
  13. package/dist/code-assets.d.ts.map +1 -0
  14. package/dist/code-assets.js +61 -0
  15. package/dist/code-assets.js.map +1 -0
  16. package/dist/data.d.ts +8 -0
  17. package/dist/data.d.ts.map +1 -0
  18. package/dist/data.js +43 -0
  19. package/dist/data.js.map +1 -0
  20. package/dist/feeds.d.ts +33 -0
  21. package/dist/feeds.d.ts.map +1 -0
  22. package/dist/feeds.js +120 -0
  23. package/dist/feeds.js.map +1 -0
  24. package/dist/html.d.ts +14 -0
  25. package/dist/html.d.ts.map +1 -0
  26. package/dist/html.js +31 -0
  27. package/dist/html.js.map +1 -0
  28. package/dist/images.d.ts +22 -0
  29. package/dist/images.d.ts.map +1 -0
  30. package/dist/images.js +95 -0
  31. package/dist/images.js.map +1 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +8 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/nav.d.ts +15 -0
  37. package/dist/nav.d.ts.map +1 -0
  38. package/dist/nav.js +19 -0
  39. package/dist/nav.js.map +1 -0
  40. package/dist/project.d.ts +31 -0
  41. package/dist/project.d.ts.map +1 -0
  42. package/dist/project.js +74 -0
  43. package/dist/project.js.map +1 -0
  44. package/dist/routes.d.ts +42 -0
  45. package/dist/routes.d.ts.map +1 -0
  46. package/dist/routes.js +106 -0
  47. package/dist/routes.js.map +1 -0
  48. package/dist/scaffold.d.ts +46 -0
  49. package/dist/scaffold.d.ts.map +1 -0
  50. package/dist/scaffold.js +163 -0
  51. package/dist/scaffold.js.map +1 -0
  52. package/dist/site.d.ts +47 -0
  53. package/dist/site.d.ts.map +1 -0
  54. package/dist/site.js +107 -0
  55. package/dist/site.js.map +1 -0
  56. package/dist/tasks.d.ts +86 -0
  57. package/dist/tasks.d.ts.map +1 -0
  58. package/dist/tasks.js +103 -0
  59. package/dist/tasks.js.map +1 -0
  60. package/package.json +48 -3
package/dist/build.js ADDED
@@ -0,0 +1,692 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, posix } from 'node:path';
4
+ import { h } from 'hastscript';
5
+ import { toHtml } from 'hast-util-to-html';
6
+ import { writeSearchIndex } from '@staticforge/assets';
7
+ import { createReport, prepareSource } from '@staticforge/content';
8
+ import { countBySeverity, createWorkerPool, mapInPool, poolSize, sortDiagnostics, } from '@staticforge/core';
9
+ import { createRenderer, loadTheme, themeAssetUrl, } from '@staticforge/render';
10
+ import { hashOf, memoized, stableJson } from './cache.js';
11
+ import { isCode, processCode } from './code-assets.js';
12
+ import { loadData } from './data.js';
13
+ import { absolutizeHtml, atomFeed, FEED_ITEMS, robotsTxt, rssFeed, sitemapXml } from './feeds.js';
14
+ import { finishFragment, finishTree } from './html.js';
15
+ import { optimizeImages } from './images.js';
16
+ import { navEntries } from './nav.js';
17
+ import { PAGE_EXTENSIONS, ProjectDirs, listFiles, resolveOutDir, resolveScratchDir, } from './project.js';
18
+ import { routeFor } from './routes.js';
19
+ import { collectionsOf, listRoute, slugify, summarize, tagRoute, tagsOf, } from './site.js';
20
+ import { MIN_PAGES_FOR_WORKERS, PARSE_TASK, RENDER_TASK, assetUrlFor, outputExists, parsePages, renderPages, } from './tasks.js';
21
+ export const MANIFEST_PATH = '.sforge/build-manifest.json';
22
+ /** Encoded image variants, kept between builds. */
23
+ export const CACHE_DIR = '.sforge/cache';
24
+ const ucfirst = (s) => s.charAt(0).toUpperCase() + s.slice(1);
25
+ export async function buildSite(options) {
26
+ if (options.pool !== undefined)
27
+ return build(options, options.pool);
28
+ // Workers start on first use, so a small site never pays for them.
29
+ const size = poolSize(options.config.build.workers);
30
+ const pool = size > 0 ? createWorkerPool(size) : undefined;
31
+ try {
32
+ return await build(options, pool);
33
+ }
34
+ finally {
35
+ await pool?.close();
36
+ }
37
+ }
38
+ async function build(options, pool) {
39
+ const started = performance.now();
40
+ const { rootDir, config, cache } = options;
41
+ const logger = options.logger;
42
+ cache?.begin();
43
+ const baseUrl = options.baseUrl ?? config.site.baseUrl;
44
+ const includeDrafts = options.drafts ?? config.build.drafts;
45
+ const minify = options.minify ?? config.build.minify;
46
+ const fingerprint = options.fingerprint ?? true;
47
+ const urlOptions = config.build;
48
+ const outDir = options.scratchDir !== undefined
49
+ ? resolveScratchDir(options.scratchDir)
50
+ : resolveOutDir(rootDir, options.outDir ?? config.build.outDir);
51
+ const diagnostics = [];
52
+ // Phase durations, logged at debug level (`--verbose`).
53
+ let phaseStart = started;
54
+ const phase = (name) => {
55
+ const now = performance.now();
56
+ logger?.debug(`build: ${name} ${Math.round(now - phaseStart)}ms`);
57
+ phaseStart = now;
58
+ };
59
+ const theme = loadTheme(config.theme, rootDir);
60
+ // 1. Claim every output path, reporting collisions.
61
+ const outputs = new Map();
62
+ const claim = (output, source) => {
63
+ const owner = outputs.get(output);
64
+ if (owner !== undefined) {
65
+ diagnostics.push({
66
+ ruleId: 'output-conflict',
67
+ severity: 'error',
68
+ message: `Both ${owner} and ${source} would be written to ${output}`,
69
+ file: source,
70
+ });
71
+ return false;
72
+ }
73
+ outputs.set(output, source);
74
+ return true;
75
+ };
76
+ const pages = [];
77
+ const copies = [];
78
+ const skippedDrafts = [];
79
+ const contentDir = join(rootDir, ProjectDirs.content);
80
+ for (const file of listFiles(contentDir)) {
81
+ const projectPath = `${ProjectDirs.content}/${file}`;
82
+ const ext = posix.extname(file).toLowerCase();
83
+ if (!PAGE_EXTENSIONS.has(ext)) {
84
+ if (claim(file, projectPath))
85
+ copies.push({ source: join(contentDir, file), output: file });
86
+ continue;
87
+ }
88
+ const format = ext === '.md' || ext === '.markdown' ? 'markdown' : 'html';
89
+ const text = readFileSync(join(contentDir, file), 'utf8');
90
+ const sourceKey = cache && hashOf(projectPath, format, text);
91
+ let prepared = sourceKey === undefined ? undefined : cache?.prepared.get(sourceKey);
92
+ if (prepared === undefined) {
93
+ prepared = prepareSource(text, projectPath, format);
94
+ if (sourceKey !== undefined)
95
+ cache?.prepared.set(sourceKey, prepared);
96
+ }
97
+ if (prepared.meta.draft && !includeDrafts) {
98
+ skippedDrafts.push(projectPath);
99
+ continue;
100
+ }
101
+ const route = routeFor(file, prepared.meta.slug, urlOptions);
102
+ if (claim(route.output, projectPath))
103
+ pages.push({ prepared, route, sourceKey });
104
+ }
105
+ const codeInputs = [];
106
+ const copyTree = (dir, prefix, label, processed) => {
107
+ for (const file of listFiles(dir)) {
108
+ const output = prefix + file;
109
+ if (!claim(output, `${label}/${file}`))
110
+ continue;
111
+ if (processed && isCode(file)) {
112
+ codeInputs.push({ source: join(dir, file), output, label: `${label}/${file}` });
113
+ }
114
+ else {
115
+ copies.push({ source: join(dir, file), output });
116
+ }
117
+ }
118
+ };
119
+ copyTree(join(rootDir, ProjectDirs.assets), 'assets/', ProjectDirs.assets, true);
120
+ copyTree(theme.assetsDir, themeAssetUrl('').slice(1), `theme:${theme.manifest.name}`, true);
121
+ copyTree(join(rootDir, ProjectDirs.static), '', ProjectDirs.static, false);
122
+ const copyByOutput = new Map(copies.map((c) => [c.output, c]));
123
+ phase('discover');
124
+ // 2. Stylesheets and scripts: bundle, minify, fingerprint.
125
+ const code = await processCode(codeInputs, { minify, fingerprint });
126
+ phase('stylesheets and scripts');
127
+ diagnostics.push(...code.diagnostics);
128
+ for (const out of code.outputs) {
129
+ if (!outputs.has(out.output))
130
+ claim(out.output, 'assets');
131
+ }
132
+ const assetUrl = assetUrlFor(code.urls);
133
+ const renderer = createRenderer(theme, { rootDir, baseUrl, assetUrl });
134
+ const hasNotFoundPage = outputs.has('404.html');
135
+ if (!hasNotFoundPage && renderer.hasLayout('404'))
136
+ claim('404.html', 'theme 404 layout');
137
+ const fileExists = (urlPath) => outputExists(outputs, urlPath);
138
+ // Global styles and scripts: theme first, then the project's.
139
+ const styles = [...theme.manifest.styles.map(themeAssetUrl), ...config.styles].map(assetUrl);
140
+ const scripts = [...theme.manifest.scripts.map(themeAssetUrl), ...config.scripts].map(assetUrl);
141
+ for (const url of [...config.styles, ...config.scripts]) {
142
+ if (url.startsWith('/') && !url.startsWith('//') && !code.urls.has(url) && !fileExists(url)) {
143
+ diagnostics.push({
144
+ ruleId: 'asset-missing',
145
+ severity: 'error',
146
+ message: `Configured stylesheet or script ${url} is not in the built site`,
147
+ hint: 'Put the file under assets/ (served at /assets/) or static/ (served at the site root).',
148
+ });
149
+ }
150
+ }
151
+ // 3. Parse every page (in workers for large sites); links resolve against the
152
+ // full set of page URLs and outputs. Images are optimized here, page by page.
153
+ const pageUrls = new Map(pages.map((p) => [p.prepared.path, p.route.url]));
154
+ const parseContext = {
155
+ language: config.site.language,
156
+ pageUrls,
157
+ outputs: new Set(outputs.keys()),
158
+ optimizeImages: config.images.optimize,
159
+ };
160
+ const parseContextKey = cache && hashOf(stableJson(parseContext));
161
+ const parseResults = await memoized(pages, cache?.parsed, (page) => hashOf(parseContextKey ?? '', page.sourceKey ?? ''), (missing) => mapInPool(pool, PARSE_TASK, missing.map((p) => p.prepared), parseContext, parsePages, {
162
+ minItems: MIN_PAGES_FOR_WORKERS,
163
+ }),
164
+ // Trees are changed later in the build, so only pages without one are kept.
165
+ (result) => result.parsed.tree === undefined);
166
+ const parsedPages = parseResults.results;
167
+ if (cache)
168
+ cache.stats.parsed = parseResults.computed;
169
+ const imageOutputs = new Map();
170
+ const imageCache = new Map();
171
+ for (const [i, page] of pages.entries()) {
172
+ const { parsed, references } = parsedPages[i];
173
+ page.parsed = parsed;
174
+ diagnostics.push(...references, ...parsed.diagnostics);
175
+ const report = createReport(page.prepared.path, diagnostics);
176
+ if (config.images.optimize && parsed.kind === 'fragment' && parsed.tree !== undefined) {
177
+ await optimizeImages(parsed.tree, {
178
+ ...config.images,
179
+ cacheDir: join(rootDir, CACHE_DIR, 'images'),
180
+ sourceOf: (urlPath) => {
181
+ try {
182
+ return copyByOutput.get(decodeURI(urlPath).slice(1))?.source;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ },
188
+ emit: (output, file) => {
189
+ if (!imageOutputs.has(output) && claim(output, 'images'))
190
+ imageOutputs.set(output, file);
191
+ },
192
+ fail: (node, message) => report({
193
+ ruleId: 'image-failed',
194
+ severity: 'warning',
195
+ message: `Image could not be optimized and is used as is: ${message}`,
196
+ }, node),
197
+ }, imageCache);
198
+ }
199
+ logger?.debug(`parsed ${page.prepared.path} → ${page.route.url}`);
200
+ }
201
+ phase('parse');
202
+ // 4. Site features: collections, tags, feeds.
203
+ const sitePages = pages.flatMap(({ prepared, route, parsed }) => parsed === undefined || parsed.kind === 'document'
204
+ ? []
205
+ : [{ source: prepared.path, url: route.url, title: parsed.title, meta: prepared.meta }]);
206
+ const hasTagPages = renderer.hasLayout('tag');
207
+ const tagUrl = (slug) => (hasTagPages ? tagRoute(slug, urlOptions).url : undefined);
208
+ const summaries = new Map(sitePages.map((p) => [p.source, summarize(p, baseUrl, tagUrl)]));
209
+ const { collections, problems } = collectionsOf(config.collections, sitePages);
210
+ for (const message of problems) {
211
+ diagnostics.push({ ruleId: 'collection-invalid', severity: 'error', message });
212
+ }
213
+ const memberOf = new Map();
214
+ for (const c of collections)
215
+ for (const p of c.pages)
216
+ if (!memberOf.has(p.source))
217
+ memberOf.set(p.source, c.name);
218
+ const tagged = new Map();
219
+ for (const page of sitePages) {
220
+ for (const name of page.meta.tags) {
221
+ const slug = slugify(name);
222
+ const entry = tagged.get(slug) ?? { name, pages: [] };
223
+ entry.pages.push(page);
224
+ tagged.set(slug, entry);
225
+ }
226
+ }
227
+ const tags = [...tagged]
228
+ .map(([slug, { name, pages: tagPages }]) => ({
229
+ name,
230
+ slug,
231
+ url: tagUrl(slug),
232
+ count: tagPages.length,
233
+ }))
234
+ .sort((a, b) => a.name.localeCompare(b.name));
235
+ const titleOf = (c) => sitePages.find((p) => p.source === c.index)?.title ?? ucfirst(c.name);
236
+ const feeds = [];
237
+ const feedJobs = [];
238
+ for (const c of collections) {
239
+ const base = c.dir === '' ? '' : `${c.dir}/`;
240
+ for (const kind of ['rss', 'atom']) {
241
+ if (!c.feed[kind])
242
+ continue;
243
+ const output = `${base}${kind === 'rss' ? 'feed.xml' : 'atom.xml'}`;
244
+ if (!claim(output, `collection ${c.name}`))
245
+ continue;
246
+ const url = `/${encodeURI(output)}`;
247
+ feeds.push({
248
+ type: kind === 'rss' ? 'application/rss+xml' : 'application/atom+xml',
249
+ title: `${config.site.title}: ${titleOf(c)} (${kind === 'rss' ? 'RSS' : 'Atom'})`,
250
+ url,
251
+ });
252
+ feedJobs.push({ output, url, kind, collection: c });
253
+ }
254
+ }
255
+ // 5. Render jobs.
256
+ const pageContext = (fields) => ({
257
+ description: undefined,
258
+ absoluteUrl: new URL(fields.url, baseUrl).href,
259
+ lang: config.site.language,
260
+ date: undefined,
261
+ updated: undefined,
262
+ tags: [],
263
+ sourcePath: undefined,
264
+ headings: [],
265
+ data: {},
266
+ ...fields,
267
+ tagLinks: tagsOf(fields.tags ?? [], tagUrl),
268
+ });
269
+ const jobs = [];
270
+ const documents = [];
271
+ const indexJobs = new Map();
272
+ for (const job of pages) {
273
+ const { prepared, route, parsed } = job;
274
+ if (parsed === undefined)
275
+ continue;
276
+ if (parsed.kind === 'document') {
277
+ documents.push(job);
278
+ continue;
279
+ }
280
+ const collection = memberOf.get(prepared.path);
281
+ const isIndexOf = collections.find((c) => c.index === prepared.path);
282
+ let layout = prepared.meta.layout ??
283
+ (route.output === '404.html' && renderer.hasLayout('404')
284
+ ? '404'
285
+ : collection !== undefined && renderer.hasLayout('post')
286
+ ? 'post'
287
+ : isIndexOf !== undefined && renderer.hasLayout('list')
288
+ ? 'list'
289
+ : theme.manifest.defaultLayout);
290
+ if (!renderer.hasLayout(layout)) {
291
+ diagnostics.push({
292
+ ruleId: 'layout-missing',
293
+ severity: 'error',
294
+ message: `Layout "${layout}" does not exist in theme "${theme.manifest.name}"`,
295
+ file: prepared.path,
296
+ line: 1,
297
+ });
298
+ layout = theme.manifest.defaultLayout;
299
+ }
300
+ const renderJob = {
301
+ route,
302
+ layout,
303
+ source: prepared.path,
304
+ content: parsed.html ?? toHtml(parsed.tree),
305
+ page: pageContext({
306
+ title: parsed.title,
307
+ description: prepared.meta.description,
308
+ url: route.url,
309
+ lang: parsed.lang,
310
+ date: prepared.meta.date,
311
+ updated: prepared.meta.updated,
312
+ tags: prepared.meta.tags,
313
+ layout,
314
+ sourcePath: prepared.path,
315
+ headings: parsed.headings,
316
+ data: prepared.meta,
317
+ collection,
318
+ }),
319
+ };
320
+ jobs.push(renderJob);
321
+ if (isIndexOf !== undefined)
322
+ indexJobs.set(isIndexOf.name, renderJob);
323
+ }
324
+ // Collection list pages: the index page is page 1, later pages are generated.
325
+ const hasList = renderer.hasLayout('list');
326
+ for (const c of collections) {
327
+ const count = Math.max(1, Math.ceil(c.pages.length / c.perPage));
328
+ const routes = Array.from({ length: count }, (_, i) => listRoute(c.dir, i + 1, urlOptions));
329
+ const title = titleOf(c);
330
+ for (let n = 1; n <= count; n++) {
331
+ const route = routes[n - 1];
332
+ const pagination = {
333
+ collection: c.name,
334
+ page: n,
335
+ pages: count,
336
+ total: c.pages.length,
337
+ items: c.pages
338
+ .slice((n - 1) * c.perPage, n * c.perPage)
339
+ .flatMap((p) => summaries.get(p.source) ?? []),
340
+ first: routes[0].url,
341
+ last: routes[count - 1].url,
342
+ prev: routes[n - 2]?.url,
343
+ next: routes[n]?.url,
344
+ };
345
+ const index = n === 1 ? indexJobs.get(c.name) : undefined;
346
+ if (index !== undefined) {
347
+ index.pagination = pagination;
348
+ continue;
349
+ }
350
+ // Without a list layout there are no generated list pages.
351
+ if (!hasList)
352
+ break;
353
+ if (n === 1 && outputs.has(route.output))
354
+ continue;
355
+ if (!claim(route.output, `collection ${c.name}`))
356
+ continue;
357
+ jobs.push({
358
+ route,
359
+ layout: 'list',
360
+ source: undefined,
361
+ content: '',
362
+ pagination,
363
+ page: pageContext({
364
+ title: n === 1 ? title : `${title} (page ${n} of ${count})`,
365
+ url: route.url,
366
+ layout: 'list',
367
+ }),
368
+ });
369
+ }
370
+ }
371
+ if (hasTagPages) {
372
+ for (const tag of tags) {
373
+ const route = tagRoute(tag.slug, urlOptions);
374
+ if (!claim(route.output, `tag ${tag.name}`))
375
+ continue;
376
+ const items = (tagged.get(tag.slug)?.pages ?? [])
377
+ .flatMap((p) => summaries.get(p.source) ?? [])
378
+ .sort((a, b) => (b.date?.getTime() ?? -Infinity) - (a.date?.getTime() ?? -Infinity) ||
379
+ a.title.localeCompare(b.title));
380
+ jobs.push({
381
+ route,
382
+ layout: 'tag',
383
+ source: undefined,
384
+ content: '',
385
+ tag: { ...tag, items },
386
+ page: pageContext({ title: `Tagged “${tag.name}”`, url: route.url, layout: 'tag' }),
387
+ });
388
+ }
389
+ }
390
+ // The tag index: generated, or a content page at its URL (tags/index.md).
391
+ let tagsUrl;
392
+ const tagsIndex = tagRoute(undefined, urlOptions);
393
+ if (outputs.has(tagsIndex.output)) {
394
+ tagsUrl = tagsIndex.url;
395
+ }
396
+ else if (tags.length > 0 && renderer.hasLayout('tags') && claim(tagsIndex.output, 'tags')) {
397
+ tagsUrl = tagsIndex.url;
398
+ jobs.push({
399
+ route: tagsIndex,
400
+ layout: 'tags',
401
+ source: undefined,
402
+ content: '',
403
+ page: pageContext({ title: 'Tags', url: tagsIndex.url, layout: 'tags' }),
404
+ });
405
+ }
406
+ const searchRoute = routeFor('search.md', undefined, urlOptions);
407
+ let search;
408
+ if (config.search.enabled && renderer.hasLayout('search')) {
409
+ if (outputs.has(searchRoute.output) || claim(searchRoute.output, 'search')) {
410
+ search = { url: searchRoute.url };
411
+ if (outputs.get(searchRoute.output) === 'search') {
412
+ jobs.push({
413
+ route: searchRoute,
414
+ layout: 'search',
415
+ source: undefined,
416
+ content: '',
417
+ page: pageContext({
418
+ title: 'Search',
419
+ url: searchRoute.url,
420
+ layout: 'search',
421
+ data: { sitemap: false },
422
+ }),
423
+ });
424
+ }
425
+ }
426
+ }
427
+ if (!hasNotFoundPage && renderer.hasLayout('404')) {
428
+ jobs.push({
429
+ route: { url: '/404.html', output: '404.html' },
430
+ layout: '404',
431
+ source: undefined,
432
+ content: '',
433
+ page: pageContext({ title: 'Page not found', url: '/404.html', layout: '404' }),
434
+ });
435
+ }
436
+ phase('site features');
437
+ // 6. Render and write.
438
+ const nav = navEntries(pages.flatMap(({ prepared, route, parsed }) => parsed === undefined ? [] : [{ url: route.url, title: parsed.title, nav: prepared.meta.nav }]));
439
+ const titles = new Map([
440
+ ...sitePages.map((p) => [p.url, p.title]),
441
+ ...jobs.map((j) => [j.route.url, j.page.title]),
442
+ ]);
443
+ const data = loadData(rootDir, diagnostics);
444
+ const now = cache?.time ?? new Date();
445
+ const base = {
446
+ site: {
447
+ title: config.site.title,
448
+ description: config.site.description,
449
+ author: config.site.author,
450
+ language: config.site.language,
451
+ baseUrl,
452
+ },
453
+ theme: { styles, scripts },
454
+ data,
455
+ build: {
456
+ generator: options.generator ?? 'staticforge',
457
+ time: now.toISOString(),
458
+ year: now.getUTCFullYear(),
459
+ },
460
+ collections: Object.fromEntries(collections.map((c) => [c.name, c.pages.flatMap((p) => summaries.get(p.source) ?? [])])),
461
+ tags,
462
+ tagsUrl,
463
+ feeds,
464
+ search,
465
+ };
466
+ rmSync(outDir, { recursive: true, force: true });
467
+ const manifest = {};
468
+ const record = (output, contents, immutable = false) => {
469
+ manifest[output] = {
470
+ sha256: createHash('sha256').update(contents).digest('hex'),
471
+ size: Buffer.byteLength(contents),
472
+ cache: immutable ? 'immutable' : 'revalidate',
473
+ };
474
+ };
475
+ const write = (output, contents, immutable = false) => {
476
+ const path = join(outDir, output);
477
+ mkdirSync(dirname(path), { recursive: true });
478
+ writeFileSync(path, contents);
479
+ record(output, contents, immutable);
480
+ };
481
+ const finish = { assetUrl, minify };
482
+ const built = [];
483
+ const indexed = [];
484
+ const bodies = new Map();
485
+ // Render with the layouts and finish (in workers for large sites), then write in job order.
486
+ const shared = {
487
+ buildId: randomUUID(),
488
+ theme: config.theme,
489
+ rootDir,
490
+ baseUrl,
491
+ assetUrls: code.urls,
492
+ minify,
493
+ base,
494
+ nav,
495
+ titles,
496
+ };
497
+ const renderItems = jobs.map((job) => ({
498
+ layout: job.layout,
499
+ url: job.route.url,
500
+ page: job.page,
501
+ content: job.content,
502
+ ...(job.pagination && { pagination: job.pagination }),
503
+ ...(job.tag && { tag: job.tag }),
504
+ }));
505
+ // A page renders the same when its item, the shared context and the templates are the same.
506
+ const renderContextKey = cache && hashOf(stableJson({ ...shared, buildId: '' }), templatesKey(theme.dir, rootDir));
507
+ const renderedPages = await memoized(renderItems, cache?.rendered, (item) => hashOf(renderContextKey ?? '', stableJson(item)), async (missing) => (await mapInPool(pool, RENDER_TASK, missing, shared, renderPages, {
508
+ minItems: MIN_PAGES_FOR_WORKERS,
509
+ })).map((result) => ('html' in result ? result.html : result)), (result) => typeof result === 'string');
510
+ const rendered = renderedPages.results.map((result) => typeof result === 'string' ? { html: result } : result);
511
+ if (cache)
512
+ cache.stats.rendered = renderedPages.computed;
513
+ for (const [i, job] of jobs.entries()) {
514
+ const result = rendered[i];
515
+ if ('error' in result) {
516
+ diagnostics.push({
517
+ ruleId: 'render-failed',
518
+ severity: 'error',
519
+ message: result.error,
520
+ ...(job.source !== undefined && { file: job.source }),
521
+ });
522
+ continue;
523
+ }
524
+ const finished = result.html;
525
+ write(job.route.output, finished);
526
+ if (job.source !== undefined)
527
+ bodies.set(job.source, job.content);
528
+ indexed.push({ url: job.route.url, html: finished });
529
+ built.push({
530
+ source: job.source,
531
+ url: job.route.url,
532
+ output: job.route.output,
533
+ layout: job.layout,
534
+ });
535
+ }
536
+ for (const { prepared, route, parsed } of documents) {
537
+ if (parsed?.tree === undefined)
538
+ continue;
539
+ injectHead(parsed.tree, styles, scripts);
540
+ finishTree(parsed.tree, finish);
541
+ const html = toHtml(parsed.tree);
542
+ write(route.output, html);
543
+ indexed.push({ url: route.url, html });
544
+ built.push({ source: prepared.path, url: route.url, output: route.output, layout: 'document' });
545
+ }
546
+ phase('render');
547
+ // Feeds, sitemap and robots.txt.
548
+ for (const feed of feedJobs) {
549
+ const c = feed.collection;
550
+ const listUrl = listRoute(c.dir, 1, urlOptions).url;
551
+ const items = c.pages.slice(0, FEED_ITEMS).map((p) => {
552
+ const body = bodies.get(p.source);
553
+ return {
554
+ page: summaries.get(p.source),
555
+ html: body === undefined ? undefined : absolutizeHtml(finishFragment(body, finish), baseUrl),
556
+ };
557
+ });
558
+ const feedOptions = {
559
+ title: `${config.site.title}: ${titleOf(c)}`,
560
+ description: config.site.description,
561
+ language: config.site.language,
562
+ author: config.site.author,
563
+ link: new URL(listUrl, baseUrl).href,
564
+ self: new URL(feed.url, baseUrl).href,
565
+ items,
566
+ fallbackDate: now,
567
+ generator: base.build.generator,
568
+ };
569
+ write(feed.output, feed.kind === 'rss' ? rssFeed(feedOptions) : atomFeed(feedOptions));
570
+ }
571
+ const metaBySource = new Map(pages.map((p) => [p.prepared.path, p.prepared.meta]));
572
+ // Pages opt out with `sitemap: false`; the generated search page does too.
573
+ const unlisted = new Set(jobs.filter((j) => j.page.data.sitemap === false).map((j) => j.route.url));
574
+ const sitemapEntries = built
575
+ .filter((p) => p.output !== '404.html' && !unlisted.has(p.url))
576
+ .filter((p) => p.source === undefined || metaBySource.get(p.source)?.sitemap !== false)
577
+ .map((p) => {
578
+ const meta = p.source === undefined ? undefined : metaBySource.get(p.source);
579
+ return { url: new URL(p.url, baseUrl).href, lastmod: meta?.updated ?? meta?.date };
580
+ })
581
+ .sort((a, b) => a.url.localeCompare(b.url));
582
+ if (!outputs.has('sitemap.xml') && claim('sitemap.xml', 'sitemap')) {
583
+ write('sitemap.xml', sitemapXml(sitemapEntries));
584
+ }
585
+ if (!outputs.has('robots.txt') && claim('robots.txt', 'robots')) {
586
+ write('robots.txt', robotsTxt(new URL('/sitemap.xml', baseUrl).href));
587
+ }
588
+ // Copies the previous build already has (same source, size and time) are hard-linked.
589
+ const copyRecords = new Map();
590
+ const copyFile = (output, source, immutable) => {
591
+ if (cache === undefined) {
592
+ write(output, readFileSync(source), immutable);
593
+ return;
594
+ }
595
+ const { size, mtimeMs } = statSync(source);
596
+ const previous = cache.previous;
597
+ const before = previous?.copies.get(output);
598
+ if (previous !== undefined &&
599
+ before?.source === source &&
600
+ before.size === size &&
601
+ before.mtimeMs === mtimeMs) {
602
+ const path = join(outDir, output);
603
+ mkdirSync(dirname(path), { recursive: true });
604
+ try {
605
+ linkSync(join(previous.outDir, output), path);
606
+ manifest[output] = {
607
+ sha256: before.sha256,
608
+ size,
609
+ cache: immutable ? 'immutable' : 'revalidate',
610
+ };
611
+ copyRecords.set(output, before);
612
+ cache.stats.linked++;
613
+ return;
614
+ }
615
+ catch {
616
+ // Gone or on another device: copy it.
617
+ }
618
+ }
619
+ write(output, readFileSync(source), immutable);
620
+ copyRecords.set(output, {
621
+ source,
622
+ size,
623
+ mtimeMs,
624
+ sha256: manifest[output].sha256,
625
+ });
626
+ };
627
+ for (const copy of copies)
628
+ copyFile(copy.output, copy.source, false);
629
+ for (const out of code.outputs)
630
+ write(out.output, out.contents, out.immutable);
631
+ for (const [output, file] of imageOutputs)
632
+ copyFile(output, file, true);
633
+ phase('feeds, sitemap and copies');
634
+ // Search index, over the finished pages.
635
+ if (config.search.enabled && options.deferSearch !== undefined) {
636
+ options.deferSearch(async () => void (await writeSearchIndex(indexed, outDir)));
637
+ }
638
+ else if (config.search.enabled) {
639
+ try {
640
+ for (const file of await writeSearchIndex(indexed, outDir)) {
641
+ record(file, readFileSync(join(outDir, file)));
642
+ }
643
+ }
644
+ catch (error) {
645
+ diagnostics.push({
646
+ ruleId: 'search-failed',
647
+ severity: 'error',
648
+ message: `Search index could not be built: ${error instanceof Error ? error.message : String(error)}`,
649
+ });
650
+ }
651
+ }
652
+ if (config.search.enabled && options.deferSearch === undefined)
653
+ phase('search index');
654
+ const sorted = sortDiagnostics(diagnostics);
655
+ const summary = countBySeverity(sorted);
656
+ write(MANIFEST_PATH, `${JSON.stringify({ version: 1, generator: base.build.generator, builtAt: base.build.time, baseUrl, files: manifest }, null, 2)}\n`);
657
+ cache?.commit({ outDir, copies: copyRecords });
658
+ return {
659
+ ok: summary.error === 0,
660
+ outDir,
661
+ pages: built.sort((a, b) => a.url.localeCompare(b.url)),
662
+ fileCount: Object.keys(manifest).length - 1,
663
+ drafts: { skipped: skippedDrafts },
664
+ diagnostics: sorted,
665
+ summary,
666
+ durationMs: Math.round(performance.now() - started),
667
+ };
668
+ }
669
+ /** Hash of the theme's and the project's template files. */
670
+ function templatesKey(themeDir, rootDir) {
671
+ const parts = [];
672
+ for (const dir of [themeDir, join(rootDir, 'templates')]) {
673
+ for (const file of listFiles(dir)) {
674
+ if (file.endsWith('.njk') || file === 'theme.json') {
675
+ parts.push(dir, file, readFileSync(join(dir, file), 'utf8'));
676
+ }
677
+ }
678
+ }
679
+ return hashOf(...parts);
680
+ }
681
+ /** Add theme and site styles/scripts to a pass-through document's <head>. */
682
+ function injectHead(tree, styles, scripts) {
683
+ const html = tree.children.find((n) => n.type === 'element' && n.tagName === 'html');
684
+ const head = html?.children.find((n) => n.type === 'element' && n.tagName === 'head');
685
+ if (head === undefined)
686
+ return;
687
+ // Insert after leading <meta> elements (charset, viewport) so author styles still win.
688
+ const firstNonMeta = head.children.findIndex((n) => !(n.type === 'element' && n.tagName === 'meta') && n.type !== 'text');
689
+ const at = firstNonMeta === -1 ? head.children.length : firstNonMeta;
690
+ head.children.splice(at, 0, ...styles.map((href) => h('link', { rel: 'stylesheet', href })), ...scripts.map((src) => h('script', { type: 'module', src })));
691
+ }
692
+ //# sourceMappingURL=build.js.map