@zeropress/build-core 0.2.1 → 0.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.
package/README.md CHANGED
@@ -55,7 +55,6 @@ import {
55
55
  - `sitemap.xml`
56
56
  - `feed.xml`
57
57
  - `robots.txt`
58
- - `meta.json`
59
58
  - writing outputs through a pluggable writer
60
59
 
61
60
  It does not:
@@ -120,7 +119,7 @@ Notes:
120
119
  - `previewData` must already satisfy the canonical preview-data contract
121
120
  - `themePackage` must already be a validated in-memory theme package
122
121
  - `sitemap.xml` and `feed.xml` are emitted only when `site.url` is a non-empty canonical URL
123
- - `robots.txt` and `meta.json` are still emitted when `generateSpecialFiles` is enabled
122
+ - `robots.txt` is still emitted when `generateSpecialFiles` is enabled
124
123
 
125
124
  ### `buildSiteFromThemeDir(input)`
126
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.2.1",
3
+ "version": "0.5.0",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,8 +34,8 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@zeropress/slug-policy": "^0.1.0",
37
- "@zeropress/preview-data-validator": "^0.4.4",
38
- "@zeropress/theme-validator": "^0.5.0",
37
+ "@zeropress/preview-data-validator": "0.5.0",
38
+ "@zeropress/theme-validator": "0.5.1",
39
39
  "highlight.js": "^11.11.1",
40
40
  "markdown-it": "^14.1.0",
41
41
  "markdown-it-anchor": "^9.2.0"
@@ -3,7 +3,7 @@ import { assertPreviewData } from '@zeropress/preview-data-validator';
3
3
  import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
4
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
5
5
  import { AssetProcessor } from '../assets/asset-processor.js';
6
- import { renderDocumentContent } from '../render/content-renderer.js';
6
+ import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
7
7
  import { ZeroPressEngine } from '../render/zeropress-engine.js';
8
8
 
9
9
  const DEFAULT_OPTIONS = {
@@ -17,6 +17,14 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
17
17
  const DEFAULT_TIME_FORMAT = 'HH:mm';
18
18
  const DEFAULT_TIMEZONE = 'UTC';
19
19
  const DEFAULT_LOCALE = 'en-US';
20
+ const DEFAULT_PERMALINKS = Object.freeze({
21
+ output_style: 'directory',
22
+ posts: '/posts/:slug/',
23
+ pages: '/:slug/',
24
+ categories: '/categories/:slug/',
25
+ tags: '/tags/:slug/',
26
+ });
27
+ const PERMALINK_OUTPUT_STYLES = new Set(['directory', 'html-extension']);
20
28
  const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
21
29
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
22
30
  const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
@@ -75,7 +83,6 @@ export async function buildSite(input) {
75
83
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
76
84
  }
77
85
  await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
78
- await writeOutput(state.writer, state.summaries, 'meta.json', buildMetaJson(state.previewData.site, state.emitted, state.generatedAt), 'application/json');
79
86
  }
80
87
 
81
88
  return finalizeBuildResult(state.writer, state.summaries, options);
@@ -86,7 +93,7 @@ async function createBuildState(input, options) {
86
93
  throw new Error('buildSite requires a writer with an async write(file) method');
87
94
  }
88
95
 
89
- assertPreviewData(input.previewData);
96
+ assertBuildPreviewData(input.previewData);
90
97
  const themePackage = await normalizeAndValidateThemePackage(input.themePackage);
91
98
 
92
99
  const engine = new ZeroPressEngine();
@@ -117,6 +124,7 @@ async function createBuildState(input, options) {
117
124
  assetOutputs,
118
125
  assetMap,
119
126
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
127
+ customHtml: previewData.custom_html,
120
128
  commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
121
129
  options,
122
130
  generatedAt: new Date(),
@@ -131,6 +139,18 @@ async function createBuildState(input, options) {
131
139
  };
132
140
  }
133
141
 
142
+ function assertBuildPreviewData(previewData) {
143
+ try {
144
+ assertPreviewData(previewData);
145
+ } catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ if (message.startsWith('Invalid preview-data')) {
148
+ throw error;
149
+ }
150
+ throw new Error(`Invalid preview-data: ${message}`);
151
+ }
152
+ }
153
+
134
154
  async function finalizeBuildResult(writer, summaries, options) {
135
155
  let manifest;
136
156
  if (options.writeManifest) {
@@ -154,12 +174,13 @@ async function finalizeBuildResult(writer, summaries, options) {
154
174
  }
155
175
 
156
176
  async function renderRoute(state, templateName, route) {
157
- const currentUrl = normalizeRoutePath(route.path);
177
+ const currentUrl = routePathToPublicUrl(route.path, state.previewData.site.permalinks.output_style);
158
178
  let html = await state.engine.render(
159
179
  templateName,
160
180
  {
161
181
  menus: state.previewData.menus,
162
182
  widgets: state.widgets,
183
+ taxonomies: state.renderData.taxonomies,
163
184
  ...route,
164
185
  meta: buildPageMeta(state.previewData.site, {
165
186
  currentUrl,
@@ -171,18 +192,19 @@ async function renderRoute(state, templateName, route) {
171
192
  createRenderContext(state.previewData.site, currentUrl),
172
193
  );
173
194
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
174
- html = injectCustomCssAssetLink(html, state.customCssHref);
175
- await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
195
+ html = injectSiteCustomizations(html, state);
196
+ await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path, state.previewData.site.permalinks.output_style), html, 'text/html');
176
197
  recordRouteEmission(state, templateName, route, currentUrl);
177
198
  }
178
199
 
179
200
  async function renderPost(state, post) {
180
- const currentUrl = `/posts/${encodeSlugSegment(post.slug)}/`;
201
+ const currentUrl = post.url;
181
202
  let html = await state.engine.render(
182
203
  'post',
183
204
  {
184
205
  menus: state.previewData.menus,
185
206
  widgets: state.widgets,
207
+ taxonomies: state.renderData.taxonomies,
186
208
  post,
187
209
  meta: buildPageMeta(state.previewData.site, {
188
210
  currentUrl,
@@ -197,8 +219,8 @@ async function renderPost(state, post) {
197
219
  createRenderContext(state.previewData.site, currentUrl),
198
220
  );
199
221
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
200
- html = injectCustomCssAssetLink(html, state.customCssHref);
201
- await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
222
+ html = injectSiteCustomizations(html, state);
223
+ await writeOutput(state.writer, state.summaries, routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style), html, 'text/html');
202
224
  state.emitted.posts.push({
203
225
  url: currentUrl,
204
226
  title: post.title,
@@ -210,12 +232,13 @@ async function renderPost(state, post) {
210
232
  }
211
233
 
212
234
  async function renderPage(state, page) {
213
- const currentUrl = `/${encodeSlugSegment(page.slug)}/`;
235
+ const currentUrl = page.url;
214
236
  let html = await state.engine.render(
215
237
  'page',
216
238
  {
217
239
  menus: state.previewData.menus,
218
240
  widgets: state.widgets,
241
+ taxonomies: state.renderData.taxonomies,
219
242
  page,
220
243
  meta: buildPageMeta(state.previewData.site, {
221
244
  currentUrl,
@@ -228,8 +251,8 @@ async function renderPage(state, page) {
228
251
  createRenderContext(state.previewData.site, currentUrl),
229
252
  );
230
253
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
231
- html = injectCustomCssAssetLink(html, state.customCssHref);
232
- await writeOutput(state.writer, state.summaries, `${encodeSlugSegment(page.slug)}/index.html`, html, 'text/html');
254
+ html = injectSiteCustomizations(html, state);
255
+ await writeOutput(state.writer, state.summaries, routePathToOutputPath(page.url, state.previewData.site.permalinks.output_style), html, 'text/html');
233
256
  state.emitted.pages.push({
234
257
  url: currentUrl,
235
258
  title: page.title,
@@ -248,6 +271,7 @@ async function maybeRenderNotFoundPage(state) {
248
271
  {
249
272
  menus: state.previewData.menus,
250
273
  widgets: state.widgets,
274
+ taxonomies: state.renderData.taxonomies,
251
275
  meta: buildPageMeta(state.previewData.site, {
252
276
  currentUrl: '/404.html',
253
277
  title: state.previewData.site.title,
@@ -258,7 +282,7 @@ async function maybeRenderNotFoundPage(state) {
258
282
  createRenderContext(state.previewData.site, '/404.html'),
259
283
  );
260
284
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
261
- html = injectCustomCssAssetLink(html, state.customCssHref);
285
+ html = injectSiteCustomizations(html, state);
262
286
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
263
287
  }
264
288
 
@@ -274,6 +298,7 @@ function normalizePreviewData(previewData) {
274
298
  timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
275
299
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
276
300
  disallowComments: previewData.site.disallowComments === true,
301
+ permalinks: normalizePermalinks(previewData.site.permalinks),
277
302
  };
278
303
 
279
304
  return {
@@ -281,6 +306,7 @@ function normalizePreviewData(previewData) {
281
306
  site: normalizedSite,
282
307
  widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
283
308
  custom_css: normalizeCustomCss(previewData.custom_css),
309
+ custom_html: normalizeCustomHtml(previewData.custom_html),
284
310
  content: {
285
311
  ...previewData.content,
286
312
  authors: previewData.content.authors.map((author) => ({
@@ -348,6 +374,38 @@ function normalizeCustomCss(customCss) {
348
374
  return content ? { content } : undefined;
349
375
  }
350
376
 
377
+ function normalizeCustomHtml(customHtml) {
378
+ if (!customHtml || typeof customHtml !== 'object') {
379
+ return undefined;
380
+ }
381
+
382
+ const headEnd = normalizeOptionalRawString(customHtml.head_end?.content);
383
+ const bodyEnd = normalizeOptionalRawString(customHtml.body_end?.content);
384
+ if (!headEnd && !bodyEnd) {
385
+ return undefined;
386
+ }
387
+
388
+ return {
389
+ ...(headEnd ? { head_end: { content: headEnd } } : {}),
390
+ ...(bodyEnd ? { body_end: { content: bodyEnd } } : {}),
391
+ };
392
+ }
393
+
394
+ function normalizePermalinks(permalinks) {
395
+ const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
396
+ const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
397
+ ? source.output_style
398
+ : DEFAULT_PERMALINKS.output_style;
399
+
400
+ return {
401
+ output_style: outputStyle,
402
+ posts: normalizeNonEmptyString(source.posts, DEFAULT_PERMALINKS.posts),
403
+ pages: normalizeNonEmptyString(source.pages, DEFAULT_PERMALINKS.pages),
404
+ categories: normalizeNonEmptyString(source.categories, DEFAULT_PERMALINKS.categories),
405
+ tags: normalizeNonEmptyString(source.tags, DEFAULT_PERMALINKS.tags),
406
+ };
407
+ }
408
+
351
409
  function createRenderData(previewData, themeMetadata = {}) {
352
410
  const themeSupportsComments = themeMetadata?.features?.comments === true;
353
411
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
@@ -376,17 +434,19 @@ function createRenderData(previewData, themeMetadata = {}) {
376
434
  prev: index > 0 ? buildAdjacentPostSummary(preparedPosts[index - 1]) : null,
377
435
  next: index < preparedPosts.length - 1 ? buildAdjacentPostSummary(preparedPosts[index + 1]) : null,
378
436
  }));
379
- const pages = previewData.content.pages.map((page) => preparePage(page));
437
+ const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
380
438
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
381
439
 
382
440
  return {
383
441
  posts,
384
442
  pages,
385
443
  postBySlug,
444
+ taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
386
445
  indexRoutes: buildPaginatedCollection({
387
446
  items: previewData.content.posts,
388
447
  postsPerPage: previewData.site.postsPerPage,
389
448
  basePath: '/',
449
+ outputStyle: previewData.site.permalinks.output_style,
390
450
  }).map((entry) => ({
391
451
  path: entry.path,
392
452
  page: entry.page,
@@ -398,6 +458,7 @@ function createRenderData(previewData, themeMetadata = {}) {
398
458
  items: previewData.content.posts,
399
459
  postsPerPage: previewData.site.postsPerPage,
400
460
  basePath: '/archive/',
461
+ outputStyle: previewData.site.permalinks.output_style,
401
462
  }).map((entry) => ({
402
463
  path: entry.path,
403
464
  page: entry.page,
@@ -412,7 +473,8 @@ function createRenderData(previewData, themeMetadata = {}) {
412
473
  postsBySlug: categoryPostsBySlug,
413
474
  postBySlug,
414
475
  postsPerPage: previewData.site.postsPerPage,
415
- buildBasePath: (category) => `/categories/${encodeSlugSegment(category.slug)}/`,
476
+ outputStyle: previewData.site.permalinks.output_style,
477
+ buildBasePath: (category) => resolvePermalink(previewData.site, 'categories', category).path,
416
478
  renderExtras: (category) => ({
417
479
  taxonomy: buildTaxonomyRouteData('category', category, categoryCountBySlug),
418
480
  }),
@@ -422,7 +484,8 @@ function createRenderData(previewData, themeMetadata = {}) {
422
484
  postsBySlug: tagPostsBySlug,
423
485
  postBySlug,
424
486
  postsPerPage: previewData.site.postsPerPage,
425
- buildBasePath: (tag) => `/tags/${encodeSlugSegment(tag.slug)}/`,
487
+ outputStyle: previewData.site.permalinks.output_style,
488
+ buildBasePath: (tag) => resolvePermalink(previewData.site, 'tags', tag).path,
426
489
  renderExtras: (tag) => ({
427
490
  taxonomy: buildTaxonomyRouteData('tag', tag, tagCountBySlug),
428
491
  }),
@@ -430,6 +493,23 @@ function createRenderData(previewData, themeMetadata = {}) {
430
493
  };
431
494
  }
432
495
 
496
+ function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug) {
497
+ return {
498
+ categories: buildGlobalTaxonomyItems(previewData.site, 'categories', previewData.content.categories, categoryCountBySlug),
499
+ tags: buildGlobalTaxonomyItems(previewData.site, 'tags', previewData.content.tags, tagCountBySlug),
500
+ };
501
+ }
502
+
503
+ function buildGlobalTaxonomyItems(site, permalinkKind, items, countBySlug) {
504
+ return items.map((item) => ({
505
+ name: item.name,
506
+ slug: item.slug,
507
+ url: resolvePermalink(site, permalinkKind, item).url,
508
+ count: countBySlug.get(item.slug) || 0,
509
+ description: typeof item.description === 'string' ? item.description : '',
510
+ }));
511
+ }
512
+
433
513
  function resolveWidgetAreas(previewData, renderData) {
434
514
  if (!previewData.widgets || typeof previewData.widgets !== 'object') {
435
515
  return {};
@@ -521,7 +601,7 @@ function resolveCategoriesWidget(baseWidget, settings, previewData) {
521
601
  .map((category) => ({
522
602
  name: category.name,
523
603
  slug: category.slug,
524
- url: `/categories/${encodeSlugSegment(category.slug)}/`,
604
+ url: resolvePermalink(previewData.site, 'categories', category).url,
525
605
  count: countBySlug.get(category.slug) || 0,
526
606
  depth: 0,
527
607
  }))
@@ -546,7 +626,7 @@ function resolveTagsWidget(baseWidget, settings, previewData) {
546
626
  .map((tag) => ({
547
627
  name: tag.name,
548
628
  slug: tag.slug,
549
- url: `/tags/${encodeSlugSegment(tag.slug)}/`,
629
+ url: resolvePermalink(previewData.site, 'tags', tag).url,
550
630
  count: countBySlug.get(tag.slug) || 0,
551
631
  }))
552
632
  .filter((tag) => tag.count > 0)
@@ -569,7 +649,7 @@ function resolveArchivesWidget(baseWidget, settings, previewData) {
569
649
  .slice(0, limit)
570
650
  .map((entry) => ({
571
651
  label: entry.label,
572
- url: '/archive/',
652
+ url: routePathToPublicUrl('/archive/', previewData.site.permalinks.output_style),
573
653
  count: entry.count,
574
654
  year: entry.year,
575
655
  month: entry.month,
@@ -657,27 +737,32 @@ function resolveProfileWidget(baseWidget, settings) {
657
737
  };
658
738
  }
659
739
 
660
- function preparePage(page) {
740
+ function preparePage(page, site) {
661
741
  const documentType = normalizeDocumentType(page.document_type);
742
+ const renderedDocument = renderDocument(page.content, documentType);
743
+ const permalink = resolvePagePermalink(site, page);
662
744
 
663
745
  return {
664
746
  ...page,
747
+ url: permalink.url,
665
748
  document_type: documentType,
666
- html: renderDocumentContent(page.content, documentType),
749
+ html: renderedDocument.html,
750
+ toc: renderedDocument.toc,
667
751
  };
668
752
  }
669
753
 
670
754
  function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
671
755
  const documentType = normalizeDocumentType(post.document_type);
672
- const html = renderDocumentContent(post.content, documentType);
756
+ const renderedDocument = renderDocument(post.content, documentType);
673
757
  const author = authorsById.get(post.author_id);
758
+ const permalink = resolvePermalink(site, 'posts', post);
674
759
  const categories = post.category_slugs
675
760
  .map((slug) => categoriesBySlug.get(slug))
676
761
  .filter(Boolean)
677
762
  .map((category) => ({
678
763
  name: category.name,
679
764
  slug: category.slug,
680
- url: `/categories/${encodeSlugSegment(category.slug)}/`,
765
+ url: resolvePermalink(site, 'categories', category).url,
681
766
  }));
682
767
  const tags = post.tag_slugs
683
768
  .map((slug) => tagsBySlug.get(slug))
@@ -685,14 +770,14 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
685
770
  .map((tag) => ({
686
771
  name: tag.name,
687
772
  slug: tag.slug,
688
- url: `/tags/${encodeSlugSegment(tag.slug)}/`,
773
+ url: resolvePermalink(site, 'tags', tag).url,
689
774
  }));
690
775
 
691
776
  return {
692
777
  public_id: post.public_id,
693
778
  title: post.title,
694
779
  slug: post.slug,
695
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
780
+ url: permalink.url,
696
781
  content: post.content,
697
782
  document_type: documentType,
698
783
  excerpt: post.excerpt,
@@ -700,6 +785,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
700
785
  updated_at_iso: post.updated_at_iso,
701
786
  author_id: post.author_id,
702
787
  featured_image: post.featured_image,
788
+ meta: post.meta,
703
789
  status: post.status,
704
790
  allow_comments: post.allow_comments,
705
791
  category_slugs: post.category_slugs,
@@ -711,10 +797,11 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
711
797
  },
712
798
  categories,
713
799
  tags,
714
- html,
800
+ html: renderedDocument.html,
801
+ toc: renderedDocument.toc,
715
802
  published_at: formatTimestamp(post.published_at_iso, site),
716
803
  updated_at: formatTimestamp(post.updated_at_iso, site),
717
- reading_time: calculateReadingTime(html),
804
+ reading_time: calculateReadingTime(renderedDocument.html),
718
805
  comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
719
806
  };
720
807
  }
@@ -744,6 +831,7 @@ function buildTaxonomyRoutes(options) {
744
831
  items: matchedPosts,
745
832
  postsPerPage: options.postsPerPage,
746
833
  basePath: options.buildBasePath(item),
834
+ outputStyle: options.outputStyle,
747
835
  });
748
836
 
749
837
  for (const entry of paginated) {
@@ -764,6 +852,7 @@ function buildTaxonomyRoutes(options) {
764
852
 
765
853
  function buildPaginatedCollection(options) {
766
854
  const basePath = normalizePaginationBasePath(options.basePath);
855
+ const outputStyle = PERMALINK_OUTPUT_STYLES.has(options.outputStyle) ? options.outputStyle : DEFAULT_PERMALINKS.output_style;
767
856
  const postsPerPage = Number.isInteger(options.postsPerPage) && options.postsPerPage > 0 ? options.postsPerPage : DEFAULT_POSTS_PER_PAGE;
768
857
  const totalPosts = options.items.length;
769
858
  const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
@@ -778,7 +867,7 @@ function buildPaginatedCollection(options) {
778
867
  page,
779
868
  totalPages,
780
869
  items: options.items.slice(start, end),
781
- paginationData: buildPaginationData(page, totalPages, totalPosts, basePath),
870
+ paginationData: buildPaginationData(page, totalPages, totalPosts, basePath, outputStyle),
782
871
  });
783
872
  }
784
873
 
@@ -811,20 +900,22 @@ function normalizePaginationBasePath(basePath) {
811
900
  return `/${normalized}/`;
812
901
  }
813
902
 
814
- function buildPaginationData(currentPage, totalPages, totalPosts, basePath) {
903
+ function buildPaginationData(currentPage, totalPages, totalPosts, basePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
904
+ const buildPageUrl = (page) => routePathToPublicUrl(buildPaginatedPath(basePath, page), outputStyle);
905
+
815
906
  return {
816
907
  currentPage,
817
908
  totalPages,
818
909
  totalPosts,
819
910
  hasNext: currentPage < totalPages,
820
911
  hasPrev: currentPage > 1,
821
- nextUrl: currentPage < totalPages ? buildPaginatedPath(basePath, currentPage + 1) : undefined,
822
- prevUrl: currentPage > 1 ? buildPaginatedPath(basePath, currentPage - 1) : undefined,
912
+ nextUrl: currentPage < totalPages ? buildPageUrl(currentPage + 1) : undefined,
913
+ prevUrl: currentPage > 1 ? buildPageUrl(currentPage - 1) : undefined,
823
914
  pages: Array.from({ length: totalPages }, (_, index) => {
824
915
  const page = index + 1;
825
916
  return {
826
917
  number: page,
827
- url: buildPaginatedPath(basePath, page),
918
+ url: buildPageUrl(page),
828
919
  current: page === currentPage,
829
920
  };
830
921
  }),
@@ -1105,6 +1196,10 @@ function normalizeOptionalString(value) {
1105
1196
  return typeof value === 'string' && value.trim() ? value.trim() : '';
1106
1197
  }
1107
1198
 
1199
+ function normalizeOptionalRawString(value) {
1200
+ return typeof value === 'string' && value.trim() ? value : '';
1201
+ }
1202
+
1108
1203
  function normalizeLocale(value) {
1109
1204
  if (typeof value !== 'string' || !value.trim()) {
1110
1205
  return DEFAULT_LOCALE;
@@ -1433,14 +1528,28 @@ async function writeOutput(writer, summaries, path, content, contentType) {
1433
1528
  });
1434
1529
  }
1435
1530
 
1436
- function routePathToOutputPath(routePath) {
1531
+ function routePathToOutputPath(routePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
1437
1532
  const normalizedPath = normalizeRoutePath(routePath);
1438
1533
  if (normalizedPath === '/') {
1439
1534
  return 'index.html';
1440
1535
  }
1536
+ if (outputStyle === 'html-extension') {
1537
+ return `${normalizedPath.replace(/^\/+|\/+$/g, '')}.html`;
1538
+ }
1441
1539
  return `${normalizedPath.replace(/^\//, '')}index.html`;
1442
1540
  }
1443
1541
 
1542
+ function routePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
1543
+ const normalizedPath = normalizeRoutePath(routePath);
1544
+ if (normalizedPath === '/') {
1545
+ return '/';
1546
+ }
1547
+ if (outputStyle === 'html-extension') {
1548
+ return normalizedPath.replace(/\/$/, '');
1549
+ }
1550
+ return normalizedPath;
1551
+ }
1552
+
1444
1553
  function normalizeRoutePath(routePath) {
1445
1554
  if (!routePath || routePath === '/') {
1446
1555
  return '/';
@@ -1449,40 +1558,130 @@ function normalizeRoutePath(routePath) {
1449
1558
  return `/${normalized}/`;
1450
1559
  }
1451
1560
 
1561
+ function resolvePagePermalink(site, page) {
1562
+ if (normalizeOptionalString(page.path)) {
1563
+ return buildRouteInfo(page.path, site.permalinks.output_style);
1564
+ }
1565
+ return resolvePermalink(site, 'pages', page);
1566
+ }
1567
+
1568
+ function resolvePermalink(site, kind, item) {
1569
+ const pattern = normalizeNonEmptyString(site.permalinks?.[kind], DEFAULT_PERMALINKS[kind]);
1570
+ return buildRouteInfo(applyPermalinkPattern(pattern, kind, item, site), site.permalinks.output_style);
1571
+ }
1572
+
1573
+ function buildRouteInfo(routePath, outputStyle) {
1574
+ const path = normalizeRoutePath(routePath);
1575
+ return {
1576
+ path,
1577
+ url: routePathToPublicUrl(path, outputStyle),
1578
+ outputPath: routePathToOutputPath(path, outputStyle),
1579
+ };
1580
+ }
1581
+
1582
+ function applyPermalinkPattern(pattern, kind, item, site) {
1583
+ const tokenValues = buildPermalinkTokenValues(kind, item, site);
1584
+ const body = String(pattern || '').replace(/^\/+|\/+$/g, '');
1585
+ const segments = body.split('/').filter(Boolean).map((segment) => {
1586
+ if (segment.startsWith(':')) {
1587
+ return tokenValues[segment.slice(1)] || '';
1588
+ }
1589
+ return segment;
1590
+ });
1591
+ return `/${segments.join('/')}/`;
1592
+ }
1593
+
1594
+ function buildPermalinkTokenValues(kind, item, site) {
1595
+ const values = {
1596
+ slug: encodeSlugSegment(item.slug),
1597
+ };
1598
+
1599
+ if (kind === 'posts') {
1600
+ const parts = getPermalinkDateParts(item.published_at_iso, site);
1601
+ values.public_id = String(item.public_id);
1602
+ values.year = parts.year;
1603
+ values.month = parts.month;
1604
+ values.day = parts.day;
1605
+ }
1606
+
1607
+ return values;
1608
+ }
1609
+
1610
+ function getPermalinkDateParts(value, site) {
1611
+ const date = toDate(value);
1612
+ const timeZone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
1613
+ const parts = new Intl.DateTimeFormat('en-US', {
1614
+ timeZone,
1615
+ year: 'numeric',
1616
+ month: '2-digit',
1617
+ day: '2-digit',
1618
+ }).formatToParts(date);
1619
+
1620
+ return {
1621
+ year: parts.find((part) => part.type === 'year')?.value || String(date.getUTCFullYear()),
1622
+ month: parts.find((part) => part.type === 'month')?.value || String(date.getUTCMonth() + 1).padStart(2, '0'),
1623
+ day: parts.find((part) => part.type === 'day')?.value || String(date.getUTCDate()).padStart(2, '0'),
1624
+ };
1625
+ }
1626
+
1452
1627
  function normalizeOutputPath(filePath) {
1453
1628
  return String(filePath || '').replace(/^\/+/, '');
1454
1629
  }
1455
1630
 
1456
1631
  function assertPlannedOutputPathsSafe(state) {
1632
+ const outputStyle = state.previewData.site.permalinks.output_style;
1457
1633
  for (const post of state.renderData.posts) {
1458
- assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
1634
+ assertSafeSlugDerivedOutputPath(post.slug, routePathToOutputPath(post.url, outputStyle));
1459
1635
  }
1460
1636
 
1461
1637
  for (const page of state.renderData.pages) {
1462
- assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
1638
+ assertSafeSlugDerivedOutputPath(page.slug, routePathToOutputPath(page.url, outputStyle));
1463
1639
  }
1464
1640
 
1465
1641
  for (const category of state.previewData.content.categories) {
1466
- assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
1642
+ assertSafeSlugDerivedOutputPath(category.slug, resolvePermalink(state.previewData.site, 'categories', category).outputPath);
1467
1643
  }
1468
1644
 
1469
1645
  for (const tag of state.previewData.content.tags) {
1470
- assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
1646
+ assertSafeSlugDerivedOutputPath(tag.slug, resolvePermalink(state.previewData.site, 'tags', tag).outputPath);
1471
1647
  }
1472
1648
 
1649
+ const routeEntries = [
1650
+ ...state.renderData.indexRoutes.map((route) => ({
1651
+ url: routePathToPublicUrl(route.path, outputStyle),
1652
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1653
+ })),
1654
+ ...state.renderData.archiveRoutes.map((route) => ({
1655
+ url: routePathToPublicUrl(route.path, outputStyle),
1656
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1657
+ })),
1658
+ ...state.renderData.categoryRoutes.map((route) => ({
1659
+ url: routePathToPublicUrl(route.path, outputStyle),
1660
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1661
+ })),
1662
+ ...state.renderData.tagRoutes.map((route) => ({
1663
+ url: routePathToPublicUrl(route.path, outputStyle),
1664
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1665
+ })),
1666
+ ...state.renderData.posts.map((post) => ({
1667
+ url: post.url,
1668
+ outputPath: routePathToOutputPath(post.url, outputStyle),
1669
+ })),
1670
+ ...state.renderData.pages.map((page) => ({
1671
+ url: page.url,
1672
+ outputPath: routePathToOutputPath(page.url, outputStyle),
1673
+ })),
1674
+ ];
1675
+ assertUniqueRoutes(routeEntries);
1676
+
1473
1677
  const plannedPaths = [
1474
- ...state.renderData.indexRoutes.map((route) => routePathToOutputPath(route.path)),
1475
- ...state.renderData.archiveRoutes.map((route) => routePathToOutputPath(route.path)),
1476
- ...state.renderData.categoryRoutes.map((route) => routePathToOutputPath(route.path)),
1477
- ...state.renderData.tagRoutes.map((route) => routePathToOutputPath(route.path)),
1478
- ...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
1479
- ...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
1678
+ ...routeEntries.map((entry) => entry.outputPath),
1480
1679
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1481
1680
  COMMENT_POLICY_OUTPUT_PATH,
1482
1681
  ];
1483
1682
 
1484
1683
  if (state.options.generateSpecialFiles) {
1485
- plannedPaths.push('404.html', 'robots.txt', 'meta.json');
1684
+ plannedPaths.push('404.html', 'robots.txt');
1486
1685
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
1487
1686
  plannedPaths.push('sitemap.xml', 'feed.xml');
1488
1687
  }
@@ -1493,6 +1692,34 @@ function assertPlannedOutputPathsSafe(state) {
1493
1692
  const normalizedPath = normalizeOutputPath(rawPath);
1494
1693
  assertSafeRelativeOutputPath(rawPath, normalizedPath);
1495
1694
  }
1695
+
1696
+ assertUniqueOutputPaths(plannedPaths);
1697
+ }
1698
+
1699
+ function assertUniqueRoutes(routeEntries) {
1700
+ const seenUrls = new Map();
1701
+ for (const entry of routeEntries) {
1702
+ const normalizedUrl = normalizeRouteCollisionKey(entry.url);
1703
+ if (seenUrls.has(normalizedUrl)) {
1704
+ throw new Error(`Duplicate public URL detected: ${entry.url}`);
1705
+ }
1706
+ seenUrls.set(normalizedUrl, entry);
1707
+ }
1708
+ }
1709
+
1710
+ function assertUniqueOutputPaths(plannedPaths) {
1711
+ const seenPaths = new Set();
1712
+ for (const plannedPath of plannedPaths) {
1713
+ const normalizedPath = normalizeOutputPath(plannedPath);
1714
+ if (seenPaths.has(normalizedPath)) {
1715
+ throw new Error(`Duplicate output path detected: ${plannedPath}`);
1716
+ }
1717
+ seenPaths.add(normalizedPath);
1718
+ }
1719
+ }
1720
+
1721
+ function normalizeRouteCollisionKey(url) {
1722
+ return String(url || '').replace(/\/+$/, '') || '/';
1496
1723
  }
1497
1724
 
1498
1725
  function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
@@ -1553,6 +1780,12 @@ function sha256(content) {
1553
1780
  return hash.digest('hex');
1554
1781
  }
1555
1782
 
1783
+ function injectSiteCustomizations(html, state) {
1784
+ let next = injectCustomCssAssetLink(html, state.customCssHref);
1785
+ next = injectCustomHtml(next, state.customHtml);
1786
+ return next;
1787
+ }
1788
+
1556
1789
  function injectCustomCssAssetLink(html, href) {
1557
1790
  if (!normalizeOptionalString(href)) {
1558
1791
  return html;
@@ -1561,6 +1794,21 @@ function injectCustomCssAssetLink(html, href) {
1561
1794
  return html.replace('</head>', ` <link rel="stylesheet" href="${escapeHtml(href)}">\n</head>`);
1562
1795
  }
1563
1796
 
1797
+ function injectCustomHtml(html, customHtml) {
1798
+ let next = html;
1799
+ const headEnd = normalizeOptionalRawString(customHtml?.head_end?.content);
1800
+ const bodyEnd = normalizeOptionalRawString(customHtml?.body_end?.content);
1801
+
1802
+ if (headEnd) {
1803
+ next = next.replace('</head>', `${headEnd}\n</head>`);
1804
+ }
1805
+ if (bodyEnd) {
1806
+ next = next.replace('</body>', `${bodyEnd}\n</body>`);
1807
+ }
1808
+
1809
+ return next;
1810
+ }
1811
+
1564
1812
  function buildSitemapXml(site, emitted, generatedAt) {
1565
1813
  const entries = [
1566
1814
  ...emitted.indexRoutes
@@ -1617,38 +1865,6 @@ function buildRobotsTxt(site) {
1617
1865
  return `${lines.join('\n')}\n`;
1618
1866
  }
1619
1867
 
1620
- function buildMetaJson(site, emitted, generatedAt) {
1621
- const pages = [
1622
- ...emitted.posts.map((post) => ({
1623
- url: post.url,
1624
- title: post.title,
1625
- description: post.description,
1626
- publishedAt: post.publishedAt,
1627
- updatedAt: post.updatedAt,
1628
- type: 'post',
1629
- metadata: { status: post.status },
1630
- })),
1631
- ...emitted.pages.map((page) => ({
1632
- url: page.url,
1633
- title: page.title,
1634
- description: page.description,
1635
- type: 'page',
1636
- metadata: { status: page.status },
1637
- })),
1638
- ];
1639
-
1640
- return JSON.stringify({
1641
- generated: formatUtcIsoSeconds(generatedAt),
1642
- site: {
1643
- title: site.title,
1644
- description: site.description,
1645
- url: site.url,
1646
- locale: site.locale,
1647
- },
1648
- pages,
1649
- }, null, 2);
1650
- }
1651
-
1652
1868
  function getContentType(assetPath) {
1653
1869
  const ext = assetPath.split('.').pop()?.toLowerCase();
1654
1870
  const contentTypes = {
@@ -2,46 +2,84 @@ import MarkdownIt from 'markdown-it';
2
2
  import hljs from 'highlight.js';
3
3
  import anchor from 'markdown-it-anchor';
4
4
 
5
- const markdown = new MarkdownIt({
6
- html: true,
7
- linkify: true,
8
- typographer: true,
9
- breaks: true,
10
- highlight(value, language) {
11
- if (language && hljs.getLanguage(language)) {
12
- try {
13
- return hljs.highlight(value, { language }).value;
14
- } catch {
15
- return value;
16
- }
17
- }
18
-
19
- try {
20
- return hljs.highlightAuto(value).value;
21
- } catch {
22
- return value;
23
- }
24
- },
25
- });
26
-
27
- markdown.use(anchor, {
28
- permalink: anchor.permalink.headerLink(),
29
- slugify: slugify,
30
- });
5
+ const TOC_LEVELS = new Set([2, 3, 4]);
31
6
 
32
7
  export function renderDocumentContent(content, documentType = 'markdown') {
8
+ return renderDocument(content, documentType).html;
9
+ }
10
+
11
+ export function renderDocument(content, documentType = 'markdown') {
33
12
  const normalizedContent = typeof content === 'string' ? content : '';
34
13
  const normalizedType = normalizeDocumentType(documentType);
35
14
 
36
15
  if (normalizedType === 'plaintext') {
37
- return transformPlaintext(normalizedContent);
16
+ return {
17
+ html: transformPlaintext(normalizedContent),
18
+ toc: [],
19
+ };
38
20
  }
39
21
 
40
22
  if (normalizedType === 'html') {
41
- return sanitizeHtml(normalizedContent);
23
+ return {
24
+ html: sanitizeHtml(normalizedContent),
25
+ toc: [],
26
+ };
42
27
  }
43
28
 
44
- return sanitizeHtml(markdown.render(normalizedContent));
29
+ return renderMarkdownDocument(normalizedContent);
30
+ }
31
+
32
+ function renderMarkdownDocument(content) {
33
+ const toc = [];
34
+ const markdown = createMarkdownRenderer(toc);
35
+
36
+ return {
37
+ html: sanitizeHtml(markdown.render(content)),
38
+ toc,
39
+ };
40
+ }
41
+
42
+ function createMarkdownRenderer(toc) {
43
+ const markdown = new MarkdownIt({
44
+ html: true,
45
+ linkify: true,
46
+ typographer: true,
47
+ breaks: true,
48
+ highlight(value, language) {
49
+ if (language && hljs.getLanguage(language)) {
50
+ try {
51
+ return hljs.highlight(value, { language }).value;
52
+ } catch {
53
+ return value;
54
+ }
55
+ }
56
+
57
+ try {
58
+ return hljs.highlightAuto(value).value;
59
+ } catch {
60
+ return value;
61
+ }
62
+ },
63
+ });
64
+
65
+ markdown.use(anchor, {
66
+ slugify: slugify,
67
+ callback(token, { slug, title }) {
68
+ const level = Number(token.tag.slice(1));
69
+ if (!TOC_LEVELS.has(level)) {
70
+ return;
71
+ }
72
+
73
+ toc.push({
74
+ level,
75
+ id: slug,
76
+ title,
77
+ href: `#${slug}`,
78
+ });
79
+ },
80
+ });
81
+
82
+ return markdown;
45
83
  }
46
84
 
47
85
  function normalizeDocumentType(value) {
@@ -1,8 +1,9 @@
1
1
  import { parsePartialToken } from './partial-resolver.js';
2
2
 
3
- const PATH_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*$/;
4
- const FOR_EXPRESSION_REGEX = /^([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)$/;
5
- const IF_EQ_EXPRESSION_REGEX = /^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+("(?:[^"\\]|\\.)*")$/;
3
+ const PATH_SEGMENT_SOURCE = '[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*';
4
+ const PATH_REGEX = new RegExp(`^${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*$`);
5
+ const FOR_EXPRESSION_REGEX = new RegExp(`^([a-zA-Z_][a-zA-Z0-9_]*)\\s+in\\s+(${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*)$`);
6
+ const IF_EQ_EXPRESSION_REGEX = new RegExp(`^(${PATH_SEGMENT_SOURCE}(?:\\.${PATH_SEGMENT_SOURCE})*)\\s+("(?:[^"\\\\]|\\\\.)*")$`);
6
7
 
7
8
  export class ControlFlowRenderer {
8
9
  constructor(options = {}) {
@@ -1,7 +1,7 @@
1
1
  export class VariableResolver {
2
2
  static MENU_PATTERN = /\{\{menu:([a-z][a-z0-9_-]{0,63})\}\}/g;
3
- static VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+)\}\}/g;
4
- static STANDALONE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
3
+ static VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*(?:\.[a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*)+)\}\}/g;
4
+ static STANDALONE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)*)\}\}/g;
5
5
 
6
6
  resolve(template, data, options = {}) {
7
7
  let result = template.replace(VariableResolver.MENU_PATTERN, (_, menuId) => {