@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 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.0-rc4",
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
- MM: pad2(read(date.getMonth, date.getUTCMonth) + 1),
31
- DD: pad2(read(date.getDate, date.getUTCDate)),
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
@@ -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
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(port, path.resolve(rootDir, outputPath), logger, normalizedReloadMode);
359
+ const server = new DevServer(normalizedPort, path.resolve(rootDir, outputPath), logger, normalizedReloadMode);
351
360
  server.start();
352
361
 
353
362
  // Watch all relevant directories
@@ -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
- 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'))) {
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
- 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 });
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 (item.isDirectory()) {
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 (item.isFile() && (item.name.endsWith('.yaml') || item.name.endsWith('.yml'))) {
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 (item.isDirectory()) {
494
+ if (itemKind === 'directory') {
177
495
  // Recursively scan subdirectories
178
496
  scanPages(dir, relativePath);
179
- } else if (item.isFile() && (item.name.endsWith('.yaml') || item.name.endsWith('.yml') || item.name.endsWith('.md'))) {
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 (frontmatter.tags) {
518
+ if (publicFrontmatter.tags) {
200
519
  // Normalize tags to array
201
- const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags];
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: frontmatter,
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, frontmatter);
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 (frontmatter.template) {
606
+ if (publicFrontmatter.template) {
286
607
  // Look up template by exact path
287
- templateToUse = templates[frontmatter.template];
608
+ templateToUse = templates[publicFrontmatter.template];
288
609
  if (!templateToUse) {
289
- throw new Error(`Template "${frontmatter.template}" not found in ${pagePath}. Available templates: ${Object.keys(templates).join(', ')}`);
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 (item.isDirectory()) {
704
+ if (itemKind === 'directory') {
383
705
  // Recursively process subdirectories
384
706
  await processAllPages(dir, relativePath);
385
- } else if (item.isFile()) {
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: system-ui, -apple-system, "Helvetica Neue", sans-serif;
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: inherit;
38
- text-decoration: underline;
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: oklch(0.145 0 0);
135
- --foreground: oklch(0.985 0 0);
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.1.32/dist/rettangoli-iife-ui.min.js":
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.1.32/dist/rettangoli-iife-ui.min.js":
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":