@rettangoli/sites 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,8 +32,8 @@ my-site/
32
32
 
33
33
  - YAML pages rendered through `jempl` + `yahtml`
34
34
  - Markdown pages rendered through `markdown-it` + Shiki (default `rtglMarkdown`)
35
- - Frontmatter (`template`, `tags`, arbitrary page metadata)
36
- - Global data (`data/*.yaml`) merged with page frontmatter
35
+ - Frontmatter (`template`, `url`, `tags`, arbitrary page metadata)
36
+ - Global data from `data/*.yaml` and optional inline `sites.config.yaml data`
37
37
  - Collections built from page tags
38
38
  - `$if`, `$for`, `$partial`, template functions
39
39
  - Static file copying from `static/` to `_site/`
@@ -75,6 +75,9 @@ imports:
75
75
  docs: https://example.com/templates/docs.yaml
76
76
  partials:
77
77
  docs/nav: https://example.com/partials/docs-nav.yaml
78
+ data:
79
+ themeCssHref: /public/theme.css
80
+ themeBodyClass: dark
78
81
  ```
79
82
 
80
83
  In the default starter template, CDN runtime scripts are controlled via `data/site.yaml`:
@@ -93,10 +96,35 @@ Example mappings:
93
96
  - `pages/index.md` -> `_site/index.html` and `_site/index.md`
94
97
  - `pages/docs/intro.md` -> `_site/docs/intro/index.html` and `_site/docs/intro.md`
95
98
 
99
+ For Markdown pages with a custom `url`, the copied `.md` file follows the custom URL path.
100
+ For example, `url: /guides/start/` writes `_site/guides/start/index.html` and `_site/guides/start.md`.
101
+
102
+ Pages use their file path as the URL by default:
103
+ - `pages/index.*` -> `/`
104
+ - `pages/about.*` -> `/about/`
105
+ - `pages/docs/intro.*` -> `/docs/intro/`
106
+
107
+ Set `url` in page frontmatter to override that path:
108
+
109
+ ```md
110
+ ---
111
+ title: Company
112
+ url: /company/
113
+ ---
114
+ ```
115
+
116
+ `url` is normalized to a site-relative clean URL with a leading and trailing slash, so `company` becomes `/company/`.
117
+ External URLs, query strings, fragments, whitespace, and `.` / `..` path segments are rejected.
118
+ Duplicate page URLs are rejected after normalization.
119
+
96
120
  `imports` lets you map aliases to remote YAML files (HTTP/HTTPS only). Use aliases in pages/templates:
97
121
  - page frontmatter: `template: base` or `template: docs`
98
122
  - template/page content: `$partial: docs/nav`
99
123
 
124
+ Use top-level `data` in `sites.config.yaml` for small global values that do not deserve their own `data/*.yaml` file.
125
+ `sites.config.yaml data` and `data/*.yaml` are merged, with `data/*.yaml` winning on conflicts.
126
+ Inline config data requires `rtgl >= 1.1.4` or `@rettangoli/sites >= 1.0.3`.
127
+
100
128
  Imported files are cached on disk under `.rettangoli/sites/imports/{templates|partials}/` (hashed filenames).
101
129
  Alias/url/hash mapping is tracked in `.rettangoli/sites/imports/index.yaml`.
102
130
  Build is cache-first: if a cached file exists, it is used without a network request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/sites",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Generate static sites using Markdown and YAML for docs, blogs, and marketing sites.",
5
5
  "author": {
6
6
  "name": "Luciano Hanyon Wu",
package/src/cli/build.js CHANGED
@@ -31,6 +31,7 @@ export const buildSite = async (options = {}) => {
31
31
  markdown: config.markdown || {},
32
32
  keepMarkdownFiles: config.build?.keepMarkdownFiles === true,
33
33
  imports: config.imports || {},
34
+ data: config.data || {},
34
35
  functions: functions || {},
35
36
  quiet,
36
37
  isScreenshotMode
@@ -17,22 +17,25 @@ const MATTER_OPTIONS = {
17
17
 
18
18
  // Deep merge utility function
19
19
  function deepMerge(target, source) {
20
- const output = { ...target };
21
-
22
- if (isObject(target) && isObject(source)) {
23
- Object.keys(source).forEach(key => {
24
- if (isObject(source[key])) {
25
- if (!(key in target)) {
26
- Object.assign(output, { [key]: source[key] });
27
- } else {
28
- output[key] = deepMerge(target[key], source[key]);
29
- }
30
- } else {
31
- Object.assign(output, { [key]: source[key] });
32
- }
33
- });
20
+ if (!isObject(source)) {
21
+ return source;
22
+ }
23
+
24
+ if (!isObject(target)) {
25
+ return { ...source };
34
26
  }
35
-
27
+
28
+ const output = { ...target };
29
+
30
+ Object.keys(source).forEach(key => {
31
+ if (isObject(source[key]) && isObject(target[key])) {
32
+ output[key] = deepMerge(target[key], source[key]);
33
+ return;
34
+ }
35
+
36
+ Object.assign(output, { [key]: source[key] });
37
+ });
38
+
36
39
  return output;
37
40
  }
38
41
 
@@ -225,6 +228,123 @@ function isSchemaSidecarFile(fileName) {
225
228
  return fileName.endsWith('.schema.yaml') || fileName.endsWith('.schema.yml');
226
229
  }
227
230
 
231
+ function hasPageExtension(fileName) {
232
+ return fileName.endsWith('.yaml') || fileName.endsWith('.yml') || fileName.endsWith('.md');
233
+ }
234
+
235
+ function hasOwn(object, key) {
236
+ return Object.prototype.hasOwnProperty.call(object, key);
237
+ }
238
+
239
+ function normalizeRelativeUrlPath(relativePath) {
240
+ return relativePath.replace(/\\/g, '/');
241
+ }
242
+
243
+ function derivePageUrlFromRelativePath(relativePath) {
244
+ const normalizedPath = normalizeRelativeUrlPath(relativePath);
245
+ const pathWithoutExtension = normalizedPath.replace(/\.(yaml|yml|md)$/, '');
246
+ const pageName = path.posix.basename(pathWithoutExtension);
247
+
248
+ if (pageName === 'index') {
249
+ const dirName = path.posix.dirname(pathWithoutExtension);
250
+ return dirName === '.' ? '/' : `/${dirName}/`;
251
+ }
252
+
253
+ return `/${pathWithoutExtension}/`;
254
+ }
255
+
256
+ function normalizePageUrlOverride(rawUrl, pagePath) {
257
+ if (typeof rawUrl !== 'string') {
258
+ throw new Error(`Invalid url in ${pagePath}: expected a string.`);
259
+ }
260
+
261
+ if (rawUrl === '') {
262
+ throw new Error(`Invalid url in ${pagePath}: expected a non-empty string.`);
263
+ }
264
+
265
+ if (/[\u0000-\u001F\u007F]/u.test(rawUrl)) {
266
+ throw new Error(`Invalid url in ${pagePath}: must not contain control characters.`);
267
+ }
268
+
269
+ if (/\s/u.test(rawUrl)) {
270
+ throw new Error(`Invalid url in ${pagePath}: must not contain whitespace.`);
271
+ }
272
+
273
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(rawUrl) || rawUrl.startsWith('//')) {
274
+ throw new Error(`Invalid url in ${pagePath}: expected a site-relative URL path.`);
275
+ }
276
+
277
+ if (rawUrl.includes('\\')) {
278
+ throw new Error(`Invalid url in ${pagePath}: must use forward slashes.`);
279
+ }
280
+
281
+ if (rawUrl.includes('?') || rawUrl.includes('#')) {
282
+ throw new Error(`Invalid url in ${pagePath}: must not include query strings or fragments.`);
283
+ }
284
+
285
+ const withLeadingSlash = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
286
+ const collapsedUrl = withLeadingSlash.replace(/\/+/g, '/');
287
+ const pathWithoutSlashes = collapsedUrl.replace(/^\/+|\/+$/g, '');
288
+
289
+ if (pathWithoutSlashes === '') {
290
+ return '/';
291
+ }
292
+
293
+ const segments = pathWithoutSlashes.split('/');
294
+ for (const segment of segments) {
295
+ let decodedSegment;
296
+ try {
297
+ decodedSegment = decodeURIComponent(segment);
298
+ } catch (error) {
299
+ throw new Error(`Invalid url in ${pagePath}: contains invalid URL encoding.`);
300
+ }
301
+
302
+ if (decodedSegment === '.' || decodedSegment === '..') {
303
+ throw new Error(`Invalid url in ${pagePath}: must not contain "." or ".." segments.`);
304
+ }
305
+
306
+ if (decodedSegment.includes('/') || decodedSegment.includes('\\')) {
307
+ throw new Error(`Invalid url in ${pagePath}: must not include encoded slashes or backslashes.`);
308
+ }
309
+
310
+ if (/[\u0000-\u001F\u007F]/u.test(decodedSegment)) {
311
+ throw new Error(`Invalid url in ${pagePath}: must not contain control characters.`);
312
+ }
313
+
314
+ if (/\s/u.test(decodedSegment)) {
315
+ throw new Error(`Invalid url in ${pagePath}: must not contain whitespace.`);
316
+ }
317
+ }
318
+
319
+ return `/${segments.join('/')}/`;
320
+ }
321
+
322
+ function resolvePageUrl(publicFrontmatter, relativePath, pagePath) {
323
+ if (hasOwn(publicFrontmatter, 'url')) {
324
+ return normalizePageUrlOverride(publicFrontmatter.url, pagePath);
325
+ }
326
+
327
+ return derivePageUrlFromRelativePath(relativePath);
328
+ }
329
+
330
+ function htmlOutputRelativePathFromUrl(url) {
331
+ if (url === '/') {
332
+ return 'index.html';
333
+ }
334
+
335
+ return `${url.slice(1, -1)}/index.html`;
336
+ }
337
+
338
+ function markdownOutputRelativePathFromUrl(url) {
339
+ if (url === '/') {
340
+ return 'index.md';
341
+ }
342
+
343
+ const segments = url.slice(1, -1).split('/');
344
+ const fileName = `${segments.pop()}.md`;
345
+ return [...segments, fileName].join('/');
346
+ }
347
+
228
348
  async function fetchRemoteYaml(url, fetchImpl, aliasLabel) {
229
349
  const effectiveFetch = fetchImpl || globalThis.fetch;
230
350
  if (typeof effectiveFetch !== 'function') {
@@ -314,6 +434,7 @@ export function createSiteBuilder({
314
434
  markdown = {},
315
435
  keepMarkdownFiles = false,
316
436
  imports = {},
437
+ data = {},
317
438
  fetchImpl,
318
439
  functions = {},
319
440
  quiet = false,
@@ -409,9 +530,13 @@ export function createSiteBuilder({
409
530
  readPartialsRecursively(partialsDir);
410
531
  }
411
532
 
533
+ if (!isObject(data)) {
534
+ throw new Error('Invalid site data: expected an object.');
535
+ }
536
+
412
537
  // Read all data files and create a JSON object
413
538
  const dataDir = path.join(rootDir, 'data');
414
- const globalData = {};
539
+ const fileData = {};
415
540
 
416
541
  if (fs.existsSync(dataDir)) {
417
542
  const files = fs.readdirSync(dataDir);
@@ -421,11 +546,13 @@ export function createSiteBuilder({
421
546
  const fileContent = fs.readFileSync(filePath, 'utf8');
422
547
  const nameWithoutExt = path.basename(file, path.extname(file));
423
548
  // Load YAML content and store under filename key
424
- globalData[nameWithoutExt] = yaml.load(fileContent, { schema: yaml.JSON_SCHEMA });
549
+ fileData[nameWithoutExt] = yaml.load(fileContent, { schema: yaml.JSON_SCHEMA });
425
550
  }
426
551
  });
427
552
  }
428
553
 
554
+ const globalData = deepMerge(data, fileData);
555
+
429
556
  // Read all templates and create a JSON object
430
557
  const templatesDir = path.join(rootDir, 'templates');
431
558
  const templates = { ...importedTemplates };
@@ -475,12 +602,11 @@ export function createSiteBuilder({
475
602
  };
476
603
  }
477
604
 
478
- // Function to scan all pages and build collections
479
- function buildCollections() {
480
- const collections = {};
605
+ function collectPageEntries() {
606
+ const pageEntries = [];
481
607
  const pagesDir = path.join(rootDir, 'pages');
482
608
 
483
- function scanPages(dir, basePath = '') {
609
+ function scanPages(basePath = '') {
484
610
  const fullDir = path.join(pagesDir, basePath);
485
611
  if (!fs.existsSync(fullDir)) return;
486
612
 
@@ -492,82 +618,122 @@ export function createSiteBuilder({
492
618
  const itemKind = resolveDirentKind(fs, itemPath, item);
493
619
 
494
620
  if (itemKind === 'directory') {
495
- // Recursively scan subdirectories
496
- scanPages(dir, relativePath);
497
- } else if (itemKind === 'file' && (item.name.endsWith('.yaml') || item.name.endsWith('.yml') || item.name.endsWith('.md'))) {
498
- // Extract frontmatter and content
621
+ scanPages(relativePath);
622
+ } else if (itemKind === 'file' && hasPageExtension(item.name)) {
499
623
  const { frontmatter, content } = extractFrontmatterAndContent(itemPath);
500
- const { frontmatter: publicFrontmatter } = splitSystemFrontmatter(frontmatter, globalData, itemPath);
624
+ const { frontmatter: publicFrontmatter, bindings } = splitSystemFrontmatter(frontmatter, globalData, itemPath);
625
+ const hasCustomUrl = hasOwn(publicFrontmatter, 'url');
626
+ const url = resolvePageUrl(publicFrontmatter, relativePath, itemPath);
627
+ const exposedFrontmatter = hasCustomUrl
628
+ ? { ...publicFrontmatter, url }
629
+ : publicFrontmatter;
630
+
631
+ pageEntries.push({
632
+ pagePath: itemPath,
633
+ relativePath: normalizeRelativeUrlPath(relativePath),
634
+ isMarkdown: item.name.endsWith('.md'),
635
+ content,
636
+ frontmatter: exposedFrontmatter,
637
+ bindings,
638
+ url,
639
+ hasCustomUrl
640
+ });
641
+ }
642
+ }
643
+ }
501
644
 
502
- // Calculate URL
503
- const baseFileName = item.name.replace(/\.(yaml|yml|md)$/, '');
504
- let url;
645
+ scanPages();
646
+ return pageEntries;
647
+ }
505
648
 
506
- // Special case: index files remain at root, others become directories
507
- if (baseFileName === 'index') {
508
- url = basePath ? '/' + basePath.replace(/\\/g, '/') : '/';
509
- if (url !== '/') {
510
- url = url + '/';
511
- }
512
- } else {
513
- const pagePath = basePath ? path.join(basePath, baseFileName) : baseFileName;
514
- url = '/' + pagePath.replace(/\\/g, '/') + '/';
515
- }
649
+ function assertUniquePageUrls(pageEntries) {
650
+ const seen = new Map();
651
+
652
+ for (const entry of pageEntries) {
653
+ const existingEntry = seen.get(entry.url);
654
+ if (existingEntry) {
655
+ throw new Error(`Duplicate page URL "${entry.url}" in ${entry.pagePath}; already used by ${existingEntry.pagePath}.`);
656
+ }
657
+ seen.set(entry.url, entry);
658
+ }
659
+ }
660
+
661
+ function assertUniqueMarkdownOutputPaths(pageEntries) {
662
+ if (!keepMarkdownFiles) {
663
+ return;
664
+ }
665
+
666
+ const seen = new Map();
667
+ for (const entry of pageEntries) {
668
+ if (!entry.isMarkdown) {
669
+ continue;
670
+ }
671
+
672
+ const markdownOutputRelativePath = entry.hasCustomUrl
673
+ ? markdownOutputRelativePathFromUrl(entry.url)
674
+ : entry.relativePath;
675
+ const existingEntry = seen.get(markdownOutputRelativePath);
676
+ if (existingEntry) {
677
+ throw new Error(`Duplicate markdown output path "${markdownOutputRelativePath}" in ${entry.pagePath}; already used by ${existingEntry.pagePath}.`);
678
+ }
679
+ seen.set(markdownOutputRelativePath, entry);
680
+ }
681
+ }
682
+
683
+ // Function to scan all pages and build collections
684
+ function buildCollections(pageEntries) {
685
+ const collections = {};
686
+
687
+ for (const entry of pageEntries) {
688
+ const publicFrontmatter = entry.frontmatter;
516
689
 
517
- // Process tags
518
- if (publicFrontmatter.tags) {
519
- // Normalize tags to array
520
- const tags = Array.isArray(publicFrontmatter.tags) ? publicFrontmatter.tags : [publicFrontmatter.tags];
521
-
522
- // Add to collections
523
- tags.forEach(tag => {
524
- if (typeof tag === 'string' && tag.trim()) {
525
- const trimmedTag = tag.trim();
526
- if (!collections[trimmedTag]) {
527
- collections[trimmedTag] = [];
528
- }
529
- collections[trimmedTag].push({
530
- data: publicFrontmatter,
531
- url: url,
532
- content: content
533
- });
534
- }
690
+ // Process tags
691
+ if (publicFrontmatter.tags) {
692
+ // Normalize tags to array
693
+ const tags = Array.isArray(publicFrontmatter.tags) ? publicFrontmatter.tags : [publicFrontmatter.tags];
694
+
695
+ // Add to collections
696
+ tags.forEach(tag => {
697
+ if (typeof tag === 'string' && tag.trim()) {
698
+ const trimmedTag = tag.trim();
699
+ if (!collections[trimmedTag]) {
700
+ collections[trimmedTag] = [];
701
+ }
702
+ collections[trimmedTag].push({
703
+ data: publicFrontmatter,
704
+ url: entry.url,
705
+ content: entry.content
535
706
  });
536
707
  }
537
- }
708
+ });
538
709
  }
539
710
  }
540
711
 
541
- scanPages('');
542
712
  return collections;
543
713
  }
544
714
 
715
+ const pageEntries = collectPageEntries();
716
+ assertUniquePageUrls(pageEntries);
717
+ assertUniqueMarkdownOutputPaths(pageEntries);
718
+
545
719
  // Build collections in first pass
546
720
  if (!quiet) console.log('Building collections...');
547
- const collections = buildCollections();
721
+ const collections = buildCollections(pageEntries);
548
722
 
549
723
  // Function to process a single page file
550
- async function processPage(pagePath, outputRelativePath, isMarkdown = false, markdownOutputRelativePath = null) {
551
- if (!quiet) console.log(`Processing ${pagePath}...`);
552
-
553
- const { frontmatter, content: rawContent } = extractFrontmatterAndContent(pagePath);
554
- const { frontmatter: publicFrontmatter, bindings: boundData } = splitSystemFrontmatter(frontmatter, globalData, pagePath);
555
-
556
- // Calculate URL for current page
557
- let url;
558
- const fileName = path.basename(outputRelativePath, '.html');
559
- const basePath = path.dirname(outputRelativePath);
724
+ async function processPage(pageEntry) {
725
+ const {
726
+ pagePath,
727
+ content: rawContent,
728
+ frontmatter: publicFrontmatter,
729
+ bindings: boundData,
730
+ isMarkdown,
731
+ url,
732
+ hasCustomUrl,
733
+ relativePath
734
+ } = pageEntry;
560
735
 
561
- // Special case: index files remain at root, others become directories
562
- if (fileName === 'index') {
563
- url = basePath && basePath !== '.' ? '/' + basePath.replace(/\\/g, '/') : '/';
564
- if (url !== '/') {
565
- url = url + '/';
566
- }
567
- } else {
568
- const pagePath = basePath && basePath !== '.' ? path.join(basePath, fileName) : fileName;
569
- url = '/' + pagePath.replace(/\\/g, '/') + '/';
570
- }
736
+ if (!quiet) console.log(`Processing ${pagePath}...`);
571
737
 
572
738
  // Deep merge global data with frontmatter and collections for the page context
573
739
  const pageData = deepMerge(globalData, publicFrontmatter);
@@ -638,35 +804,9 @@ export function createSiteBuilder({
638
804
  htmlString = convertToHtml(resultArray);
639
805
  }
640
806
 
641
- // Create output directory and file path for new index.html structure
642
- const pageFileName = path.basename(outputRelativePath, '.html');
643
- const dirPath = path.dirname(outputRelativePath);
644
-
645
- let outputPath, outputDir;
646
-
647
- // Special case: index files remain as index.html, others become directory/index.html
648
- if (pageFileName === 'index') {
649
- if (dirPath && dirPath !== '.') {
650
- // Nested index file: pages/blog/index.yaml -> _site/blog/index.html
651
- outputPath = path.join(outputRootDir, dirPath, 'index.html');
652
- outputDir = path.join(outputRootDir, dirPath);
653
- } else {
654
- // Root index file: pages/index.yaml -> _site/index.html
655
- outputPath = path.join(outputRootDir, 'index.html');
656
- outputDir = path.join(outputRootDir);
657
- }
658
- } else {
659
- // Regular file: pages/test.yaml -> _site/test/index.html
660
- if (dirPath && dirPath !== '.') {
661
- // Nested regular file: pages/blog/post.yaml -> _site/blog/post/index.html
662
- outputPath = path.join(outputRootDir, dirPath, pageFileName, 'index.html');
663
- outputDir = path.join(outputRootDir, dirPath, pageFileName);
664
- } else {
665
- // Root level regular file: pages/test.yaml -> _site/test/index.html
666
- outputPath = path.join(outputRootDir, pageFileName, 'index.html');
667
- outputDir = path.join(outputRootDir, pageFileName);
668
- }
669
- }
807
+ const outputRelativePath = htmlOutputRelativePathFromUrl(url);
808
+ const outputPath = path.join(outputRootDir, ...outputRelativePath.split('/'));
809
+ const outputDir = path.dirname(outputPath);
670
810
 
671
811
  if (!fs.existsSync(outputDir)) {
672
812
  fs.mkdirSync(outputDir, { recursive: true });
@@ -676,8 +816,11 @@ export function createSiteBuilder({
676
816
  fs.writeFileSync(outputPath, htmlString);
677
817
  if (!quiet) console.log(` -> Written to ${outputPath}`);
678
818
 
679
- if (isMarkdown && keepMarkdownFiles && typeof markdownOutputRelativePath === 'string') {
680
- const markdownOutputPath = path.join(outputRootDir, markdownOutputRelativePath);
819
+ if (isMarkdown && keepMarkdownFiles) {
820
+ const markdownOutputRelativePath = hasCustomUrl
821
+ ? markdownOutputRelativePathFromUrl(url)
822
+ : relativePath;
823
+ const markdownOutputPath = path.join(outputRootDir, ...markdownOutputRelativePath.split('/'));
681
824
  const markdownOutputDir = path.dirname(markdownOutputPath);
682
825
  if (!fs.existsSync(markdownOutputDir)) {
683
826
  fs.mkdirSync(markdownOutputDir, { recursive: true });
@@ -687,37 +830,9 @@ export function createSiteBuilder({
687
830
  }
688
831
  }
689
832
 
690
- // Process all YAML and Markdown files in pages directory recursively
691
- async function processAllPages(dir, basePath = '') {
692
- const pagesDir = path.join(rootDir, 'pages');
693
- const fullDir = path.join(pagesDir, basePath);
694
-
695
- if (!fs.existsSync(fullDir)) return;
696
-
697
- const items = fs.readdirSync(fullDir, { withFileTypes: true });
698
-
699
- for (const item of items) {
700
- const itemPath = path.join(fullDir, item.name);
701
- const relativePath = basePath ? path.join(basePath, item.name) : item.name;
702
- const itemKind = resolveDirentKind(fs, itemPath, item);
703
-
704
- if (itemKind === 'directory') {
705
- // Recursively process subdirectories
706
- await processAllPages(dir, relativePath);
707
- } else if (itemKind === 'file') {
708
- if (item.name.endsWith('.yaml') || item.name.endsWith('.yml')) {
709
- // Process YAML file
710
- const outputFileName = item.name.replace(/\.(yaml|yml)$/, '.html');
711
- const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
712
- await processPage(itemPath, outputRelativePath, false);
713
- } else if (item.name.endsWith('.md')) {
714
- // Process Markdown file
715
- const outputFileName = item.name.replace('.md', '.html');
716
- const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
717
- await processPage(itemPath, outputRelativePath, true, relativePath);
718
- }
719
- // Ignore other file types
720
- }
833
+ async function processAllPages() {
834
+ for (const pageEntry of pageEntries) {
835
+ await processPage(pageEntry);
721
836
  }
722
837
  }
723
838
 
@@ -774,7 +889,7 @@ export function createSiteBuilder({
774
889
  copyStaticFiles();
775
890
 
776
891
  // Process all pages (can overwrite static files)
777
- await processAllPages('');
892
+ await processAllPages();
778
893
 
779
894
  if (!quiet) console.log('Build complete!');
780
895
  };
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import yaml from 'js-yaml';
4
4
 
5
- const ALLOWED_TOP_LEVEL_KEYS = new Set(['markdown', 'markdownit', 'build', 'imports']);
5
+ const ALLOWED_TOP_LEVEL_KEYS = new Set(['markdown', 'markdownit', 'build', 'imports', 'data']);
6
6
  const MARKDOWN_BOOLEAN_KEYS = new Set(['html', 'linkify', 'typographer', 'breaks', 'xhtmlOut']);
7
7
  const MARKDOWN_STRING_KEYS = new Set(['langPrefix', 'quotes', 'preset']);
8
8
  const MARKDOWN_NUMBER_KEYS = new Set(['maxNesting']);
@@ -121,6 +121,25 @@ function validateBuildConfig(value, configPath) {
121
121
  return { ...value };
122
122
  }
123
123
 
124
+ function validateDataConfig(value, configPath) {
125
+ if (!isPlainObject(value)) {
126
+ throw new Error(`Invalid data config in "${configPath}": expected an object.`);
127
+ }
128
+
129
+ const normalized = {};
130
+
131
+ for (const [rawKey, rawValue] of Object.entries(value)) {
132
+ const key = String(rawKey).trim();
133
+ if (key === '') {
134
+ throw new Error(`Invalid data config in "${configPath}": keys must be non-empty.`);
135
+ }
136
+
137
+ normalized[key] = rawValue;
138
+ }
139
+
140
+ return normalized;
141
+ }
142
+
124
143
  function validateImportUrl(url, configPath, groupKey, alias) {
125
144
  if (typeof url !== 'string' || url.trim() === '') {
126
145
  throw new Error(`Invalid imports.${groupKey} value for alias "${alias}" in "${configPath}": expected a non-empty URL string.`);
@@ -200,7 +219,7 @@ function validateConfig(rawConfig, configPath) {
200
219
  for (const key of Object.keys(config)) {
201
220
  if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) {
202
221
  throw new Error(
203
- `Unsupported key "${key}" in "${configPath}". Supported keys: markdownit (recommended), markdown (legacy alias), build, imports.`
222
+ `Unsupported key "${key}" in "${configPath}". Supported keys: markdownit (recommended), markdown (legacy alias), build, imports, data.`
204
223
  );
205
224
  }
206
225
  }
@@ -292,6 +311,10 @@ function validateConfig(rawConfig, configPath) {
292
311
  normalizedConfig.imports = validateImportsConfig(config.imports, configPath);
293
312
  }
294
313
 
314
+ if (config.data !== undefined) {
315
+ normalizedConfig.data = validateDataConfig(config.data, configPath);
316
+ }
317
+
295
318
  return normalizedConfig;
296
319
  }
297
320