@janga/norna 0.7.2 → 0.7.4

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.
Files changed (88) hide show
  1. package/README.md +16 -16
  2. package/bin/norna-cli.mjs +12 -7
  3. package/docs/README.md +11 -12
  4. package/docs/commands.md +9 -9
  5. package/docs/configuration.md +14 -145
  6. package/docs/content.md +17 -15
  7. package/docs/design/command-organization.md +58 -57
  8. package/docs/design/site-examples-structure.md +6 -5
  9. package/docs/engine-development.md +5 -5
  10. package/docs/getting-started.md +20 -26
  11. package/docs/images-and-metadata.md +1 -1
  12. package/docs/local-development.md +2 -2
  13. package/docs/publishing.md +1 -1
  14. package/docs/routes.md +2 -2
  15. package/docs/site-structure.md +8 -7
  16. package/docs/theme.md +133 -7
  17. package/docs/typography.md +78 -43
  18. package/examples/dog-gallery/node_modules/.vite/deps/_metadata.json +74 -0
  19. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  20. package/examples/dog-gallery/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  21. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  22. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  23. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  24. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  25. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  26. package/examples/dog-gallery/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  27. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  28. package/examples/dog-gallery/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  29. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  30. package/examples/dog-gallery/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  31. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  32. package/examples/dog-gallery/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  33. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  34. package/examples/dog-gallery/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  35. package/examples/dog-gallery/node_modules/.vite/deps/package.json +3 -0
  36. package/examples/dog-gallery/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  37. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  38. package/examples/dog-gallery/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  39. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  40. package/examples/dog-gallery/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  41. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  42. package/examples/dog-gallery/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  43. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  44. package/examples/dog-gallery/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  45. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  46. package/examples/dog-gallery/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  47. package/examples/dog-gallery/site/config.mjs +12 -42
  48. package/examples/dog-gallery/site/content.md +28 -6
  49. package/examples/dog-gallery/site/routes/dog-care/route-content.md +17 -3
  50. package/examples/dog-gallery/site/theme.md +83 -26
  51. package/fixtures/basic/site/config.mjs +2 -22
  52. package/fixtures/basic/site/content.md +1 -1
  53. package/fixtures/basic/site/theme.md +7 -3
  54. package/package.json +3 -3
  55. package/scripts/check-config.mjs +4 -3
  56. package/scripts/deploy-site.mjs +3 -3
  57. package/scripts/init-site.mjs +10 -8
  58. package/scripts/lib/frontmatter-yaml.mjs +85 -0
  59. package/scripts/lib/presentation.mjs +10 -11
  60. package/scripts/lib/project-config.mjs +146 -45
  61. package/scripts/lib/site-content.mjs +21 -3
  62. package/scripts/lib/theme-config.mjs +34 -0
  63. package/scripts/lib/typography.mjs +155 -35
  64. package/scripts/show-typography.mjs +127 -159
  65. package/scripts/sync-content-sections.mjs +6 -6
  66. package/scripts/test-content-check.mjs +3 -4
  67. package/scripts/test-engine-commands.mjs +21 -3
  68. package/scripts/test-package-check.mjs +34 -17
  69. package/scripts/test-temporary-visibility.mjs +2 -3
  70. package/src/components/SitePage.astro +2 -3
  71. package/src/components/SiteSection.astro +115 -19
  72. package/src/content.config.ts +66 -6
  73. package/src/layouts/BaseLayout.astro +11 -0
  74. package/src/styles/global.css +83 -52
  75. package/starters/basic/README.md +11 -15
  76. package/starters/basic/package.json +2 -2
  77. package/starters/basic/site/config.mjs +3 -24
  78. package/starters/basic/site/content.md +40 -7
  79. package/starters/basic/site/theme.md +84 -18
  80. package/starters/project/.github/workflows/deploy.yml +65 -0
  81. package/starters/project/README.md +72 -0
  82. package/starters/project/package-lock.json +4296 -0
  83. package/starters/project/package.json +35 -0
  84. package/starters/project/site/config.mjs +43 -0
  85. package/starters/project/site/content.md +128 -0
  86. package/starters/project/site/public/robots.txt +2 -0
  87. package/starters/project/site/routes/guide/route-content.md +85 -0
  88. package/starters/project/site/theme.md +43 -0
@@ -1,10 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { findMap } from './lib/frontmatter-yaml.mjs';
2
3
  import {
3
4
  defaultTypography,
4
5
  resolveTypographyOverride,
5
6
  resolveTypographyConfig,
6
7
  toYamlLines,
7
8
  typographyPresets,
9
+ typographyRhythms,
8
10
  } from './lib/typography.mjs';
