@zeropress/build-core 0.2.2 → 0.5.1

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.2",
3
+ "version": "0.5.1",
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.5",
38
- "@zeropress/theme-validator": "^0.5.0",
37
+ "@zeropress/preview-data-validator": "0.5.1",
38
+ "@zeropress/theme-validator": "0.5.2",
39
39
  "highlight.js": "^11.11.1",
40
40
  "markdown-it": "^14.1.0",
41
41
  "markdown-it-anchor": "^9.2.0"
@@ -24,6 +24,14 @@ const DEFAULT_PERMALINKS = Object.freeze({
24
24
  categories: '/categories/:slug/',
25
25
  tags: '/tags/:slug/',
26
26
  });
27
+ const DEFAULT_FRONT_PAGE = Object.freeze({
28
+ type: 'theme_index',
29
+ });
30
+ const DEFAULT_POST_INDEX = Object.freeze({
31
+ enabled: true,
32
+ path: '/',
33
+ paginate: true,
34
+ });
27
35
  const PERMALINK_OUTPUT_STYLES = new Set(['directory', 'html-extension']);
28
36
  const COMMENT_POLICY_OUTPUT_PATH = '_zeropress/comment-policy.json';
29
37
  const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
@@ -34,6 +42,10 @@ export async function buildSite(input) {
34
42
  const state = await createBuildState(input, options);
35
43
  assertPlannedOutputPathsSafe(state);
36
44
 
45
+ if (state.renderData.frontPageRoute) {
46
+ await renderFrontPage(state, state.renderData.frontPageRoute);
47
+ }
48
+
37
49
  for (const route of state.renderData.indexRoutes) {
38
50
  await renderRoute(state, 'index', route);
39
51
  }
@@ -83,7 +95,6 @@ export async function buildSite(input) {
83
95
  await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.generatedAt), 'application/rss+xml');
84
96
  }
85
97
  await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(state.previewData.site), 'text/plain');
86
- await writeOutput(state.writer, state.summaries, 'meta.json', buildMetaJson(state.previewData.site, state.emitted, state.generatedAt), 'application/json');
87
98
  }
88
99
 
89
100
  return finalizeBuildResult(state.writer, state.summaries, options);
@@ -94,7 +105,7 @@ async function createBuildState(input, options) {
94
105
  throw new Error('buildSite requires a writer with an async write(file) method');
95
106
  }
96
107
 
97
- assertPreviewData(input.previewData);
108
+ assertBuildPreviewData(input.previewData);
98
109
  const themePackage = await normalizeAndValidateThemePackage(input.themePackage);
99
110
 
100
111
  const engine = new ZeroPressEngine();
