@janga/norna 0.7.7 → 0.7.9
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 +6 -5
- package/docs/README.md +5 -1
- package/docs/commands.md +4 -3
- package/docs/configuration.md +1 -1
- package/docs/content.md +64 -14
- package/docs/design/norna-diagram-design.md +156 -0
- package/docs/getting-started.md +48 -7
- package/docs/images-and-metadata.md +11 -6
- package/docs/routes.md +14 -3
- package/docs/site-structure.md +6 -3
- package/docs/theme.md +7 -4
- package/docs/typography.md +4 -5
- package/examples/dog-gallery/site/content.md +1 -36
- package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +0 -8
- package/examples/typography-presets/.astro/collections/site.schema.json +776 -0
- package/examples/typography-presets/.astro/collections/theme.schema.json +668 -0
- package/examples/typography-presets/.astro/content-assets.mjs +4 -0
- package/examples/typography-presets/.astro/content-modules.mjs +1 -0
- package/examples/typography-presets/.astro/content.d.ts +175 -0
- package/examples/typography-presets/.astro/data-store.json +1 -0
- package/examples/typography-presets/.astro/dev-local.json +8 -0
- package/examples/typography-presets/.astro/dev.json +15 -0
- package/examples/typography-presets/.astro/dev.log +24 -0
- package/examples/typography-presets/.astro/settings.json +5 -0
- package/examples/typography-presets/.astro/types.d.ts +2 -0
- package/examples/typography-presets/node_modules/.vite/deps/_metadata.json +80 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/examples/typography-presets/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
- package/examples/typography-presets/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/embla-carousel.js +1389 -0
- package/examples/typography-presets/node_modules/.vite/deps/embla-carousel.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
- package/examples/typography-presets/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
- package/examples/typography-presets/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/package.json +3 -0
- package/examples/typography-presets/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
- package/examples/typography-presets/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
- package/examples/typography-presets/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
- package/examples/typography-presets/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
- package/examples/typography-presets/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
- package/examples/typography-presets/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
- package/examples/typography-presets/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
- package/examples/typography-presets/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
- package/examples/typography-presets/site/.norna/generated-images.json +290 -0
- package/examples/typography-presets/site/config.mjs +42 -0
- package/examples/typography-presets/site/content.md +134 -0
- package/examples/typography-presets/site/images/compact-gallery/black-schnauzer.jpg +0 -0
- package/examples/typography-presets/site/images/compact-gallery/retriever-portrait.jpg +0 -0
- package/examples/typography-presets/site/images/compact-gallery/schnauzer-portrait.jpg +0 -0
- package/examples/typography-presets/site/images/quiet-gallery/puppy-portrait.jpg +0 -0
- package/examples/typography-presets/site/images/quiet-gallery/shepherd-portrait.jpg +0 -0
- package/examples/typography-presets/site/images/quiet-gallery/white-puppy.jpg +0 -0
- package/examples/typography-presets/site/images/statement/black-pedigree.jpg +0 -0
- package/examples/typography-presets/site/images/statement/sheepdog-grass.jpg +0 -0
- package/examples/typography-presets/site/images/statement/sheepdog-wide.jpg +0 -0
- package/examples/typography-presets/site/images/text-forward/boxer-portrait.jpg +0 -0
- package/examples/typography-presets/site/images/text-forward/grass-puppy.jpg +0 -0
- package/examples/typography-presets/site/images/text-forward/grey-street-dog.jpg +0 -0
- package/examples/typography-presets/site/public/robots.txt +2 -0
- package/examples/typography-presets/site/theme.md +43 -0
- package/package.json +1 -1
- package/scripts/lib/site-content.mjs +76 -0
- package/scripts/lib/theme-config.mjs +2 -0
- package/scripts/sync-content-sections.mjs +158 -22
- package/scripts/test-content-check.mjs +31 -1
- package/scripts/test-content-model-v2.mjs +238 -6
- package/scripts/test-package-check.mjs +17 -0
- package/src/components/SiteSection.astro +1 -1
- package/src/lib/sectionContent.ts +10 -2
- package/src/styles/global.css +1 -1
- package/starters/basic/README.md +4 -2
- package/starters/basic/site/content.md +2 -8
- package/starters/project/README.md +7 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Typography Presets
|
|
3
|
+
description: A Norna example page showing every built-in typography preset.
|
|
4
|
+
navigation:
|
|
5
|
+
label: Presets
|
|
6
|
+
sections:
|
|
7
|
+
quiet-gallery:
|
|
8
|
+
presentation:
|
|
9
|
+
typography:
|
|
10
|
+
preset: quiet-gallery
|
|
11
|
+
compact-gallery:
|
|
12
|
+
presentation:
|
|
13
|
+
typography:
|
|
14
|
+
preset: compact-gallery
|
|
15
|
+
text-forward:
|
|
16
|
+
presentation:
|
|
17
|
+
typography:
|
|
18
|
+
preset: text-forward
|
|
19
|
+
statement:
|
|
20
|
+
presentation:
|
|
21
|
+
typography:
|
|
22
|
+
preset: statement
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quiet Gallery {#quiet-gallery}
|
|
26
|
+
|
|
27
|
+
This section uses the `quiet-gallery` preset. Its purpose is to keep text
|
|
28
|
+
restrained so images can carry much of the page without the typography
|
|
29
|
+
competing for attention.
|
|
30
|
+
|
|
31
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a
|
|
32
|
+
ante venenatis dapibus posuere velit aliquet. Maecenas faucibus mollis
|
|
33
|
+
interdum. Donec ullamcorper nulla non metus auctor fringilla.
|
|
34
|
+
|
|
35
|
+
### Reading rhythm
|
|
36
|
+
|
|
37
|
+
Curabitur blandit tempus porttitor. Praesent commodo cursus magna, vel
|
|
38
|
+
scelerisque nisl consectetur et. Vestibulum id ligula porta felis euismod
|
|
39
|
+
semper.
|
|
40
|
+
|
|
41
|
+
```norna-image-stack
|
|
42
|
+
- image: puppy-portrait.jpg
|
|
43
|
+
alt: A puppy sitting outdoors and looking toward the camera.
|
|
44
|
+
caption: Puppy portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
45
|
+
- image: shepherd-portrait.jpg
|
|
46
|
+
alt: A shepherd dog standing outdoors and looking toward the camera.
|
|
47
|
+
caption: Shepherd dog portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
48
|
+
- image: white-puppy.jpg
|
|
49
|
+
alt: A white puppy outdoors in winter.
|
|
50
|
+
caption: White puppy outdoors. Photo by Bicanski, CC0, via Pixnio.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Compact Gallery {#compact-gallery}
|
|
54
|
+
|
|
55
|
+
This section uses the `compact-gallery` preset. Its purpose is to make denser
|
|
56
|
+
image-led pages easier to scan when a site contains many sections, many images
|
|
57
|
+
or short information blocks.
|
|
58
|
+
|
|
59
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lacinia
|
|
60
|
+
bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod.
|
|
61
|
+
|
|
62
|
+
### Dense sections
|
|
63
|
+
|
|
64
|
+
Nullam id dolor id nibh ultricies vehicula ut id elit. Fusce dapibus, tellus ac
|
|
65
|
+
cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit
|
|
66
|
+
amet risus.
|
|
67
|
+
|
|
68
|
+
```norna-image-stack
|
|
69
|
+
- image: schnauzer-portrait.jpg
|
|
70
|
+
alt: A schnauzer-type dog sitting outdoors.
|
|
71
|
+
caption: Schnauzer portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
72
|
+
- image: black-schnauzer.jpg
|
|
73
|
+
alt: A black schnauzer-type dog standing on grass.
|
|
74
|
+
caption: Black hunting dog portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
75
|
+
- image: retriever-portrait.jpg
|
|
76
|
+
alt: A retriever-type dog sitting outdoors.
|
|
77
|
+
caption: Retriever portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Text Forward {#text-forward}
|
|
81
|
+
|
|
82
|
+
This section uses the `text-forward` preset. Its purpose is to give longer
|
|
83
|
+
explanatory text more presence while still supporting image captions and
|
|
84
|
+
image-led sections.
|
|
85
|
+
|
|
86
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed posuere
|
|
87
|
+
consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.
|
|
88
|
+
Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.
|
|
89
|
+
|
|
90
|
+
### Longer explanations
|
|
91
|
+
|
|
92
|
+
Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Donec sed odio
|
|
93
|
+
dui. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget
|
|
94
|
+
lacinia odio sem nec elit.
|
|
95
|
+
|
|
96
|
+
```norna-image-stack
|
|
97
|
+
- image: grass-puppy.jpg
|
|
98
|
+
alt: A puppy standing in green grass.
|
|
99
|
+
caption: Puppy in grass. Photo by Bicanski, CC0, via Pixnio.
|
|
100
|
+
- image: grey-street-dog.jpg
|
|
101
|
+
alt: A grey dog standing on a paved street.
|
|
102
|
+
caption: Grey dog on pavement. Photo by Bicanski, CC0, via Pixnio.
|
|
103
|
+
- image: boxer-portrait.jpg
|
|
104
|
+
alt: A brown boxer-type dog looking at the camera.
|
|
105
|
+
caption: Brown dog portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Statement {#statement}
|
|
109
|
+
|
|
110
|
+
This section uses the `statement` preset. Its purpose is to make short,
|
|
111
|
+
declarative sections feel more direct and programmatic. It is intentionally
|
|
112
|
+
stronger than the other presets and should usually be used sparingly.
|
|
113
|
+
|
|
114
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec id elit non mi
|
|
115
|
+
porta gravida at eget metus. Integer posuere erat a ante venenatis dapibus
|
|
116
|
+
posuere velit aliquet.
|
|
117
|
+
|
|
118
|
+
### Short emphasis
|
|
119
|
+
|
|
120
|
+
Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Aenean
|
|
121
|
+
lacinia bibendum nulla sed consectetur. Maecenas sed diam eget risus varius
|
|
122
|
+
blandit sit amet non magna.
|
|
123
|
+
|
|
124
|
+
```norna-image-stack
|
|
125
|
+
- image: sheepdog-grass.jpg
|
|
126
|
+
alt: A sheepdog puppy sitting in green grass.
|
|
127
|
+
caption: Sheepdog puppy in grass. Photo by Bicanski, CC0, via Pixnio.
|
|
128
|
+
- image: sheepdog-wide.jpg
|
|
129
|
+
alt: A sheepdog puppy lying outdoors.
|
|
130
|
+
caption: Sheepdog puppy outdoors. Photo by Bicanski, CC0, via Pixnio.
|
|
131
|
+
- image: black-pedigree.jpg
|
|
132
|
+
alt: A black dog standing in grass.
|
|
133
|
+
caption: Black pedigree dog portrait. Photo by Bicanski, CC0, via Pixnio.
|
|
134
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
navigation:
|
|
3
|
+
brand: Typography Presets
|
|
4
|
+
layout:
|
|
5
|
+
density: normal
|
|
6
|
+
pageWidth: 1320px
|
|
7
|
+
gutter:
|
|
8
|
+
desktop: clamp(1.25rem, 4vw, 3rem)
|
|
9
|
+
mobile: 1rem
|
|
10
|
+
spacing:
|
|
11
|
+
firstSectionTop:
|
|
12
|
+
desktop: clamp(1.5rem, 3vw, 2.5rem)
|
|
13
|
+
mobile: 1rem
|
|
14
|
+
sectionGap:
|
|
15
|
+
desktop: clamp(2rem, 4vw, 3.5rem)
|
|
16
|
+
mobile: 2rem
|
|
17
|
+
finalSectionBottom:
|
|
18
|
+
desktop: clamp(2rem, 4vw, 3.5rem)
|
|
19
|
+
mobile: 2rem
|
|
20
|
+
bodyToImages:
|
|
21
|
+
desktop: 1.25rem
|
|
22
|
+
mobile: 1rem
|
|
23
|
+
imageGap:
|
|
24
|
+
desktop: clamp(1.25rem, 3vw, 2.25rem)
|
|
25
|
+
mobile: 1.5rem
|
|
26
|
+
gallery:
|
|
27
|
+
width: 70vw
|
|
28
|
+
maxAvailableWidthPercent:
|
|
29
|
+
desktop: 100
|
|
30
|
+
mobile: 100
|
|
31
|
+
maxAvailableHeightPercent:
|
|
32
|
+
desktop: 72
|
|
33
|
+
mobile: 68
|
|
34
|
+
typography:
|
|
35
|
+
fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
|
36
|
+
preset: quiet-gallery
|
|
37
|
+
rhythm: normal
|
|
38
|
+
presentation:
|
|
39
|
+
backgroundColor: "#f8f5ee"
|
|
40
|
+
textColor: "#17201d"
|
|
41
|
+
frame:
|
|
42
|
+
colors: presentation
|
|
43
|
+
---
|
package/package.json
CHANGED
|
@@ -22,6 +22,9 @@ const inlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
|
|
|
22
22
|
const frontmatterDelimiterRegex = /^---\s*$/;
|
|
23
23
|
const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'navigation', 'presentation', 'frame', 'sections']);
|
|
24
24
|
const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'layout', 'gallery', 'typography', 'presentation', 'frame']);
|
|
25
|
+
const frontmatterColorKeys = new Set(['backgroundColor', 'textColor', 'color']);
|
|
26
|
+
const quotedHexColorRegex = /^["']#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})["']$/;
|
|
27
|
+
const unquotedHexColorRegex = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
25
28
|
const knownNestedFrontmatterKeys = new Set([
|
|
26
29
|
'align',
|
|
27
30
|
'alt',
|
|
@@ -261,6 +264,79 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
261
264
|
}
|
|
262
265
|
};
|
|
263
266
|
|
|
267
|
+
const stripInlineYamlComment = (value) => {
|
|
268
|
+
let quote = null;
|
|
269
|
+
|
|
270
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
271
|
+
const character = value[index];
|
|
272
|
+
const previous = value[index - 1];
|
|
273
|
+
|
|
274
|
+
if ((character === '"' || character === "'") && previous !== '\\') {
|
|
275
|
+
quote = quote === character ? null : quote ?? character;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (character === '#' && quote === null && /\s/.test(previous ?? '')) {
|
|
280
|
+
return value.slice(0, index).trimEnd();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return value.trim();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export const validateFrontmatterColorValues = (frontmatter, addIssue, label = 'Frontmatter') => {
|
|
288
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
289
|
+
const pathStack = [];
|
|
290
|
+
|
|
291
|
+
for (const [index, line] of lines.entries()) {
|
|
292
|
+
const lineNumber = index + 1;
|
|
293
|
+
const trimmed = line.trim();
|
|
294
|
+
|
|
295
|
+
if (!trimmed || trimmed.startsWith('#') || frontmatterDelimiterRegex.test(line)) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const match = line.match(/^(\s*)([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/);
|
|
300
|
+
if (!match) continue;
|
|
301
|
+
|
|
302
|
+
const indent = match[1].length;
|
|
303
|
+
const key = match[2];
|
|
304
|
+
const rawValue = match[3].trim();
|
|
305
|
+
|
|
306
|
+
while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent) {
|
|
307
|
+
pathStack.pop();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const pathLabel = [...pathStack.map((entry) => entry.key), key].join('.');
|
|
311
|
+
|
|
312
|
+
if (!frontmatterColorKeys.has(key)) {
|
|
313
|
+
if (!rawValue) {
|
|
314
|
+
pathStack.push({ indent, key });
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const value = stripInlineYamlComment(rawValue);
|
|
320
|
+
|
|
321
|
+
if (quotedHexColorRegex.test(value)) continue;
|
|
322
|
+
|
|
323
|
+
if (unquotedHexColorRegex.test(rawValue)) {
|
|
324
|
+
addIssue({
|
|
325
|
+
severity: 'error',
|
|
326
|
+
message: `${label} line ${lineNumber} defines ${pathLabel} as an unquoted hex color. YAML treats "#" as the start of a comment unless the value is quoted.`,
|
|
327
|
+
fix: `Write it as ${key}: "#ffd84d" or another quoted hex color.`,
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
addIssue({
|
|
333
|
+
severity: 'error',
|
|
334
|
+
message: `${label} line ${lineNumber} defines ${pathLabel} with invalid color value ${rawValue || '(empty)'}.`,
|
|
335
|
+
fix: `Use a quoted hex color in #rgb, #rrggbb, or #rrggbbaa form, for example ${key}: "#ffd84d".`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
264
340
|
export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
|
|
265
341
|
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
266
342
|
knownTopLevelFrontmatterKeys: knownContentTopLevelFrontmatterKeys,
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { parseYamlMapping } from './frontmatter-yaml.mjs';
|
|
3
3
|
import {
|
|
4
4
|
splitSiteFile,
|
|
5
|
+
validateFrontmatterColorValues,
|
|
5
6
|
validateFrontmatterIndentation,
|
|
6
7
|
validateThemeFrontmatterStructure,
|
|
7
8
|
} from './site-content.mjs';
|
|
@@ -22,6 +23,7 @@ export const readThemeConfig = async () => {
|
|
|
22
23
|
|
|
23
24
|
validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
|
|
24
25
|
validateThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
|
|
26
|
+
validateFrontmatterColorValues(frontmatter, (issue) => issues.push(issue), siteThemeLabel);
|
|
25
27
|
|
|
26
28
|
if (issues.length > 0) {
|
|
27
29
|
throw new Error([
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
1
2
|
import { mkdir, rename, stat } from 'node:fs/promises';
|
|
2
3
|
import { createInterface } from 'node:readline/promises';
|
|
3
4
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
5
7
|
import {
|
|
6
8
|
extractMarkdownImageReferences,
|
|
7
9
|
extractNornaMarkdownBlockDiagnostics,
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
readThemeFile,
|
|
19
21
|
toPosixPath,
|
|
20
22
|
validateContentFrontmatterStructure,
|
|
23
|
+
validateFrontmatterColorValues,
|
|
21
24
|
validateFrontmatterIndentation,
|
|
22
25
|
validateThemeFrontmatterStructure,
|
|
23
26
|
} from './lib/site-content.mjs';
|
|
@@ -25,8 +28,11 @@ import { readImageDimensions } from './lib/image-dimensions.mjs';
|
|
|
25
28
|
import {
|
|
26
29
|
siteThemeLabel,
|
|
27
30
|
siteThemePath,
|
|
31
|
+
siteProjectRoot,
|
|
28
32
|
} from './lib/site-paths.mjs';
|
|
29
33
|
|
|
34
|
+
const execFileAsync = promisify(execFile);
|
|
35
|
+
|
|
30
36
|
const args = new Set(process.argv.slice(2));
|
|
31
37
|
const shouldWrite = args.has('--write');
|
|
32
38
|
const shouldCheck = args.has('--check') || !shouldWrite;
|
|
@@ -60,7 +66,7 @@ const promptForWrite = async () => {
|
|
|
60
66
|
if (!process.stdin.isTTY) return false;
|
|
61
67
|
|
|
62
68
|
const rl = createInterface({ input, output });
|
|
63
|
-
const answer = await rl.question('This will move image files
|
|
69
|
+
const answer = await rl.question('This will move image files into the page or route image roots where Norna references them. Continue? [y/N] ');
|
|
64
70
|
rl.close();
|
|
65
71
|
|
|
66
72
|
return answer.trim().toLowerCase() === 'y';
|
|
@@ -172,16 +178,110 @@ const getExpectedImagePath = (contentFile, sectionId, imageName) =>
|
|
|
172
178
|
const getExpectedImageLabel = (contentFile, sectionId, imageName) =>
|
|
173
179
|
`${contentFile.imagesLabel}/${sectionId}/${imageName}`;
|
|
174
180
|
|
|
175
|
-
const
|
|
176
|
-
(
|
|
181
|
+
const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
|
|
182
|
+
(globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
|
|
177
183
|
|
|
178
184
|
const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
|
|
179
185
|
|
|
186
|
+
const getRootLabel = (contentFile) => contentFile.isHome
|
|
187
|
+
? contentFile.imagesLabel
|
|
188
|
+
: contentFile.imagesLabel.replace(/\/images$/, '');
|
|
189
|
+
|
|
190
|
+
const getCandidateLabel = ({ contentFile, imagePath }) =>
|
|
191
|
+
`${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
|
|
192
|
+
|
|
193
|
+
const addExpectedReference = (expectedReferencesByPath, expectedPath, expectedReference) => {
|
|
194
|
+
if (!expectedReferencesByPath.has(expectedPath)) {
|
|
195
|
+
expectedReferencesByPath.set(expectedPath, []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expectedReferencesByPath.get(expectedPath).push(expectedReference);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const addGlobalImageCandidate = (globalImageCandidatesByName, imageName, candidate) => {
|
|
202
|
+
if (!globalImageCandidatesByName.has(imageName)) {
|
|
203
|
+
globalImageCandidatesByName.set(imageName, []);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
globalImageCandidatesByName.get(imageName).push(candidate);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const assertCleanGitForCrossRootSync = async (moves) => {
|
|
210
|
+
if (!moves.some((move) => move.crossRoot)) return;
|
|
211
|
+
|
|
212
|
+
let stdout = '';
|
|
213
|
+
try {
|
|
214
|
+
({ stdout } = await execFileAsync('git', ['status', '--short'], {
|
|
215
|
+
cwd: siteProjectRoot,
|
|
216
|
+
maxBuffer: 1024 * 1024,
|
|
217
|
+
}));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
addIssue({
|
|
220
|
+
severity: 'error',
|
|
221
|
+
message: 'Cross-route content sync requires a clean git working tree, but git status could not be checked.',
|
|
222
|
+
fix: 'Run content:sync inside a Git worktree, or move cross-route image files manually.',
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!stdout.trim()) return;
|
|
228
|
+
|
|
229
|
+
addIssue({
|
|
230
|
+
severity: 'error',
|
|
231
|
+
message: 'Cross-route content sync requires a clean git working tree before moving files between page or route image roots.',
|
|
232
|
+
fix: `Commit or stash your current changes, then run content:sync again. Current changes:\n${stdout.trim()}`,
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const addConflictingMoveIssues = () => {
|
|
237
|
+
const movesBySource = new Map();
|
|
238
|
+
const movesByDestination = new Map();
|
|
239
|
+
|
|
240
|
+
for (const move of imageMoves) {
|
|
241
|
+
const sourceKey = move.from;
|
|
242
|
+
const destinationKey = move.to;
|
|
243
|
+
|
|
244
|
+
if (!movesBySource.has(sourceKey)) {
|
|
245
|
+
movesBySource.set(sourceKey, []);
|
|
246
|
+
}
|
|
247
|
+
movesBySource.get(sourceKey).push(move);
|
|
248
|
+
|
|
249
|
+
if (!movesByDestination.has(destinationKey)) {
|
|
250
|
+
movesByDestination.set(destinationKey, []);
|
|
251
|
+
}
|
|
252
|
+
movesByDestination.get(destinationKey).push(move);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const moves of movesBySource.values()) {
|
|
256
|
+
const destinations = new Set(moves.map((move) => move.to));
|
|
257
|
+
if (destinations.size <= 1) continue;
|
|
258
|
+
|
|
259
|
+
const firstMove = moves[0];
|
|
260
|
+
addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
|
|
261
|
+
severity: 'error',
|
|
262
|
+
message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.sectionId, move.imageName)).join(', ')}.`,
|
|
263
|
+
fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const moves of movesByDestination.values()) {
|
|
268
|
+
const sources = new Set(moves.map((move) => move.from));
|
|
269
|
+
if (sources.size <= 1) continue;
|
|
270
|
+
|
|
271
|
+
const firstMove = moves[0];
|
|
272
|
+
addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
|
|
273
|
+
severity: 'error',
|
|
274
|
+
message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.sectionId, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
|
|
275
|
+
fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
180
280
|
const validateImageReference = async (
|
|
181
281
|
contentFile,
|
|
182
282
|
section,
|
|
183
283
|
reference,
|
|
184
|
-
|
|
284
|
+
globalImageCandidatesByName,
|
|
185
285
|
expectedReferencesByPath,
|
|
186
286
|
) => {
|
|
187
287
|
const imageName = reference.image;
|
|
@@ -194,11 +294,11 @@ const validateImageReference = async (
|
|
|
194
294
|
return expectedPath;
|
|
195
295
|
}
|
|
196
296
|
|
|
197
|
-
const candidates =
|
|
297
|
+
const candidates = getGlobalImageCandidates(globalImageCandidatesByName, imageName, expectedPath);
|
|
198
298
|
if (candidates.length === 0) {
|
|
199
299
|
addSectionIssue(contentFile, section, {
|
|
200
300
|
severity: 'error',
|
|
201
|
-
message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under
|
|
301
|
+
message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page or route image root.`,
|
|
202
302
|
fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the Norna-managed image reference.`,
|
|
203
303
|
});
|
|
204
304
|
return null;
|
|
@@ -207,20 +307,21 @@ const validateImageReference = async (
|
|
|
207
307
|
if (candidates.length > 1) {
|
|
208
308
|
addSectionIssue(contentFile, section, {
|
|
209
309
|
severity: 'error',
|
|
210
|
-
message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(
|
|
211
|
-
fix: 'Move the intended file manually or rename files so the
|
|
310
|
+
message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getCandidateLabel).join(', ')}.`,
|
|
311
|
+
fix: 'Move the intended file manually or rename files so the move is unambiguous.',
|
|
212
312
|
});
|
|
213
313
|
return null;
|
|
214
314
|
}
|
|
215
315
|
|
|
216
|
-
const
|
|
316
|
+
const sourceCandidate = candidates[0];
|
|
317
|
+
const sourcePath = sourceCandidate.imagePath;
|
|
217
318
|
const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
|
|
218
|
-
.filter((expectedReference) => expectedReference.section.id !== section.id);
|
|
319
|
+
.filter((expectedReference) => expectedReference.contentFile !== contentFile || expectedReference.section.id !== section.id);
|
|
219
320
|
|
|
220
321
|
if (referencesAtCurrentLocation.length > 0) {
|
|
221
322
|
addSectionIssue(contentFile, section, {
|
|
222
323
|
severity: 'error',
|
|
223
|
-
message: `Cannot relocate "${imageName}" from ${
|
|
324
|
+
message: `Cannot relocate "${imageName}" from ${getCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
|
|
224
325
|
fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
|
|
225
326
|
});
|
|
226
327
|
return null;
|
|
@@ -231,15 +332,19 @@ const validateImageReference = async (
|
|
|
231
332
|
from: sourcePath,
|
|
232
333
|
to: expectedPath,
|
|
233
334
|
contentFile,
|
|
335
|
+
sourceContentFile: sourceCandidate.contentFile,
|
|
234
336
|
sectionId: section.id,
|
|
337
|
+
crossRoot: sourceCandidate.contentFile !== contentFile,
|
|
235
338
|
});
|
|
236
339
|
referencedImagePaths.add(sourcePath);
|
|
237
340
|
|
|
238
341
|
if (!shouldWrite) {
|
|
239
342
|
addSectionIssue(contentFile, section, {
|
|
240
343
|
severity: 'error',
|
|
241
|
-
message: `Image "${imageName}" is used here but is located in ${
|
|
242
|
-
fix:
|
|
344
|
+
message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
|
|
345
|
+
fix: sourceCandidate.contentFile === contentFile
|
|
346
|
+
? 'Run norna content:sync to move it inside the current page or route image root.'
|
|
347
|
+
: `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-route sync requires a clean git working tree when files are moved.`,
|
|
243
348
|
});
|
|
244
349
|
}
|
|
245
350
|
|
|
@@ -290,6 +395,7 @@ try {
|
|
|
290
395
|
themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
|
|
291
396
|
validateFrontmatterIndentation(themeFrontmatter, addIssue);
|
|
292
397
|
validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
|
|
398
|
+
validateFrontmatterColorValues(themeFrontmatter, addIssue, siteThemeLabel);
|
|
293
399
|
} catch (error) {
|
|
294
400
|
if (error?.code !== 'ENOENT') {
|
|
295
401
|
throw error;
|
|
@@ -302,23 +408,30 @@ const themeInlineStyleNames = themeFrontmatter
|
|
|
302
408
|
|
|
303
409
|
const contentFiles = await getContentFiles();
|
|
304
410
|
const allImageFiles = [];
|
|
411
|
+
const contentFileContexts = [];
|
|
412
|
+
const globalImageCandidatesByName = new Map();
|
|
413
|
+
const globalExpectedReferencesByPath = new Map();
|
|
305
414
|
|
|
306
415
|
for (const contentFile of contentFiles) {
|
|
307
416
|
const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
308
417
|
const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
|
|
309
418
|
validateFrontmatterIndentation(frontmatter, addIssue);
|
|
310
419
|
validateContentFrontmatterStructure(frontmatter, addIssue);
|
|
420
|
+
validateFrontmatterColorValues(frontmatter, addIssue, contentFile.contentLabel);
|
|
311
421
|
|
|
312
422
|
const frontmatterSections = getFrontmatterSections(frontmatter);
|
|
313
423
|
const frontmatterIds = new Set(frontmatterSections.map((section) => section.id));
|
|
314
424
|
const { sections } = getBodySections(body);
|
|
315
425
|
const sectionsById = new Map();
|
|
316
426
|
const blockResultsBySectionId = new Map();
|
|
317
|
-
const expectedReferencesByPath = new Map();
|
|
318
427
|
const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
|
|
319
428
|
|
|
320
|
-
for (const candidates of imageCandidatesByName
|
|
321
|
-
|
|
429
|
+
for (const [imageName, candidates] of imageCandidatesByName) {
|
|
430
|
+
for (const imagePath of candidates) {
|
|
431
|
+
const candidate = { contentFile, imagePath };
|
|
432
|
+
allImageFiles.push(candidate);
|
|
433
|
+
addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
|
|
434
|
+
}
|
|
322
435
|
}
|
|
323
436
|
|
|
324
437
|
for (const section of sections) {
|
|
@@ -354,13 +467,30 @@ for (const contentFile of contentFiles) {
|
|
|
354
467
|
|
|
355
468
|
for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
|
|
356
469
|
const expectedPath = getExpectedImagePath(contentFile, section.id, reference.image);
|
|
357
|
-
|
|
358
|
-
expectedReferencesByPath.set(expectedPath, []);
|
|
359
|
-
}
|
|
360
|
-
expectedReferencesByPath.get(expectedPath).push({ section, reference });
|
|
470
|
+
addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
|
|
361
471
|
}
|
|
362
472
|
}
|
|
363
473
|
|
|
474
|
+
contentFileContexts.push({
|
|
475
|
+
contentFile,
|
|
476
|
+
frontmatterIds,
|
|
477
|
+
sections,
|
|
478
|
+
sectionsById,
|
|
479
|
+
blockResultsBySectionId,
|
|
480
|
+
body,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const context of contentFileContexts) {
|
|
485
|
+
const {
|
|
486
|
+
contentFile,
|
|
487
|
+
frontmatterIds,
|
|
488
|
+
sections,
|
|
489
|
+
sectionsById,
|
|
490
|
+
blockResultsBySectionId,
|
|
491
|
+
body,
|
|
492
|
+
} = context;
|
|
493
|
+
|
|
364
494
|
for (const id of frontmatterIds) {
|
|
365
495
|
if (!sectionsById.has(id)) {
|
|
366
496
|
addContentIssue(contentFile, {
|
|
@@ -417,8 +547,8 @@ for (const contentFile of contentFiles) {
|
|
|
417
547
|
contentFile,
|
|
418
548
|
section,
|
|
419
549
|
reference,
|
|
420
|
-
|
|
421
|
-
|
|
550
|
+
globalImageCandidatesByName,
|
|
551
|
+
globalExpectedReferencesByPath,
|
|
422
552
|
);
|
|
423
553
|
if (sourcePath) {
|
|
424
554
|
sourcePathsByImage.set(reference.image, sourcePath);
|
|
@@ -436,6 +566,12 @@ const unreferencedImages = allImageFiles
|
|
|
436
566
|
.map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
|
|
437
567
|
.sort((left, right) => left.localeCompare(right, 'sv'));
|
|
438
568
|
|
|
569
|
+
addConflictingMoveIssues();
|
|
570
|
+
|
|
571
|
+
if (shouldWrite) {
|
|
572
|
+
await assertCleanGitForCrossRootSync(imageMoves);
|
|
573
|
+
}
|
|
574
|
+
|
|
439
575
|
if (hasErrors()) {
|
|
440
576
|
printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
|
|
441
577
|
process.exit(1);
|