@rettangoli/sites 1.0.3 → 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,7 +32,7 @@ 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)
35
+ - Frontmatter (`template`, `url`, `tags`, arbitrary page metadata)
36
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
@@ -96,6 +96,27 @@ Example mappings:
96
96
  - `pages/index.md` -> `_site/index.html` and `_site/index.md`
97
97
  - `pages/docs/intro.md` -> `_site/docs/intro/index.html` and `_site/docs/intro.md`
98
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
+
99
120
  `imports` lets you map aliases to remote YAML files (HTTP/HTTPS only). Use aliases in pages/templates:
100
121
  - page frontmatter: `template: base` or `template: docs`
101
122
  - template/page content: `$partial: docs/nav`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/sites",
3
- "version": "1.0.3",
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",
@@ -228,6 +228,123 @@ function isSchemaSidecarFile(fileName) {
228
228
  return fileName.endsWith('.schema.yaml') || fileName.endsWith('.schema.yml');
229
229
  }
230
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
+
231
348
  async function fetchRemoteYaml(url, fetchImpl, aliasLabel) {
232
349
  const effectiveFetch = fetchImpl || globalThis.fetch;
233
350
  if (typeof effectiveFetch !== 'function') {
@@ -485,12 +602,11 @@ export function createSiteBuilder({
485
602
  };
486
603
  }
487
604
 
488
- // Function to scan all pages and build collections
489
- function buildCollections() {
490
- const collections = {};
605
+ function collectPageEntries() {
606
+ const pageEntries = [];
491
607
  const pagesDir = path.join(rootDir, 'pages');
492
608
 
493
- function scanPages(dir, basePath = '') {
609
+ function scanPages(basePath = '') {
494
610
  const fullDir = path.join(pagesDir, basePath);
495
611
  if (!fs.existsSync(fullDir)) return;
496
612
 
@@ -502,82 +618,122 @@ export function createSiteBuilder({
502
618
  const itemKind = resolveDirentKind(fs, itemPath, item);
503
619
 
504
620
  if (itemKind === 'directory') {
505
- // Recursively scan subdirectories
506
- scanPages(dir, relativePath);
507
- } else if (itemKind === 'file' && (item.name.endsWith('.yaml') || item.name.endsWith('.yml') || item.name.endsWith('.md'))) {
508
- // Extract frontmatter and content
621
+ scanPages(relativePath);
622
+ } else if (itemKind === 'file' && hasPageExtension(item.name)) {
509
623
  const { frontmatter, content } = extractFrontmatterAndContent(itemPath);
510
- 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
+ }
511
644
 
512
- // Calculate URL
513
- const baseFileName = item.name.replace(/\.(yaml|yml|md)$/, '');
514
- let url;
645
+ scanPages();
646
+ return pageEntries;
647
+ }
515
648
 
516
- // Special case: index files remain at root, others become directories
517
- if (baseFileName === 'index') {
518
- url = basePath ? '/' + basePath.replace(/\\/g, '/') : '/';
519
- if (url !== '/') {
520
- url = url + '/';
521
- }
522
- } else {
523
- const pagePath = basePath ? path.join(basePath, baseFileName) : baseFileName;
524
- url = '/' + pagePath.replace(/\\/g, '/') + '/';
525
- }
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
+ }
526
682
 
527
- // Process tags
528
- if (publicFrontmatter.tags) {
529
- // Normalize tags to array
530
- const tags = Array.isArray(publicFrontmatter.tags) ? publicFrontmatter.tags : [publicFrontmatter.tags];
531
-
532
- // Add to collections
533
- tags.forEach(tag => {
534
- if (typeof tag === 'string' && tag.trim()) {
535
- const trimmedTag = tag.trim();
536
- if (!collections[trimmedTag]) {
537
- collections[trimmedTag] = [];
538
- }
539
- collections[trimmedTag].push({
540
- data: publicFrontmatter,
541
- url: url,
542
- content: content
543
- });
544
- }
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;
689
+
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
545
706
  });
546
707
  }
547
- }
708
+ });
548
709
  }
549
710
  }
550
711
 
551
- scanPages('');
552
712
  return collections;
553
713
  }
554
714
 
715
+ const pageEntries = collectPageEntries();
716
+ assertUniquePageUrls(pageEntries);
717
+ assertUniqueMarkdownOutputPaths(pageEntries);
718
+
555
719
  // Build collections in first pass
556
720
  if (!quiet) console.log('Building collections...');
557
- const collections = buildCollections();
721
+ const collections = buildCollections(pageEntries);
558
722
 
559
723
  // Function to process a single page file
