@rettangoli/sites 1.0.0-rc3 → 1.0.0-rc5

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
@@ -69,6 +69,11 @@ markdownit:
69
69
  fallback: section
70
70
  build:
71
71
  keepMarkdownFiles: false
72
+ imports:
73
+ templates:
74
+ docs/documentation: https://example.com/templates/docs-documentation.yaml
75
+ partials:
76
+ docs/nav: https://example.com/partials/docs-nav.yaml
72
77
  ```
73
78
 
74
79
  In the default starter template, CDN runtime scripts are controlled via `data/site.yaml`:
@@ -87,8 +92,46 @@ Example mappings:
87
92
  - `pages/index.md` -> `_site/index.html` and `_site/index.md`
88
93
  - `pages/docs/intro.md` -> `_site/docs/intro/index.html` and `_site/docs/intro.md`
89
94
 
95
+ `imports` lets you map aliases to remote YAML files (HTTP/HTTPS only). Use aliases in pages/templates:
96
+ - page frontmatter: `template: docs/documentation`
97
+ - template/page content: `$partial: docs/nav`
98
+
99
+ Imported files are cached on disk under `.rettangoli/sites/imports/{templates|partials}/` (hashed filenames).
100
+ Alias/url/hash mapping is tracked in `.rettangoli/sites/imports/index.yaml`.
101
+ Build is cache-first: if a cached file exists, it is used without a network request.
102
+
103
+ When an alias exists both remotely and locally, local files under `templates/` and `partials/` override the imported one.
104
+
90
105
  If you want to publish a manual `llms.txt`, place it in `static/llms.txt`; it will be copied to `_site/llms.txt`.
91
106
 
107
+ ## Pre-published Import Assets
108
+
109
+ `@rettangoli/sites` publishes reusable template/partial YAML assets under `sites/` for URL imports.
110
+
111
+ - Docs template: `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/docs/documentation.yaml`
112
+ - Docs partial: `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/partials/docs/mobile-nav.yaml`
113
+ - Rettangoli.dev shells: `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/*.yaml`
114
+ - Default scaffold base: `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/default/base.yaml`
115
+
116
+ See `sites/README.md` for full alias examples and required data contract.
117
+
118
+ ## Template Authoring Pattern
119
+
120
+ Keep base templates as shells with minimal logic:
121
+
122
+ - document root (`html`, `head`, `body`)
123
+ - main content slot (`"${content}"`)
124
+ - stable layout containers
125
+
126
+ Put variant-specific behavior and data wiring in partials instead.
127
+ Partials accept explicit parameters via `$partial`, so they are the preferred place for:
128
+
129
+ - section-specific navigation data
130
+ - conditional UI branches
131
+ - reusable interactive blocks
132
+
133
+ This keeps one template reusable across many page variants and avoids duplicated template files.
134
+
92
135
  ## Commands
93
136
 
94
137
  ```bash
@@ -121,7 +164,7 @@ Available in YAML templates/pages without extra setup:
121
164
 
122
165
  `formatDate` tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`.
123
166
  `decodeURI`/`decodeURIComponent` return the original input when decoding fails.
124
- `sort` supports `order` as `asc` or `desc` and returns a new array.
167
+ `sort` supports `order` as `asc` or `desc` (default: `asc`), accepts dot-path keys (for example `data.date`), and returns a new array.
125
168
  `md` returns raw rendered HTML from Markdown for template insertion.
126
169
 
127
170
  ## Screenshots
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/sites",
3
- "version": "1.0.0-rc3",
3
+ "version": "1.0.0-rc5",
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",
@@ -14,7 +14,8 @@
14
14
  "files": [
15
15
  "src",
16
16
  "components",
17
- "templates"
17
+ "templates",
18
+ "sites"
18
19
  ],
19
20
  "dependencies": {
20
21
  "gray-matter": "^4.0.3",
@@ -0,0 +1,107 @@
1
+ # Rettangoli Sites Import Assets
2
+
3
+ This folder contains publish-only YAML templates/partials for `@rettangoli/sites` URL imports.
4
+ These files are distribution assets, not `@rettangoli/sites` runtime source code.
5
+
6
+ ## Docs Bundle
7
+
8
+ Template URL:
9
+
10
+ `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/docs/documentation.yaml`
11
+
12
+ Partial URL:
13
+
14
+ `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/partials/docs/mobile-nav.yaml`
15
+
16
+ `sites.config.yaml` example:
17
+
18
+ ```yaml
19
+ imports:
20
+ templates:
21
+ docs/documentation: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/docs/documentation.yaml
22
+ partials:
23
+ docs/mobile-nav: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/partials/docs/mobile-nav.yaml
24
+ ```
25
+
26
+ Page frontmatter example:
27
+
28
+ ```yaml
29
+ ---
30
+ template: docs/documentation
31
+ title: Getting Started
32
+ sidebarId: intro
33
+ ---
34
+ ```
35
+
36
+ Required template data:
37
+
38
+ - `title`
39
+ - `docsLayout.sidebar.header` (object for sidebar header)
40
+ - `docsLayout.sidebar.items` (sidebar items)
41
+ - `docsLayout.assets.stylesheets` (array of stylesheet URLs)
42
+ - `docsLayout.assets.scripts` (array of script URLs)
43
+
44
+ Optional data:
45
+
46
+ - `docsLayout.metaDescription`
47
+ - `docsLayout.canonicalUrl`
48
+
49
+ Create `data/docsLayout.yaml`:
50
+
51
+ ```yaml
52
+ assets:
53
+ stylesheets:
54
+ - https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc14/dist/themes/base.css
55
+ - https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc14/dist/themes/theme-rtgl-slate.css
56
+ scripts:
57
+ - https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js
58
+ - https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc14/dist/rettangoli-iife-ui.min.js
59
+
60
+ metaDescription: Documentation portal
61
+ canonicalUrl: https://example.com/docs/getting-started/
62
+
63
+ sidebar:
64
+ header:
65
+ label: Docs
66
+ href: /
67
+ items:
68
+ - title: Introduction
69
+ type: groupLabel
70
+ items:
71
+ - id: intro
72
+ title: Introduction
73
+ href: /docs/introduction/
74
+ ```
75
+
76
+ ## Rettangoli.dev Shell Templates
77
+
78
+ These templates mirror `apps/rettangoli.dev/templates/*` so the app can consume
79
+ them via URL imports instead of local template files.
80
+
81
+ Template URLs:
82
+
83
+ - `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/base.yaml`
84
+ - `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/documentation.yaml`
85
+ - `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/fe-documentation.yaml`
86
+ - `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/sites-documentation.yaml`
87
+ - `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/vt-documentation.yaml`
88
+
89
+ `sites.config.yaml` alias example:
90
+
91
+ ```yaml
92
+ imports:
93
+ templates:
94
+ base: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/base.yaml
95
+ documentation: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/documentation.yaml
96
+ fe-documentation: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/fe-documentation.yaml
97
+ sites-documentation: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/sites-documentation.yaml
98
+ vt-documentation: https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/rettangoli-dev/vt-documentation.yaml
99
+ ```
100
+
101
+ ## Default Scaffold Base Template
102
+
103
+ This template mirrors `packages/rettangoli-sites/templates/default/templates/base.yaml`.
104
+
105
+ Template URL:
106
+
107
+ - `https://cdn.jsdelivr.net/npm/@rettangoli/sites@<version>/sites/templates/default/base.yaml`
@@ -0,0 +1,7 @@
1
+ - $if docsLayout && docsLayout.sidebar && docsLayout.sidebar.header && docsLayout.sidebar.items:
2
+ - 'rtgl-view hide md-show w=f d=v bgc=bg':
3
+ - 'rtgl-view pos=fix w=f h=56 bgc=bg av=c ph=lg bwb=xs z=10 style="top:0;left:0"':
4
+ - 'a href="${docsLayout.sidebar.header.href}" style="text-decoration:none;display:contents;color:inherit;"':
5
+ - rtgl-text s=md: ${docsLayout.sidebar.header.label}
6
+ - rtgl-view h=56:
7
+ - rtgl-sidebar selected-item-id=${sidebarId} header="${encodeURIComponent(jsonStringify(docsLayout.sidebar.header))}" items="${encodeURIComponent(jsonStringify(docsLayout.sidebar.items))}" w=f:
@@ -0,0 +1,20 @@
1
+ - html lang="en":
2
+ - head:
3
+ - meta charset="utf-8":
4
+ - meta name="viewport" content="width=device-width,initial-scale=1":
5
+ - title: ${title} - ${site.name}
6
+ - link rel="stylesheet" href="/css/theme.css":
7
+ - $if site.assets.loadConstructStyleSheetsPolyfill:
8
+ - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
+ - $if site.assets.loadUiFromCdn:
10
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
11
+ - body.dark:
12
+ - rtgl-view w="f":
13
+ - rtgl-view h="64":
14
+ - rtgl-view w="f" ah="c":
15
+ - rtgl-view w="f" ph="lg" pb="lg" ah="c" md-w="100vw" lg-w="768" w="1024":
16
+ - "${content}"
17
+ - rtgl-view h="30vh":
18
+ - $partial: footer
19
+ - rtgl-view pos="abs" edge="t" ah="c" bgc="bg":
20
+ - $partial: header
@@ -0,0 +1,34 @@
1
+ - html lang="en":
2
+ - head:
3
+ - meta charset="utf-8":
4
+ - meta name="viewport" content="width=device-width,initial-scale=1":
5
+ - title: ${title}
6
+ - $if docsLayout && docsLayout.metaDescription:
7
+ - meta name="description" content="${docsLayout.metaDescription}":
8
+ - $if docsLayout && docsLayout.canonicalUrl:
9
+ - link rel="canonical" href="${docsLayout.canonicalUrl}":
10
+ - $if docsLayout && docsLayout.assets && docsLayout.assets.stylesheets:
11
+ - $for href in docsLayout.assets.stylesheets:
12
+ - link rel="stylesheet" href="${href}":
13
+ - $if docsLayout && docsLayout.assets && docsLayout.assets.scripts:
14
+ - $for src in docsLayout.assets.scripts:
15
+ - script src="${src}":
16
+
17
+ - body.dark:
18
+ - $partial: docs/mobile-nav
19
+ docsLayout: ${docsLayout}
20
+ sidebarId: ${sidebarId}
21
+
22
+ - rtgl-view bgc="bg" w="f" d=h:
23
+ - $if docsLayout && docsLayout.sidebar && docsLayout.sidebar.header && docsLayout.sidebar.items:
24
+ - 'rtgl-view md-hide pos=fix h=100vh bgc=bg style="left: 0"':
25
+ - rtgl-sidebar selected-item-id=${sidebarId} header="${encodeURIComponent(jsonStringify(docsLayout.sidebar.header))}" items="${encodeURIComponent(jsonStringify(docsLayout.sidebar.items))}":
26
+ - rtgl-view md-hide xl-show hide w=272:
27
+ - rtgl-view w="1fg" ah=c:
28
+ - rtgl-view pv="lg" lg-w="100vw" w="720" ph="lg" sv id="content-container":
29
+ - rtgl-view hide md-show h=56:
30
+ - rtgl-text s="h1" mb="lg": ${title}
31
+ - "${content}"
32
+ - rtgl-view h=200:
33
+ - 'rtgl-view xl-hide style="position: fixed; right: 0"':
34
+ - rtgl-page-outline id="page-outline" target-id="content-container" scroll-container-id="window" offset-top="80":
@@ -0,0 +1,24 @@
1
+ - html lang="en":
2
+ - head:
3
+ - $partial: seo1
4
+ page: ${page}
5
+ seo: ${seo}
6
+ title: ${title}
7
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/base.css":
8
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/theme-rtgl-slate.css":
9
+ - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
10
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
11
+ - script src="/public/rtgl-icons.js":
12
+
13
+ - body.dark:
14
+ - rtgl-view:
15
+ - rtgl-view pos="abs" edge="t" ah="c" bgc="bg":
16
+ - $partial: navbar1
17
+ title: ${site.navbar.title}
18
+ cta: ${site.navbar.cta}
19
+ - rtgl-view w=f:
20
+ - rtgl-view h="48":
21
+ - rtgl-view w="f" ah="c":
22
+ - rtgl-view w=f ph="lg" pb="lg" ah=c:
23
+ - "${content}"
24
+ - script src="/public/mobile-nav.js":
@@ -0,0 +1,33 @@
1
+ - html lang="en":
2
+ - head:
3
+ - $partial: seo1
4
+ page: ${page}
5
+ seo: ${seo}
6
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/base.css":
7
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/theme-rtgl-slate.css":
8
+ - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
10
+ - script src="/public/rtgl-icons.js":
11
+
12
+ - body.dark:
13
+ - $partial: mobile-nav1
14
+ headerLabel: ${docs.header.label}
15
+ headerHref: ${docs.header.href}
16
+ header: ${docs.header}
17
+ items: ${docs.items}
18
+ sidebarId: ${sidebarId}
19
+
20
+ - rtgl-view bgc="bg" w="f" d=h:
21
+ - 'rtgl-view md-hide pos=fix h=100vh bgc=bg style="left: 0"':
22
+ - rtgl-sidebar selected-item-id=${sidebarId} header="${encodeURIComponent(jsonStringify(docs.header))}" items="${encodeURIComponent(jsonStringify(docs.items))}":
23
+ - rtgl-view md-hide xl-show hide w=272:
24
+ - rtgl-view w="1fg" ah=c:
25
+ - rtgl-view pv="lg" lg-w="100vw" w="720" ph="lg" sv id="content-container":
26
+ - rtgl-view hide md-show h=56:
27
+ - rtgl-text s="h1" mb="lg": ${title}
28
+ - "${content}"
29
+ - rtgl-view h=200:
30
+ - 'rtgl-view xl-hide style="position: fixed; right: 0"':
31
+ - rtgl-page-outline id="page-outline" target-id="content-container" scroll-container-id="window" offset-top="80":
32
+
33
+ - script src="/public/mobile-nav.js":
@@ -0,0 +1,33 @@
1
+ - html lang="en":
2
+ - head:
3
+ - $partial: seo1
4
+ page: ${page}
5
+ seo: ${seo}
6
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/base.css":
7
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/theme-rtgl-slate.css":
8
+ - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
10
+ - script src="/public/rtgl-icons.js":
11
+
12
+ - body.dark:
13
+ - $partial: mobile-nav1
14
+ headerLabel: ${feDocs.header.label}
15
+ headerHref: ${feDocs.header.href}
16
+ header: ${feDocs.header}
17
+ items: ${feDocs.items}
18
+ sidebarId: ${sidebarId}
19
+
20
+ - rtgl-view bgc="bg" w="f" d=h:
21
+ - 'rtgl-view md-hide pos=fix h=100vh bgc=bg style="left: 0"':
22
+ - rtgl-sidebar selected-item-id=${sidebarId} header="${encodeURIComponent(jsonStringify(feDocs.header))}" items="${encodeURIComponent(jsonStringify(feDocs.items))}":
23
+ - rtgl-view md-hide xl-show hide w=272:
24
+ - rtgl-view w="1fg" ah=c:
25
+ - rtgl-view pv="lg" lg-w="100vw" w="720" ph="lg" sv id="content-container":
26
+ - rtgl-view hide md-show h=56:
27
+ - rtgl-text s="h1" mb="lg": ${title}
28
+ - "${content}"
29
+ - rtgl-view h=200:
30
+ - 'rtgl-view xl-hide style="position: fixed; right: 0"':
31
+ - rtgl-page-outline id="page-outline" target-id="content-container" scroll-container-id="window" offset-top="80":
32
+
33
+ - script src="/public/mobile-nav.js":
@@ -0,0 +1,33 @@
1
+ - html lang="en":
2
+ - head:
3
+ - $partial: seo1
4
+ page: ${page}
5
+ seo: ${seo}
6
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/base.css":
7
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/theme-rtgl-slate.css":
8
+ - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
10
+ - script src="/public/rtgl-icons.js":
11
+
12
+ - body.dark:
13
+ - $partial: mobile-nav1
14
+ headerLabel: ${sitesDocs.header.label}
15
+ headerHref: ${sitesDocs.header.href}
16
+ header: ${sitesDocs.header}
17
+ items: ${sitesDocs.items}
18
+ sidebarId: ${sidebarId}
19
+
20
+ - rtgl-view bgc="bg" w="f" d=h:
21
+ - 'rtgl-view md-hide pos=fix h=100vh bgc=bg style="left: 0"':
22
+ - rtgl-sidebar selected-item-id=${sidebarId} header="${encodeURIComponent(jsonStringify(sitesDocs.header))}" items="${encodeURIComponent(jsonStringify(sitesDocs.items))}":
23
+ - rtgl-view md-hide xl-show hide w=272:
24
+ - rtgl-view w="1fg" ah=c:
25
+ - rtgl-view pv="lg" lg-w="100vw" w="720" ph="lg" sv id="content-container":
26
+ - rtgl-view hide md-show h=56:
27
+ - rtgl-text s="h1" mb="lg": ${title}
28
+ - "${content}"
29
+ - rtgl-view h=200:
30
+ - 'rtgl-view xl-hide style="position: fixed; right: 0"':
31
+ - rtgl-page-outline id="page-outline" target-id="content-container" scroll-container-id="window" offset-top="80":
32
+
33
+ - script src="/public/mobile-nav.js":
@@ -0,0 +1,33 @@
1
+ - html lang="en":
2
+ - head:
3
+ - $partial: seo1
4
+ page: ${page}
5
+ seo: ${seo}
6
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/base.css":
7
+ - link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/themes/theme-rtgl-slate.css":
8
+ - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
10
+ - script src="/public/rtgl-icons.js":
11
+
12
+ - body.dark:
13
+ - $partial: mobile-nav1
14
+ headerLabel: ${vtDocs.header.label}
15
+ headerHref: ${vtDocs.header.href}
16
+ header: ${vtDocs.header}
17
+ items: ${vtDocs.items}
18
+ sidebarId: ${sidebarId}
19
+
20
+ - rtgl-view bgc="bg" w="f" d=h:
21
+ - 'rtgl-view md-hide pos=fix h=100vh bgc=bg style="left: 0"':
22
+ - rtgl-sidebar selected-item-id=${sidebarId} header="${encodeURIComponent(jsonStringify(vtDocs.header))}" items="${encodeURIComponent(jsonStringify(vtDocs.items))}":
23
+ - rtgl-view md-hide xl-show hide w=272:
24
+ - rtgl-view w="1fg" ah=c:
25
+ - rtgl-view pv="lg" lg-w="100vw" w="720" ph="lg" sv id="content-container":
26
+ - rtgl-view hide md-show h=56:
27
+ - rtgl-text s="h1" mb="lg": ${title}
28
+ - "${content}"
29
+ - rtgl-view h=200:
30
+ - 'rtgl-view xl-hide style="position: fixed; right: 0"':
31
+ - rtgl-page-outline id="page-outline" target-id="content-container" scroll-container-id="window" offset-top="80":
32
+
33
+ - script src="/public/mobile-nav.js":
@@ -75,15 +75,45 @@ function safeDecode(value, decoder) {
75
75
  }
76
76
  }
77
77
 
78
+ function readSortValue(item, key, keyParts) {
79
+ if (key == null) {
80
+ return item;
81
+ }
82
+
83
+ if (item == null) {
84
+ return undefined;
85
+ }
86
+
87
+ if (!keyParts) {
88
+ return item?.[key];
89
+ }
90
+
91
+ if (Object.prototype.hasOwnProperty.call(item, key)) {
92
+ return item[key];
93
+ }
94
+
95
+ let current = item;
96
+ for (const part of keyParts) {
97
+ if (!part) {
98
+ return undefined;
99
+ }
100
+ current = current?.[part];
101
+ }
102
+ return current;
103
+ }
104
+
78
105
  function sortImpl(value, key, order = 'asc') {
79
106
  if (!Array.isArray(value)) {
80
107
  return [];
81
108
  }
109
+
82
110
  const normalizedOrder = String(order ?? 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc';
83
111
  const factor = normalizedOrder === 'asc' ? 1 : -1;
112
+ const keyParts = typeof key === 'string' && key.includes('.') ? key.split('.') : null;
113
+
84
114
  return [...value].sort((left, right) => {
85
- const leftRaw = key == null ? left : left?.[key];
86
- const rightRaw = key == null ? right : right?.[key];
115
+ const leftRaw = readSortValue(left, key, keyParts);
116
+ const rightRaw = readSortValue(right, key, keyParts);
87
117
 
88
118
  if (leftRaw == null && rightRaw == null) return 0;
89
119
  if (leftRaw == null) return 1;
package/src/cli/build.js CHANGED
@@ -30,6 +30,7 @@ export const buildSite = async (options = {}) => {
30
30
  md,
31
31
  markdown: config.markdown || {},
32
32
  keepMarkdownFiles: config.build?.keepMarkdownFiles === true,
33
+ imports: config.imports || {},
33
34
  functions: functions || {},
34
35
  quiet,
35
36
  isScreenshotMode
@@ -1,6 +1,7 @@
1
1
  import { convertToHtml } from 'yahtml';
2
2
  import { parseAndRender } from 'jempl';
3
3
  import path from 'path';
4
+ import { createHash } from 'crypto';
4
5
  import yaml from 'js-yaml';
5
6
  import matter from 'gray-matter';
6
7
 
@@ -39,6 +40,204 @@ function isObject(item) {
39
40
  return item && typeof item === 'object' && !Array.isArray(item);
40
41
  }
41
42
 
43
+ function parseYamlWithContext(content, contextLabel) {
44
+ try {
45
+ return yaml.load(content, { schema: yaml.JSON_SCHEMA });
46
+ } catch (error) {
47
+ throw new Error(`${contextLabel}: Invalid YAML: ${error.message}`);
48
+ }
49
+ }
50
+
51
+ function buildImportCacheHash(url) {
52
+ return createHash('sha256').update(url).digest('hex');
53
+ }
54
+
55
+ function buildImportCachePath(rootDir, importGroup, hash) {
56
+ return path.join(rootDir, '.rettangoli', 'sites', 'imports', importGroup, `${hash}.yaml`);
57
+ }
58
+
59
+ function buildImportIndexPath(rootDir) {
60
+ return path.join(rootDir, '.rettangoli', 'sites', 'imports', 'index.yaml');
61
+ }
62
+
63
+ function toRelativePath(rootDir, absolutePath) {
64
+ const relativePath = path.relative(rootDir, absolutePath);
65
+ return relativePath.replace(/\\/g, '/');
66
+ }
67
+
68
+ function normalizeImportIndex(rawIndex, indexPath) {
69
+ if (rawIndex == null) {
70
+ return { version: 1, entries: [] };
71
+ }
72
+
73
+ if (!isObject(rawIndex)) {
74
+ throw new Error(`Invalid import index "${indexPath}": expected a YAML object.`);
75
+ }
76
+
77
+ if (rawIndex.version !== undefined && rawIndex.version !== 1) {
78
+ throw new Error(`Unsupported import index version "${rawIndex.version}" in "${indexPath}".`);
79
+ }
80
+
81
+ const rawEntries = rawIndex.entries ?? [];
82
+ if (!Array.isArray(rawEntries)) {
83
+ throw new Error(`Invalid import index "${indexPath}": expected "entries" to be an array.`);
84
+ }
85
+
86
+ const entries = rawEntries.map((entry, index) => {
87
+ if (!isObject(entry)) {
88
+ throw new Error(`Invalid import index "${indexPath}" at entries[${index}]: expected an object.`);
89
+ }
90
+
91
+ const normalizedEntry = {
92
+ alias: entry.alias,
93
+ type: entry.type,
94
+ url: entry.url,
95
+ hash: entry.hash,
96
+ path: entry.path
97
+ };
98
+
99
+ for (const [key, value] of Object.entries(normalizedEntry)) {
100
+ if (typeof value !== 'string' || value.trim() === '') {
101
+ throw new Error(`Invalid import index "${indexPath}" at entries[${index}].${key}: expected a non-empty string.`);
102
+ }
103
+ }
104
+
105
+ return normalizedEntry;
106
+ });
107
+
108
+ return { version: 1, entries };
109
+ }
110
+
111
+ function readImportIndex(fs, rootDir) {
112
+ const indexPath = buildImportIndexPath(rootDir);
113
+ if (!fs.existsSync(indexPath)) {
114
+ return { version: 1, entries: [] };
115
+ }
116
+
117
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
118
+ const parsed = parseYamlWithContext(indexContent, `import index "${indexPath}"`);
119
+ return normalizeImportIndex(parsed, indexPath);
120
+ }
121
+
122
+ function upsertImportIndexEntry(importIndex, entry) {
123
+ const existingIndex = importIndex.entries.findIndex((item) => item.type === entry.type && item.alias === entry.alias);
124
+ if (existingIndex === -1) {
125
+ importIndex.entries.push(entry);
126
+ return;
127
+ }
128
+ importIndex.entries[existingIndex] = entry;
129
+ }
130
+
131
+ function writeImportIndex(fs, rootDir, importIndex) {
132
+ const indexPath = buildImportIndexPath(rootDir);
133
+ const indexDir = path.dirname(indexPath);
134
+ if (!fs.existsSync(indexDir)) {
135
+ fs.mkdirSync(indexDir, { recursive: true });
136
+ }
137
+
138
+ const sortedEntries = [...importIndex.entries].sort((a, b) => {
139
+ const left = `${a.type}:${a.alias}`;
140
+ const right = `${b.type}:${b.alias}`;
141
+ return left.localeCompare(right);
142
+ });
143
+
144
+ const output = {
145
+ version: 1,
146
+ entries: sortedEntries
147
+ };
148
+ fs.writeFileSync(indexPath, yaml.dump(output, { noRefs: true, lineWidth: -1 }));
149
+ }
150
+
151
+ function readImportedYamlFromCache(fs, cachePath, aliasLabel) {
152
+ if (!fs.existsSync(cachePath)) {
153
+ return null;
154
+ }
155
+
156
+ const content = fs.readFileSync(cachePath, 'utf8');
157
+ return parseYamlWithContext(content, `${aliasLabel} (cache: ${cachePath})`);
158
+ }
159
+
160
+ async function fetchRemoteYaml(url, fetchImpl, aliasLabel) {
161
+ const effectiveFetch = fetchImpl || globalThis.fetch;
162
+ if (typeof effectiveFetch !== 'function') {
163
+ throw new Error(`${aliasLabel}: Remote imports require global fetch support (Node.js 18+).`);
164
+ }
165
+
166
+ const response = await effectiveFetch(url);
167
+ if (!response.ok) {
168
+ throw new Error(`${aliasLabel}: HTTP ${response.status} ${response.statusText}`.trim());
169
+ }
170
+
171
+ const content = await response.text();
172
+ const parsed = parseYamlWithContext(content, aliasLabel);
173
+ return { parsed, content };
174
+ }
175
+
176
+ function writeImportedYamlCache(fs, cachePath, content) {
177
+ const cacheDir = path.dirname(cachePath);
178
+ if (!fs.existsSync(cacheDir)) {
179
+ fs.mkdirSync(cacheDir, { recursive: true });
180
+ }
181
+
182
+ try {
183
+ fs.writeFileSync(cachePath, content);
184
+ } catch (error) {
185
+ // Non-fatal: build can still proceed with fetched content.
186
+ }
187
+ }
188
+
189
+ async function loadImportedAliases({
190
+ fs,
191
+ rootDir,
192
+ importMap,
193
+ importGroup,
194
+ typeLabel,
195
+ fetchImpl,
196
+ importIndex
197
+ }) {
198
+ const resolved = {};
199
+ if (!isObject(importMap)) {
200
+ return resolved;
201
+ }
202
+
203
+ for (const [alias, url] of Object.entries(importMap)) {
204
+ const aliasLabel = `imported ${typeLabel} "${alias}" from "${url}"`;
205
+ const hash = buildImportCacheHash(url);
206
+ const cachePath = buildImportCachePath(rootDir, importGroup, hash);
207
+ const relativeCachePath = toRelativePath(rootDir, cachePath);
208
+
209
+ const cachedValue = readImportedYamlFromCache(fs, cachePath, aliasLabel);
210
+ if (cachedValue !== null) {
211
+ resolved[alias] = cachedValue;
212
+ upsertImportIndexEntry(importIndex, {
213
+ alias,
214
+ type: typeLabel,
215
+ url,
216
+ hash,
217
+ path: relativeCachePath
218
+ });
219
+ continue;
220
+ }
221
+
222
+ try {
223
+ const fetched = await fetchRemoteYaml(url, fetchImpl, aliasLabel);
224
+ writeImportedYamlCache(fs, cachePath, fetched.content);
225
+ resolved[alias] = fetched.parsed;
226
+ upsertImportIndexEntry(importIndex, {
227
+ alias,
228
+ type: typeLabel,
229
+ url,
230
+ hash,
231
+ path: relativeCachePath
232
+ });
233
+ } catch (error) {
234
+ throw new Error(`Failed to load ${aliasLabel}: ${error.message}`);
235
+ }
236
+ }
237
+
238
+ return resolved;
239
+ }
240
+
42
241
  export function createSiteBuilder({
43
242
  fs,
44
243
  rootDir = '.',
@@ -46,6 +245,8 @@ export function createSiteBuilder({
46
245
  md,
47
246
  markdown = {},
48
247
  keepMarkdownFiles = false,
248
+ imports = {},
249
+ fetchImpl,
49
250
  functions = {},
50
251
  quiet = false,
51
252
  isScreenshotMode = false
@@ -79,24 +280,62 @@ export function createSiteBuilder({
79
280
  fs.mkdirSync(outputRootDir, { recursive: true });
80
281
  }
81
282
 
283
+ const importIndex = readImportIndex(fs, rootDir);
284
+
285
+ const importedTemplates = await loadImportedAliases({
286
+ fs,
287
+ rootDir,
288
+ importMap: imports.templates,
289
+ importGroup: 'templates',
290
+ typeLabel: 'template',
291
+ fetchImpl,
292
+ importIndex
293
+ });
294
+ const importedPartials = await loadImportedAliases({
295
+ fs,
296
+ rootDir,
297
+ importMap: imports.partials,
298
+ importGroup: 'partials',
299
+ typeLabel: 'partial',
300
+ fetchImpl,
301
+ importIndex
302
+ });
303
+
304
+ if (importIndex.entries.length > 0) {
305
+ writeImportIndex(fs, rootDir, importIndex);
306
+ }
307
+
82
308
  // Read all partials and create a JSON object
83
309
  const partialsDir = path.join(rootDir, 'partials');
84
- const partials = {};
310
+ const partials = { ...importedPartials };
85
311
 
86
- if (fs.existsSync(partialsDir)) {
87
- const files = fs.readdirSync(partialsDir, { withFileTypes: true });
88
- files.forEach(file => {
89
- if (!file.isFile() || (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml'))) {
312
+ function readPartialsRecursively(dir, basePath = '') {
313
+ if (!fs.existsSync(dir)) return;
314
+
315
+ const items = fs.readdirSync(dir, { withFileTypes: true });
316
+ items.forEach(item => {
317
+ const itemPath = path.join(dir, item.name);
318
+ if (item.isDirectory()) {
319
+ const newBasePath = basePath ? `${basePath}/${item.name}` : item.name;
320
+ readPartialsRecursively(itemPath, newBasePath);
90
321
  return;
91
322
  }
92
- const filePath = path.join(partialsDir, file.name);
93
- const fileContent = fs.readFileSync(filePath, 'utf8');
94
- const nameWithoutExt = path.basename(file.name, path.extname(file.name));
95
- // Convert partial content from YAML string to JSON
96
- partials[nameWithoutExt] = yaml.load(fileContent, { schema: yaml.JSON_SCHEMA });
323
+
324
+ if (!item.isFile() || (!item.name.endsWith('.yaml') && !item.name.endsWith('.yml'))) {
325
+ return;
326
+ }
327
+
328
+ const fileContent = fs.readFileSync(itemPath, 'utf8');
329
+ const nameWithoutExt = path.basename(item.name, path.extname(item.name));
330
+ const partialKey = basePath ? `${basePath}/${nameWithoutExt}` : nameWithoutExt;
331
+ partials[partialKey] = yaml.load(fileContent, { schema: yaml.JSON_SCHEMA });
97
332
  });
98
333
  }
99
334
 
335
+ if (fs.existsSync(partialsDir)) {
336
+ readPartialsRecursively(partialsDir);
337
+ }
338
+
100
339
  // Read all data files and create a JSON object
101
340
  const dataDir = path.join(rootDir, 'data');
102
341
  const globalData = {};
@@ -116,7 +355,7 @@ export function createSiteBuilder({
116
355
 
117
356
  // Read all templates and create a JSON object
118
357
  const templatesDir = path.join(rootDir, 'templates');
119
- const templates = {};
358
+ const templates = { ...importedTemplates };
120
359
 
121
360
  function readTemplatesRecursively(dir, basePath = '') {
122
361
  if (!fs.existsSync(dir)) return;
@@ -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']);
5
+ const ALLOWED_TOP_LEVEL_KEYS = new Set(['markdown', 'markdownit', 'build', 'imports']);
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']);
@@ -27,6 +27,8 @@ const ALLOWED_HEADING_ANCHORS_KEYS = new Set([...HEADING_ANCHORS_BOOLEAN_KEYS, .
27
27
  const ALLOWED_HEADING_ANCHOR_SLUG_MODES = new Set(['ascii', 'unicode']);
28
28
  const BUILD_BOOLEAN_KEYS = new Set(['keepMarkdownFiles']);
29
29
  const ALLOWED_BUILD_KEYS = new Set([...BUILD_BOOLEAN_KEYS]);
30
+ const ALLOWED_IMPORT_GROUP_KEYS = new Set(['templates', 'partials']);
31
+ const ALLOWED_IMPORT_PROTOCOLS = new Set(['http:', 'https:']);
30
32
  let didWarnLegacyMarkdownKey = false;
31
33
 
32
34
  function isPlainObject(value) {
@@ -119,6 +121,71 @@ function validateBuildConfig(value, configPath) {
119
121
  return { ...value };
120
122
  }
121
123
 
124
+ function validateImportUrl(url, configPath, groupKey, alias) {
125
+ if (typeof url !== 'string' || url.trim() === '') {
126
+ throw new Error(`Invalid imports.${groupKey} value for alias "${alias}" in "${configPath}": expected a non-empty URL string.`);
127
+ }
128
+
129
+ let parsed;
130
+ try {
131
+ parsed = new URL(url);
132
+ } catch {
133
+ throw new Error(`Invalid imports.${groupKey} URL for alias "${alias}" in "${configPath}": "${url}" is not a valid URL.`);
134
+ }
135
+
136
+ if (!ALLOWED_IMPORT_PROTOCOLS.has(parsed.protocol)) {
137
+ throw new Error(
138
+ `Invalid imports.${groupKey} URL for alias "${alias}" in "${configPath}": protocol "${parsed.protocol}" is not supported. Allowed protocols: http:, https:.`
139
+ );
140
+ }
141
+
142
+ return parsed.toString();
143
+ }
144
+
145
+ function validateImportGroup(value, configPath, groupKey) {
146
+ if (!isPlainObject(value)) {
147
+ throw new Error(`Invalid imports.${groupKey} in "${configPath}": expected an object of alias-to-URL mappings.`);
148
+ }
149
+
150
+ const normalized = {};
151
+
152
+ for (const [rawAlias, rawUrl] of Object.entries(value)) {
153
+ const alias = String(rawAlias).trim();
154
+ if (alias === '') {
155
+ throw new Error(`Invalid imports.${groupKey} in "${configPath}": alias keys must be non-empty.`);
156
+ }
157
+ normalized[alias] = validateImportUrl(rawUrl, configPath, groupKey, alias);
158
+ }
159
+
160
+ return normalized;
161
+ }
162
+
163
+ function validateImportsConfig(value, configPath) {
164
+ if (!isPlainObject(value)) {
165
+ throw new Error(`Invalid imports config in "${configPath}": expected an object.`);
166
+ }
167
+
168
+ const normalized = {};
169
+
170
+ for (const key of Object.keys(value)) {
171
+ if (!ALLOWED_IMPORT_GROUP_KEYS.has(key)) {
172
+ throw new Error(
173
+ `Unsupported imports group "${key}" in "${configPath}". Supported groups: ${Array.from(ALLOWED_IMPORT_GROUP_KEYS).join(', ')}.`
174
+ );
175
+ }
176
+ }
177
+
178
+ if (value.templates !== undefined) {
179
+ normalized.templates = validateImportGroup(value.templates, configPath, 'templates');
180
+ }
181
+
182
+ if (value.partials !== undefined) {
183
+ normalized.partials = validateImportGroup(value.partials, configPath, 'partials');
184
+ }
185
+
186
+ return normalized;
187
+ }
188
+
122
189
  function validateConfig(rawConfig, configPath) {
123
190
  if (rawConfig == null) {
124
191
  return {};
@@ -133,7 +200,7 @@ function validateConfig(rawConfig, configPath) {
133
200
  for (const key of Object.keys(config)) {
134
201
  if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) {
135
202
  throw new Error(
136
- `Unsupported key "${key}" in "${configPath}". Supported keys: markdownit (recommended), markdown (legacy alias), build.`
203
+ `Unsupported key "${key}" in "${configPath}". Supported keys: markdownit (recommended), markdown (legacy alias), build, imports.`
137
204
  );
138
205
  }
139
206
  }
@@ -221,6 +288,10 @@ function validateConfig(rawConfig, configPath) {
221
288
  normalizedConfig.build = validateBuildConfig(config.build, configPath);
222
289
  }
223
290
 
291
+ if (config.imports !== undefined) {
292
+ normalizedConfig.imports = validateImportsConfig(config.imports, configPath);
293
+ }
294
+
224
295
  return normalizedConfig;
225
296
  }
226
297
 
@@ -233,7 +233,7 @@ Use these directly in `${...}` expressions:
233
233
 
234
234
  Date format tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`.
235
235
  `decodeURI`/`decodeURIComponent` return the original input when decoding fails.
236
- `sort` supports `order` as `asc` or `desc` and returns a new array.
236
+ `sort` supports `order` as `asc` or `desc` (default: `asc`), accepts dot-path keys (for example `data.date`), and returns a new array.
237
237
  `md` returns raw rendered HTML from Markdown for template insertion.
238
238
 
239
239
  ## Static Files
@@ -7,7 +7,7 @@
7
7
  - $if site.assets.loadConstructStyleSheetsPolyfill:
8
8
  - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
9
  - $if site.assets.loadUiFromCdn:
10
- - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@0.1.32/dist/rettangoli-iife-ui.min.js":
10
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
11
11
  - body.dark:
12
12
  - rtgl-view w="f":
13
13
  - rtgl-view h="64":
@@ -7,7 +7,7 @@
7
7
  - $if site.assets.loadConstructStyleSheetsPolyfill:
8
8
  - script src="https://cdn.jsdelivr.net/npm/construct-style-sheets-polyfill@3.1.0/dist/adoptedStyleSheets.min.js":
9
9
  - $if site.assets.loadUiFromCdn:
10
- - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@0.1.32/dist/rettangoli-iife-ui.min.js":
10
+ - script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc13/dist/rettangoli-iife-ui.min.js":
11
11
  - body.dark:
12
12
  - rtgl-view h="64":
13
13
  - rtgl-view w="f" ah="c":