@@ -125,10 +136,12 @@ async function createBuildState(input, options) {
125
136
  assetOutputs,
126
137
  assetMap,
127
138
  customCssHref: customCssAsset ? `/${customCssAsset.path}` : '',
139
+ customHtml: previewData.custom_html,
128
140
  commentPolicyContent: buildCommentPolicyManifest(renderData.posts),
129
141
  options,
130
142
  generatedAt: new Date(),
131
143
  emitted: {
144
+ frontPage: null,
132
145
  indexRoutes: [],
133
146
  archiveRoutes: [],
134
147
  categoryRoutes: [],
@@ -139,6 +152,18 @@ async function createBuildState(input, options) {
139
152
  };
140
153
  }
141
154
 
155
+ function assertBuildPreviewData(previewData) {
156
+ try {
157
+ assertPreviewData(previewData);
158
+ } catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error);
160
+ if (message.startsWith('Invalid preview-data')) {
161
+ throw error;
162
+ }
163
+ throw new Error(`Invalid preview-data: ${message}`);
164
+ }
165
+ }
166
+
142
167
  async function finalizeBuildResult(writer, summaries, options) {
143
168
  let manifest;
144
169
  if (options.writeManifest) {
@@ -163,12 +188,18 @@ async function finalizeBuildResult(writer, summaries, options) {
163
188
 
164
189
  async function renderRoute(state, templateName, route) {
165
190
  const currentUrl = routePathToPublicUrl(route.path, state.previewData.site.permalinks.output_style);
191
+ const routeContext = buildRouteContext(route.route_type || templateName, currentUrl, {
192
+ isFrontPage: route.is_front_page === true,
193
+ isPostIndex: route.is_post_index === true,
194
+ });
166
195
  let html = await state.engine.render(
167
196
  templateName,
168
197
  {
169
198
  menus: state.previewData.menus,
170
199
  widgets: state.widgets,
200
+ taxonomies: state.renderData.taxonomies,
171
201
  ...route,
202
+ route: routeContext,
172
203
  meta: buildPageMeta(state.previewData.site, {
173
204
  currentUrl,
174
205
  title: state.previewData.site.title,
@@ -179,11 +210,92 @@ async function renderRoute(state, templateName, route) {
179
210
  createRenderContext(state.previewData.site, currentUrl),
180
211
  );
181
212
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
182
- html = injectCustomCssAssetLink(html, state.customCssHref);
213
+ html = injectSiteCustomizations(html, state);
183
214
  await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path, state.previewData.site.permalinks.output_style), html, 'text/html');
184
215
  recordRouteEmission(state, templateName, route, currentUrl);
185
216
  }
186
217
 
218
+ async function renderFrontPage(state, route) {
219
+ const currentUrl = '/';
220
+ const routeContext = buildRouteContext('front_page', currentUrl, {
221
+ isFrontPage: true,
222
+ isPostIndex: false,
223
+ });
224
+
225
+ if (route.front_page_type === 'standalone_html') {
226
+ await writeOutput(state.writer, state.summaries, 'index.html', route.html, 'text/html');
227
+ state.emitted.frontPage = {
228
+ url: currentUrl,
229
+ title: state.previewData.site.title,
230
+ description: state.previewData.site.description,
231
+ includeInFeed: false,
232
+ };
233
+ return;
234
+ }
235
+
236
+ if (route.front_page_type === 'page') {
237
+ const page = {
238
+ ...route.page,
239
+ url: currentUrl,
240
+ };
241
+ let html = await state.engine.render(
242
+ 'page',
243
+ {
244
+ menus: state.previewData.menus,
245
+ widgets: state.widgets,
246
+ taxonomies: state.renderData.taxonomies,
247
+ page,
248
+ route: routeContext,
249
+ meta: buildPageMeta(state.previewData.site, {
250
+ currentUrl,
251
+ title: buildDocumentTitle(page.title, state.previewData.site.title),
252
+ description: page.excerpt,
253
+ ogType: 'website',
254
+ image: page.featured_image,
255
+ }),
256
+ },
257
+ createRenderContext(state.previewData.site, currentUrl),
258
+ );
259
+ html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
260
+ html = injectSiteCustomizations(html, state);
261
+ await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
262
+ state.emitted.frontPage = {
263
+ url: currentUrl,
264
+ title: page.title,
265
+ description: page.excerpt,
266
+ includeInFeed: false,
267
+ };
268
+ return;
269
+ }
270
+
271
+ let html = await state.engine.render(
272
+ 'index',
273
+ {
274
+ menus: state.previewData.menus,
275
+ widgets: state.widgets,
276
+ taxonomies: state.renderData.taxonomies,
277
+ ...route,
278
+ route: routeContext,
279
+ meta: buildPageMeta(state.previewData.site, {
280
+ currentUrl,
281
+ title: state.previewData.site.title,
282
+ description: state.previewData.site.description,
283
+ ogType: 'website',
284
+ }),
285
+ },
286
+ createRenderContext(state.previewData.site, currentUrl),
287
+ );
288
+ html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
289
+ html = injectSiteCustomizations(html, state);
290
+ await writeOutput(state.writer, state.summaries, 'index.html', html, 'text/html');
291
+ state.emitted.frontPage = {
292
+ url: currentUrl,
293
+ title: state.previewData.site.title,
294
+ description: state.previewData.site.description,
295
+ includeInFeed: false,
296
+ };
297
+ }
298
+
187
299
  async function renderPost(state, post) {
188
300
  const currentUrl = post.url;
189
301
  let html = await state.engine.render(
@@ -191,7 +303,9 @@ async function renderPost(state, post) {
191
303
  {
192
304
  menus: state.previewData.menus,
193
305
  widgets: state.widgets,
306
+ taxonomies: state.renderData.taxonomies,
194
307
  post,
308
+ route: buildRouteContext('post', currentUrl),
195
309
  meta: buildPageMeta(state.previewData.site, {
196
310
  currentUrl,
197
311
  title: buildDocumentTitle(post.title, state.previewData.site.title),
@@ -205,7 +319,7 @@ async function renderPost(state, post) {
205
319
  createRenderContext(state.previewData.site, currentUrl),
206
320
  );
207
321
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
208
- html = injectCustomCssAssetLink(html, state.customCssHref);
322
+ html = injectSiteCustomizations(html, state);
209
323
  await writeOutput(state.writer, state.summaries, routePathToOutputPath(post.url, state.previewData.site.permalinks.output_style), html, 'text/html');
210
324
  state.emitted.posts.push({
211
325
  url: currentUrl,
@@ -219,14 +333,19 @@ async function renderPost(state, post) {
219
333
 
220
334
  async function renderPage(state, page) {
221
335
  const currentUrl = page.url;
336
+ const outputPath = pageToOutputPath(page, state.previewData.site.permalinks.output_style);
337
+ const canonicalUrl = normalizeOptionalString(page.canonical_url) || currentUrl;
222
338
  let html = await state.engine.render(
223
339
  'page',
224
340
  {
225
341
  menus: state.previewData.menus,
226
342
  widgets: state.widgets,
343
+ taxonomies: state.renderData.taxonomies,
227
344
  page,
345
+ route: buildRouteContext('page', currentUrl),
228
346
  meta: buildPageMeta(state.previewData.site, {
229
347
  currentUrl,
348
+ canonicalUrl,
230
349
  title: buildDocumentTitle(page.title, state.previewData.site.title),
231
350
  description: page.excerpt,
232
351
  ogType: 'website',
@@ -236,13 +355,14 @@ async function renderPage(state, page) {
236
355
  createRenderContext(state.previewData.site, currentUrl),
237
356
  );
238
357
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
239
- html = injectCustomCssAssetLink(html, state.customCssHref);
240
- await writeOutput(state.writer, state.summaries, routePathToOutputPath(page.url, state.previewData.site.permalinks.output_style), html, 'text/html');
358
+ html = injectSiteCustomizations(html, state);
359
+ await writeOutput(state.writer, state.summaries, outputPath, html, 'text/html');
241
360
  state.emitted.pages.push({
242
361
  url: currentUrl,
243
362
  title: page.title,
244
363
  description: page.excerpt,
245
364
  status: page.status,
365
+ includeInSitemap: page.omit_from_sitemap !== true,
246
366
  });
247
367
  }
248
368
 
@@ -256,6 +376,8 @@ async function maybeRenderNotFoundPage(state) {
256
376
  {
257
377
  menus: state.previewData.menus,
258
378
  widgets: state.widgets,
379
+ taxonomies: state.renderData.taxonomies,
380
+ route: buildRouteContext('not_found', '/404.html'),
259
381
  meta: buildPageMeta(state.previewData.site, {
260
382
  currentUrl: '/404.html',
261
383
  title: state.previewData.site.title,
@@ -266,7 +388,7 @@ async function maybeRenderNotFoundPage(state) {
266
388
  createRenderContext(state.previewData.site, '/404.html'),
267
389
  );
268
390
  html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
269
- html = injectCustomCssAssetLink(html, state.customCssHref);
391
+ html = injectSiteCustomizations(html, state);
270
392
  await writeOutput(state.writer, state.summaries, '404.html', html, 'text/html');
271
393
  }
272
394
 
@@ -283,6 +405,9 @@ function normalizePreviewData(previewData) {
283
405
  locale: normalizeLocale(previewData.site.locale || DEFAULT_LOCALE),
284
406
  disallowComments: previewData.site.disallowComments === true,
285
407
  permalinks: normalizePermalinks(previewData.site.permalinks),
408
+ front_page: normalizeFrontPage(previewData.site.front_page),
409
+ post_index: normalizePostIndex(previewData.site.post_index),
410
+ footer: normalizeSiteFooter(previewData.site.footer),
286
411
  };
287
412
 
288
413
  return {
@@ -290,6 +415,7 @@ function normalizePreviewData(previewData) {
290
415
  site: normalizedSite,
291
416
  widgets: normalizeWidgetAreas(previewData.widgets, normalizedSite.mediaBaseUrl),
292
417
  custom_css: normalizeCustomCss(previewData.custom_css),
418
+ custom_html: normalizeCustomHtml(previewData.custom_html),
293
419
  content: {
294
420
  ...previewData.content,
295
421
  authors: previewData.content.authors.map((author) => ({
@@ -333,6 +459,22 @@ function normalizeWidgetAreas(widgetAreas, mediaBaseUrl) {
333
459
  );
334
460
  }
335
461
 
462
+ function normalizeSiteFooter(footer) {
463
+ const source = footer && typeof footer === 'object' && !Array.isArray(footer) ? footer : {};
464
+ const attribution = source.attribution && typeof source.attribution === 'object' && !Array.isArray(source.attribution)
465
+ ? source.attribution
466
+ : {};
467
+
468
+ return {
469
+ ...source,
470
+ copyright_text: normalizeOptionalString(source.copyright_text),
471
+ attribution: {
472
+ ...attribution,
473
+ enabled: attribution.enabled !== false,
474
+ },
475
+ };
476
+ }
477
+
336
478
  function normalizeWidgetItem(item, mediaBaseUrl) {
337
479
  const normalizedItem = {
338
480
  ...item,
@@ -357,6 +499,23 @@ function normalizeCustomCss(customCss) {
357
499
  return content ? { content } : undefined;
358
500
  }
359
501
 
502
+ function normalizeCustomHtml(customHtml) {
503
+ if (!customHtml || typeof customHtml !== 'object') {
504
+ return undefined;
505
+ }
506
+
507
+ const headEnd = normalizeOptionalRawString(customHtml.head_end?.content);
508
+ const bodyEnd = normalizeOptionalRawString(customHtml.body_end?.content);
509
+ if (!headEnd && !bodyEnd) {
510
+ return undefined;
511
+ }
512
+
513
+ return {
514
+ ...(headEnd ? { head_end: { content: headEnd } } : {}),
515
+ ...(bodyEnd ? { body_end: { content: bodyEnd } } : {}),
516
+ };
517
+ }
518
+
360
519
  function normalizePermalinks(permalinks) {
361
520
  const source = permalinks && typeof permalinks === 'object' ? permalinks : {};
362
521
  const outputStyle = typeof source.output_style === 'string' && PERMALINK_OUTPUT_STYLES.has(source.output_style)
@@ -372,8 +531,37 @@ function normalizePermalinks(permalinks) {
372
531
  };
373
532
  }
374
533
 
534
+ function normalizeFrontPage(frontPage) {
535
+ if (!frontPage || typeof frontPage !== 'object') {
536
+ return { ...DEFAULT_FRONT_PAGE };
537
+ }
538
+
539
+ const type = ['theme_index', 'page', 'standalone_html'].includes(frontPage.type)
540
+ ? frontPage.type
541
+ : DEFAULT_FRONT_PAGE.type;
542
+
543
+ return {
544
+ type,
545
+ ...(type === 'page' ? { page_slug: normalizeOptionalString(frontPage.page_slug) } : {}),
546
+ ...(type === 'standalone_html' ? { html: normalizeOptionalRawString(frontPage.html) } : {}),
547
+ };
548
+ }
549
+
550
+ function normalizePostIndex(postIndex) {
551
+ if (!postIndex || typeof postIndex !== 'object') {
552
+ return { ...DEFAULT_POST_INDEX };
553
+ }
554
+
555
+ return {
556
+ enabled: postIndex.enabled !== false,
557
+ path: normalizeNonEmptyString(postIndex.path, DEFAULT_POST_INDEX.path),
558
+ paginate: postIndex.paginate !== false,
559
+ };
560
+ }
561
+
375
562
  function createRenderData(previewData, themeMetadata = {}) {
376
563
  const themeSupportsComments = themeMetadata?.features?.comments === true;
564
+ const themeSupportsPostIndex = themeMetadata?.features?.postIndex !== false;
377
565
  const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
378
566
  const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
379
567
  const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
@@ -402,23 +590,38 @@ function createRenderData(previewData, themeMetadata = {}) {
402
590
  }));
403
591
  const pages = previewData.content.pages.map((page) => preparePage(page, previewData.site));
404
592
  const postBySlug = new Map(posts.map((post) => [post.slug, post]));
593
+ const frontPage = previewData.site.front_page;
594
+ const postIndex = previewData.site.post_index;
595
+ const effectivePostIndexEnabled = postIndex.enabled !== false && themeSupportsPostIndex;
596
+ const effectivePostIndexPaginate = effectivePostIndexEnabled && postIndex.paginate !== false;
597
+ const postIndexBasePath = normalizeRoutePath(postIndex.path || DEFAULT_POST_INDEX.path);
598
+
599
+ if (frontPage.type !== 'theme_index' && effectivePostIndexEnabled && postIndexBasePath === '/') {
600
+ throw new Error('Invalid front page configuration: site.front_page occupies "/" so site.post_index.path must not be "/". Set site.post_index.path to a non-root path or disable site.post_index.');
601
+ }
602
+
603
+ const frontPageRoute = buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath);
604
+ const pageFrontPageSlug = frontPage.type === 'page' ? frontPage.page_slug : '';
605
+ const preparedPages = pageFrontPageSlug
606
+ ? pages.filter((page) => page.slug !== pageFrontPageSlug)
607
+ : pages;
405
608
 
406
609
  return {
407
610
  posts,
408
- pages,
611
+ pages: preparedPages,
409
612
  postBySlug,
410
- indexRoutes: buildPaginatedCollection({
613
+ taxonomies: buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug),
614
+ frontPageRoute,
615
+ indexRoutes: buildPostIndexRoutes({
616
+ enabled: effectivePostIndexEnabled,
617
+ paginate: effectivePostIndexPaginate,
411
618
  items: previewData.content.posts,
412
619
  postsPerPage: previewData.site.postsPerPage,
413
- basePath: '/',
620
+ basePath: postIndexBasePath,
414
621
  outputStyle: previewData.site.permalinks.output_style,
415
- }).map((entry) => ({
416
- path: entry.path,
417
- page: entry.page,
418
- totalPages: entry.totalPages,
419
- posts: buildStructuredPostCollection(entry.items, postBySlug),
420
- pagination: buildStructuredPagination(entry.paginationData),
421
- })),
622
+ postBySlug,
623
+ frontPage,
624
+ }),
422
625
  archiveRoutes: buildPaginatedCollection({
423
626
  items: previewData.content.posts,
424
627
  postsPerPage: previewData.site.postsPerPage,
@@ -458,6 +661,101 @@ function createRenderData(previewData, themeMetadata = {}) {
458
661
  };
459
662
  }
460
663
 
664
+ function buildGlobalTaxonomies(previewData, categoryCountBySlug, tagCountBySlug) {
665
+ return {
666
+ categories: buildGlobalTaxonomyItems(previewData.site, 'categories', previewData.content.categories, categoryCountBySlug),
667
+ tags: buildGlobalTaxonomyItems(previewData.site, 'tags', previewData.content.tags, tagCountBySlug),
668
+ };
669
+ }
670
+
671
+ function buildFrontPageRoute(frontPage, pages, effectivePostIndexEnabled, postIndexBasePath) {
672
+ if (frontPage.type === 'theme_index') {
673
+ if (effectivePostIndexEnabled && postIndexBasePath === '/') {
674
+ return null;
675
+ }
676
+
677
+ return {
678
+ path: '/',
679
+ front_page_type: 'theme_index',
680
+ posts: buildStructuredPostCollection([], new Map()),
681
+ pagination: buildStructuredPagination(buildDisabledPaginationData(0)),
682
+ };
683
+ }
684
+
685
+ if (frontPage.type === 'page') {
686
+ const page = pages.find((entry) => entry.slug === frontPage.page_slug);
687
+ if (!page) {
688
+ throw new Error(`Invalid front page configuration: site.front_page.page_slug "${frontPage.page_slug}" does not match a page.`);
689
+ }
690
+
691
+ return {
692
+ path: '/',
693
+ front_page_type: 'page',
694
+ page,
695
+ };
696
+ }
697
+
698
+ if (frontPage.type === 'standalone_html') {
699
+ if (!normalizeOptionalRawString(frontPage.html)) {
700
+ throw new Error('Invalid front page configuration: site.front_page.html is required for standalone_html.');
701
+ }
702
+
703
+ return {
704
+ path: '/',
705
+ front_page_type: 'standalone_html',
706
+ html: frontPage.html,
707
+ };
708
+ }
709
+
710
+ return null;
711
+ }
712
+
713
+ function buildPostIndexRoutes(options) {
714
+ if (!options.enabled) {
715
+ return [];
716
+ }
717
+
718
+ if (!options.paginate) {
719
+ const items = options.items.slice(0, options.postsPerPage);
720
+ return [{
721
+ path: options.basePath,
722
+ route_type: 'post_index',
723
+ is_front_page: options.basePath === '/' && options.frontPage.type === 'theme_index',
724
+ is_post_index: true,
725
+ page: 1,
726
+ totalPages: 1,
727
+ posts: buildStructuredPostCollection(items, options.postBySlug),
728
+ pagination: buildStructuredPagination(buildDisabledPaginationData(options.items.length)),
729
+ }];
730
+ }
731
+
732
+ return buildPaginatedCollection({
733
+ items: options.items,
734
+ postsPerPage: options.postsPerPage,
735
+ basePath: options.basePath,
736
+ outputStyle: options.outputStyle,
737
+ }).map((entry) => ({
738
+ path: entry.path,
739
+ route_type: 'post_index',
740
+ is_front_page: entry.path === '/' && options.frontPage.type === 'theme_index',
741
+ is_post_index: true,
742
+ page: entry.page,
743
+ totalPages: entry.totalPages,
744
+ posts: buildStructuredPostCollection(entry.items, options.postBySlug),
745
+ pagination: buildStructuredPagination(entry.paginationData),
746
+ }));
747
+ }
748
+
749
+ function buildGlobalTaxonomyItems(site, permalinkKind, items, countBySlug) {
750
+ return items.map((item) => ({
751
+ name: item.name,
752
+ slug: item.slug,
753
+ url: resolvePermalink(site, permalinkKind, item).url,
754
+ count: countBySlug.get(item.slug) || 0,
755
+ description: typeof item.description === 'string' ? item.description : '',
756
+ }));
757
+ }
758
+
461
759
  function resolveWidgetAreas(previewData, renderData) {
462
760
  if (!previewData.widgets || typeof previewData.widgets !== 'object') {
463
761
  return {};
@@ -852,6 +1150,7 @@ function buildPaginationData(currentPage, totalPages, totalPosts, basePath, outp
852
1150
  const buildPageUrl = (page) => routePathToPublicUrl(buildPaginatedPath(basePath, page), outputStyle);
853
1151
 
854
1152
  return {
1153
+ enabled: true,
855
1154
  currentPage,
856
1155
  totalPages,
857
1156
  totalPosts,
@@ -870,8 +1169,23 @@ function buildPaginationData(currentPage, totalPages, totalPosts, basePath, outp
870
1169
  };
871
1170
  }
872
1171
 
1172
+ function buildDisabledPaginationData(totalPosts) {
1173
+ return {
1174
+ enabled: false,
1175
+ currentPage: 1,
1176
+ totalPages: 1,
1177
+ totalPosts,
1178
+ hasNext: false,
1179
+ hasPrev: false,
1180
+ nextUrl: undefined,
1181
+ prevUrl: undefined,
1182
+ pages: [],
1183
+ };
1184
+ }
1185
+
873
1186
  function buildStructuredPagination(paginationData) {
874
1187
  return {
1188
+ enabled: paginationData.enabled !== false,
875
1189
  current_page: paginationData.currentPage,
876
1190
  total_pages: paginationData.totalPages,
877
1191
  total_items: paginationData.totalPosts,
@@ -1144,6 +1458,10 @@ function normalizeOptionalString(value) {
1144
1458
  return typeof value === 'string' && value.trim() ? value.trim() : '';
1145
1459
  }
1146
1460
 
1461
+ function normalizeOptionalRawString(value) {
1462
+ return typeof value === 'string' && value.trim() ? value : '';
1463
+ }
1464
+
1147
1465
  function normalizeLocale(value) {
1148
1466
  if (typeof value !== 'string' || !value.trim()) {
1149
1467
  return DEFAULT_LOCALE;
@@ -1311,10 +1629,20 @@ function createRenderContext(site, currentUrl) {
1311
1629
  };
1312
1630
  }
1313
1631
 
1632
+ function buildRouteContext(type, url, options = {}) {
1633
+ return {
1634
+ type,
1635
+ is_front_page: options.isFrontPage === true,
1636
+ is_post_index: options.isPostIndex === true,
1637
+ path: url,
1638
+ url,
1639
+ };
1640
+ }
1641
+
1314
1642
  function buildPageMeta(site, options = {}) {
1315
1643
  const resolvedTitle = normalizeNonEmptyString(options.title, site.title);
1316
1644
  const resolvedDescription = normalizeOptionalString(options.description);
1317
- const canonicalUrl = resolveMetaCanonicalUrl(site, options.currentUrl);
1645
+ const canonicalUrl = resolveMetaCanonicalUrl(site, options.canonicalUrl || options.currentUrl);
1318
1646
  const ogImage = resolveMetaImageUrl(options.image);
1319
1647
  const ogType = normalizeNonEmptyString(options.ogType, 'website');
1320
1648
  const publishedTime = normalizeOptionalString(options.publishedTime);
@@ -1494,6 +1822,22 @@ function routePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output
1494
1822
  return normalizedPath;
1495
1823
  }
1496
1824
 
1825
+ function pagePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output_style) {
1826
+ const normalizedPath = normalizeRoutePath(routePath);
1827
+ if (outputStyle !== 'html-extension') {
1828
+ return routePathToPublicUrl(normalizedPath, outputStyle);
1829
+ }
1830
+
1831
+ const withoutTrailingSlash = normalizedPath.replace(/\/$/, '');
1832
+ if (withoutTrailingSlash === '/index') {
1833
+ return '/';
1834
+ }
1835
+ if (withoutTrailingSlash.endsWith('/index')) {
1836
+ return `${withoutTrailingSlash.slice(0, -'/index'.length)}/`;
1837
+ }
1838
+ return withoutTrailingSlash;
1839
+ }
1840
+
1497
1841
  function normalizeRoutePath(routePath) {
1498
1842
  if (!routePath || routePath === '/') {
1499
1843
  return '/';
@@ -1504,7 +1848,7 @@ function normalizeRoutePath(routePath) {
1504
1848
 
1505
1849
  function resolvePagePermalink(site, page) {
1506
1850
  if (normalizeOptionalString(page.path)) {
1507
- return buildRouteInfo(page.path, site.permalinks.output_style);
1851
+ return buildRouteInfo(page.path, site.permalinks.output_style, { pagePath: true });
1508
1852
  }
1509
1853
  return resolvePermalink(site, 'pages', page);
1510
1854
  }
@@ -1514,15 +1858,21 @@ function resolvePermalink(site, kind, item) {
1514
1858
  return buildRouteInfo(applyPermalinkPattern(pattern, kind, item, site), site.permalinks.output_style);
1515
1859
  }
1516
1860
 
1517
- function buildRouteInfo(routePath, outputStyle) {
1861
+ function buildRouteInfo(routePath, outputStyle, options = {}) {
1518
1862
  const path = normalizeRoutePath(routePath);
1519
1863
  return {
1520
1864
  path,
1521
- url: routePathToPublicUrl(path, outputStyle),
1865
+ url: options.pagePath ? pagePathToPublicUrl(path, outputStyle) : routePathToPublicUrl(path, outputStyle),
1522
1866
  outputPath: routePathToOutputPath(path, outputStyle),
1523
1867
  };
1524
1868
  }
1525
1869
 
1870
+ function pageToOutputPath(page, outputStyle) {
1871
+ return normalizeOptionalString(page.path)
1872
+ ? routePathToOutputPath(page.path, outputStyle)
1873
+ : routePathToOutputPath(page.url, outputStyle);
1874
+ }
1875
+
1526
1876
  function applyPermalinkPattern(pattern, kind, item, site) {
1527
1877
  const tokenValues = buildPermalinkTokenValues(kind, item, site);
1528
1878
  const body = String(pattern || '').replace(/^\/+|\/+$/g, '');
@@ -1579,7 +1929,7 @@ function assertPlannedOutputPathsSafe(state) {
1579
1929
  }
1580
1930
 
1581
1931
  for (const page of state.renderData.pages) {
1582
- assertSafeSlugDerivedOutputPath(page.slug, routePathToOutputPath(page.url, outputStyle));
1932
+ assertSafeSlugDerivedOutputPath(page.slug, pageToOutputPath(page, outputStyle));
1583
1933
  }
1584
1934
 
1585
1935
  for (const category of state.previewData.content.categories) {
@@ -1591,6 +1941,10 @@ function assertPlannedOutputPathsSafe(state) {
1591
1941
  }
1592
1942
 
1593
1943
  const routeEntries = [
1944
+ ...(state.renderData.frontPageRoute ? [{
1945
+ url: '/',
1946
+ outputPath: 'index.html',
1947
+ }] : []),
1594
1948
  ...state.renderData.indexRoutes.map((route) => ({
1595
1949
  url: routePathToPublicUrl(route.path, outputStyle),
1596
1950
  outputPath: routePathToOutputPath(route.path, outputStyle),
@@ -1613,7 +1967,7 @@ function assertPlannedOutputPathsSafe(state) {
1613
1967
  })),
1614
1968
  ...state.renderData.pages.map((page) => ({
1615
1969
  url: page.url,
1616
- outputPath: routePathToOutputPath(page.url, outputStyle),
1970
+ outputPath: pageToOutputPath(page, outputStyle),
1617
1971
  })),
1618
1972
  ];
1619
1973
  assertUniqueRoutes(routeEntries);
@@ -1625,7 +1979,7 @@ function assertPlannedOutputPathsSafe(state) {
1625
1979
  ];
1626
1980
 
1627
1981
  if (state.options.generateSpecialFiles) {
1628
- plannedPaths.push('404.html', 'robots.txt', 'meta.json');
1982
+ plannedPaths.push('404.html', 'robots.txt');
1629
1983
  if (hasCanonicalSiteUrl(state.previewData.site.url)) {
1630
1984
  plannedPaths.push('sitemap.xml', 'feed.xml');
1631
1985
  }
@@ -1724,6 +2078,12 @@ function sha256(content) {
1724
2078
  return hash.digest('hex');
1725
2079
  }
1726
2080
 
2081
+ function injectSiteCustomizations(html, state) {
2082
+ let next = injectCustomCssAssetLink(html, state.customCssHref);
2083
+ next = injectCustomHtml(next, state.customHtml);
2084
+ return next;
2085
+ }
2086
+
1727
2087
  function injectCustomCssAssetLink(html, href) {
1728
2088
  if (!normalizeOptionalString(href)) {
1729
2089
  return html;
@@ -1732,14 +2092,36 @@ function injectCustomCssAssetLink(html, href) {
1732
2092
  return html.replace('</head>', ` <link rel="stylesheet" href="${escapeHtml(href)}">\n</head>`);
1733
2093
  }
1734
2094
 
2095
+ function injectCustomHtml(html, customHtml) {
2096
+ let next = html;
2097
+ const headEnd = normalizeOptionalRawString(customHtml?.head_end?.content);
2098
+ const bodyEnd = normalizeOptionalRawString(customHtml?.body_end?.content);
2099
+
2100
+ if (headEnd) {
2101
+ next = next.replace('</head>', `${headEnd}\n</head>`);
2102
+ }
2103
+ if (bodyEnd) {
2104
+ next = next.replace('</body>', `${bodyEnd}\n</body>`);
2105
+ }
2106
+
2107
+ return next;
2108
+ }
2109
+
1735
2110
  function buildSitemapXml(site, emitted, generatedAt) {
1736
2111
  const entries = [
2112
+ ...(emitted.frontPage
2113
+ ? [{
2114
+ url: emitted.frontPage.url,
2115
+ changefreq: 'daily',
2116
+ priority: 1.0,
2117
+ }]
2118
+ : []),
1737
2119
  ...emitted.indexRoutes
1738
- .filter((route) => route.url === '/')
2120
+ .filter((route) => route.page === 1)
1739
2121
  .map((route) => ({
1740
2122
  url: route.url,
1741
2123
  changefreq: 'daily',
1742
- priority: 1.0,
2124
+ priority: route.url === '/' ? 1.0 : 0.7,
1743
2125
  })),
1744
2126
  ...emitted.posts.map((post) => ({
1745
2127
  url: post.url,
@@ -1747,11 +2129,13 @@ function buildSitemapXml(site, emitted, generatedAt) {
1747
2129
  changefreq: 'weekly',
1748
2130
  priority: 0.8,
1749
2131
  })),
1750
- ...emitted.pages.map((page) => ({
1751
- url: page.url,
1752
- changefreq: 'monthly',
1753
- priority: 0.7,
1754
- })),
2132
+ ...emitted.pages
2133
+ .filter((page) => page.includeInSitemap !== false)
2134
+ .map((page) => ({
2135
+ url: page.url,
2136
+ changefreq: 'monthly',
2137
+ priority: 0.7,
2138
+ })),
1755
2139
  ];
1756
2140
 
1757
2141
  const body = entries.map((entry) => {
@@ -1788,38 +2172,6 @@ function buildRobotsTxt(site) {
1788
2172
  return `${lines.join('\n')}\n`;
1789
2173
  }
1790
2174
 
1791
- function buildMetaJson(site, emitted, generatedAt) {
1792
- const pages = [
1793
- ...emitted.posts.map((post) => ({
1794
- url: post.url,
1795
- title: post.title,
1796
- description: post.description,
1797
- publishedAt: post.publishedAt,
1798
- updatedAt: post.updatedAt,
1799
- type: 'post',
1800
- metadata: { status: post.status },
1801
- })),
1802
- ...emitted.pages.map((page) => ({
1803
- url: page.url,
1804
- title: page.title,
1805
- description: page.description,
1806
- type: 'page',
1807
- metadata: { status: page.status },
1808
- })),
1809
- ];
1810
-
1811
- return JSON.stringify({
1812
- generated: formatUtcIsoSeconds(generatedAt),
1813
- site: {
1814
- title: site.title,
1815
- description: site.description,
1816
- url: site.url,
1817
- locale: site.locale,
1818
- },
1819
- pages,
1820
- }, null, 2);
1821
- }
1822
-
1823
2175
  function getContentType(assetPath) {
1824
2176
  const ext = assetPath.split('.').pop()?.toLowerCase();
1825
2177
  const contentTypes = {
@@ -3,6 +3,13 @@ import hljs from 'highlight.js';
3
3
  import anchor from 'markdown-it-anchor';
4
4
 
5
5
  const TOC_LEVELS = new Set([2, 3, 4]);
6
+ const ALERT_TYPES = new Map([
7
+ ['NOTE', { className: 'note', title: 'Note' }],
8
+ ['TIP', { className: 'tip', title: 'Tip' }],
9
+ ['IMPORTANT', { className: 'important', title: 'Important' }],
10
+ ['WARNING', { className: 'warning', title: 'Warning' }],
11
+ ['CAUTION', { className: 'caution', title: 'Caution' }],
12
+ ]);
6
13
 
7
14
  export function renderDocumentContent(content, documentType = 'markdown') {
8
15
  return renderDocument(content, documentType).html;
@@ -62,6 +69,8 @@ function createMarkdownRenderer(toc) {
62
69
  },
63
70
  });
64
71
 
72
+ markdown.use(markdownTaskLists);
73
+ markdown.use(markdownAlerts);
65
74
  markdown.use(anchor, {
66
75
  slugify: slugify,
67
76
  callback(token, { slug, title }) {
@@ -82,6 +91,189 @@ function createMarkdownRenderer(toc) {
82
91
  return markdown;
83
92
  }
84
93
 
94
+ function markdownTaskLists(markdown) {
95
+ markdown.core.ruler.after('inline', 'zeropress_task_lists', (state) => {
96
+ const listStack = [];
97
+ const listItemStack = [];
98
+
99
+ for (let index = 0; index < state.tokens.length; index += 1) {
100
+ const token = state.tokens[index];
101
+
102
+ if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
103
+ listStack.push(index);
104
+ continue;
105
+ }
106
+
107
+ if (token.type === 'bullet_list_close' || token.type === 'ordered_list_close') {
108
+ listStack.pop();
109
+ continue;
110
+ }
111
+
112
+ if (token.type === 'list_item_open') {
113
+ listItemStack.push({
114
+ index,
115
+ listIndex: listStack[listStack.length - 1],
116
+ });
117
+ continue;
118
+ }
119
+
120
+ if (token.type === 'list_item_close') {
121
+ listItemStack.pop();
122
+ continue;
123
+ }
124
+
125
+ if (token.type !== 'inline' || listItemStack.length === 0) {
126
+ continue;
127
+ }
128
+
129
+ const markerMatch = token.content.match(/^\s*\[([ xX])\](?:\s+|$)/);
130
+ if (!markerMatch) {
131
+ continue;
132
+ }
133
+
134
+ const listItem = listItemStack[listItemStack.length - 1];
135
+ const listToken = state.tokens[listItem.listIndex];
136
+ const listItemToken = state.tokens[listItem.index];
137
+ if (!listToken || !listItemToken) {
138
+ continue;
139
+ }
140
+
141
+ addTokenClass(listToken, 'contains-task-list');
142
+ addTokenClass(listItemToken, 'task-list-item');
143
+
144
+ const checkboxToken = new state.Token('html_inline', '', 0);
145
+ const checked = markerMatch[1].toLowerCase() === 'x' ? ' checked' : '';
146
+ checkboxToken.content = `<input class="task-list-item-checkbox" type="checkbox"${checked} disabled> `;
147
+
148
+ const strippedContent = token.content.slice(markerMatch[0].length);
149
+ token.content = strippedContent;
150
+ token.children = [];
151
+ state.md.inline.parse(strippedContent, state.md, state.env, token.children);
152
+ token.children.unshift(checkboxToken);
153
+ }
154
+ });
155
+ }
156
+
157
+ function markdownAlerts(markdown) {
158
+ markdown.core.ruler.after('inline', 'zeropress_alerts', (state) => {
159
+ for (let index = 0; index < state.tokens.length; index += 1) {
160
+ const token = state.tokens[index];
161
+ if (token.type !== 'blockquote_open') {
162
+ continue;
163
+ }
164
+
165
+ const closeIndex = findMatchingBlockquoteClose(state.tokens, index);
166
+ if (closeIndex === -1) {
167
+ continue;
168
+ }
169
+
170
+ const firstParagraph = findFirstParagraph(state.tokens, index + 1, closeIndex);
171
+ if (!firstParagraph) {
172
+ continue;
173
+ }
174
+
175
+ const inlineToken = state.tokens[firstParagraph.inlineIndex];
176
+ const alert = stripAlertMarker(inlineToken, state);
177
+ if (!alert) {
178
+ continue;
179
+ }
180
+
181
+ token.tag = 'aside';
182
+ token.attrSet('class', `zp-alert zp-alert--${alert.className}`);
183
+ token.attrSet('role', 'note');
184
+ state.tokens[closeIndex].tag = 'aside';
185
+
186
+ if (inlineToken.content.trim() === '') {
187
+ state.tokens.splice(firstParagraph.openIndex, 3);
188
+ }
189
+
190
+ state.tokens.splice(index + 1, 0, ...createAlertTitleTokens(state, alert.title));
191
+ index += 3;
192
+ }
193
+ });
194
+ }
195
+
196
+ function findMatchingBlockquoteClose(tokens, openIndex) {
197
+ let depth = 0;
198
+
199
+ for (let index = openIndex; index < tokens.length; index += 1) {
200
+ if (tokens[index].type === 'blockquote_open') {
201
+ depth += 1;
202
+ continue;
203
+ }
204
+
205
+ if (tokens[index].type === 'blockquote_close') {
206
+ depth -= 1;
207
+ if (depth === 0) {
208
+ return index;
209
+ }
210
+ }
211
+ }
212
+
213
+ return -1;
214
+ }
215
+
216
+ function findFirstParagraph(tokens, startIndex, endIndex) {
217
+ if (
218
+ startIndex + 2 < endIndex
219
+ && tokens[startIndex].type === 'paragraph_open'
220
+ && tokens[startIndex + 1].type === 'inline'
221
+ && tokens[startIndex + 2].type === 'paragraph_close'
222
+ ) {
223
+ return {
224
+ openIndex: startIndex,
225
+ inlineIndex: startIndex + 1,
226
+ closeIndex: startIndex + 2,
227
+ };
228
+ }
229
+
230
+ return null;
231
+ }
232
+
233
+ function stripAlertMarker(inlineToken, state) {
234
+ const markerMatch = inlineToken.content.match(/^\s*\[!([A-Za-z]+)\][ \t]*(?:\n)?/);
235
+ if (!markerMatch) {
236
+ return null;
237
+ }
238
+
239
+ const alertType = ALERT_TYPES.get(markerMatch[1].toUpperCase());
240
+ if (!alertType) {
241
+ return null;
242
+ }
243
+
244
+ const strippedContent = inlineToken.content.slice(markerMatch[0].length);
245
+ inlineToken.content = strippedContent;
246
+ inlineToken.children = [];
247
+ state.md.inline.parse(strippedContent, state.md, state.env, inlineToken.children);
248
+
249
+ return alertType;
250
+ }
251
+
252
+ function createAlertTitleTokens(state, title) {
253
+ const openToken = new state.Token('paragraph_open', 'p', 1);
254
+ openToken.attrSet('class', 'zp-alert__title');
255
+
256
+ const inlineToken = new state.Token('inline', '', 0);
257
+ inlineToken.content = title;
258
+ const textToken = new state.Token('text', '', 0);
259
+ textToken.content = title;
260
+ inlineToken.children = [textToken];
261
+
262
+ const closeToken = new state.Token('paragraph_close', 'p', -1);
263
+
264
+ return [openToken, inlineToken, closeToken];
265
+ }
266
+
267
+ function addTokenClass(token, className) {
268
+ const existingValue = token.attrGet('class') || '';
269
+ const existingClasses = existingValue.split(/\s+/).filter(Boolean);
270
+ if (existingClasses.includes(className)) {
271
+ return;
272
+ }
273
+
274
+ token.attrJoin('class', className);
275
+ }
276
+
85
277
  function normalizeDocumentType(value) {
86
278
  return value === 'plaintext' || value === 'html' ? value : 'markdown';
87
279
  }
@@ -113,16 +305,18 @@ function sanitizeHtml(html) {
113
305
  'strong', 'em', 'u', 's', 'code', 'pre',
114
306
  'a', 'img',
115
307
  'ul', 'ol', 'li',
116
- 'blockquote',
308
+ 'blockquote', 'aside',
117
309
  'table', 'thead', 'tbody', 'tr', 'th', 'td',
118
310
  'div', 'span', 'nav',
119
- 'iframe',
311
+ 'iframe', 'input',
120
312
  ]);
121
313
 
122
314
  const allowedAttributes = {
123
315
  a: new Set(['href', 'title', 'class', 'id']),
316
+ aside: new Set(['role', 'class', 'id']),
124
317
  img: new Set(['src', 'alt', 'title', 'class', 'id', 'width', 'height']),
125
318
  iframe: new Set(['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class']),
319
+ input: new Set(['type', 'checked', 'disabled', 'class', 'id']),
126
320
  '*': new Set(['class', 'id']),
127
321
  };
128
322
 
@@ -163,11 +357,15 @@ function sanitizeHtml(html) {
163
357
  continue;
164
358
  }
165
359
 
360
+ if (normalizedTag === 'input' && attributeName === 'type' && attributeValue !== 'checkbox') {
361
+ continue;
362
+ }
363
+
166
364
  filteredAttributes.push(`${attributeName}="${attributeValue}"`);
167
365
  }
168
366
  }
169
367
 
170
- const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img';
368
+ const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img' || normalizedTag === 'input';
171
369
  const attributeSuffix = filteredAttributes.length > 0 ? ` ${filteredAttributes.join(' ')}` : '';
172
370
  return isSelfClosing ? `<${normalizedTag}${attributeSuffix} />` : `<${normalizedTag}${attributeSuffix}>`;
173
371
  });
@@ -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) => {