@zeropress/build-core 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeropress/build-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@zeropress/slug-policy": "^0.1.0",
37
- "@zeropress/preview-data-validator": "^0.4.4",
37
+ "@zeropress/preview-data-validator": "^0.4.5",
38
38
  "@zeropress/theme-validator": "^0.5.0",
39
39
  "highlight.js": "^11.11.1",
40
40
  "markdown-it": "^14.1.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:']);
@@ -154,7 +162,7 @@ async function finalizeBuildResult(writer, summaries, options) {
154
162
  }
155
163
 
156
164
  async function renderRoute(state, templateName, route) {
157
- const currentUrl = normalizeRoutePath(route.path);
165
+ const currentUrl = routePathToPublicUrl(route.path, state.previewData.site.permalinks.output_style);
158
166
  let html = await state.engine.render(
159
167
  templateName,
160
168
  {
@@ -172,12 +180,12 @@ async function renderRoute(state, templateName, route) {
172
180
  );
173
181
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
174
182
  html = injectCustomCssAssetLink(html, state.customCssHref);
175
- await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
183
+ await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path, state.previewData.site.permalinks.output_style), html, 'text/html');
176
184
  recordRouteEmission(state, templateName, route, currentUrl);
177
185
  }
178
186
 
179
187
  async function renderPost(state, post) {
180
- const currentUrl = `/posts/${encodeSlugSegment(post.slug)}/`;
188
+ const currentUrl = post.url;
181
189
  let html = await state.engine.render(
182
190
  'post',
183
191
  {
@@ -198,7 +206,7 @@ async function renderPost(state, post) {
198
206
  );
199
207
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
200
208
  html = injectCustomCssAssetLink(html, state.customCssHref);
201
- await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
209
+ await writeOutput(state.writer, state.summaries, routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style), html, 'text/html');
202
210
  state.emitted.posts.push({
203
211
  url: currentUrl,
204
212
  title: post.title,
@@ -210,7 +218,7 @@ async function renderPost(state, post) {
210
218
  }
211
219
 
212
220
  async function renderPage(state, page) {
213
- const currentUrl = `/${encodeSlugSegment(page.slug)}/`;
221
+ const currentUrl = page.url;
214
222
  let html = await state.engine.render(
215
223
  'page',
216
224
  {
@@ -229,7 +237,7 @@ async function renderPage(state, page) {
229
237
  );
230
238
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
231
239
  html = injectCustomCssAssetLink(html, state.customCssHref);
232
- await writeOutput(state.writer, state.summaries, `${encodeSlugSegment(page.slug)}/index.html`, html, 'text/html');
240
+ await writeOutput(state.writer, state.summaries, routePathToOutputPath(page.url, state.previewData.site.permalinks.output_style), html, 'text/html');
233
241
  state.emitted.pages.push({
234
242
  url: currentUrl,
235
243
  title: page.title,
@@ -274,6 +282,7 @@ function normalizePreviewData(previewData) {
274
282
  timezone: normalizeNonEmptyString(previewData.site.timezone, DEFAULT_TIMEZONE),
275
283
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
276
284
  disallowComments: previewData.site.disallowComments === true,
285
+ permalinks: normalizePermalinks(previewData.site.permalinks),
277
286
  };
278
287
 
279
288
  return {
@@ -348,6 +357,21 @@ function normalizeCustomCss(customCss) {
348
357
  return content ? { content } : undefined;
349
358
  }
350
359
 
360
+ function normalizePermalinks(permalinks) {
361
+ const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
362
+ const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
363
+ ? source.output_style
364
+ : DEFAULT_PERMALINKS.output_style;
365
+
366
+ return {
367
+ output_style: outputStyle,
368
+ posts: normalizeNonEmptyString(source.posts, DEFAULT_PERMALINKS.posts),
369
+ pages: normalizeNonEmptyString(source.pages, DEFAULT_PERMALINKS.pages),
370
+ categories: normalizeNonEmptyString(source.categories, DEFAULT_PERMALINKS.categories),
371
+ tags: normalizeNonEmptyString(source.tags, DEFAULT_PERMALINKS.tags),
372
+ };
373
+ }
374
+
351
375
  function createRenderData(previewData, themeMetadata = {}) {
352
376
  const themeSupportsComments = themeMetadata?.features?.comments === true;
353
377
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
@@ -376,7 +400,7 @@ function createRenderData(previewData, themeMetadata = {}) {
376
400
  prev: index > 0 ? buildAdjacentPostSummary(preparedPosts[index - 1]) : null,
377
401
  next: index < preparedPosts.length - 1 ? buildAdjacentPostSummary(preparedPosts[index + 1]) : null,
378
402
  }));
379
- const pages = previewData.content.pages.map((page) => preparePage(page));
403
+ const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
380
404
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
381
405
 
382
406
  return {
@@ -387,6 +411,7 @@ function createRenderData(previewData, themeMetadata = {}) {
387
411
  items: previewData.content.posts,
388
412
  postsPerPage: previewData.site.postsPerPage,
389
413
  basePath: '/',
414
+ outputStyle: previewData.site.permalinks.output_style,
390
415
  }).map((entry) => ({
391
416
  path: entry.path,
392
417
  page: entry.page,
@@ -398,6 +423,7 @@ function createRenderData(previewData, themeMetadata = {}) {
398
423
  items: previewData.content.posts,
399
424
  postsPerPage: previewData.site.postsPerPage,
400
425
  basePath: '/archive/',
426
+ outputStyle: previewData.site.permalinks.output_style,
401
427
  }).map((entry) => ({
402
428
  path: entry.path,
403
429
  page: entry.page,
@@ -412,7 +438,8 @@ function createRenderData(previewData, themeMetadata = {}) {
412
438
  postsBySlug: categoryPostsBySlug,
413
439
  postBySlug,
414
440
  postsPerPage: previewData.site.postsPerPage,
415
- buildBasePath: (category) => `/categories/${encodeSlugSegment(category.slug)}/`,
441
+ outputStyle: previewData.site.permalinks.output_style,
442
+ buildBasePath: (category) => resolvePermalink(previewData.site, 'categories', category).path,
416
443
  renderExtras: (category) => ({
417
444
  taxonomy: buildTaxonomyRouteData('category', category, categoryCountBySlug),
418
445
  }),
@@ -422,7 +449,8 @@ function createRenderData(previewData, themeMetadata = {}) {
422
449
  postsBySlug: tagPostsBySlug,
423
450
  postBySlug,
424
451
  postsPerPage: previewData.site.postsPerPage,
425
- buildBasePath: (tag) => `/tags/${encodeSlugSegment(tag.slug)}/`,
452
+ outputStyle: previewData.site.permalinks.output_style,
453
+ buildBasePath: (tag) => resolvePermalink(previewData.site, 'tags', tag).path,
426
454
  renderExtras: (tag) => ({
427
455
  taxonomy: buildTaxonomyRouteData('tag', tag, tagCountBySlug),
428
456
  }),
@@ -521,7 +549,7 @@ function resolveCategoriesWidget(baseWidget, settings, previewData) {
521
549
  .map((category) => ({
522
550
  name: category.name,
523
551
  slug: category.slug,
524
- url: `/categories/${encodeSlugSegment(category.slug)}/`,
552
+ url: resolvePermalink(previewData.site, 'categories', category).url,
525
553
  count: countBySlug.get(category.slug) || 0,
526
554
  depth: 0,
527
555
  }))
@@ -546,7 +574,7 @@ function resolveTagsWidget(baseWidget, settings, previewData) {
546
574
  .map((tag) => ({
547
575
  name: tag.name,
548
576
  slug: tag.slug,
549
- url: `/tags/${encodeSlugSegment(tag.slug)}/`,
577
+ url: resolvePermalink(previewData.site, 'tags', tag).url,
550
578
  count: countBySlug.get(tag.slug) || 0,
551
579
  }))
552
580
  .filter((tag) => tag.count > 0)
@@ -569,7 +597,7 @@ function resolveArchivesWidget(baseWidget, settings, previewData) {
569
597
  .slice(0, limit)
570
598
  .map((entry) => ({
571
599
  label: entry.label,
572
- url: '/archive/',
600
+ url: routePathToPublicUrl('/archive/', previewData.site.permalinks.output_style),
573
601
  count: entry.count,
574
602
  year: entry.year,
575
603
  month: entry.month,
@@ -657,27 +685,32 @@ function resolveProfileWidget(baseWidget, settings) {
657
685
  };
658
686
  }
659
687
 
660
- function preparePage(page) {
688
+ function preparePage(page, site) {
661
689
  const documentType = normalizeDocumentType(page.document_type);
690
+ const renderedDocument = renderDocument(page.content, documentType);
691
+ const permalink = resolvePagePermalink(site, page);
662
692
 
663
693
  return {
664
694
  ...page,
695
+ url: permalink.url,
665
696
  document_type: documentType,
666
- html: renderDocumentContent(page.content, documentType),
697
+ html: renderedDocument.html,
698
+ toc: renderedDocument.toc,
667
699
  };
668
700
  }
669
701
 
670
702
  function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, themeSupportsComments) {
671
703
  const documentType = normalizeDocumentType(post.document_type);
672
- const html = renderDocumentContent(post.content, documentType);
704
+ const renderedDocument = renderDocument(post.content, documentType);
673
705
  const author = authorsById.get(post.author_id);
706
+ const permalink = resolvePermalink(site, 'posts', post);
674
707
  const categories = post.category_slugs
675
708
  .map((slug) => categoriesBySlug.get(slug))
676
709
  .filter(Boolean)
677
710
  .map((category) => ({
678
711
  name: category.name,
679
712
  slug: category.slug,
680
- url: `/categories/${encodeSlugSegment(category.slug)}/`,
713
+ url: resolvePermalink(site, 'categories', category).url,
681
714
  }));
682
715
  const tags = post.tag_slugs
683
716
  .map((slug) => tagsBySlug.get(slug))
@@ -685,14 +718,14 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
685
718
  .map((tag) => ({
686
719
  name: tag.name,
687
720
  slug: tag.slug,
688
- url: `/tags/${encodeSlugSegment(tag.slug)}/`,
721
+ url: resolvePermalink(site, 'tags', tag).url,
689
722
  }));
690
723
 
691
724
  return {
692
725
  public_id: post.public_id,
693
726
  title: post.title,
694
727
  slug: post.slug,
695
- url: `/posts/${encodeSlugSegment(post.slug)}/`,
728
+ url: permalink.url,
696
729
  content: post.content,
697
730
  document_type: documentType,
698
731
  excerpt: post.excerpt,
@@ -700,6 +733,7 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
700
733
  updated_at_iso: post.updated_at_iso,
701
734
  author_id: post.author_id,
702
735
  featured_image: post.featured_image,
736
+ meta: post.meta,
703
737
  status: post.status,
704
738
  allow_comments: post.allow_comments,
705
739
  category_slugs: post.category_slugs,
@@ -711,10 +745,11 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug, them
711
745
  },
712
746
  categories,
713
747
  tags,
714
- html,
748
+ html: renderedDocument.html,
749
+ toc: renderedDocument.toc,
715
750
  published_at: formatTimestamp(post.published_at_iso, site),
716
751
  updated_at: formatTimestamp(post.updated_at_iso, site),
717
- reading_time: calculateReadingTime(html),
752
+ reading_time: calculateReadingTime(renderedDocument.html),
718
753
  comments_enabled: themeSupportsComments && site.disallowComments !== true && post.allow_comments === true,
719
754
  };
720
755
  }
@@ -744,6 +779,7 @@ function buildTaxonomyRoutes(options) {
744
779
  items: matchedPosts,
745
780
  postsPerPage: options.postsPerPage,
746
781
  basePath: options.buildBasePath(item),
782
+ outputStyle: options.outputStyle,
747
783
  });
748
784
 
749
785
  for (const entry of paginated) {
@@ -764,6 +800,7 @@ function buildTaxonomyRoutes(options) {
764
800
 
765
801
  function buildPaginatedCollection(options) {
766
802
  const basePath = normalizePaginationBasePath(options.basePath);
803
+ const outputStyle = PERMALINK_OUTPUT_STYLES.has(options.outputStyle) ? options.outputStyle : DEFAULT_PERMALINKS.output_style;
767
804
  const postsPerPage = Number.isInteger(options.postsPerPage) && options.postsPerPage > 0 ? options.postsPerPage : DEFAULT_POSTS_PER_PAGE;
768
805
  const totalPosts = options.items.length;
769
806
  const totalPages = Math.max(1, Math.ceil(totalPosts / postsPerPage));
@@ -778,7 +815,7 @@ function buildPaginatedCollection(options) {
778
815
  page,
779
816
  totalPages,
780
817
  items: options.items.slice(start, end),
781
- paginationData: buildPaginationData(page, totalPages, totalPosts, basePath),
818
+ paginationData: buildPaginationData(page, totalPages, totalPosts, basePath, outputStyle),
782
819
  });
783
820
  }
784
821
 
@@ -811,20 +848,22 @@ function normalizePaginationBasePath(basePath) {
811
848
  return `/${normalized}/`;
812
849
  }
813
850
 
814
- function buildPaginationData(currentPage, totalPages, totalPosts, basePath) {
851
+ function buildPaginationData(currentPage, totalPages, totalPosts, basePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
852
+ const buildPageUrl = (page) => routePathToPublicUrl(buildPaginatedPath(basePath, page), outputStyle);
853
+
815
854
  return {
816
855
  currentPage,
817
856
  totalPages,
818
857
  totalPosts,
819
858
  hasNext: currentPage < totalPages,
820
859
  hasPrev: currentPage > 1,
821
- nextUrl: currentPage < totalPages ? buildPaginatedPath(basePath, currentPage + 1) : undefined,
822
- prevUrl: currentPage > 1 ? buildPaginatedPath(basePath, currentPage - 1) : undefined,
860
+ nextUrl: currentPage < totalPages ? buildPageUrl(currentPage + 1) : undefined,
861
+ prevUrl: currentPage > 1 ? buildPageUrl(currentPage - 1) : undefined,
823
862
  pages: Array.from({ length: totalPages }, (_, index) => {
824
863
  const page = index + 1;
825
864
  return {
826
865
  number: page,
827
- url: buildPaginatedPath(basePath, page),
866
+ url: buildPageUrl(page),
828
867
  current: page === currentPage,
829
868
  };
830
869
  }),
@@ -1433,14 +1472,28 @@ async function writeOutput(writer, summaries, path, content, contentType) {
1433
1472
  });
1434
1473
  }
1435
1474
 
1436
- function routePathToOutputPath(routePath) {
1475
+ function routePathToOutputPath(routePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
1437
1476
  const normalizedPath = normalizeRoutePath(routePath);
1438
1477
  if (normalizedPath === '/') {
1439
1478
  return 'index.html';
1440
1479
  }
1480
+ if (outputStyle === 'html-extension') {
1481
+ return `${normalizedPath.replace(/^\/+|\/+$/g, '')}.html`;
1482
+ }
1441
1483
  return `${normalizedPath.replace(/^\//, '')}index.html`;
1442
1484
  }
1443
1485
 
1486
+ function routePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
1487
+ const normalizedPath = normalizeRoutePath(routePath);
1488
+ if (normalizedPath === '/') {
1489
+ return '/';
1490
+ }
1491
+ if (outputStyle === 'html-extension') {
1492
+ return normalizedPath.replace(/\/$/, '');
1493
+ }
1494
+ return normalizedPath;
1495
+ }
1496
+
1444
1497
  function normalizeRoutePath(routePath) {
1445
1498
  if (!routePath || routePath === '/') {
1446
1499
  return '/';
@@ -1449,34 +1502,124 @@ function normalizeRoutePath(routePath) {
1449
1502
  return `/${normalized}/`;
1450
1503
  }
1451
1504
 
1505
+ function resolvePagePermalink(site, page) {
1506
+ if (normalizeOptionalString(page.path)) {
1507
+ return buildRouteInfo(page.path, site.permalinks.output_style);
1508
+ }
1509
+ return resolvePermalink(site, 'pages', page);
1510
+ }
1511
+
1512
+ function resolvePermalink(site, kind, item) {
1513
+ const pattern = normalizeNonEmptyString(site.permalinks?.[kind], DEFAULT_PERMALINKS[kind]);
1514
+ return buildRouteInfo(applyPermalinkPattern(pattern, kind, item, site), site.permalinks.output_style);
1515
+ }
1516
+
1517
+ function buildRouteInfo(routePath, outputStyle) {
1518
+ const path = normalizeRoutePath(routePath);
1519
+ return {
1520
+ path,
1521
+ url: routePathToPublicUrl(path, outputStyle),
1522
+ outputPath: routePathToOutputPath(path, outputStyle),
1523
+ };
1524
+ }
1525
+
1526
+ function applyPermalinkPattern(pattern, kind, item, site) {
1527
+ const tokenValues = buildPermalinkTokenValues(kind, item, site);
1528
+ const body = String(pattern || '').replace(/^\/+|\/+$/g, '');
1529
+ const segments = body.split('/').filter(Boolean).map((segment) => {
1530
+ if (segment.startsWith(':')) {
1531
+ return tokenValues[segment.slice(1)] || '';
1532
+ }
1533
+ return segment;
1534
+ });
1535
+ return `/${segments.join('/')}/`;
1536
+ }
1537
+
1538
+ function buildPermalinkTokenValues(kind, item, site) {
1539
+ const values = {
1540
+ slug: encodeSlugSegment(item.slug),
1541
+ };
1542
+
1543
+ if (kind === 'posts') {
1544
+ const parts = getPermalinkDateParts(item.published_at_iso, site);
1545
+ values.public_id = String(item.public_id);
1546
+ values.year = parts.year;
1547
+ values.month = parts.month;
1548
+ values.day = parts.day;
1549
+ }
1550
+
1551
+ return values;
1552
+ }
1553
+
1554
+ function getPermalinkDateParts(value, site) {
1555
+ const date = toDate(value);
1556
+ const timeZone = normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE);
1557
+ const parts = new Intl.DateTimeFormat('en-US', {
1558
+ timeZone,
1559
+ year: 'numeric',
1560
+ month: '2-digit',
1561
+ day: '2-digit',
1562
+ }).formatToParts(date);
1563
+
1564
+ return {
1565
+ year: parts.find((part) => part.type === 'year')?.value || String(date.getUTCFullYear()),
1566
+ month: parts.find((part) => part.type === 'month')?.value || String(date.getUTCMonth() + 1).padStart(2, '0'),
1567
+ day: parts.find((part) => part.type === 'day')?.value || String(date.getUTCDate()).padStart(2, '0'),
1568
+ };
1569
+ }
1570
+
1452
1571
  function normalizeOutputPath(filePath) {
1453
1572
  return String(filePath || '').replace(/^\/+/, '');
1454
1573
  }
1455
1574
 
1456
1575
  function assertPlannedOutputPathsSafe(state) {
1576
+ const outputStyle = state.previewData.site.permalinks.output_style;
1457
1577
  for (const post of state.renderData.posts) {
1458
- assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
1578
+ assertSafeSlugDerivedOutputPath(post.slug, routePathToOutputPath(post.url, outputStyle));
1459
1579
  }
1460
1580
 
1461
1581
  for (const page of state.renderData.pages) {
1462
- assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
1582
+ assertSafeSlugDerivedOutputPath(page.slug, routePathToOutputPath(page.url, outputStyle));
1463
1583
  }
1464
1584
 
1465
1585
  for (const category of state.previewData.content.categories) {
1466
- assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
1586
+ assertSafeSlugDerivedOutputPath(category.slug, resolvePermalink(state.previewData.site, 'categories', category).outputPath);
1467
1587
  }
1468
1588
 
1469
1589
  for (const tag of state.previewData.content.tags) {
1470
- assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
1590
+ assertSafeSlugDerivedOutputPath(tag.slug, resolvePermalink(state.previewData.site, 'tags', tag).outputPath);
1471
1591
  }
1472
1592
 
1593
+ const routeEntries = [
1594
+ ...state.renderData.indexRoutes.map((route) => ({
1595
+ url: routePathToPublicUrl(route.path, outputStyle),
1596
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1597
+ })),
1598
+ ...state.renderData.archiveRoutes.map((route) => ({
1599
+ url: routePathToPublicUrl(route.path, outputStyle),
1600
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1601
+ })),
1602
+ ...state.renderData.categoryRoutes.map((route) => ({
1603
+ url: routePathToPublicUrl(route.path, outputStyle),
1604
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1605
+ })),
1606
+ ...state.renderData.tagRoutes.map((route) => ({
1607
+ url: routePathToPublicUrl(route.path, outputStyle),
1608
+ outputPath: routePathToOutputPath(route.path, outputStyle),
1609
+ })),
1610
+ ...state.renderData.posts.map((post) => ({
1611
+ url: post.url,
1612
+ outputPath: routePathToOutputPath(post.url, outputStyle),
1613
+ })),
1614
+ ...state.renderData.pages.map((page) => ({
1615
+ url: page.url,
1616
+ outputPath: routePathToOutputPath(page.url, outputStyle),
1617
+ })),
1618
+ ];
1619
+ assertUniqueRoutes(routeEntries);
1620
+
1473
1621
  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`),
1622
+ ...routeEntries.map((entry) => entry.outputPath),
1480
1623
  ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1481
1624
  COMMENT_POLICY_OUTPUT_PATH,
1482
1625
  ];
@@ -1493,6 +1636,34 @@ function assertPlannedOutputPathsSafe(state) {
1493
1636
  const normalizedPath = normalizeOutputPath(rawPath);
1494
1637
  assertSafeRelativeOutputPath(rawPath, normalizedPath);
1495
1638
  }
1639
+
1640
+ assertUniqueOutputPaths(plannedPaths);
1641
+ }
1642
+
1643
+ function assertUniqueRoutes(routeEntries) {
1644
+ const seenUrls = new Map();
1645
+ for (const entry of routeEntries) {
1646
+ const normalizedUrl = normalizeRouteCollisionKey(entry.url);
1647
+ if (seenUrls.has(normalizedUrl)) {
1648
+ throw new Error(`Duplicate public URL detected: ${entry.url}`);
1649
+ }
1650
+ seenUrls.set(normalizedUrl, entry);
1651
+ }
1652
+ }
1653
+
1654
+ function assertUniqueOutputPaths(plannedPaths) {
1655
+ const seenPaths = new Set();
1656
+ for (const plannedPath of plannedPaths) {
1657
+ const normalizedPath = normalizeOutputPath(plannedPath);
1658
+ if (seenPaths.has(normalizedPath)) {
1659
+ throw new Error(`Duplicate output path detected: ${plannedPath}`);
1660
+ }
1661
+ seenPaths.add(normalizedPath);
1662
+ }
1663
+ }
1664
+
1665
+ function normalizeRouteCollisionKey(url) {
1666
+ return String(url || '').replace(/\/+$/, '') || '/';
1496
1667
  }
1497
1668
 
1498
1669
  function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
@@ -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) {