@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 +30 -2
- package/package.json +1 -1
- package/src/cli/build.js +1 -0
- package/src/createSiteBuilder.js +258 -143
- package/src/utils/loadSiteConfig.js +25 -2
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
|
|
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
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
|
package/src/createSiteBuilder.js
CHANGED
|
@@ -17,22 +17,25 @@ const MATTER_OPTIONS = {
|
|
|
17
17
|
|
|
18
18
|
// Deep merge utility function
|
|
19
19
|
function deepMerge(target, source) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
const collections = {};
|
|
605
|
+
function collectPageEntries() {
|
|
606
|
+
const pageEntries = [];
|
|
481
607
|
const pagesDir = path.join(rootDir, 'pages');
|
|
482
608
|
|
|
483
|
-
function scanPages(
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
645
|
+
scanPages();
|
|
646
|
+
return pageEntries;
|
|
647
|
+
}
|
|
505
648
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
const
|
|
643
|
-
const
|
|
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
|
|
680
|
-
const
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|