560
- async function processPage(pagePath, outputRelativePath, isMarkdown = false, markdownOutputRelativePath = null) {
561
- if (!quiet) console.log(`Processing ${pagePath}...`);
562
-
563
- const { frontmatter, content: rawContent } = extractFrontmatterAndContent(pagePath);
564
- const { frontmatter: publicFrontmatter, bindings: boundData } = splitSystemFrontmatter(frontmatter, globalData, pagePath);
565
-
566
- // Calculate URL for current page
567
- let url;
568
- const fileName = path.basename(outputRelativePath, '.html');
569
- 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;
570
735
 
571
- // Special case: index files remain at root, others become directories
572
- if (fileName === 'index') {
573
- url = basePath && basePath !== '.' ? '/' + basePath.replace(/\\/g, '/') : '/';
574
- if (url !== '/') {
575
- url = url + '/';
576
- }
577
- } else {
578
- const pagePath = basePath && basePath !== '.' ? path.join(basePath, fileName) : fileName;
579
- url = '/' + pagePath.replace(/\\/g, '/') + '/';
580
- }
736
+ if (!quiet) console.log(`Processing ${pagePath}...`);
581
737
 
582
738
  // Deep merge global data with frontmatter and collections for the page context
583
739
  const pageData = deepMerge(globalData, publicFrontmatter);
@@ -648,35 +804,9 @@ export function createSiteBuilder({
648
804
  htmlString = convertToHtml(resultArray);
649
805
  }
650
806
 
651
- // Create output directory and file path for new index.html structure
652
- const pageFileName = path.basename(outputRelativePath, '.html');
653
- const dirPath = path.dirname(outputRelativePath);
654
-
655
- let outputPath, outputDir;
656
-
657
- // Special case: index files remain as index.html, others become directory/index.html
658
- if (pageFileName === 'index') {
659
- if (dirPath && dirPath !== '.') {
660
- // Nested index file: pages/blog/index.yaml -> _site/blog/index.html
661
- outputPath = path.join(outputRootDir, dirPath, 'index.html');
662
- outputDir = path.join(outputRootDir, dirPath);
663
- } else {
664
- // Root index file: pages/index.yaml -> _site/index.html
665
- outputPath = path.join(outputRootDir, 'index.html');
666
- outputDir = path.join(outputRootDir);
667
- }
668
- } else {
669
- // Regular file: pages/test.yaml -> _site/test/index.html
670
- if (dirPath && dirPath !== '.') {
671
- // Nested regular file: pages/blog/post.yaml -> _site/blog/post/index.html
672
- outputPath = path.join(outputRootDir, dirPath, pageFileName, 'index.html');
673
- outputDir = path.join(outputRootDir, dirPath, pageFileName);
674
- } else {
675
- // Root level regular file: pages/test.yaml -> _site/test/index.html
676
- outputPath = path.join(outputRootDir, pageFileName, 'index.html');
677
- outputDir = path.join(outputRootDir, pageFileName);
678
- }
679
- }
807
+ const outputRelativePath = htmlOutputRelativePathFromUrl(url);
808
+ const outputPath = path.join(outputRootDir, ...outputRelativePath.split('/'));
809
+ const outputDir = path.dirname(outputPath);
680
810
 
681
811
  if (!fs.existsSync(outputDir)) {
682
812
  fs.mkdirSync(outputDir, { recursive: true });
@@ -686,8 +816,11 @@ export function createSiteBuilder({
686
816
  fs.writeFileSync(outputPath, htmlString);
687
817
  if (!quiet) console.log(` -> Written to ${outputPath}`);
688
818
 
689
- if (isMarkdown && keepMarkdownFiles && typeof markdownOutputRelativePath === 'string') {
690
- 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('/'));
691
824
  const markdownOutputDir = path.dirname(markdownOutputPath);
692
825
  if (!fs.existsSync(markdownOutputDir)) {
693
826
  fs.mkdirSync(markdownOutputDir, { recursive: true });
@@ -697,37 +830,9 @@ export function createSiteBuilder({
697
830
  }
698
831
  }
699
832
 
700
- // Process all YAML and Markdown files in pages directory recursively
701
- async function processAllPages(dir, basePath = '') {
702
- const pagesDir = path.join(rootDir, 'pages');
703
- const fullDir = path.join(pagesDir, basePath);
704
-
705
- if (!fs.existsSync(fullDir)) return;
706
-
707
- const items = fs.readdirSync(fullDir, { withFileTypes: true });
708
-
709
- for (const item of items) {
710
- const itemPath = path.join(fullDir, item.name);
711
- const relativePath = basePath ? path.join(basePath, item.name) : item.name;
712
- const itemKind = resolveDirentKind(fs, itemPath, item);
713
-
714
- if (itemKind === 'directory') {
715
- // Recursively process subdirectories
716
- await processAllPages(dir, relativePath);
717
- } else if (itemKind === 'file') {
718
- if (item.name.endsWith('.yaml') || item.name.endsWith('.yml')) {
719
- // Process YAML file
720
- const outputFileName = item.name.replace(/\.(yaml|yml)$/, '.html');
721
- const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
722
- await processPage(itemPath, outputRelativePath, false);
723
- } else if (item.name.endsWith('.md')) {
724
- // Process Markdown file
725
- const outputFileName = item.name.replace('.md', '.html');
726
- const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
727
- await processPage(itemPath, outputRelativePath, true, relativePath);
728
- }
729
- // Ignore other file types
730
- }
833
+ async function processAllPages() {
834
+ for (const pageEntry of pageEntries) {
835
+ await processPage(pageEntry);
731
836
  }
732
837
  }
733
838
 
@@ -784,7 +889,7 @@ export function createSiteBuilder({
784
889
  copyStaticFiles();
785
890
 
786
891
  // Process all pages (can overwrite static files)
787
- await processAllPages('');
892
+ await processAllPages();
788
893
 
789
894
  if (!quiet) console.log('Build complete!');
790
895
  };