9
11
  import {
10
12
  getContentFiles,
@@ -23,76 +25,56 @@ import {
23
25
 
24
26
  const mode = process.argv[2] ?? 'show';
25
27
 
26
- const typographyRoles = ['heading', 'body', 'caption'];
27
- const typographyFields = {
28
- heading: ['align', 'size', 'lineHeight', 'spacing'],
29
- body: ['align', 'size', 'lineHeight', 'paragraphSpacing'],
30
- caption: ['align', 'size', 'lineHeight', 'spacing'],
31
- };
32
- const responsiveFields = new Set(['align']);
33
-
28
+ const typographyValuePaths = [
29
+ ['headings', 'h1', 'align', 'desktop'],
30
+ ['headings', 'h1', 'align', 'mobile'],
31
+ ['headings', 'h1', 'size'],
32
+ ['headings', 'h1', 'lineHeight'],
33
+ ['headings', 'h1', 'spacingBefore'],
34
+ ['headings', 'h1', 'spacingAfter'],
35
+ ['headings', 'h2', 'align', 'desktop'],
36
+ ['headings', 'h2', 'align', 'mobile'],
37
+ ['headings', 'h2', 'size'],
38
+ ['headings', 'h2', 'lineHeight'],
39
+ ['headings', 'h2', 'spacingBefore'],
40
+ ['headings', 'h2', 'spacingAfter'],
41
+ ['headings', 'h3', 'align', 'desktop'],
42
+ ['headings', 'h3', 'align', 'mobile'],
43
+ ['headings', 'h3', 'size'],
44
+ ['headings', 'h3', 'lineHeight'],
45
+ ['headings', 'h3', 'spacingBefore'],
46
+ ['headings', 'h3', 'spacingAfter'],
47
+ ['headings', 'h4', 'align', 'desktop'],
48
+ ['headings', 'h4', 'align', 'mobile'],
49
+ ['headings', 'h4', 'size'],
50
+ ['headings', 'h4', 'lineHeight'],
51
+ ['headings', 'h4', 'spacingBefore'],
52
+ ['headings', 'h4', 'spacingAfter'],
53
+ ['body', 'align', 'desktop'],
54
+ ['body', 'align', 'mobile'],
55
+ ['body', 'size'],
56
+ ['body', 'lineHeight'],
57
+ ['body', 'paragraphSpacing'],
58
+ ['caption', 'align', 'desktop'],
59
+ ['caption', 'align', 'mobile'],
60
+ ['caption', 'size'],
61
+ ['caption', 'lineHeight'],
62
+ ['caption', 'spacingBefore'],
63
+ ];
64
+ const rhythmValuePaths = new Set([
65
+ 'headings.h1.spacingBefore',
66
+ 'headings.h1.spacingAfter',
67
+ 'headings.h2.spacingBefore',
68
+ 'headings.h2.spacingAfter',
69
+ 'headings.h3.spacingBefore',
70
+ 'headings.h3.spacingAfter',
71
+ 'headings.h4.spacingBefore',
72
+ 'headings.h4.spacingAfter',
73
+ 'body.paragraphSpacing',
74
+ 'caption.spacingBefore',
75
+ ]);
34
76
  const countIndent = (line) => line.match(/^\s*/)?.[0].length ?? 0;
35
77
 
36
- const parseScalar = (rawValue) => {
37
- const value = rawValue.trim();
38
-
39
- if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
40
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
41
- return value.slice(1, -1);
42
- }
43
-
44
- return value;
45
- };
46
-
47
- const parseMapping = (lines, startIndex, baseIndent) => {
48
- const value = {};
49
- let index = startIndex;
50
-
51
- while (index < lines.length) {
52
- const line = lines[index];
53
- if (!line.trim() || line.trim().startsWith('#')) {
54
- index += 1;
55
- continue;
56
- }
57
-
58
- const indent = countIndent(line);
59
- if (indent <= baseIndent) break;
60
- if (line.trim().startsWith('- ')) break;
61
-
62
- const match = line.trim().match(/^([a-zA-Z][a-zA-Z0-9-]*):(?:\s+(.*))?$/);
63
- if (!match) break;
64
-
65
- const [, key, rawValue] = match;
66
- if (rawValue === undefined) {
67
- const parsed = parseMapping(lines, index + 1, indent);
68
- value[key] = parsed.value;
69
- index = parsed.nextIndex;
70
- } else {
71
- value[key] = parseScalar(rawValue);
72
- index += 1;
73
- }
74
- }
75
-
76
- return { value, nextIndex: index };
77
- };
78
-
79
- const findMap = (lines, label, parentStart = 0, parentEnd = lines.length, requiredIndent = null) => {
80
- for (let index = parentStart; index < parentEnd; index += 1) {
81
- const line = lines[index];
82
- const match = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9-]*):\s*$/);
83
- if (!match || match[2] !== label) continue;
84
- if (requiredIndent !== null && match[1].length !== requiredIndent) continue;
85
-
86
- return {
87
- index,
88
- indent: match[1].length,
89
- ...parseMapping(lines, index + 1, match[1].length),
90
- };
91
- }
92
-
93
- return null;
94
- };
95
-
96
78
  const isPlainObject = (value) => (
97
79
  value !== null &&
98
80
  typeof value === 'object' &&
@@ -129,54 +111,29 @@ const setPath = (value, path, entry) => {
129
111
  const annotateResolvedValues = (resolved, sources) => {
130
112
  const annotated = {};
131
113
 
132
- for (const role of typographyRoles) {
133
- for (const field of typographyFields[role]) {
134
- if (responsiveFields.has(field)) {
135
- for (const viewport of ['desktop', 'mobile']) {
136
- const path = [role, field, viewport];
137
- const source = getPath(sources, path);
138
- setPath(annotated, path, {
139
- value: getPath(resolved.values, path),
140
- source: source.source,
141
- ...(source.inherited ? { inherited: true } : {}),
142
- });
143
- }
144
- continue;
145
- }
146
-
147
- const path = [role, field];
148
- const source = getPath(sources, path);
149
- setPath(annotated, path, {
150
- value: getPath(resolved.values, path),
151
- source: source.source,
152
- ...(source.inherited ? { inherited: true } : {}),
153
- });
154
- }
114
+ for (const path of typographyValuePaths) {
115
+ const source = getPath(sources, path);
116
+ setPath(annotated, path, {
117
+ value: getPath(resolved.values, path),
118
+ source: source.source,
119
+ ...(source.inherited ? { inherited: true } : {}),
120
+ });
155
121
  }
156
122
 
157
123
  return annotated;
158
124
  };
159
125
 
160
- const presetSources = (presetName) => {
126
+ const defaultSources = (presetName, rhythmName) => {
161
127
  const sources = {};
162
128
 
163
- for (const role of typographyRoles) {
164
- for (const field of typographyFields[role]) {
165
- if (responsiveFields.has(field)) {
166
- for (const viewport of ['desktop', 'mobile']) {
167
- setPath(sources, [role, field, viewport], {
168
- source: `preset:${presetName}`,
169
- inherited: false,
170
- });
171
- }
172
- continue;
173
- }
174
-
175
- setPath(sources, [role, field], {
176
- source: `preset:${presetName}`,
177
- inherited: false,
178
- });
179
- }
129
+ for (const path of typographyValuePaths) {
130
+ const pathKey = path.join('.');
131
+ setPath(sources, path, {
132
+ source: rhythmValuePaths.has(pathKey)
133
+ ? `rhythm:${rhythmName}`
134
+ : `preset:${presetName}`,
135
+ inherited: false,
136
+ });
180
137
  }
181
138
 
182
139
  return sources;
@@ -186,28 +143,12 @@ const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
186
143
  const overrides = typographyConfig?.overrides;
187
144
  if (!overrides) return sources;
188
145
 
189
- for (const role of typographyRoles) {
190
- for (const field of typographyFields[role]) {
191
- if (responsiveFields.has(field)) {
192
- for (const viewport of ['desktop', 'mobile']) {
193
- const path = [role, field, viewport];
194
- if (hasPath(overrides, path)) {
195
- setPath(sources, path, {
196
- source: `${sourceLabel} override`,
197
- inherited: false,
198
- });
199
- }
200
- }
201
- continue;
202
- }
203
-
204
- const path = [role, field];
205
- if (hasPath(overrides, path)) {
206
- setPath(sources, path, {
207
- source: `${sourceLabel} override`,
208
- inherited: false,
209
- });
210
- }
146
+ for (const path of typographyValuePaths) {
147
+ if (hasPath(overrides, path)) {
148
+ setPath(sources, path, {
149
+ source: `${sourceLabel} override`,
150
+ inherited: false,
151
+ });
211
152
  }
212
153
  }
213
154
 
@@ -217,25 +158,11 @@ const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
217
158
  const inheritedSources = (sources) => {
218
159
  const inherited = structuredClone(sources);
219
160
 
220
- for (const role of typographyRoles) {
221
- for (const field of typographyFields[role]) {
222
- if (responsiveFields.has(field)) {
223
- for (const viewport of ['desktop', 'mobile']) {
224
- const path = [role, field, viewport];
225
- setPath(inherited, path, {
226
- ...getPath(sources, path),
227
- inherited: true,
228
- });
229
- }
230
- continue;
231
- }
232
-
233
- const path = [role, field];
234
- setPath(inherited, path, {
235
- ...getPath(sources, path),
236
- inherited: true,
237
- });
238
- }
161
+ for (const path of typographyValuePaths) {
162
+ setPath(inherited, path, {
163
+ ...getPath(sources, path),
164
+ inherited: true,
165
+ });
239
166
  }
240
167
 
241
168
  return inherited;
@@ -244,7 +171,7 @@ const inheritedSources = (sources) => {
244
171
  const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
245
172
  const resolved = resolveTypographyConfig(typographyConfig ?? defaultTypography);
246
173
  const sources = applyOverrideSources(
247
- presetSources(resolved.preset),
174
+ defaultSources(resolved.preset, resolved.rhythm),
248
175
  typographyConfig,
249
176
  sourceLabel,
250
177
  );
@@ -255,14 +182,44 @@ const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
255
182
  source: typographyConfig?.preset ? sourceLabel : 'engine default',
256
183
  ...(typographyConfig?.preset ? {} : { inherited: true }),
257
184
  },
185
+ rhythm: {
186
+ value: resolved.rhythm,
187
+ source: typographyConfig?.rhythm ? sourceLabel : 'engine default',
188
+ ...(typographyConfig?.rhythm ? {} : { inherited: true }),
189
+ },
258
190
  resolved,
259
191
  sources,
260
192
  };
261
193
  };
262
194
 
263
195
  const resolveAnnotatedTypographyOverride = (baseAnnotated, typographyConfig, sourceLabel) => {
264
- if (typographyConfig?.preset) {
265
- return resolveAnnotatedTypographyConfig(typographyConfig, sourceLabel);
196
+ if (typographyConfig?.preset || typographyConfig?.rhythm) {
197
+ const resolvedConfig = {
198
+ preset: typographyConfig.preset ?? baseAnnotated.resolved.preset,
199
+ rhythm: typographyConfig.rhythm ?? baseAnnotated.resolved.rhythm,
200
+ overrides: typographyConfig.overrides,
201
+ };
202
+ const resolved = resolveTypographyConfig(resolvedConfig);
203
+ const sources = applyOverrideSources(
204
+ defaultSources(resolved.preset, resolved.rhythm),
205
+ typographyConfig,
206
+ sourceLabel,
207
+ );
208
+
209
+ return {
210
+ preset: {
211
+ value: resolved.preset,
212
+ source: typographyConfig.preset ? sourceLabel : baseAnnotated.preset.source,
213
+ ...(typographyConfig.preset ? {} : { inherited: true }),
214
+ },
215
+ rhythm: {
216
+ value: resolved.rhythm,
217
+ source: typographyConfig.rhythm ? sourceLabel : baseAnnotated.rhythm.source,
218
+ ...(typographyConfig.rhythm ? {} : { inherited: true }),
219
+ },
220
+ resolved,
221
+ sources,
222
+ };
266
223
  }
267
224
 
268
225
  if (typographyConfig?.overrides) {
@@ -279,6 +236,11 @@ const resolveAnnotatedTypographyOverride = (baseAnnotated, typographyConfig, sou
279
236
  source: baseAnnotated.preset.source,
280
237
  inherited: true,
281
238
  },
239
+ rhythm: {
240
+ value: resolved.rhythm,
241
+ source: baseAnnotated.rhythm.source,
242
+ inherited: true,
243
+ },
282
244
  resolved,
283
245
  sources,
284
246
  };
@@ -290,6 +252,11 @@ const resolveAnnotatedTypographyOverride = (baseAnnotated, typographyConfig, sou
290
252
  source: baseAnnotated.preset.source,
291
253
  inherited: true,
292
254
  },
255
+ rhythm: {
256
+ value: baseAnnotated.resolved.rhythm,
257
+ source: baseAnnotated.rhythm.source,
258
+ inherited: true,
259
+ },
293
260
  resolved: baseAnnotated.resolved,
294
261
  sources: inheritedSources(baseAnnotated.sources),
295
262
  };
@@ -297,6 +264,7 @@ const resolveAnnotatedTypographyOverride = (baseAnnotated, typographyConfig, sou
297
264
 
298
265
  const formatAnnotatedTypography = (annotated) => ({
299
266
  preset: annotated.preset,
267
+ rhythm: annotated.rhythm,
300
268
  resolved: annotateResolvedValues(annotated.resolved, annotated.sources),
301
269
  });
302
270
 
@@ -355,10 +323,7 @@ const readThemeTypography = async () => {
355
323
  }
356
324
 
357
325
  const themeLines = themeFrontmatterBody.split(/\r?\n/);
358
- const themePresentation = findMap(themeLines, 'presentation', 0, themeLines.length, 0);
359
- const themeTypographyConfig = themePresentation
360
- ? findMap(themeLines, 'typography', themePresentation.index + 1, themePresentation.nextIndex)?.value
361
- : null;
326
+ const themeTypographyConfig = findMap(themeLines, 'typography', 0, themeLines.length, 0)?.value;
362
327
  return resolveAnnotatedTypographyConfig(themeTypographyConfig ?? defaultTypography, siteThemeLabel);
363
328
  };
364
329
 
@@ -401,7 +366,10 @@ const readPageTypography = async (contentFile, themeTypography) => {
401
366
  };
402
367
 
403
368
  if (mode === 'presets') {
404
- console.log(toYamlLines(typographyPresets).join('\n'));
369
+ console.log(toYamlLines({
370
+ presets: typographyPresets,
371
+ rhythms: typographyRhythms,
372
+ }).join('\n'));
405
373
  } else if (mode === 'show') {
406
374
  const themeTypography = await readThemeTypography();
407
375
  const pages = await Promise.all((await getContentFiles()).map((contentFile) => readPageTypography(contentFile, themeTypography)));
@@ -429,5 +397,5 @@ if (mode === 'presets') {
429
397
 
430
398
  console.log(toYamlLines(output).join('\n'));
431
399
  } else {
432
- throw new Error('Usage: norna typography:presets|typography:show');
400
+ throw new Error('Usage: norna typography presets|show');
433
401
  }
@@ -59,7 +59,7 @@ const promptForWrite = async () => {
59
59
  if (!process.stdin.isTTY) return false;
60
60
 
61
61
  const rl = createInterface({ input, output });
62
- const answer = await rl.question(`This will rewrite Markdown sections in ${siteContentLabel} and move gallery image files if needed. Continue? [y/N] `);
62
+ const answer = await rl.question(`This will rewrite Markdown sections in ${siteContentLabel} and move image files if needed. Continue? [y/N] `);
63
63
  rl.close();
64
64
 
65
65
  return answer.trim().toLowerCase() === 'y';
@@ -269,7 +269,7 @@ const validateRouteContentFiles = async () => {
269
269
  if (referencedImages.has(imageName)) {
270
270
  const previousSectionId = referencedImages.get(imageName);
271
271
  const message = `Image "${imageName}" is referenced more than once, in "${previousSectionId}" and "${contentFile.contentLabel}:${section.id}".`;
272
- const fix = 'Each image filename can be referenced by only one gallery row.';
272
+ const fix = 'Each image filename can be referenced by only one image row.';
273
273
 
274
274
  addIssue({ severity: 'error', sectionId: previousSectionId, message, fix });
275
275
  addIssue({
@@ -291,7 +291,7 @@ const validateRouteContentFiles = async () => {
291
291
  sectionId: `${contentFile.contentLabel}:${section.id}`,
292
292
  sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
293
293
  message: `Image "${imageName}" does not exist anywhere under ${contentFile.imagesLabel}/.`,
294
- fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the gallery row.`,
294
+ fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the image row.`,
295
295
  });
296
296
  continue;
297
297
  }
@@ -380,7 +380,7 @@ const getReportLines = (title) => {
380
380
  leftKey.localeCompare(rightKey, 'sv')
381
381
  ));
382
382
 
383
- lines.push('', 'Section and Gallery Issues');
383
+ lines.push('', 'Section And Image Issues');
384
384
 
385
385
  for (const [, group] of orderedSectionGroups) {
386
386
  lines.push('', `[${group.label}]`);
@@ -506,7 +506,7 @@ for (const section of frontmatterSections) {
506
506
  const message = previousSectionId === section.id
507
507
  ? `Image "${imageName}" is referenced more than once in this section.`
508
508
  : `Image "${imageName}" is referenced more than once, in sections "${previousSectionId}" and "${section.id}".`;
509
- const fix = 'Each image filename can be referenced by only one gallery row.';
509
+ const fix = 'Each image filename can be referenced by only one image row.';
510
510
 
511
511
  addSectionIssue(previousSectionId, { severity: 'error', message, fix });
512
512
  if (previousSectionId !== section.id) {
@@ -522,7 +522,7 @@ for (const section of frontmatterSections) {
522
522
  addSectionIssue(section.id, {
523
523
  severity: 'error',
524
524
  message: `Image "${imageName}" does not exist anywhere under ${siteImagesLabel}/.`,
525
- fix: `Add the source image to ${siteImagesLabel}/${section.id}/ or remove the gallery row.`,
525
+ fix: `Add the source image to ${siteImagesLabel}/${section.id}/ or remove the image row.`,
526
526
  });
527
527
  continue;
528
528
  }
@@ -43,9 +43,8 @@ const makePngHeader = ({ width, height }) => {
43
43
  };
44
44
 
45
45
  const defaultTheme = `---
46
- presentation:
47
- typography:
48
- preset: quiet-gallery
46
+ typography:
47
+ preset: quiet-gallery
49
48
  frame:
50
49
  colors: presentation
51
50
  ---
@@ -114,7 +113,7 @@ test('content:check groups section issues, global issues, and unreferenced image
114
113
 
115
114
  assert.equal(result.status, 1, output);
116
115
  assert.match(output, /^Content check failed\./m);
117
- assert.match(output, /Section and Gallery Issues\n\n\[min-konst\]\n Errors:/);
116
+ assert.match(output, /Section And Image Issues\n\n\[min-konst\]\n Errors:/);
118
117
  assert.match(output, /Image "vav\.jpeg" is used here but is located in site\/images\/mitt-hem\/\./);
119
118
  assert.match(output, /Image "missing\.jpeg" does not exist anywhere under site\/images\/\./);
120
119
  assert.match(output, /Image "duplicate\.jpg" is referenced more than once in this section\./);
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { typographyPresets, typographyRhythms } from './lib/typography.mjs';
7
8
 
8
9
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
10
  const cliPath = path.join(repoRoot, 'bin', 'norna.mjs');
@@ -47,12 +48,29 @@ try {
47
48
  assert.equal(initializedPackageJson.scripts['engine:update'], undefined);
48
49
  assert.equal(initializedPackageJson.scripts['engine:version'], undefined);
49
50
 
50
- const presetsResult = runCli(['typography:presets']);
51
+ const presetsResult = runCli(['typography', 'presets']);
51
52
  assert.equal(presetsResult.status, 0, presetsResult.stderr || presetsResult.stdout);
52
53
  assert.match(presetsResult.stdout, /quiet-gallery:/);
53
54
  assert.match(presetsResult.stdout, /text-forward:/);
54
-
55
- const showResult = runCli(['--site-dir', path.join(initializedSiteRoot, 'site'), 'typography:show']);
55
+ assert.match(presetsResult.stdout, /rhythms:/);
56
+ assert.match(presetsResult.stdout, /normal:/);
57
+ for (const [presetName, preset] of Object.entries(typographyPresets)) {
58
+ for (const [level, heading] of Object.entries(preset.headings)) {
59
+ assert.equal(heading.size, 'medium', `${presetName} ${level} should use medium size`);
60
+ }
61
+ assert.equal(preset.body.size, 'medium', `${presetName} body should use medium size`);
62
+ assert.equal(preset.caption.size, 'medium', `${presetName} caption should use medium size`);
63
+ }
64
+ for (const [rhythmName, rhythm] of Object.entries(typographyRhythms)) {
65
+ for (const [level, heading] of Object.entries(rhythm.headings)) {
66
+ assert.match(heading.spacingBefore, /^(0|[\d.]+em)$/, `${rhythmName} ${level} spacingBefore should be text-relative`);
67
+ assert.match(heading.spacingAfter, /^[\d.]+em$/, `${rhythmName} ${level} spacingAfter should be text-relative`);
68
+ }
69
+ assert.match(rhythm.body.paragraphSpacing, /^[\d.]+em$/, `${rhythmName} body paragraphSpacing should be text-relative`);
70
+ assert.match(rhythm.caption.spacingBefore, /^[\d.]+em$/, `${rhythmName} caption spacingBefore should be text-relative`);
71
+ }
72
+
73
+ const showResult = runCli(['--site-dir', path.join(initializedSiteRoot, 'site'), 'typography', 'show']);
56
74
  assert.equal(showResult.status, 0, showResult.stderr || showResult.stdout);
57
75
  assert.match(showResult.stdout, /theme:/);
58
76
  assert.match(showResult.stdout, /pages:/);
@@ -181,18 +181,18 @@ try {
181
181
  await writeFile(
182
182
  packageCheckConfigPath,
183
183
  packageCheckConfig
184
- .replace("url: 'https://example.com/'", "url: 'https://example.com/gallery/'")
185
- .replace("basePath: '/'", "basePath: '/gallery/'"),
184
+ .replace("url: 'https://example.com/'", "url: 'https://example.com/site/'")
185
+ .replace("basePath: '/'", "basePath: '/site/'"),
186
186
  );
187
187
  const packageCheckThemePath = path.join(siteProjectRoot, 'site', 'theme.md');
188
188
  const packageCheckTheme = await readFile(packageCheckThemePath, 'utf8');
189
189
  await writeFile(
190
190
  packageCheckThemePath,
191
- packageCheckTheme.replace('brand: Example Gallery', 'brand: Package Check Brand'),
191
+ packageCheckTheme.replace('brand: Example Site', 'brand: Package Check Brand'),
192
192
  );
193
193
  await mkdir(path.join(siteProjectRoot, 'site', 'routes', 'about'), { recursive: true });
194
194
  await writeFile(path.join(siteProjectRoot, 'site', 'routes', 'about', 'route-content.md'), `---
195
- title: About the gallery
195
+ title: About the site
196
196
  description: Route used by package checks.
197
197
  navigation:
198
198
  label: About
@@ -237,6 +237,14 @@ This route verifies that packaged norna sites can build route pages.
237
237
  path.join(siteProjectRoot, 'dist', 'index.html'),
238
238
  '--section-heading-font-size-desktop: clamp(1.35rem, 1.1rem + 0.9vw, 2rem)',
239
239
  );
240
+ await assertFileIncludes(
241
+ path.join(siteProjectRoot, 'dist', 'index.html'),
242
+ '--section-heading-spacing-after: 0.55em',
243
+ );
244
+ await assertFileIncludes(
245
+ path.join(siteProjectRoot, 'dist', 'index.html'),
246
+ '--section-markdown-h3-spacing-before: 1.5em',
247
+ );
240
248
  await assertFileIncludes(
241
249
  path.join(siteProjectRoot, 'dist', 'index.html'),
242
250
  "--font-sans: Arial, 'Helvetica Neue', Helvetica, sans-serif",
@@ -247,15 +255,15 @@ This route verifies that packaged norna sites can build route pages.
247
255
  );
248
256
  await assertFileIncludes(
249
257
  path.join(siteProjectRoot, 'dist', 'about', 'index.html'),
250
- '<link rel="canonical" href="https://example.com/gallery/about/">',
258
+ '<link rel="canonical" href="https://example.com/site/about/">',
251
259
  );
252
260
  await assertFileIncludes(
253
261
  path.join(siteProjectRoot, 'dist', 'about', 'index.html'),
254
- 'href="/gallery/about/" aria-current="page"',
262
+ 'href="/site/about/" aria-current="page"',
255
263
  );
256
264
  await assertFileIncludes(
257
265
  path.join(siteProjectRoot, 'dist', 'index.html'),
258
- '<a class="site-brand" href="/gallery/">Package Check Brand</a>',
266
+ '<a class="site-brand" href="/site/">Package Check Brand</a>',
259
267
  );
260
268
  await assertFileIncludes(
261
269
  path.join(siteProjectRoot, 'dist', 'index.html'),
@@ -273,6 +281,14 @@ This route verifies that packaged norna sites can build route pages.
273
281
  path.join(siteProjectRoot, 'dist', 'index.html'),
274
282
  '--section-caption-line-height: 1.35',
275
283
  );
284
+ await assertFileIncludes(
285
+ path.join(siteProjectRoot, 'dist', 'index.html'),
286
+ '--section-caption-spacing-before: 0.5em',
287
+ );
288
+ await assertFileIncludes(
289
+ path.join(siteProjectRoot, 'dist', 'index.html'),
290
+ '--section-caption-font-size-desktop: clamp(0.86rem, 0.84rem + 0.08vw, 0.92rem)',
291
+ );
276
292
  await assertFileIncludes(
277
293
  path.join(siteProjectRoot, 'dist', 'index.html'),
278
294
  'class="inline-style inline-style-highlight" style="--inline-style-color: #ffd84d"',
@@ -306,7 +322,7 @@ This route verifies that packaged norna sites can build route pages.
306
322
  await assertFileExists(path.join(siteProjectRoot, 'dist', 'favicon.ico'));
307
323
  await assertFileIncludes(
308
324
  path.join(siteProjectRoot, 'dist', 'index.html'),
309
- '<link rel="icon" sizes="any" href="/gallery/favicon.ico">',
325
+ '<link rel="icon" sizes="any" href="/site/favicon.ico">',
310
326
  );
311
327
  await assertFileExcludes(
312
328
  path.join(siteProjectRoot, 'dist', 'index.html'),
@@ -316,12 +332,12 @@ This route verifies that packaged norna sites can build route pages.
316
332
  const siteContent = await readFile(siteContentPath, 'utf8');
317
333
  const siteThemePath = path.join(siteProjectRoot, 'site', 'theme.md');
318
334
  const siteTheme = await readFile(siteThemePath, 'utf8');
319
- await writeFile(siteThemePath, siteTheme.replace('\n preset: quiet-gallery', '\n preset: noisy-gallery'));
335
+ await writeFile(siteThemePath, siteTheme.replace('\n preset: quiet-gallery', '\n preset: noisy-gallery'));
320
336
  await runExpectFailure(
321
337
  npxBin,
322
338
  ['norna', 'build'],
323
339
  { cwd: siteProjectRoot, env: npmEnv },
324
- 'presentation.typography.preset',
340
+ 'typography.preset',
325
341
  );
326
342
  await writeFile(
327
343
  siteThemePath,
@@ -371,14 +387,14 @@ This route verifies that packaged norna sites can build route pages.
371
387
  siteContentPath,
372
388
  siteContent.replace(
373
389
  ' typography:\n preset: statement',
374
- ' typography:\n overrides:\n caption:\n spacing: wide',
390
+ ' typography:\n overrides:\n caption:\n spacingBefore: wide',
375
391
  ),
376
392
  );
377
393
  await runExpectFailure(
378
394
  npxBin,
379
395
  ['norna', 'build'],
380
396
  { cwd: siteProjectRoot, env: npmEnv },
381
- 'sections.0.presentation.typography.overrides.caption.spacing',
397
+ 'sections.0.presentation.typography.overrides.caption.spacingBefore',
382
398
  );
383
399
  await writeFile(
384
400
  siteContentPath,
@@ -390,11 +406,9 @@ This route verifies that packaged norna sites can build route pages.
390
406
  { cwd: siteProjectRoot, env: npmEnv },
391
407
  'sections.0.presentation.typography.preset',
392
408
  );
393
- const siteConfigPath = path.join(siteProjectRoot, 'site', 'config.mjs');
394
- const siteConfig = await readFile(siteConfigPath, 'utf8');
395
409
  await writeFile(
396
- siteConfigPath,
397
- siteConfig.replace(
410
+ siteThemePath,
411
+ siteTheme.replace(
398
412
  'fontFamily: "Arial, \'Helvetica Neue\', Helvetica, sans-serif"',
399
413
  'fontFamily: "Arial; color: red"',
400
414
  ),
@@ -405,9 +419,12 @@ This route verifies that packaged norna sites can build route pages.
405
419
  { cwd: siteProjectRoot, env: npmEnv },
406
420
  'typography.fontFamily',
407
421
  );
422
+ await writeFile(siteThemePath, siteTheme);
423
+ const siteConfigPath = path.join(siteProjectRoot, 'site', 'config.mjs');
424
+ const siteConfig = await readFile(siteConfigPath, 'utf8');
408
425
  await writeFile(
409
426
  siteConfigPath,
410
- siteConfig.replace("basePath: '/gallery/'", "basePath: 'gallery'"),
427
+ siteConfig.replace("basePath: '/site/'", "basePath: 'site'"),
411
428
  );
412
429
  await runExpectFailure(
413
430
  npxBin,
@@ -49,9 +49,8 @@ try {
49
49
  };
50
50
  `);
51
51
  await writeFile(path.join(siteDir, 'theme.md'), `---
52
- presentation:
53
- typography:
54
- preset: quiet-gallery
52
+ typography:
53
+ preset: quiet-gallery
55
54
  frame:
56
55
  colors: presentation
57
56
  ---
@@ -27,10 +27,9 @@ const navigationPages = getNavigationPages(sitePages);
27
27
  const homePage = sitePages.find((page) => page.isHome) ?? currentPage;
28
28
  const themeData = theme?.data ?? {};
29
29
  const brandLabel = themeData.navigation?.brand ?? homePage.title;
30
- const pagePresentation = resolvePagePresentation(themeData.presentation, entry.data.presentation);
30
+ const pagePresentation = resolvePagePresentation(themeData, entry.data.presentation);
31
31
  const frameColors = resolveFrameColors({
32
- themePresentation: themeData.presentation,
33
- themeFrame: themeData.frame,
32
+ theme: themeData,
34
33
  pagePresentation,
35
34
  pageFrame: entry.data.frame,
36
35
  });