@rettangoli/sites 1.0.0-rc4 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -1
- package/package.json +6 -1
- package/src/builtinTemplateFunctions.js +34 -3
- package/src/cli/build.js +1 -0
- package/src/cli/watch.js +10 -1
- package/src/createSiteBuilder.js +346 -24
- package/src/utils/loadSiteConfig.js +73 -2
- package/templates/default/README.md +22 -1
- package/templates/default/static/css/theme.css +226 -9
- package/templates/default/templates/base.yaml +2 -2
- package/templates/default/templates/post.yaml +2 -2
package/README.md
CHANGED
|
@@ -69,6 +69,12 @@ markdownit:
|
|
|
69
69
|
fallback: section
|
|
70
70
|
build:
|
|
71
71
|
keepMarkdownFiles: false
|
|
72
|
+
imports:
|
|
73
|
+
templates:
|
|
74
|
+
base: https://example.com/templates/base.yaml
|
|
75
|
+
docs: https://example.com/templates/docs.yaml
|
|
76
|
+
partials:
|
|
77
|
+
docs/nav: https://example.com/partials/docs-nav.yaml
|
|
72
78
|
```
|
|
73
79
|
|
|
74
80
|
In the default starter template, CDN runtime scripts are controlled via `data/site.yaml`:
|
|
@@ -87,8 +93,73 @@ Example mappings:
|
|
|
87
93
|
- `pages/index.md` -> `_site/index.html` and `_site/index.md`
|
|
88
94
|
- `pages/docs/intro.md` -> `_site/docs/intro/index.html` and `_site/docs/intro.md`
|
|
89
95
|
|
|
96
|
+
`imports` lets you map aliases to remote YAML files (HTTP/HTTPS only). Use aliases in pages/templates:
|
|
97
|
+
- page frontmatter: `template: base` or `template: docs`
|
|
98
|
+
- template/page content: `$partial: docs/nav`
|
|
99
|
+
|
|
100
|
+
Imported files are cached on disk under `.rettangoli/sites/imports/{templates|partials}/` (hashed filenames).
|
|
101
|
+
Alias/url/hash mapping is tracked in `.rettangoli/sites/imports/index.yaml`.
|
|
102
|
+
Build is cache-first: if a cached file exists, it is used without a network request.
|
|
103
|
+
|
|
104
|
+
When an alias exists both remotely and locally, local files under `templates/` and `partials/` override the imported one.
|
|
105
|
+
|
|
90
106
|
If you want to publish a manual `llms.txt`, place it in `static/llms.txt`; it will be copied to `_site/llms.txt`.
|
|
91
107
|
|
|
108
|
+
## System Frontmatter
|
|
109
|
+
|
|
110
|
+
Use `_bind` to map global data keys into page-local variables.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
---
|
|
116
|
+
template: base
|
|
117
|
+
_bind:
|
|
118
|
+
docs: feDocs
|
|
119
|
+
---
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This resolves `docs` from `data/feDocs.yaml` for that page.
|
|
123
|
+
`_bind` is a system property and is not exposed to templates directly.
|
|
124
|
+
|
|
125
|
+
Rules:
|
|
126
|
+
|
|
127
|
+
- `_bind` must be an object
|
|
128
|
+
- each `_bind` value must be a non-empty string
|
|
129
|
+
- each `_bind` value must point to an existing `data/*.yaml` key
|
|
130
|
+
- `_bind` is removed from public frontmatter before rendering/collections
|
|
131
|
+
|
|
132
|
+
Binding order:
|
|
133
|
+
|
|
134
|
+
1. build page context from `deepMerge(globalData, frontmatterWithoutSystemKeys)`
|
|
135
|
+
2. apply `_bind` aliases on top (alias wins for that key)
|
|
136
|
+
|
|
137
|
+
## Reusable Asset Package
|
|
138
|
+
|
|
139
|
+
`@rettangoli/sites` is the engine only.
|
|
140
|
+
|
|
141
|
+
Reusable themes, templates, partials, helper assets, schemas, and VT coverage now live in `packages/rettangoli-sitekit/` and publish from `@rettangoli/sitekit`.
|
|
142
|
+
|
|
143
|
+
Use `@rettangoli/sitekit` when you want curated importable site assets.
|
|
144
|
+
Keep `@rettangoli/sites` for build/watch/init behavior.
|
|
145
|
+
|
|
146
|
+
## Template Authoring Pattern
|
|
147
|
+
|
|
148
|
+
Keep base templates as shells with minimal logic:
|
|
149
|
+
|
|
150
|
+
- document root (`html`, `head`, `body`)
|
|
151
|
+
- main content slot (`"${content}"`)
|
|
152
|
+
- stable layout containers
|
|
153
|
+
|
|
154
|
+
Put variant-specific behavior and data wiring in partials instead.
|
|
155
|
+
Partials accept explicit parameters via `$partial`, so they are the preferred place for:
|
|
156
|
+
|
|
157
|
+
- section-specific navigation data
|
|
158
|
+
- conditional UI branches
|
|
159
|
+
- reusable interactive blocks
|
|
160
|
+
|
|
161
|
+
This keeps one template reusable across many page variants and avoids duplicated template files.
|
|
162
|
+
|
|
92
163
|
## Commands
|
|
93
164
|
|
|
94
165
|
```bash
|
|
@@ -116,12 +187,14 @@ Available in YAML templates/pages without extra setup:
|
|
|
116
187
|
- `formatDate(value, format = "YYYYMMDDHHmmss", useUtc = true)`
|
|
117
188
|
- `now(format = "YYYYMMDDHHmmss", useUtc = true)`
|
|
118
189
|
- `sort(list, key, order = "asc")`
|
|
190
|
+
- `chunk(list, size = 1, pad = false, fillValue = null)`
|
|
119
191
|
- `md(content)`
|
|
120
192
|
- `toQueryString(object)`
|
|
121
193
|
|
|
122
|
-
`formatDate` tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`.
|
|
194
|
+
`formatDate` tokens: `YYYY`, `MMM`, `MM`, `DD`, `D`, `HH`, `mm`, `ss`.
|
|
123
195
|
`decodeURI`/`decodeURIComponent` return the original input when decoding fails.
|
|
124
196
|
`sort` supports `order` as `asc` or `desc` (default: `asc`), accepts dot-path keys (for example `data.date`), and returns a new array.
|
|
197
|
+
`chunk` splits arrays into rows of `size`; with `pad = true`, the last row is padded with `fillValue`.
|
|
125
198
|
`md` returns raw rendered HTML from Markdown for template insertion.
|
|
126
199
|
|
|
127
200
|
## Screenshots
|
|
@@ -134,6 +207,16 @@ Use VT against your generated site:
|
|
|
134
207
|
2. Add `vt` config in `rettangoli.config.yaml`.
|
|
135
208
|
3. Run `rtgl vt generate`, `rtgl vt report`, and `rtgl vt accept`.
|
|
136
209
|
|
|
210
|
+
Docker runtime (recommended for stable Playwright/browser versions):
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
IMAGE="han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.5"
|
|
214
|
+
docker pull "$IMAGE"
|
|
215
|
+
docker run --rm -v "$PWD:/workspace" -w /workspace "$IMAGE" rtgl vt screenshot
|
|
216
|
+
docker run --rm -v "$PWD:/workspace" -w /workspace "$IMAGE" rtgl vt report
|
|
217
|
+
docker run --rm -v "$PWD:/workspace" -w /workspace "$IMAGE" rtgl vt accept
|
|
218
|
+
```
|
|
219
|
+
|
|
137
220
|
Example:
|
|
138
221
|
|
|
139
222
|
```yaml
|
|
@@ -157,6 +240,8 @@ url: /
|
|
|
157
240
|
|
|
158
241
|
`bun run preview` (or any equivalent local server command) must serve your built site on `vt.url` (for example serving `_site/` on port `4173`).
|
|
159
242
|
|
|
243
|
+
For a maintained example asset pack and VT lab, see `packages/rettangoli-sitekit/`.
|
|
244
|
+
|
|
160
245
|
## Full Architecture And Analysis
|
|
161
246
|
|
|
162
247
|
See `docs/architecture-and-analysis.md` for:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/sites",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
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",
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"admin",
|
|
49
49
|
"dashboard"
|
|
50
50
|
],
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "https://github.com/yuusoft-org/rettangoli",
|
|
54
|
+
"directory": "packages/rettangoli-sites"
|
|
55
|
+
},
|
|
51
56
|
"bugs": {
|
|
52
57
|
"url": "https://github.com/yuusoft-org/rettangoli/issues"
|
|
53
58
|
},
|
|
@@ -25,16 +25,20 @@ function formatDateImpl(value, format = 'YYYYMMDDHHmmss', useUtc = true) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const read = (localGetter, utcGetter) => (useUtc ? utcGetter.call(date) : localGetter.call(date));
|
|
28
|
+
const monthIndex = read(date.getMonth, date.getUTCMonth);
|
|
29
|
+
const day = read(date.getDate, date.getUTCDate);
|
|
28
30
|
const tokens = {
|
|
29
31
|
YYYY: String(read(date.getFullYear, date.getUTCFullYear)),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
MMM: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][monthIndex],
|
|
33
|
+
MM: pad2(monthIndex + 1),
|
|
34
|
+
DD: pad2(day),
|
|
35
|
+
D: String(day),
|
|
32
36
|
HH: pad2(read(date.getHours, date.getUTCHours)),
|
|
33
37
|
mm: pad2(read(date.getMinutes, date.getUTCMinutes)),
|
|
34
38
|
ss: pad2(read(date.getSeconds, date.getUTCSeconds)),
|
|
35
39
|
};
|
|
36
40
|
|
|
37
|
-
return String(format).replace(/YYYY|MM|DD|HH|mm|ss/g, (token) => tokens[token]);
|
|
41
|
+
return String(format).replace(/YYYY|MMM|MM|DD|D|HH|mm|ss/g, (token) => tokens[token]);
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
function jsonStringify(value, space = 0) {
|
|
@@ -141,6 +145,32 @@ function sortImpl(value, key, order = 'asc') {
|
|
|
141
145
|
});
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
function chunkImpl(value, size = 1, pad = false, fillValue = null) {
|
|
149
|
+
if (!Array.isArray(value)) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsedSize = Number(size);
|
|
154
|
+
if (!Number.isFinite(parsedSize) || parsedSize <= 0) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const chunkSize = Math.max(1, Math.trunc(parsedSize));
|
|
159
|
+
const rows = [];
|
|
160
|
+
for (let index = 0; index < value.length; index += chunkSize) {
|
|
161
|
+
rows.push(value.slice(index, index + chunkSize));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (pad && rows.length > 0) {
|
|
165
|
+
const lastRow = rows[rows.length - 1];
|
|
166
|
+
while (lastRow.length < chunkSize) {
|
|
167
|
+
lastRow.push(fillValue);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
|
|
144
174
|
function mdImpl(content) {
|
|
145
175
|
return {
|
|
146
176
|
__html: markdownRenderer.render(String(content ?? '')),
|
|
@@ -156,6 +186,7 @@ export const builtinTemplateFunctions = {
|
|
|
156
186
|
formatDate: formatDateImpl,
|
|
157
187
|
now: (format = 'YYYYMMDDHHmmss', useUtc = true) => formatDateImpl(new Date(), format, useUtc),
|
|
158
188
|
sort: sortImpl,
|
|
189
|
+
chunk: chunkImpl,
|
|
159
190
|
md: mdImpl,
|
|
160
191
|
toQueryString,
|
|
161
192
|
};
|
package/src/cli/build.js
CHANGED
package/src/cli/watch.js
CHANGED
|
@@ -7,6 +7,14 @@ import { loadSiteConfig } from '../utils/loadSiteConfig.js';
|
|
|
7
7
|
|
|
8
8
|
const RELOAD_MODES = new Set(['body', 'full']);
|
|
9
9
|
|
|
10
|
+
function normalizePort(port) {
|
|
11
|
+
const normalizedPort = Number(port);
|
|
12
|
+
if (!Number.isInteger(normalizedPort) || normalizedPort < 1 || normalizedPort > 65535) {
|
|
13
|
+
throw new Error(`Invalid port "${port}". Allowed values: integers from 1 to 65535.`);
|
|
14
|
+
}
|
|
15
|
+
return normalizedPort;
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export function createClientScript(reloadMode = 'body') {
|
|
11
19
|
const shouldUseBodyReplacement = reloadMode === 'body';
|
|
12
20
|
const reloadSnippet = shouldUseBodyReplacement
|
|
@@ -332,6 +340,7 @@ const watchSite = async (options = {}) => {
|
|
|
332
340
|
quiet = false,
|
|
333
341
|
reloadMode = 'body'
|
|
334
342
|
} = options;
|
|
343
|
+
const normalizedPort = normalizePort(port);
|
|
335
344
|
const normalizedReloadMode = String(reloadMode).toLowerCase();
|
|
336
345
|
if (!RELOAD_MODES.has(normalizedReloadMode)) {
|
|
337
346
|
throw new Error(`Invalid reload mode "${reloadMode}". Allowed values: body, full.`);
|
|
@@ -347,7 +356,7 @@ const watchSite = async (options = {}) => {
|
|
|
347
356
|
logger.log('Initial build complete');
|
|
348
357
|
|
|
349
358
|
// Start custom dev server
|
|
350
|
-
const server = new DevServer(
|
|
359
|
+
const server = new DevServer(normalizedPort, path.resolve(rootDir, outputPath), logger, normalizedReloadMode);
|
|
351
360
|
server.start();
|
|
352
361
|
|
|
353
362
|
// Watch all relevant directories
|
package/src/createSiteBuilder.js
CHANGED
|
@@ -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,272 @@ function isObject(item) {
|
|
|
39
40
|
return item && typeof item === 'object' && !Array.isArray(item);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function splitSystemFrontmatter(frontmatter, globalData, pagePath) {
|
|
44
|
+
const normalized = isObject(frontmatter) ? { ...frontmatter } : {};
|
|
45
|
+
const bindConfig = normalized._bind;
|
|
46
|
+
delete normalized._bind;
|
|
47
|
+
|
|
48
|
+
if (bindConfig === undefined) {
|
|
49
|
+
return { frontmatter: normalized, bindings: {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!isObject(bindConfig)) {
|
|
53
|
+
throw new Error(`Invalid _bind in ${pagePath}: expected an object mapping local names to global data keys.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const bindings = {};
|
|
57
|
+
for (const [rawLocalKey, rawSourceKey] of Object.entries(bindConfig)) {
|
|
58
|
+
const localKey = String(rawLocalKey).trim();
|
|
59
|
+
if (localKey === '') {
|
|
60
|
+
throw new Error(`Invalid _bind in ${pagePath}: local key names must be non-empty.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof rawSourceKey !== 'string' || rawSourceKey.trim() === '') {
|
|
64
|
+
throw new Error(`Invalid _bind in ${pagePath} for "${localKey}": expected a non-empty global data key string.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sourceKey = rawSourceKey.trim();
|
|
68
|
+
if (!Object.prototype.hasOwnProperty.call(globalData, sourceKey)) {
|
|
69
|
+
throw new Error(`Invalid _bind in ${pagePath} for "${localKey}": global data key "${sourceKey}" not found.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
bindings[localKey] = globalData[sourceKey];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { frontmatter: normalized, bindings };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseYamlWithContext(content, contextLabel) {
|
|
79
|
+
try {
|
|
80
|
+
return yaml.load(content, { schema: yaml.JSON_SCHEMA });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(`${contextLabel}: Invalid YAML: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildImportCacheHash(url) {
|
|
87
|
+
return createHash('sha256').update(url).digest('hex');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildImportCachePath(rootDir, importGroup, hash) {
|
|
91
|
+
return path.join(rootDir, '.rettangoli', 'sites', 'imports', importGroup, `${hash}.yaml`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildImportIndexPath(rootDir) {
|
|
95
|
+
return path.join(rootDir, '.rettangoli', 'sites', 'imports', 'index.yaml');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toRelativePath(rootDir, absolutePath) {
|
|
99
|
+
const relativePath = path.relative(rootDir, absolutePath);
|
|
100
|
+
return relativePath.replace(/\\/g, '/');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeImportIndex(rawIndex, indexPath) {
|
|
104
|
+
if (rawIndex == null) {
|
|
105
|
+
return { version: 1, entries: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!isObject(rawIndex)) {
|
|
109
|
+
throw new Error(`Invalid import index "${indexPath}": expected a YAML object.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (rawIndex.version !== undefined && rawIndex.version !== 1) {
|
|
113
|
+
throw new Error(`Unsupported import index version "${rawIndex.version}" in "${indexPath}".`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rawEntries = rawIndex.entries ?? [];
|
|
117
|
+
if (!Array.isArray(rawEntries)) {
|
|
118
|
+
throw new Error(`Invalid import index "${indexPath}": expected "entries" to be an array.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entries = rawEntries.map((entry, index) => {
|
|
122
|
+
if (!isObject(entry)) {
|
|
123
|
+
throw new Error(`Invalid import index "${indexPath}" at entries[${index}]: expected an object.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const normalizedEntry = {
|
|
127
|
+
alias: entry.alias,
|
|
128
|
+
type: entry.type,
|
|
129
|
+
url: entry.url,
|
|
130
|
+
hash: entry.hash,
|
|
131
|
+
path: entry.path
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (const [key, value] of Object.entries(normalizedEntry)) {
|
|
135
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
136
|
+
throw new Error(`Invalid import index "${indexPath}" at entries[${index}].${key}: expected a non-empty string.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return normalizedEntry;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { version: 1, entries };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readImportIndex(fs, rootDir) {
|
|
147
|
+
const indexPath = buildImportIndexPath(rootDir);
|
|
148
|
+
if (!fs.existsSync(indexPath)) {
|
|
149
|
+
return { version: 1, entries: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
153
|
+
const parsed = parseYamlWithContext(indexContent, `import index "${indexPath}"`);
|
|
154
|
+
return normalizeImportIndex(parsed, indexPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function upsertImportIndexEntry(importIndex, entry) {
|
|
158
|
+
const existingIndex = importIndex.entries.findIndex((item) => item.type === entry.type && item.alias === entry.alias);
|
|
159
|
+
if (existingIndex === -1) {
|
|
160
|
+
importIndex.entries.push(entry);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
importIndex.entries[existingIndex] = entry;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function writeImportIndex(fs, rootDir, importIndex) {
|
|
167
|
+
const indexPath = buildImportIndexPath(rootDir);
|
|
168
|
+
const indexDir = path.dirname(indexPath);
|
|
169
|
+
if (!fs.existsSync(indexDir)) {
|
|
170
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sortedEntries = [...importIndex.entries].sort((a, b) => {
|
|
174
|
+
const left = `${a.type}:${a.alias}`;
|
|
175
|
+
const right = `${b.type}:${b.alias}`;
|
|
176
|
+
return left.localeCompare(right);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const output = {
|
|
180
|
+
version: 1,
|
|
181
|
+
entries: sortedEntries
|
|
182
|
+
};
|
|
183
|
+
fs.writeFileSync(indexPath, yaml.dump(output, { noRefs: true, lineWidth: -1 }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readImportedYamlFromCache(fs, cachePath, aliasLabel) {
|
|
187
|
+
if (!fs.existsSync(cachePath)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const content = fs.readFileSync(cachePath, 'utf8');
|
|
192
|
+
return parseYamlWithContext(content, `${aliasLabel} (cache: ${cachePath})`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveDirentKind(fs, itemPath, item) {
|
|
196
|
+
if (item.isDirectory()) {
|
|
197
|
+
return 'directory';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (item.isFile()) {
|
|
201
|
+
return 'file';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof item.isSymbolicLink === 'function' && item.isSymbolicLink()) {
|
|
205
|
+
let stats;
|
|
206
|
+
try {
|
|
207
|
+
stats = fs.statSync(itemPath);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(`Broken symbolic link at "${itemPath}": ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (stats.isDirectory()) {
|
|
213
|
+
return 'directory';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (stats.isFile()) {
|
|
217
|
+
return 'file';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isSchemaSidecarFile(fileName) {
|
|
225
|
+
return fileName.endsWith('.schema.yaml') || fileName.endsWith('.schema.yml');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function fetchRemoteYaml(url, fetchImpl, aliasLabel) {
|
|
229
|
+
const effectiveFetch = fetchImpl || globalThis.fetch;
|
|
230
|
+
if (typeof effectiveFetch !== 'function') {
|
|
231
|
+
throw new Error(`${aliasLabel}: Remote imports require global fetch support (Node.js 18+).`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const response = await effectiveFetch(url);
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
throw new Error(`${aliasLabel}: HTTP ${response.status} ${response.statusText}`.trim());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const content = await response.text();
|
|
240
|
+
const parsed = parseYamlWithContext(content, aliasLabel);
|
|
241
|
+
return { parsed, content };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function writeImportedYamlCache(fs, cachePath, content) {
|
|
245
|
+
const cacheDir = path.dirname(cachePath);
|
|
246
|
+
if (!fs.existsSync(cacheDir)) {
|
|
247
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
fs.writeFileSync(cachePath, content);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// Non-fatal: build can still proceed with fetched content.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function loadImportedAliases({
|
|
258
|
+
fs,
|
|
259
|
+
rootDir,
|
|
260
|
+
importMap,
|
|
261
|
+
importGroup,
|
|
262
|
+
typeLabel,
|
|
263
|
+
fetchImpl,
|
|
264
|
+
importIndex
|
|
265
|
+
}) {
|
|
266
|
+
const resolved = {};
|
|
267
|
+
if (!isObject(importMap)) {
|
|
268
|
+
return resolved;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const [alias, url] of Object.entries(importMap)) {
|
|
272
|
+
const aliasLabel = `imported ${typeLabel} "${alias}" from "${url}"`;
|
|
273
|
+
const hash = buildImportCacheHash(url);
|
|
274
|
+
const cachePath = buildImportCachePath(rootDir, importGroup, hash);
|
|
275
|
+
const relativeCachePath = toRelativePath(rootDir, cachePath);
|
|
276
|
+
|
|
277
|
+
const cachedValue = readImportedYamlFromCache(fs, cachePath, aliasLabel);
|
|
278
|
+
if (cachedValue !== null) {
|
|
279
|
+
resolved[alias] = cachedValue;
|
|
280
|
+
upsertImportIndexEntry(importIndex, {
|
|
281
|
+
alias,
|
|
282
|
+
type: typeLabel,
|
|
283
|
+
url,
|
|
284
|
+
hash,
|
|
285
|
+
path: relativeCachePath
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const fetched = await fetchRemoteYaml(url, fetchImpl, aliasLabel);
|
|
292
|
+
writeImportedYamlCache(fs, cachePath, fetched.content);
|
|
293
|
+
resolved[alias] = fetched.parsed;
|
|
294
|
+
upsertImportIndexEntry(importIndex, {
|
|
295
|
+
alias,
|
|
296
|
+
type: typeLabel,
|
|
297
|
+
url,
|
|
298
|
+
hash,
|
|
299
|
+
path: relativeCachePath
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
throw new Error(`Failed to load ${aliasLabel}: ${error.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return resolved;
|
|
307
|
+
}
|
|
308
|
+
|
|
42
309
|
export function createSiteBuilder({
|
|
43
310
|
fs,
|
|
44
311
|
rootDir = '.',
|
|
@@ -46,6 +313,8 @@ export function createSiteBuilder({
|
|
|
46
313
|
md,
|
|
47
314
|
markdown = {},
|
|
48
315
|
keepMarkdownFiles = false,
|
|
316
|
+
imports = {},
|
|
317
|
+
fetchImpl,
|
|
49
318
|
functions = {},
|
|
50
319
|
quiet = false,
|
|
51
320
|
isScreenshotMode = false
|
|
@@ -79,24 +348,67 @@ export function createSiteBuilder({
|
|
|
79
348
|
fs.mkdirSync(outputRootDir, { recursive: true });
|
|
80
349
|
}
|
|
81
350
|
|
|
351
|
+
const importIndex = readImportIndex(fs, rootDir);
|
|
352
|
+
|
|
353
|
+
const importedTemplates = await loadImportedAliases({
|
|
354
|
+
fs,
|
|
355
|
+
rootDir,
|
|
356
|
+
importMap: imports.templates,
|
|
357
|
+
importGroup: 'templates',
|
|
358
|
+
typeLabel: 'template',
|
|
359
|
+
fetchImpl,
|
|
360
|
+
importIndex
|
|
361
|
+
});
|
|
362
|
+
const importedPartials = await loadImportedAliases({
|
|
363
|
+
fs,
|
|
364
|
+
rootDir,
|
|
365
|
+
importMap: imports.partials,
|
|
366
|
+
importGroup: 'partials',
|
|
367
|
+
typeLabel: 'partial',
|
|
368
|
+
fetchImpl,
|
|
369
|
+
importIndex
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (importIndex.entries.length > 0) {
|
|
373
|
+
writeImportIndex(fs, rootDir, importIndex);
|
|
374
|
+
}
|
|
375
|
+
|
|
82
376
|
// Read all partials and create a JSON object
|
|
83
377
|
const partialsDir = path.join(rootDir, 'partials');
|
|
84
|
-
const partials = {};
|
|
378
|
+
const partials = { ...importedPartials };
|
|
85
379
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
380
|
+
function readPartialsRecursively(dir, basePath = '') {
|
|
381
|
+
if (!fs.existsSync(dir)) return;
|
|
382
|
+
|
|
383
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
384
|
+
items.forEach(item => {
|
|
385
|
+
const itemPath = path.join(dir, item.name);
|
|
386
|
+
const itemKind = resolveDirentKind(fs, itemPath, item);
|
|
387
|
+
if (itemKind === 'directory') {
|
|
388
|
+
const newBasePath = basePath ? `${basePath}/${item.name}` : item.name;
|
|
389
|
+
readPartialsRecursively(itemPath, newBasePath);
|
|
90
390
|
return;
|
|
91
391
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
392
|
+
|
|
393
|
+
if (
|
|
394
|
+
itemKind !== 'file' ||
|
|
395
|
+
(!item.name.endsWith('.yaml') && !item.name.endsWith('.yml')) ||
|
|
396
|
+
isSchemaSidecarFile(item.name)
|
|
397
|
+
) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const fileContent = fs.readFileSync(itemPath, 'utf8');
|
|
402
|
+
const nameWithoutExt = path.basename(item.name, path.extname(item.name));
|
|
403
|
+
const partialKey = basePath ? `${basePath}/${nameWithoutExt}` : nameWithoutExt;
|
|
404
|
+
partials[partialKey] = yaml.load(fileContent, { schema: yaml.JSON_SCHEMA });
|
|
97
405
|
});
|
|
98
406
|
}
|
|
99
407
|
|
|
408
|
+
if (fs.existsSync(partialsDir)) {
|
|
409
|
+
readPartialsRecursively(partialsDir);
|
|
410
|
+
}
|
|
411
|
+
|
|
100
412
|
// Read all data files and create a JSON object
|
|
101
413
|
const dataDir = path.join(rootDir, 'data');
|
|
102
414
|
const globalData = {};
|
|
@@ -116,7 +428,7 @@ export function createSiteBuilder({
|
|
|
116
428
|
|
|
117
429
|
// Read all templates and create a JSON object
|
|
118
430
|
const templatesDir = path.join(rootDir, 'templates');
|
|
119
|
-
const templates = {};
|
|
431
|
+
const templates = { ...importedTemplates };
|
|
120
432
|
|
|
121
433
|
function readTemplatesRecursively(dir, basePath = '') {
|
|
122
434
|
if (!fs.existsSync(dir)) return;
|
|
@@ -125,12 +437,17 @@ export function createSiteBuilder({
|
|
|
125
437
|
|
|
126
438
|
items.forEach(item => {
|
|
127
439
|
const itemPath = path.join(dir, item.name);
|
|
440
|
+
const itemKind = resolveDirentKind(fs, itemPath, item);
|
|
128
441
|
|
|
129
|
-
if (
|
|
442
|
+
if (itemKind === 'directory') {
|
|
130
443
|
// Recursively read subdirectories
|
|
131
444
|
const newBasePath = basePath ? `${basePath}/${item.name}` : item.name;
|
|
132
445
|
readTemplatesRecursively(itemPath, newBasePath);
|
|
133
|
-
} else if (
|
|
446
|
+
} else if (
|
|
447
|
+
itemKind === 'file' &&
|
|
448
|
+
(item.name.endsWith('.yaml') || item.name.endsWith('.yml')) &&
|
|
449
|
+
!isSchemaSidecarFile(item.name)
|
|
450
|
+
) {
|
|
134
451
|
// Read and convert YAML file
|
|
135
452
|
const fileContent = fs.readFileSync(itemPath, 'utf8');
|
|
136
453
|
const nameWithoutExt = path.basename(item.name, path.extname(item.name));
|
|
@@ -172,13 +489,15 @@ export function createSiteBuilder({
|
|
|
172
489
|
for (const item of items) {
|
|
173
490
|
const itemPath = path.join(fullDir, item.name);
|
|
174
491
|
const relativePath = basePath ? path.join(basePath, item.name) : item.name;
|
|
492
|
+
const itemKind = resolveDirentKind(fs, itemPath, item);
|
|
175
493
|
|
|
176
|
-
if (
|
|
494
|
+
if (itemKind === 'directory') {
|
|
177
495
|
// Recursively scan subdirectories
|
|
178
496
|
scanPages(dir, relativePath);
|
|
179
|
-
} else if (
|
|
497
|
+
} else if (itemKind === 'file' && (item.name.endsWith('.yaml') || item.name.endsWith('.yml') || item.name.endsWith('.md'))) {
|
|
180
498
|
// Extract frontmatter and content
|
|
181
499
|
const { frontmatter, content } = extractFrontmatterAndContent(itemPath);
|
|
500
|
+
const { frontmatter: publicFrontmatter } = splitSystemFrontmatter(frontmatter, globalData, itemPath);
|
|
182
501
|
|
|
183
502
|
// Calculate URL
|
|
184
503
|
const baseFileName = item.name.replace(/\.(yaml|yml|md)$/, '');
|
|
@@ -196,9 +515,9 @@ export function createSiteBuilder({
|
|
|
196
515
|
}
|
|
197
516
|
|
|
198
517
|
// Process tags
|
|
199
|
-
if (
|
|
518
|
+
if (publicFrontmatter.tags) {
|
|
200
519
|
// Normalize tags to array
|
|
201
|
-
const tags = Array.isArray(
|
|
520
|
+
const tags = Array.isArray(publicFrontmatter.tags) ? publicFrontmatter.tags : [publicFrontmatter.tags];
|
|
202
521
|
|
|
203
522
|
// Add to collections
|
|
204
523
|
tags.forEach(tag => {
|
|
@@ -208,7 +527,7 @@ export function createSiteBuilder({
|
|
|
208
527
|
collections[trimmedTag] = [];
|
|
209
528
|
}
|
|
210
529
|
collections[trimmedTag].push({
|
|
211
|
-
data:
|
|
530
|
+
data: publicFrontmatter,
|
|
212
531
|
url: url,
|
|
213
532
|
content: content
|
|
214
533
|
});
|
|
@@ -232,6 +551,7 @@ export function createSiteBuilder({
|
|
|
232
551
|
if (!quiet) console.log(`Processing ${pagePath}...`);
|
|
233
552
|
|
|
234
553
|
const { frontmatter, content: rawContent } = extractFrontmatterAndContent(pagePath);
|
|
554
|
+
const { frontmatter: publicFrontmatter, bindings: boundData } = splitSystemFrontmatter(frontmatter, globalData, pagePath);
|
|
235
555
|
|
|
236
556
|
// Calculate URL for current page
|
|
237
557
|
let url;
|
|
@@ -250,7 +570,8 @@ export function createSiteBuilder({
|
|
|
250
570
|
}
|
|
251
571
|
|
|
252
572
|
// Deep merge global data with frontmatter and collections for the page context
|
|
253
|
-
const pageData = deepMerge(globalData,
|
|
573
|
+
const pageData = deepMerge(globalData, publicFrontmatter);
|
|
574
|
+
Object.assign(pageData, boundData);
|
|
254
575
|
pageData.collections = collections;
|
|
255
576
|
pageData.page = { url };
|
|
256
577
|
pageData.build = { isScreenshotMode };
|
|
@@ -282,11 +603,11 @@ export function createSiteBuilder({
|
|
|
282
603
|
|
|
283
604
|
// Find the template specified in frontmatter
|
|
284
605
|
let templateToUse = null;
|
|
285
|
-
if (
|
|
606
|
+
if (publicFrontmatter.template) {
|
|
286
607
|
// Look up template by exact path
|
|
287
|
-
templateToUse = templates[
|
|
608
|
+
templateToUse = templates[publicFrontmatter.template];
|
|
288
609
|
if (!templateToUse) {
|
|
289
|
-
throw new Error(`Template "${
|
|
610
|
+
throw new Error(`Template "${publicFrontmatter.template}" not found in ${pagePath}. Available templates: ${Object.keys(templates).join(', ')}`);
|
|
290
611
|
}
|
|
291
612
|
}
|
|
292
613
|
|
|
@@ -378,11 +699,12 @@ export function createSiteBuilder({
|
|
|
378
699
|
for (const item of items) {
|
|
379
700
|
const itemPath = path.join(fullDir, item.name);
|
|
380
701
|
const relativePath = basePath ? path.join(basePath, item.name) : item.name;
|
|
702
|
+
const itemKind = resolveDirentKind(fs, itemPath, item);
|
|
381
703
|
|
|
382
|
-
if (
|
|
704
|
+
if (itemKind === 'directory') {
|
|
383
705
|
// Recursively process subdirectories
|
|
384
706
|
await processAllPages(dir, relativePath);
|
|
385
|
-
} else if (
|
|
707
|
+
} else if (itemKind === 'file') {
|
|
386
708
|
if (item.name.endsWith('.yaml') || item.name.endsWith('.yml')) {
|
|
387
709
|
// Process YAML file
|
|
388
710
|
const outputFileName = item.name.replace(/\.(yaml|yml)$/, '.html');
|
|
@@ -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
|
|
|
@@ -228,12 +228,14 @@ Use these directly in `${...}` expressions:
|
|
|
228
228
|
- `formatDate(value, format = "YYYYMMDDHHmmss", useUtc = true)`
|
|
229
229
|
- `now(format = "YYYYMMDDHHmmss", useUtc = true)`
|
|
230
230
|
- `sort(list, key, order = "asc")`
|
|
231
|
+
- `chunk(list, size = 1, pad = false, fillValue = null)`
|
|
231
232
|
- `md(content)`
|
|
232
233
|
- `toQueryString(object)`
|
|
233
234
|
|
|
234
|
-
Date format tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`.
|
|
235
|
+
Date format tokens: `YYYY`, `MMM`, `MM`, `DD`, `D`, `HH`, `mm`, `ss`.
|
|
235
236
|
`decodeURI`/`decodeURIComponent` return the original input when decoding fails.
|
|
236
237
|
`sort` supports `order` as `asc` or `desc` (default: `asc`), accepts dot-path keys (for example `data.date`), and returns a new array.
|
|
238
|
+
`chunk` splits arrays into rows of `size`; with `pad = true`, the last row is padded with `fillValue`.
|
|
237
239
|
`md` returns raw rendered HTML from Markdown for template insertion.
|
|
238
240
|
|
|
239
241
|
## Static Files
|
|
@@ -242,3 +244,22 @@ Everything in `static/` is copied to `_site/`:
|
|
|
242
244
|
|
|
243
245
|
- `static/css/theme.css` → `_site/css/theme.css`
|
|
244
246
|
- `static/images/logo.png` → `_site/images/logo.png`
|
|
247
|
+
|
|
248
|
+
## Theme Classes
|
|
249
|
+
|
|
250
|
+
`static/css/theme.css` now includes multiple themes in one file.
|
|
251
|
+
Set exactly one class on `body` (or `html`) to choose the active palette.
|
|
252
|
+
|
|
253
|
+
Examples:
|
|
254
|
+
- `slate-dark` (default in starter templates)
|
|
255
|
+
- `slate-light`
|
|
256
|
+
- `mono-dark`
|
|
257
|
+
- `mono-light`
|
|
258
|
+
- `catppuccin-mocha`
|
|
259
|
+
- `catppuccin-macchiato`
|
|
260
|
+
- `catppuccin-frappe`
|
|
261
|
+
- `catppuccin-latte`
|
|
262
|
+
- `github-dark`
|
|
263
|
+
- `github-light`
|
|
264
|
+
- `nord-dark`
|
|
265
|
+
- `nord-light`
|
|
@@ -23,7 +23,7 @@ body {
|
|
|
23
23
|
height: 100%;
|
|
24
24
|
width: 100%;
|
|
25
25
|
margin: 0;
|
|
26
|
-
font-family:
|
|
26
|
+
font-family: var(--font-family-sans);
|
|
27
27
|
display: flex;
|
|
28
28
|
flex-direction: column;
|
|
29
29
|
background-color: var(--background);
|
|
@@ -34,14 +34,16 @@ body {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
a {
|
|
37
|
-
color:
|
|
38
|
-
text-decoration:
|
|
37
|
+
color: var(--anchor-color);
|
|
38
|
+
text-decoration: var(--anchor-text-decoration);
|
|
39
39
|
text-decoration-thickness: 1px;
|
|
40
40
|
text-underline-offset: 4px;
|
|
41
41
|
text-decoration-color: var(--muted-foreground);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
a:hover {
|
|
45
|
+
color: var(--anchor-color-hover);
|
|
46
|
+
text-decoration: var(--anchor-text-decoration-hover);
|
|
45
47
|
text-decoration-color: var(--foreground);
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -51,6 +53,7 @@ a:hover {
|
|
|
51
53
|
|
|
52
54
|
:root {
|
|
53
55
|
--width-stretch: 100%;
|
|
56
|
+
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
54
57
|
|
|
55
58
|
--spacing-xs: 2px;
|
|
56
59
|
--spacing-sm: 4px;
|
|
@@ -64,6 +67,7 @@ a:hover {
|
|
|
64
67
|
--border-radius-lg: 8px;
|
|
65
68
|
--border-radius-xl: 16px;
|
|
66
69
|
--border-radius-f: 50%;
|
|
70
|
+
--border-radius-full: 9999px;
|
|
67
71
|
|
|
68
72
|
--border-width-xs: 1px;
|
|
69
73
|
--border-width-sm: 2px;
|
|
@@ -71,9 +75,9 @@ a:hover {
|
|
|
71
75
|
--border-width-lg: 8px;
|
|
72
76
|
--border-width-xl: 16px;
|
|
73
77
|
|
|
74
|
-
--shadow-sm: 0px 2px 6px rgba(0, 0, 0, .45);
|
|
75
|
-
--shadow-md: 0px 5px 12px rgba(0, 0, 0, .45);
|
|
76
|
-
--shadow-lg: 0px 10px 24px rgba(0, 0, 0, .45);
|
|
78
|
+
--shadow-sm: 0px 2px 6px rgba(0, 0, 0, .45), 0px 3px 5px rgba(0, 0, 0, .35), inset 0px .5px 0px rgba(255, 255, 255, .08), inset 0px 0px .5px rgba(255, 255, 255, .35);
|
|
79
|
+
--shadow-md: 0px 5px 12px rgba(0, 0, 0, .45), 0px 3px 5px rgba(0, 0, 0, .35), inset 0px .5px 0px rgba(255, 255, 255, .08), inset 0px 0px .5px rgba(255, 255, 255, .35);
|
|
80
|
+
--shadow-lg: 0px 10px 24px rgba(0, 0, 0, .45), 0px 3px 5px rgba(0, 0, 0, .35), inset 0px .5px 0px rgba(255, 255, 255, .08), inset 0px 0px .5px rgba(255, 255, 255, .35);
|
|
77
81
|
|
|
78
82
|
--h1-font-size: 3rem;
|
|
79
83
|
--h1-font-weight: 600;
|
|
@@ -115,10 +119,74 @@ a:hover {
|
|
|
115
119
|
--xs-line-height: 1;
|
|
116
120
|
--xs-letter-spacing: normal;
|
|
117
121
|
|
|
122
|
+
--anchor-color: inherit;
|
|
123
|
+
--anchor-color-hover: inherit;
|
|
124
|
+
--anchor-text-decoration: underline;
|
|
125
|
+
--anchor-text-decoration-hover: underline;
|
|
126
|
+
|
|
127
|
+
/* Sensible fallback in case no theme class is set. */
|
|
128
|
+
--primary: oklch(0.922 0 0);
|
|
129
|
+
--primary-foreground: oklch(0.305 0 0);
|
|
130
|
+
--secondary: oklch(0.269 0 0);
|
|
131
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
132
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
133
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
134
|
+
--background: rgb(29 29 29);
|
|
135
|
+
--foreground: rgb(242 242 242);
|
|
136
|
+
--muted: oklch(0.269 0 0);
|
|
137
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
138
|
+
--accent: oklch(0.371 0 0);
|
|
139
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
140
|
+
--border: oklch(1 0 0 / 10%);
|
|
141
|
+
--input: oklch(1 0 0 / 15%);
|
|
142
|
+
--ring: oklch(0.556 0 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* rtgl mono */
|
|
146
|
+
.mono-light {
|
|
147
|
+
--primary: #171717;
|
|
148
|
+
--primary-foreground: #fafafa;
|
|
149
|
+
--secondary: #f5f5f5;
|
|
150
|
+
--secondary-foreground: #171717;
|
|
151
|
+
--destructive: #e40014;
|
|
152
|
+
--destructive-foreground: #fcf3f3;
|
|
153
|
+
--background: #ffffff;
|
|
154
|
+
--foreground: #0a0a0a;
|
|
155
|
+
--muted: #f5f5f5;
|
|
156
|
+
--muted-foreground: #737373;
|
|
157
|
+
--accent: #f5f5f5;
|
|
158
|
+
--accent-foreground: #171717;
|
|
159
|
+
--border: #e5e5e5;
|
|
160
|
+
--input: #e5e5e5;
|
|
161
|
+
--ring: #a1a1a1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.mono-dark {
|
|
165
|
+
--background: #0a0a0a;
|
|
166
|
+
--foreground: #fafafa;
|
|
167
|
+
--primary: #e5e5e5;
|
|
168
|
+
--primary-foreground: #171717;
|
|
169
|
+
--secondary: #262626;
|
|
170
|
+
--secondary-foreground: #fafafa;
|
|
171
|
+
--muted: #262626;
|
|
172
|
+
--muted-foreground: #a1a1a1;
|
|
173
|
+
--accent: #404040;
|
|
174
|
+
--accent-foreground: #fafafa;
|
|
175
|
+
--destructive: #ff6568;
|
|
176
|
+
--destructive-foreground: #df2225;
|
|
177
|
+
--border: #ffffff1a;
|
|
178
|
+
--input: #ffffff26;
|
|
179
|
+
--ring: #737373;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* rtgl slate */
|
|
183
|
+
.slate-light {
|
|
118
184
|
--primary: oklch(0.205 0 0);
|
|
119
185
|
--primary-foreground: oklch(0.985 0 0);
|
|
120
186
|
--secondary: oklch(0.97 0 0);
|
|
121
187
|
--secondary-foreground: oklch(0.205 0 0);
|
|
188
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
189
|
+
--destructive-foreground: oklch(0.145 0 0);
|
|
122
190
|
--background: oklch(1 0 0);
|
|
123
191
|
--foreground: oklch(0.145 0 0);
|
|
124
192
|
--muted: oklch(0.97 0 0);
|
|
@@ -130,9 +198,9 @@ a:hover {
|
|
|
130
198
|
--ring: oklch(0.708 0 0);
|
|
131
199
|
}
|
|
132
200
|
|
|
133
|
-
.dark {
|
|
134
|
-
--background:
|
|
135
|
-
--foreground:
|
|
201
|
+
.slate-dark {
|
|
202
|
+
--background: rgb(29 29 29);
|
|
203
|
+
--foreground: rgb(242 242 242);
|
|
136
204
|
--primary: oklch(0.922 0 0);
|
|
137
205
|
--primary-foreground: oklch(0.305 0 0);
|
|
138
206
|
--secondary: oklch(0.269 0 0);
|
|
@@ -141,11 +209,160 @@ a:hover {
|
|
|
141
209
|
--muted-foreground: oklch(0.708 0 0);
|
|
142
210
|
--accent: oklch(0.371 0 0);
|
|
143
211
|
--accent-foreground: oklch(0.985 0 0);
|
|
212
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
213
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
144
214
|
--border: oklch(1 0 0 / 10%);
|
|
145
215
|
--input: oklch(1 0 0 / 15%);
|
|
146
216
|
--ring: oklch(0.556 0 0);
|
|
147
217
|
}
|
|
148
218
|
|
|
219
|
+
/* catppuccin */
|
|
220
|
+
.catppuccin-latte {
|
|
221
|
+
--primary: #1e66f5;
|
|
222
|
+
--primary-foreground: #eff1f5;
|
|
223
|
+
--secondary: #ccd0da;
|
|
224
|
+
--secondary-foreground: #4c4f69;
|
|
225
|
+
--destructive: #d20f39;
|
|
226
|
+
--destructive-foreground: #eff1f5;
|
|
227
|
+
--background: #eff1f5;
|
|
228
|
+
--foreground: #4c4f69;
|
|
229
|
+
--muted: #e6e9ef;
|
|
230
|
+
--muted-foreground: #6c6f85;
|
|
231
|
+
--accent: #dce0e8;
|
|
232
|
+
--accent-foreground: #4c4f69;
|
|
233
|
+
--border: #bcc0cc;
|
|
234
|
+
--input: #ccd0da;
|
|
235
|
+
--ring: #7287fd;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.catppuccin-frappe {
|
|
239
|
+
--primary: #8caaee;
|
|
240
|
+
--primary-foreground: #303446;
|
|
241
|
+
--secondary: #414559;
|
|
242
|
+
--secondary-foreground: #c6d0f5;
|
|
243
|
+
--destructive: #e78284;
|
|
244
|
+
--destructive-foreground: #303446;
|
|
245
|
+
--background: #303446;
|
|
246
|
+
--foreground: #c6d0f5;
|
|
247
|
+
--muted: #414559;
|
|
248
|
+
--muted-foreground: #a5adce;
|
|
249
|
+
--accent: #51576d;
|
|
250
|
+
--accent-foreground: #c6d0f5;
|
|
251
|
+
--border: #626880;
|
|
252
|
+
--input: #51576d;
|
|
253
|
+
--ring: #babbf1;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.catppuccin-macchiato {
|
|
257
|
+
--primary: #8aadf4;
|
|
258
|
+
--primary-foreground: #24273a;
|
|
259
|
+
--secondary: #363a4f;
|
|
260
|
+
--secondary-foreground: #cad3f5;
|
|
261
|
+
--destructive: #ed8796;
|
|
262
|
+
--destructive-foreground: #24273a;
|
|
263
|
+
--background: #24273a;
|
|
264
|
+
--foreground: #cad3f5;
|
|
265
|
+
--muted: #363a4f;
|
|
266
|
+
--muted-foreground: #a5adcb;
|
|
267
|
+
--accent: #494d64;
|
|
268
|
+
--accent-foreground: #cad3f5;
|
|
269
|
+
--border: #6e738d;
|
|
270
|
+
--input: #494d64;
|
|
271
|
+
--ring: #b7bdf8;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.catppuccin-mocha {
|
|
275
|
+
--background: #1e1e2e;
|
|
276
|
+
--foreground: #cdd6f4;
|
|
277
|
+
--primary: #89b4fa;
|
|
278
|
+
--primary-foreground: #1e1e2e;
|
|
279
|
+
--secondary: #313244;
|
|
280
|
+
--secondary-foreground: #cdd6f4;
|
|
281
|
+
--muted: #313244;
|
|
282
|
+
--muted-foreground: #a6adc8;
|
|
283
|
+
--accent: #45475a;
|
|
284
|
+
--accent-foreground: #cdd6f4;
|
|
285
|
+
--destructive: #f38ba8;
|
|
286
|
+
--destructive-foreground: #1e1e2e;
|
|
287
|
+
--border: #585b70;
|
|
288
|
+
--input: #45475a;
|
|
289
|
+
--ring: #b4befe;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* common extras */
|
|
293
|
+
.github-light {
|
|
294
|
+
--background: #ffffff;
|
|
295
|
+
--foreground: #1f2328;
|
|
296
|
+
--primary: #0969da;
|
|
297
|
+
--primary-foreground: #ffffff;
|
|
298
|
+
--secondary: #f6f8fa;
|
|
299
|
+
--secondary-foreground: #24292f;
|
|
300
|
+
--muted: #f6f8fa;
|
|
301
|
+
--muted-foreground: #57606a;
|
|
302
|
+
--accent: #ddf4ff;
|
|
303
|
+
--accent-foreground: #0969da;
|
|
304
|
+
--destructive: #cf222e;
|
|
305
|
+
--destructive-foreground: #ffffff;
|
|
306
|
+
--border: #d0d7de;
|
|
307
|
+
--input: #d0d7de;
|
|
308
|
+
--ring: #0969da;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.github-dark {
|
|
312
|
+
--background: #0d1117;
|
|
313
|
+
--foreground: #c9d1d9;
|
|
314
|
+
--primary: #58a6ff;
|
|
315
|
+
--primary-foreground: #0d1117;
|
|
316
|
+
--secondary: #21262d;
|
|
317
|
+
--secondary-foreground: #c9d1d9;
|
|
318
|
+
--muted: #21262d;
|
|
319
|
+
--muted-foreground: #8b949e;
|
|
320
|
+
--accent: #30363d;
|
|
321
|
+
--accent-foreground: #c9d1d9;
|
|
322
|
+
--destructive: #f85149;
|
|
323
|
+
--destructive-foreground: #0d1117;
|
|
324
|
+
--border: #30363d;
|
|
325
|
+
--input: #30363d;
|
|
326
|
+
--ring: #58a6ff;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.nord-light {
|
|
330
|
+
--background: #eceff4;
|
|
331
|
+
--foreground: #2e3440;
|
|
332
|
+
--primary: #5e81ac;
|
|
333
|
+
--primary-foreground: #eceff4;
|
|
334
|
+
--secondary: #e5e9f0;
|
|
335
|
+
--secondary-foreground: #2e3440;
|
|
336
|
+
--muted: #e5e9f0;
|
|
337
|
+
--muted-foreground: #4c566a;
|
|
338
|
+
--accent: #d8dee9;
|
|
339
|
+
--accent-foreground: #2e3440;
|
|
340
|
+
--destructive: #bf616a;
|
|
341
|
+
--destructive-foreground: #eceff4;
|
|
342
|
+
--border: #d8dee9;
|
|
343
|
+
--input: #d8dee9;
|
|
344
|
+
--ring: #81a1c1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.nord-dark {
|
|
348
|
+
--background: #2e3440;
|
|
349
|
+
--foreground: #eceff4;
|
|
350
|
+
--primary: #88c0d0;
|
|
351
|
+
--primary-foreground: #2e3440;
|
|
352
|
+
--secondary: #3b4252;
|
|
353
|
+
--secondary-foreground: #eceff4;
|
|
354
|
+
--muted: #3b4252;
|
|
355
|
+
--muted-foreground: #d8dee9;
|
|
356
|
+
--accent: #434c5e;
|
|
357
|
+
--accent-foreground: #eceff4;
|
|
358
|
+
--destructive: #bf616a;
|
|
359
|
+
--destructive-foreground: #eceff4;
|
|
360
|
+
--border: #4c566a;
|
|
361
|
+
--input: #4c566a;
|
|
362
|
+
--ring: #88c0d0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
149
366
|
h1 {
|
|
150
367
|
font-size: var(--h1-font-size);
|
|
151
368
|
font-weight: var(--h1-font-weight);
|
|
@@ -7,8 +7,8 @@
|
|
|
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.
|
|
11
|
-
- body.dark:
|
|
10
|
+
- script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/rettangoli-iife-ui.min.js":
|
|
11
|
+
- body.slate-dark:
|
|
12
12
|
- rtgl-view w="f":
|
|
13
13
|
- rtgl-view h="64":
|
|
14
14
|
- rtgl-view w="f" ah="c":
|
|
@@ -7,8 +7,8 @@
|
|
|
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.
|
|
11
|
-
- body.dark:
|
|
10
|
+
- script src="https://cdn.jsdelivr.net/npm/@rettangoli/ui@1.0.0-rc15/dist/rettangoli-iife-ui.min.js":
|
|
11
|
+
- body.slate-dark:
|
|
12
12
|
- rtgl-view h="64":
|
|
13
13
|
- rtgl-view w="f" ah="c":
|
|
14
14
|
- rtgl-view d="h" g="xl" pb="lg" md-w="100vw" lg-w="768" w="1024" ph="lg":
|