@zeropress/build-core 0.1.11 → 0.1.13

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.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Shared ZeroPress build core",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,8 +33,9 @@
33
33
  "node": ">=18.18.0"
34
34
  },
35
35
  "dependencies": {
36
- "@zeropress/preview-data-validator": "^0.4.1",
37
- "@zeropress/theme-validator": "^0.3.0",
36
+ "@zeropress/slug-policy": "^0.1.0",
37
+ "@zeropress/preview-data-validator": "^0.4.3",
38
+ "@zeropress/theme-validator": "^0.3.2",
38
39
  "highlight.js": "^11.11.1",
39
40
  "markdown-it": "^14.1.0",
40
41
  "markdown-it-anchor": "^9.2.0"
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { assertPreviewData } from '@zeropress/preview-data-validator';
3
+ import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
3
4
  import { validateThemeFiles } from '@zeropress/theme-validator';
4
5
  import { AssetProcessor } from '../assets/asset-processor.js';
5
6
  import { renderDocumentContent } from '../render/content-renderer.js';
@@ -17,9 +18,11 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
17
18
  const DEFAULT_TIME_FORMAT = 'HH:mm';
18
19
  const DEFAULT_TIMEZONE = 'UTC';
19
20
  const DEFAULT_LOCALE = 'en-US';
21
+ const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
20
22
  export async function buildSite(input) {
21
23
  const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
22
24
  const state = await createBuildState(input, options);
25
+ assertPlannedOutputPathsSafe(state);
23
26
 
24
27
  for (const route of state.renderData.indexRoutes) {
25
28
  await renderRoute(state, 'index', route);
@@ -81,10 +84,15 @@ async function createBuildState(input, options) {
81
84
  const summaries = [];
82
85
  const themePackage = input.themePackage;
83
86
  const previewData = normalizePreviewData(input.previewData);
87
+ const renderData = createRenderData(previewData);
84
88
 
85
89
  engine.initialize(themePackage);
86
90
 
87
91
  const assetOutputs = await buildAssetOutputs(themePackage.assets, assetProcessor, options);
92
+ const customCssAsset = await buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
93
+ if (customCssAsset) {
94
+ assetOutputs.push(customCssAsset);
95
+ }
88
96
  const assetMap = new Map(
89
97
  assetOutputs.map((asset) => [`/assets/${asset.originalPath}`, `/${asset.path}`]),
90
98
  );
@@ -92,12 +100,14 @@ async function createBuildState(input, options) {
92
100
  return {
93
101
  writer: input.writer,
94
102
  previewData,
95
- renderData: createRenderData(previewData),
103
+ renderData,
104
+ renderedWidgets: renderWidgetAreas(previewData, renderData),
96
105
  engine,
97
106
  assetProcessor,
98
107
  summaries,
99
108
  assetOutputs,
100
109
  assetMap,
110
+ customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
101
111
  options,
102
112
  generatedAt: new Date(),
103
113
  emitted: {
@@ -139,6 +149,7 @@ async function renderRoute(state, templateName, route) {
139
149
  templateName,
140
150
  {
141
151
  menus: state.previewData.menus,
152
+ widgets: state.renderedWidgets,
142
153
  ...route,
143
154
  meta: buildPageMeta(state.previewData.site, {
144
155
  currentUrl,
@@ -150,6 +161,7 @@ async function renderRoute(state, templateName, route) {
150
161
  createRenderContext(state.previewData.site, currentUrl),
151
162
  );
152
163
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
164
+ html = injectCustomCssAssetLink(html, state.customCssHref);
153
165
  await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
154
166
  recordRouteEmission(state, templateName, route, currentUrl);
155
167
  }
@@ -160,6 +172,7 @@ async function renderPost(state, post) {
160
172
  'post',
161
173
  {
162
174
  menus: state.previewData.menus,
175
+ widgets: state.renderedWidgets,
163
176
  post,
164
177
  meta: buildPageMeta(state.previewData.site, {
165
178
  currentUrl,
@@ -174,6 +187,7 @@ async function renderPost(state, post) {
174
187
  createRenderContext(state.previewData.site, currentUrl),
175
188
  );
176
189
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
190
+ html = injectCustomCssAssetLink(html, state.customCssHref);
177
191
  if (state.options.injectHtmx) {
178
192
  html = injectHtmxScript(html);
179
193
  }
@@ -194,6 +208,7 @@ async function renderPage(state, page) {
194
208
  'page',
195
209
  {
196
210
  menus: state.previewData.menus,
211
+ widgets: state.renderedWidgets,
197
212
  page,
198
213
  meta: buildPageMeta(state.previewData.site, {
199
214
  currentUrl,
@@ -206,6 +221,7 @@ async function renderPage(state, page) {
206
221
  createRenderContext(state.previewData.site, currentUrl),
207
222
  );
208
223
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
224
+ html = injectCustomCssAssetLink(html, state.customCssHref);
209
225
  await writeOutput(state.writer, state.summaries, `${encodeSlugSegment(page.slug)}/index.html`, html, 'text/html');
210
226
  state.emitted.pages.push({
211
227
  url: currentUrl,
@@ -224,6 +240,7 @@ async function maybeRenderNotFoundPage(state) {
224
240
  '404',
225
241
  {
226
242
  menus: state.previewData.menus,
243
+ widgets: state.renderedWidgets,
227
244
  meta: buildPageMeta(state.previewData.site, {
228
245
  currentUrl: '/404.html',
229
246
  title: state.previewData.site.title,
@@ -234,6 +251,7 @@ async function maybeRenderNotFoundPage(state) {
234
251
  createRenderContext(state.previewData.site, '/404.html'),
235
252
  );
236
253
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
254
+ html = injectCustomCssAssetLink(html, state.customCssHref);
237
255
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
238
256
  }
239
257
 
@@ -254,6 +272,8 @@ function normalizePreviewData(previewData) {
254
272
  return {
255
273
  ...previewData,
256
274
  site: normalizedSite,
275
+ widgets: normalizeWidgetAreas(previewData.widgets),
276
+ custom_css: normalizeCustomCss(previewData.custom_css),
257
277
  content: {
258
278
  ...previewData.content,
259
279
  authors: previewData.content.authors.map((author) => ({
@@ -278,6 +298,33 @@ function normalizePreviewData(previewData) {
278
298
  };
279
299
  }
280
300
 
301
+ function normalizeWidgetAreas(widgetAreas) {
302
+ if (!widgetAreas || typeof widgetAreas !== 'object') {
303
+ return {};
304
+ }
305
+
306
+ return Object.fromEntries(
307
+ Object.entries(widgetAreas).map(([widgetAreaId, widgetArea]) => [
308
+ widgetAreaId,
309
+ {
310
+ ...widgetArea,
311
+ name: normalizeNonEmptyString(widgetArea?.name, widgetAreaId),
312
+ items: Array.isArray(widgetArea?.items)
313
+ ? widgetArea.items.map((item) => ({
314
+ ...item,
315
+ title: normalizeNonEmptyString(item?.title, 'Widget'),
316
+ }))
317
+ : [],
318
+ },
319
+ ]),
320
+ );
321
+ }
322
+
323
+ function normalizeCustomCss(customCss) {
324
+ const content = normalizeOptionalString(customCss?.content);
325
+ return content ? { content } : undefined;
326
+ }
327
+
281
328
  function createRenderData(previewData) {
282
329
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
283
330
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
@@ -352,6 +399,253 @@ function createRenderData(previewData) {
352
399
  };
353
400
  }
354
401
 
402
+ function renderWidgetAreas(previewData, renderData) {
403
+ if (!previewData.widgets || typeof previewData.widgets !== 'object') {
404
+ return {};
405
+ }
406
+
407
+ return Object.fromEntries(
408
+ Object.entries(previewData.widgets).map(([widgetAreaId, widgetArea]) => [
409
+ widgetAreaId,
410
+ renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId),
411
+ ]),
412
+ );
413
+ }
414
+
415
+ function renderWidgetArea(widgetArea, previewData, renderData, widgetAreaId) {
416
+ if (!widgetArea || !Array.isArray(widgetArea.items) || widgetArea.items.length === 0) {
417
+ return '';
418
+ }
419
+
420
+ return widgetArea.items
421
+ .map((item, index) => renderWidgetItem(item, previewData, renderData, widgetAreaId, index))
422
+ .filter(Boolean)
423
+ .join('\n');
424
+ }
425
+
426
+ function renderWidgetItem(item, previewData, renderData, widgetAreaId, index) {
427
+ if (!item || typeof item !== 'object') {
428
+ return '';
429
+ }
430
+
431
+ const title = normalizeNonEmptyString(item.title, 'Widget');
432
+
433
+ switch (item.type) {
434
+ case 'recent-posts':
435
+ return renderRecentPostsWidget(title, item.settings, renderData);
436
+ case 'categories':
437
+ return renderCategoriesWidget(title, item.settings, previewData);
438
+ case 'tags':
439
+ return renderTagsWidget(title, item.settings, previewData);
440
+ case 'archives':
441
+ return renderArchivesWidget(title, item.settings, previewData);
442
+ case 'image':
443
+ return renderImageWidget(title, item.settings);
444
+ case 'text':
445
+ return renderTextWidget(title, item.settings);
446
+ case 'link-list':
447
+ return renderLinkListWidget(title, item.settings);
448
+ case 'search':
449
+ return renderSearchWidget(title, item.settings, widgetAreaId, index);
450
+ case 'profile':
451
+ return renderProfileWidget(title, item.settings);
452
+ default:
453
+ return '';
454
+ }
455
+ }
456
+
457
+ function renderRecentPostsWidget(title, settings, renderData) {
458
+ const limit = clampInteger(settings?.limit, 5, 1, 20);
459
+ const showDate = settings?.show_date !== false;
460
+ const items = renderData.posts
461
+ .slice(0, limit)
462
+ .map((post) => {
463
+ const dateMeta = showDate
464
+ ? `<span class="widget-list__meta">${escapeHtml(post.published_at)}</span>`
465
+ : '';
466
+
467
+ return `<li class="widget-list__item"><a href="/posts/${escapeHtml(encodeSlugSegment(post.slug))}/">${escapeHtml(post.title)}</a>${dateMeta}</li>`;
468
+ })
469
+ .join('');
470
+
471
+ if (!items) {
472
+ return '';
473
+ }
474
+
475
+ return renderWidgetCard(title, 'recent-posts', `<ul class="widget-list">${items}</ul>`);
476
+ }
477
+
478
+ function renderCategoriesWidget(title, settings, previewData) {
479
+ const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'category_slugs');
480
+ const items = previewData.content.categories
481
+ .map((category) => {
482
+ const count = countBySlug.get(category.slug) || 0;
483
+ if (count === 0) {
484
+ return '';
485
+ }
486
+
487
+ const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${count})</span>` : '';
488
+ return `<a href="/categories/${escapeHtml(encodeSlugSegment(category.slug))}/" class="category-link">${escapeHtml(category.name)}${suffix}</a>`;
489
+ })
490
+ .filter(Boolean)
491
+ .join('');
492
+
493
+ if (!items) {
494
+ return '';
495
+ }
496
+
497
+ return renderWidgetCard(title, 'categories', `<div class="taxonomy-list taxonomy-list--stack widget-taxonomy">${items}</div>`);
498
+ }
499
+
500
+ function renderTagsWidget(title, settings, previewData) {
501
+ const countBySlug = buildTaxonomyCountMap(previewData.content.posts, 'tag_slugs');
502
+ const limit = clampInteger(settings?.limit, 20, 1, 100);
503
+ const items = previewData.content.tags
504
+ .map((tag) => ({
505
+ ...tag,
506
+ count: countBySlug.get(tag.slug) || 0,
507
+ }))
508
+ .filter((tag) => tag.count > 0)
509
+ .slice(0, limit)
510
+ .map((tag) => {
511
+ const suffix = settings?.show_count === true ? ` <span class="widget-taxonomy__count">(${tag.count})</span>` : '';
512
+ return `<a href="/tags/${escapeHtml(encodeSlugSegment(tag.slug))}/" class="tag-link">${escapeHtml(tag.name)}${suffix}</a>`;
513
+ })
514
+ .join('');
515
+
516
+ if (!items) {
517
+ return '';
518
+ }
519
+
520
+ return renderWidgetCard(title, 'tags', `<div class="taxonomy-list widget-taxonomy">${items}</div>`);
521
+ }
522
+
523
+ function renderArchivesWidget(title, settings, previewData) {
524
+ const limit = clampInteger(settings?.limit, 12, 1, 120);
525
+ const archiveEntries = buildArchiveEntries(previewData.content.posts, previewData.site).slice(0, limit);
526
+ const items = archiveEntries
527
+ .map((entry) => `<li class="widget-list__item"><a href="/archive/">${escapeHtml(entry.label)}</a><span class="widget-list__meta">${entry.count} posts</span></li>`)
528
+ .join('');
529
+
530
+ if (!items) {
531
+ return '';
532
+ }
533
+
534
+ return renderWidgetCard(title, 'archives', `<ul class="widget-list">${items}</ul>`);
535
+ }
536
+
537
+ function renderImageWidget(title, settings) {
538
+ const src = normalizeOptionalString(settings?.src);
539
+ if (!src) {
540
+ return '';
541
+ }
542
+
543
+ const alt = escapeHtml(normalizeOptionalString(settings?.alt));
544
+ const caption = normalizeOptionalString(settings?.caption);
545
+ const href = normalizeOptionalString(settings?.href);
546
+ const target = normalizeLinkTarget(settings?.target);
547
+ const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
548
+ const image = `<img class="widget-image__media" src="${escapeHtml(src)}" alt="${alt}">`;
549
+ const linkedImage = href ? `<a href="${escapeHtml(href)}" target="${target}"${rel}>${image}</a>` : image;
550
+ const captionHtml = caption ? `<figcaption class="widget-image__caption">${escapeHtml(caption)}</figcaption>` : '';
551
+
552
+ return renderWidgetCard(title, 'image', `<figure class="widget-image">${linkedImage}${captionHtml}</figure>`);
553
+ }
554
+
555
+ function renderTextWidget(title, settings) {
556
+ const content = typeof settings?.content === 'string' ? settings.content : '';
557
+ const html = renderDocumentContent(content, normalizeDocumentType(settings?.document_type));
558
+ if (!normalizeOptionalString(html)) {
559
+ return '';
560
+ }
561
+
562
+ return renderWidgetCard(title, 'text', `<div class="widget-copy">${html}</div>`);
563
+ }
564
+
565
+ function renderLinkListWidget(title, settings) {
566
+ const links = Array.isArray(settings?.links) ? settings.links : [];
567
+ const items = links
568
+ .map((link) => {
569
+ const label = normalizeOptionalString(link?.label);
570
+ const url = normalizeOptionalString(link?.url);
571
+ if (!label || !url) {
572
+ return '';
573
+ }
574
+
575
+ const target = normalizeLinkTarget(link?.target);
576
+ const rel = target === '_blank' ? ' rel="noreferrer noopener"' : '';
577
+ return `<a href="${escapeHtml(url)}" target="${target}"${rel}>${escapeHtml(label)}</a>`;
578
+ })
579
+ .filter(Boolean)
580
+ .join('');
581
+
582
+ if (!items) {
583
+ return '';
584
+ }
585
+
586
+ return renderWidgetCard(title, 'link-list', `<div class="taxonomy-list taxonomy-list--stack widget-link-list">${items}</div>`);
587
+ }
588
+
589
+ function renderSearchWidget(title, settings, widgetAreaId, index) {
590
+ const placeholder = escapeHtml(normalizeNonEmptyString(settings?.placeholder, 'Search...'));
591
+ const buttonLabel = escapeHtml(normalizeNonEmptyString(settings?.button_label, 'Search'));
592
+ const searchId = `widget-search-${widgetAreaId}-${index + 1}`;
593
+
594
+ return renderWidgetCard(title, 'search', [
595
+ '<div class="widget-search" data-search-root>',
596
+ ` <label class="sr-only" for="${escapeHtml(searchId)}">${escapeHtml(title)}</label>`,
597
+ ' <form class="search-theme-wrapper" data-search-form>',
598
+ ` <input id="${escapeHtml(searchId)}" class="search-input" data-theme-search type="search" placeholder="${placeholder}" autocomplete="off" enterkeyhint="search">`,
599
+ ` <button class="widget-search-button" type="submit">${buttonLabel}</button>`,
600
+ ' </form>',
601
+ ' <div class="search-results" data-search-results hidden></div>',
602
+ ' <p class="search-feedback search-feedback--sidebar" data-search-feedback aria-live="polite"></p>',
603
+ '</div>',
604
+ ].join('\n'));
605
+ }
606
+
607
+ function renderProfileWidget(title, settings) {
608
+ const displayName = normalizeOptionalString(settings?.display_name);
609
+ const affiliation = normalizeOptionalString(settings?.affiliation);
610
+ const bioShort = normalizeOptionalString(settings?.bio_short);
611
+ const avatar = normalizeOptionalString(settings?.avatar);
612
+
613
+ if (!displayName && !bioShort && !avatar) {
614
+ return '';
615
+ }
616
+
617
+ const avatarHtml = avatar
618
+ ? `<img class="widget-profile__avatar" src="${escapeHtml(avatar)}" alt="${escapeHtml(displayName || title)}">`
619
+ : '';
620
+ const affiliationHtml = affiliation ? `<p class="widget-profile__affiliation">${escapeHtml(affiliation)}</p>` : '';
621
+ const bioHtml = bioShort ? `<p class="sidebar-copy">${escapeHtml(bioShort)}</p>` : '';
622
+
623
+ return renderWidgetCard(title, 'profile', [
624
+ '<div class="widget-profile">',
625
+ avatarHtml,
626
+ ' <div class="widget-profile__body">',
627
+ displayName ? ` <p class="widget-profile__name">${escapeHtml(displayName)}</p>` : '',
628
+ affiliationHtml ? ` ${affiliationHtml}` : '',
629
+ bioHtml ? ` ${bioHtml}` : '',
630
+ ' </div>',
631
+ '</div>',
632
+ ].filter(Boolean).join('\n'));
633
+ }
634
+
635
+ function renderWidgetCard(title, modifier, body) {
636
+ if (!normalizeOptionalString(body)) {
637
+ return '';
638
+ }
639
+
640
+ return [
641
+ `<section class="content-card sidebar-card widget-card widget-card--${escapeHtml(modifier)}">`,
642
+ ' <p class="section-kicker">Widget</p>',
643
+ ` <h2>${escapeHtml(title)}</h2>`,
644
+ ` ${body}`,
645
+ '</section>',
646
+ ].join('\n');
647
+ }
648
+
355
649
  function preparePage(page) {
356
650
  const documentType = normalizeDocumentType(page.document_type);
357
651
 
@@ -638,6 +932,51 @@ function renderPagination(paginationData) {
638
932
  return `<nav class="pagination">\n ${prevLink}\n <span class="pages">${pageLinks}</span>\n ${nextLink}\n</nav>`;
639
933
  }
640
934
 
935
+ function buildTaxonomyCountMap(posts, fieldName) {
936
+ const counts = new Map();
937
+
938
+ for (const post of posts) {
939
+ const values = Array.isArray(post?.[fieldName]) ? post[fieldName] : [];
940
+ for (const value of values) {
941
+ counts.set(value, (counts.get(value) || 0) + 1);
942
+ }
943
+ }
944
+
945
+ return counts;
946
+ }
947
+
948
+ function buildArchiveEntries(posts, site) {
949
+ const entries = new Map();
950
+
951
+ for (const post of posts) {
952
+ const publishedAt = normalizeIsoTimestamp(post?.published_at_iso);
953
+ if (!publishedAt) {
954
+ continue;
955
+ }
956
+
957
+ const date = toDate(publishedAt);
958
+ const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
959
+ const current = entries.get(key) || { date, count: 0 };
960
+ current.count += 1;
961
+ entries.set(key, current);
962
+ }
963
+
964
+ return Array.from(entries.values())
965
+ .sort((left, right) => right.date.getTime() - left.date.getTime())
966
+ .map((entry) => ({
967
+ label: formatArchiveLabel(entry.date, site),
968
+ count: entry.count,
969
+ }));
970
+ }
971
+
972
+ function formatArchiveLabel(date, site) {
973
+ return new Intl.DateTimeFormat(normalizeLocale(site.locale || DEFAULT_LOCALE), {
974
+ timeZone: normalizeNonEmptyString(site.timezone, DEFAULT_TIMEZONE),
975
+ year: 'numeric',
976
+ month: 'long',
977
+ }).format(date);
978
+ }
979
+
641
980
  function formatTimestamp(value, site) {
642
981
  const date = toDate(value);
643
982
  const locale = normalizeLocale(site.locale || DEFAULT_LOCALE);
@@ -776,6 +1115,8 @@ async function assertValidThemePackage(themePackage) {
776
1115
  ...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
777
1116
  ...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
778
1117
  ...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
1118
+ ...(themePackage.metadata.menuSlots ? { menuSlots: themePackage.metadata.menuSlots } : {}),
1119
+ ...(themePackage.metadata.widgetAreas ? { widgetAreas: themePackage.metadata.widgetAreas } : {}),
779
1120
  settings: themePackage.metadata.settings || {},
780
1121
  }));
781
1122
 
@@ -827,6 +1168,24 @@ async function buildAssetOutputs(assets, assetProcessor, options) {
827
1168
  return outputs;
828
1169
  }
829
1170
 
1171
+ async function buildCustomCssAsset(customCss, assetProcessor, options) {
1172
+ const content = normalizeOptionalString(customCss?.content);
1173
+ if (!content) {
1174
+ return null;
1175
+ }
1176
+
1177
+ const sourceBuffer = new TextEncoder().encode(content);
1178
+ const processedContent = await assetProcessor.processCSS(content);
1179
+ const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
1180
+
1181
+ return {
1182
+ originalPath: '__zeropress_custom.css',
1183
+ path: `assets/zeropress-custom${hash}.css`,
1184
+ content: processedContent,
1185
+ contentType: 'text/css',
1186
+ };
1187
+ }
1188
+
830
1189
  function createRenderContext(site, currentUrl) {
831
1190
  return {
832
1191
  site,
@@ -959,7 +1318,9 @@ function isAbsoluteUrl(value) {
959
1318
  }
960
1319
 
961
1320
  async function writeOutput(writer, summaries, path, content, contentType) {
962
- const normalizedPath = normalizeOutputPath(path);
1321
+ const rawPath = String(path || '');
1322
+ const normalizedPath = normalizeOutputPath(rawPath);
1323
+ assertSafeRelativeOutputPath(rawPath, normalizedPath);
963
1324
  await writer.write({ path: normalizedPath, content, contentType });
964
1325
  summaries.push({
965
1326
  path: normalizedPath,
@@ -989,6 +1350,95 @@ function normalizeOutputPath(filePath) {
989
1350
  return String(filePath || '').replace(/^\/+/, '');
990
1351
  }
991
1352
 
1353
+ function assertPlannedOutputPathsSafe(state) {
1354
+ for (const post of state.renderData.posts) {
1355
+ assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
1356
+ }
1357
+
1358
+ for (const page of state.renderData.pages) {
1359
+ assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
1360
+ }
1361
+
1362
+ for (const category of state.previewData.content.categories) {
1363
+ assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
1364
+ }
1365
+
1366
+ for (const tag of state.previewData.content.tags) {
1367
+ assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
1368
+ }
1369
+
1370
+ const plannedPaths = [
1371
+ ...state.renderData.indexRoutes.map((route) => routePathToOutputPath(route.path)),
1372
+ ...state.renderData.archiveRoutes.map((route) => routePathToOutputPath(route.path)),
1373
+ ...state.renderData.categoryRoutes.map((route) => routePathToOutputPath(route.path)),
1374
+ ...state.renderData.tagRoutes.map((route) => routePathToOutputPath(route.path)),
1375
+ ...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
1376
+ ...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
1377
+ ...state.assetOutputs.map((assetOutput) => assetOutput.path),
1378
+ ];
1379
+
1380
+ if (state.options.generateSpecialFiles) {
1381
+ plannedPaths.push('404.html', 'robots.txt', 'meta.json');
1382
+ if (hasCanonicalSiteUrl(state.previewData.site.url)) {
1383
+ plannedPaths.push('sitemap.xml', 'feed.xml');
1384
+ }
1385
+ }
1386
+
1387
+ for (const plannedPath of plannedPaths) {
1388
+ const rawPath = String(plannedPath || '');
1389
+ const normalizedPath = normalizeOutputPath(rawPath);
1390
+ assertSafeRelativeOutputPath(rawPath, normalizedPath);
1391
+ }
1392
+ }
1393
+
1394
+ function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
1395
+ const originalSlug = typeof rawSlug === 'string' ? rawSlug : '';
1396
+ const decodedSlug = encodeSlugSegment(rawSlug);
1397
+
1398
+ if (
1399
+ !isSafeSlugPathSegment(originalSlug) ||
1400
+ !isSafeSlugPathSegment(decodedSlug)
1401
+ ) {
1402
+ throw new Error(`Unsafe output path detected: ${outputPath}`);
1403
+ }
1404
+ }
1405
+
1406
+ function isSafeSlugPathSegment(value) {
1407
+ return isSafeSlugSegment(value);
1408
+ }
1409
+
1410
+ function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputPath(rawPath)) {
1411
+ const originalPath = String(rawPath || '');
1412
+ const candidatePath = String(normalizedPath || '');
1413
+ const normalizedSeparators = candidatePath.replace(/\\/g, '/');
1414
+
1415
+ if (originalPath.trim() === '' || candidatePath.trim() === '') {
1416
+ throw new Error(`Unsafe output path detected: ${originalPath || candidatePath || '<empty>'}`);
1417
+ }
1418
+
1419
+ if (OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(originalPath) || OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(candidatePath)) {
1420
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1421
+ }
1422
+
1423
+ if (candidatePath.includes('%')) {
1424
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1425
+ }
1426
+
1427
+ if (
1428
+ originalPath.startsWith('/') ||
1429
+ originalPath.startsWith('\\') ||
1430
+ /^[A-Za-z]:[\\/]/.test(originalPath) ||
1431
+ normalizedSeparators.startsWith('//')
1432
+ ) {
1433
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1434
+ }
1435
+
1436
+ const segments = normalizedSeparators.split('/');
1437
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
1438
+ throw new Error(`Unsafe output path detected: ${originalPath}`);
1439
+ }
1440
+ }
1441
+
992
1442
  function getContentSize(content) {
993
1443
  return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
994
1444
  }
@@ -1003,6 +1453,14 @@ function injectHtmxScript(html) {
1003
1453
  return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
1004
1454
  }
1005
1455
 
1456
+ function injectCustomCssAssetLink(html, href) {
1457
+ if (!normalizeOptionalString(href)) {
1458
+ return html;
1459
+ }
1460
+
1461
+ return html.replace('</head>', ` <link rel="stylesheet" href="${escapeHtml(href)}">\n</head>`);
1462
+ }
1463
+
1006
1464
  function buildSitemapXml(site, emitted, generatedAt) {
1007
1465
  const entries = [
1008
1466
  ...emitted.indexRoutes
@@ -1120,23 +1578,6 @@ function encodeSlugSegment(slug) {
1120
1578
  return normalizeStoredSlug(slug);
1121
1579
  }
1122
1580
 
1123
- function normalizeStoredSlug(slug) {
1124
- if (typeof slug !== 'string') {
1125
- return '';
1126
- }
1127
-
1128
- const trimmed = slug.trim();
1129
- if (!trimmed.includes('%')) {
1130
- return trimmed;
1131
- }
1132
-
1133
- try {
1134
- return decodeURIComponent(trimmed);
1135
- } catch {
1136
- return trimmed;
1137
- }
1138
- }
1139
-
1140
1581
  function decodeRoutePath(routePath) {
1141
1582
  try {
1142
1583
  return decodeURI(routePath);
@@ -1186,3 +1627,12 @@ function escapeHtml(value) {
1186
1627
  .replaceAll('"', '&quot;')
1187
1628
  .replaceAll("'", '&#39;');
1188
1629
  }
1630
+
1631
+ function clampInteger(value, fallback, min, max) {
1632
+ const normalized = Number.isInteger(value) ? value : fallback;
1633
+ return Math.min(max, Math.max(min, normalized));
1634
+ }
1635
+
1636
+ function normalizeLinkTarget(value) {
1637
+ return value === '_blank' ? '_blank' : '_self';
1638
+ }
package/src/index.d.ts CHANGED
@@ -12,6 +12,10 @@ export interface ThemePackage {
12
12
  title: string;
13
13
  description?: string;
14
14
  }>;
15
+ widgetAreas?: Record<string, {
16
+ title: string;
17
+ description?: string;
18
+ }>;
15
19
  namespace?: string;
16
20
  slug?: string;
17
21
  license?: 'MIT' | 'Apache-2.0' | 'BSD-3-Clause' | 'GPL-3.0-only' | 'GPL-3.0-or-later';
@@ -51,6 +51,8 @@ export async function loadThemePackageFromDir(themeDir) {
51
51
  description: themeJson.description,
52
52
  thumbnail: themeJson.thumbnail,
53
53
  settings: themeJson.settings || {},
54
+ menuSlots: themeJson.menuSlots,
55
+ widgetAreas: themeJson.widgetAreas,
54
56
  namespace: themeJson.namespace,
55
57
  slug: themeJson.slug,
56
58
  license: themeJson.license,