@janga/norna 0.7.13 → 0.7.15
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 +15 -9
- package/bin/norna-cli.mjs +11 -4
- package/docs/README.md +71 -43
- package/docs/commands.md +34 -20
- package/docs/configuration.md +43 -62
- package/docs/content.md +34 -7
- package/docs/design/command-organization.md +15 -8
- package/docs/design/norna-diagram-design.md +6 -6
- package/docs/design/site-examples-structure.md +1 -1
- package/docs/engine-development.md +22 -9
- package/docs/getting-started.md +29 -16
- package/docs/how-to/embedded-site.md +1 -1
- package/docs/images-and-metadata.md +3 -3
- package/docs/local-development.md +11 -3
- package/docs/public-files.md +105 -0
- package/docs/publishing.md +4 -8
- package/docs/routes.md +6 -7
- package/docs/site-files.md +144 -0
- package/docs/sitewide-content.md +47 -33
- package/docs/theme.md +37 -49
- package/docs/typography.md +25 -31
- package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
- package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
- package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
- package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
- package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
- package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
- package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
- package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
- package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
- package/examples/feature-demos/media-and-surfaces/README.md +1 -1
- package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
- package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
- package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
- package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
- package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
- package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
- package/examples/feature-demos/sitewide-content/README.md +8 -0
- package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
- package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
- package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
- package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
- package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
- package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
- package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
- package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
- package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
- package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
- package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
- package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
- package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
- package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
- package/fixtures/basic/site/{config.md → config.yaml} +0 -2
- package/fixtures/basic/site/content.md +3 -3
- package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
- package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
- package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
- package/fixtures/content-model-v2/site/theme.yaml +4 -0
- package/package.json +10 -3
- package/schemas/config.schema.json +77 -0
- package/schemas/content-frontmatter.schema.json +81 -0
- package/schemas/manifest.json +9 -0
- package/schemas/sitewide-content.schema.json +215 -0
- package/schemas/theme.schema.json +1208 -0
- package/scripts/check-config.mjs +18 -11
- package/scripts/export-theme-preset.mjs +1 -1
- package/scripts/generate-schemas.mjs +283 -0
- package/scripts/init-site.mjs +22 -27
- package/scripts/lib/documentation-links.mjs +8 -0
- package/scripts/lib/editor-language-service.mjs +609 -0
- package/scripts/lib/example-sites.mjs +2 -2
- package/scripts/lib/git-status.mjs +7 -0
- package/scripts/lib/logo-assets.mjs +15 -10
- package/scripts/lib/norna-markdown-blocks.mjs +90 -11
- package/scripts/lib/presentation.mjs +23 -12
- package/scripts/lib/project-config.mjs +17 -41
- package/scripts/lib/public-asset-conventions.mjs +112 -0
- package/scripts/lib/schema-definitions.mjs +213 -0
- package/scripts/lib/schema-value-definitions.mjs +86 -0
- package/scripts/lib/site-content.mjs +24 -18
- package/scripts/lib/site-paths.mjs +4 -8
- package/scripts/lib/sitewide-content.mjs +24 -0
- package/scripts/lib/theme-config.mjs +15 -35
- package/scripts/lib/theme-presets.mjs +76 -66
- package/scripts/lib/typography.mjs +15 -15
- package/scripts/lib/yaml-config.mjs +50 -0
- package/scripts/list-theme-presets.mjs +15 -0
- package/scripts/release.mjs +84 -8
- package/scripts/show-typography.mjs +22 -46
- package/scripts/sync-content-sections.mjs +2 -2
- package/scripts/test-client-javascript.mjs +28 -28
- package/scripts/test-content-check.mjs +21 -26
- package/scripts/test-content-model-v2.mjs +72 -40
- package/scripts/test-documentation.mjs +70 -4
- package/scripts/test-editor-language-service.mjs +158 -0
- package/scripts/test-engine-commands.mjs +65 -34
- package/scripts/test-package-check.mjs +37 -30
- package/scripts/test-project-config.mjs +15 -20
- package/scripts/test-schemas.mjs +102 -0
- package/scripts/test-temporary-visibility.mjs +5 -13
- package/scripts/test-theme-presets.mjs +64 -37
- package/src/components/CardList.astro +2 -5
- package/src/components/ImageCarousel.astro +21 -21
- package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
- package/src/components/SectionNavigationScript.astro +28 -0
- package/src/components/SiteNavigation.astro +6 -5
- package/src/components/SitePage.astro +8 -8
- package/src/components/SiteSection.astro +18 -21
- package/src/content.config.ts +17 -209
- package/src/layouts/BaseLayout.astro +9 -9
- package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
- package/src/lib/routeThemes.ts +9 -29
- package/src/lib/sectionContent.ts +3 -3
- package/src/lib/sitePublicAssets.ts +5 -21
- package/src/styles/global.css +73 -73
- package/starters/basic/.github/workflows/deploy.yml +1 -1
- package/starters/basic/README.md +16 -13
- package/starters/basic/package.json +3 -2
- package/starters/basic/site/{config.md → config.yaml} +0 -2
- package/starters/basic/site/content.md +1 -1
- package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
- package/starters/basic/site/{theme.md → theme.yaml} +8 -15
- package/starters/project/.github/workflows/deploy.yml +1 -1
- package/starters/project/README.md +22 -15
- package/starters/project/package.json +3 -2
- package/starters/project/site/{config.md → config.yaml} +0 -2
- package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
- package/starters/project/site/{theme.md → theme.yaml} +4 -8
- package/docs/site-structure.md +0 -98
- package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
- package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
- package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
- package/examples/feature-demos/theme-presets/site/config.md +0 -3
- package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
- package/examples/feature-demos/theme-presets/site/theme.md +0 -3
- package/fixtures/content-model-v2/site/theme.md +0 -7
- package/scripts/lib/sitewide-config.mjs +0 -34
- package/src/components/MobileSectionNavigationScript.astro +0 -14
- /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
- /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
- /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
- /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
- /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
- /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
- /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { documentationLink } from './documentation-links.mjs';
|
|
3
|
+
|
|
4
|
+
const field = (description, options = {}) => Object.freeze({ description, ...options });
|
|
5
|
+
const value = (title, description) => Object.freeze({ title, description });
|
|
6
|
+
const nornaBlocksDocumentation = documentationLink('Norna blocks reference', 'content.md', 'norna-blocks');
|
|
7
|
+
|
|
8
|
+
export const nornaMarkdownBlockDefinitions = Object.freeze({
|
|
9
|
+
'norna-image-stack': Object.freeze({
|
|
10
|
+
description: 'Display one or more managed images in a vertical stack.',
|
|
11
|
+
documentation: nornaBlocksDocumentation,
|
|
12
|
+
item: Object.freeze({
|
|
13
|
+
start: field('Start another managed image.', { key: 'image', prefix: '- ' }),
|
|
14
|
+
fields: Object.freeze({
|
|
15
|
+
alt: field('Optional alternative text for the image.'),
|
|
16
|
+
caption: field('Optional visible caption below the image.'),
|
|
17
|
+
}),
|
|
18
|
+
}),
|
|
19
|
+
}),
|
|
20
|
+
'norna-image-carousel': Object.freeze({
|
|
21
|
+
description: 'Display two or more managed images in an interactive carousel.',
|
|
22
|
+
documentation: nornaBlocksDocumentation,
|
|
23
|
+
item: Object.freeze({
|
|
24
|
+
start: field('Start another managed carousel image.', { key: 'image', prefix: '- ' }),
|
|
25
|
+
fields: Object.freeze({
|
|
26
|
+
alt: field('Optional alternative text for the image.'),
|
|
27
|
+
caption: field('Optional visible caption for the image.'),
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
}),
|
|
31
|
+
'norna-card-list': Object.freeze({
|
|
32
|
+
description: 'Display a structured list of cards.',
|
|
33
|
+
documentation: nornaBlocksDocumentation,
|
|
34
|
+
options: Object.freeze({
|
|
35
|
+
layout: field('Place each card image above, left, or right of its text. Defaults to image-top.', {
|
|
36
|
+
default: 'image-top',
|
|
37
|
+
values: Object.freeze({
|
|
38
|
+
'image-top': value('Image above', 'Place the image above the card text.'),
|
|
39
|
+
'image-left': value('Image left', 'Place the image to the left of the card text.'),
|
|
40
|
+
'image-right': value('Image right', 'Place the image to the right of the card text.'),
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
flow: field('Arrange cards as a responsive grid or a vertical stack. Defaults to grid.', {
|
|
44
|
+
default: 'grid',
|
|
45
|
+
values: Object.freeze({
|
|
46
|
+
grid: value('Grid', 'Arrange cards in a responsive grid.'),
|
|
47
|
+
stack: value('Stack', 'Arrange cards in one vertical column.'),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
size: field('Set the coordinated card and image size. Defaults to m.', {
|
|
51
|
+
default: 'm',
|
|
52
|
+
values: Object.freeze({
|
|
53
|
+
s: value('Small', 'Use compact cards and images.'),
|
|
54
|
+
m: value('Medium', 'Use the balanced default card size.'),
|
|
55
|
+
l: value('Large', 'Use larger cards and images.'),
|
|
56
|
+
xl: value('Extra large', 'Use the largest card and image size.'),
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
width: field('Limit the width of the complete card list. Defaults to normal.', {
|
|
60
|
+
default: 'normal',
|
|
61
|
+
values: Object.freeze({
|
|
62
|
+
text: value('Text', 'Match the active body-text width.'),
|
|
63
|
+
narrow: value('Narrow', 'Use a narrow card-list width.'),
|
|
64
|
+
normal: value('Normal', 'Use the balanced default card-list width.'),
|
|
65
|
+
wide: value('Wide', 'Allow the card list to use more horizontal space.'),
|
|
66
|
+
}),
|
|
67
|
+
}),
|
|
68
|
+
}),
|
|
69
|
+
item: Object.freeze({
|
|
70
|
+
start: field('Start another card. Every card requires a title.', { key: 'title', prefix: '- ' }),
|
|
71
|
+
fields: Object.freeze({
|
|
72
|
+
text: field('Optional card text.'),
|
|
73
|
+
image: field('Optional managed image filename.'),
|
|
74
|
+
link: field('Optional URL opened when the card is activated.'),
|
|
75
|
+
'badge-text': field('Optional short badge displayed on the card.'),
|
|
76
|
+
}),
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
2
80
|
|
|
3
|
-
export const nornaBlockTypes = new Set(
|
|
81
|
+
export const nornaBlockTypes = new Set(Object.keys(nornaMarkdownBlockDefinitions));
|
|
4
82
|
|
|
5
83
|
const blockTypeLabels = {
|
|
6
84
|
'norna-image-stack': 'norna-image-stack',
|
|
@@ -10,7 +88,7 @@ const blockTypeLabels = {
|
|
|
10
88
|
|
|
11
89
|
const knownBlockTypeList = Array.from(nornaBlockTypes).join(', ');
|
|
12
90
|
const imageNameRegex = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
|
|
13
|
-
const markerTagName = 'norna-
|
|
91
|
+
const markerTagName = 'norna-block';
|
|
14
92
|
const imageStackExample = [
|
|
15
93
|
'```norna-image-stack',
|
|
16
94
|
'- image: filename.jpg',
|
|
@@ -30,10 +108,11 @@ const cardListExample = [
|
|
|
30
108
|
' badge-text: Recommended',
|
|
31
109
|
'```',
|
|
32
110
|
].join('\n');
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
111
|
+
const cardListDefinition = nornaMarkdownBlockDefinitions['norna-card-list'];
|
|
112
|
+
const cardListLayouts = new Set(Object.keys(cardListDefinition.options.layout.values));
|
|
113
|
+
const cardListFlows = new Set(Object.keys(cardListDefinition.options.flow.values));
|
|
114
|
+
const cardListSizes = new Set(Object.keys(cardListDefinition.options.size.values));
|
|
115
|
+
const cardListWidths = new Set(Object.keys(cardListDefinition.options.width.values));
|
|
37
116
|
|
|
38
117
|
const formatLocation = ({ label, line } = {}) => [
|
|
39
118
|
label,
|
|
@@ -294,10 +373,10 @@ const parseImageListBlock = (source, options = {}) => {
|
|
|
294
373
|
const parseCardListBlock = (source, options = {}) => {
|
|
295
374
|
const cards = [];
|
|
296
375
|
const allowedKeys = new Set(['text', 'image', 'link', 'badge-text']);
|
|
297
|
-
let layout =
|
|
298
|
-
let flow =
|
|
299
|
-
let size =
|
|
300
|
-
let width =
|
|
376
|
+
let layout = cardListDefinition.options.layout.default;
|
|
377
|
+
let flow = cardListDefinition.options.flow.default;
|
|
378
|
+
let size = cardListDefinition.options.size.default;
|
|
379
|
+
let width = cardListDefinition.options.width.default;
|
|
301
380
|
let current = null;
|
|
302
381
|
|
|
303
382
|
for (const [index, line] of normalizeLines(source).entries()) {
|
|
@@ -551,7 +630,7 @@ export const createNornaMarkdownBlockMarker = (type, source) => {
|
|
|
551
630
|
|
|
552
631
|
export const splitNornaMarkdownBlockMarkers = (html, options = {}) => {
|
|
553
632
|
const blocks = [];
|
|
554
|
-
const markerRegex = /<norna-
|
|
633
|
+
const markerRegex = /<norna-block\s+data-type="([^"]+)"\s+data-source="([^"]*)"\s*><\/norna-block>|<pre\b([^>]*)>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g;
|
|
555
634
|
let cursor = 0;
|
|
556
635
|
|
|
557
636
|
for (const match of html.matchAll(markerRegex)) {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import { resolveThemeConfig } from './theme-presets.mjs';
|
|
6
6
|
|
|
7
7
|
export const presentationPaletteNames = ['dark', 'light', 'paper'];
|
|
8
|
+
const sectionSurfaceNames = ['base', 'soft', 'emphasis'];
|
|
8
9
|
|
|
9
10
|
const presentationPalettes = Object.freeze({
|
|
10
11
|
dark: {
|
|
@@ -66,8 +67,6 @@ const presentationPalettes = Object.freeze({
|
|
|
66
67
|
},
|
|
67
68
|
});
|
|
68
69
|
|
|
69
|
-
const normalizePresentation = (presentation) => presentation ?? {};
|
|
70
|
-
|
|
71
70
|
export const getPresentationPalette = (paletteName = 'dark') => {
|
|
72
71
|
const palette = presentationPalettes[paletteName];
|
|
73
72
|
if (!palette) {
|
|
@@ -93,19 +92,33 @@ export const getPresentationCssVariables = (presentation) => {
|
|
|
93
92
|
};
|
|
94
93
|
};
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
const getSectionSurfaces = (value = ['base'], sourceLabel = 'theme.yaml') => {
|
|
96
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 3) {
|
|
97
|
+
throw new Error(`sectionSurfaces must contain one to three values in ${sourceLabel}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const surface of value) {
|
|
101
|
+
if (!sectionSurfaceNames.includes(surface)) {
|
|
102
|
+
throw new Error(`Unknown section surface "${surface}" in ${sourceLabel}. Use one of: ${sectionSurfaceNames.join(', ')}.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (new Set(value).size !== value.length) {
|
|
107
|
+
throw new Error(`Each section surface may appear only once in ${sourceLabel}.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return value;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
|
|
97
114
|
const normalizedTheme = resolveThemeConfig(theme, sourceLabel);
|
|
98
|
-
const
|
|
99
|
-
const paletteName = normalizedThemePresentation.palette ?? 'dark';
|
|
115
|
+
const paletteName = normalizedTheme.palette ?? 'dark';
|
|
100
116
|
const palette = getPresentationPalette(paletteName);
|
|
101
117
|
|
|
102
118
|
return {
|
|
103
119
|
paletteName,
|
|
104
120
|
palette,
|
|
105
|
-
sectionSurfaces:
|
|
106
|
-
mode: normalizedThemePresentation.sectionSurfaces?.mode ?? 'none',
|
|
107
|
-
sequence: normalizedThemePresentation.sectionSurfaces?.sequence ?? ['base', 'soft', 'emphasis'],
|
|
108
|
-
},
|
|
121
|
+
sectionSurfaces: getSectionSurfaces(normalizedTheme.sectionSurfaces, sourceLabel),
|
|
109
122
|
typography: resolveTypographyConfig(normalizedTheme.typography ?? defaultTypography),
|
|
110
123
|
};
|
|
111
124
|
};
|
|
@@ -117,9 +130,7 @@ export const resolvePagePresentation = (theme, sourceLabel) => {
|
|
|
117
130
|
};
|
|
118
131
|
|
|
119
132
|
export const resolveSectionSurface = (pagePresentation, sectionIndex) => {
|
|
120
|
-
const surfaceName = pagePresentation.sectionSurfaces
|
|
121
|
-
? pagePresentation.sectionSurfaces.sequence[sectionIndex % pagePresentation.sectionSurfaces.sequence.length]
|
|
122
|
-
: 'base';
|
|
133
|
+
const surfaceName = pagePresentation.sectionSurfaces[sectionIndex % pagePresentation.sectionSurfaces.length];
|
|
123
134
|
|
|
124
135
|
return {
|
|
125
136
|
name: surfaceName,
|
|
@@ -1,52 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parseYamlMapping } from './frontmatter-yaml.mjs';
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
3
2
|
import {
|
|
4
|
-
|
|
5
|
-
validateConfigFrontmatterStructure,
|
|
6
|
-
validateFrontmatterIndentation,
|
|
3
|
+
validateConfigYamlStructure,
|
|
7
4
|
} from './site-content.mjs';
|
|
8
5
|
import {
|
|
9
|
-
legacySiteConfigPath,
|
|
10
6
|
siteConfigLabel,
|
|
11
7
|
siteConfigPath,
|
|
12
8
|
siteThemeLabel,
|
|
13
9
|
} from './site-paths.mjs';
|
|
14
10
|
import { readThemeConfig } from './theme-config.mjs';
|
|
15
11
|
import { resolveThemeConfig } from './theme-presets.mjs';
|
|
12
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
16
13
|
|
|
17
|
-
const fileExists = (filePath) => access(filePath).then(() => true, () => false);
|
|
18
14
|
const readSiteConfig = async () => {
|
|
19
|
-
const source = await readFile(siteConfigPath, 'utf8').catch(
|
|
20
|
-
if (error?.code === 'ENOENT' && await fileExists(legacySiteConfigPath)) {
|
|
21
|
-
throw new Error(`${siteConfigLabel} is required. Replace the obsolete config.mjs with a frontmatter-only config.md file.`);
|
|
22
|
-
}
|
|
23
|
-
|
|
15
|
+
const source = await readFile(siteConfigPath, 'utf8').catch((error) => {
|
|
24
16
|
if (error?.code === 'ENOENT') {
|
|
25
17
|
throw new Error(`${siteConfigLabel} is required. Create it before running Norna.`);
|
|
26
18
|
}
|
|
27
19
|
|
|
28
20
|
throw error;
|
|
29
21
|
});
|
|
30
|
-
const { body, frontmatter, frontmatterBody } = splitSiteFile(source, siteConfigLabel);
|
|
31
|
-
const issues = [];
|
|
32
|
-
|
|
33
|
-
validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
|
|
34
|
-
validateConfigFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
|
|
35
22
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (issues.length > 0) {
|
|
43
|
-
throw new Error([
|
|
44
|
-
`${siteConfigLabel} has invalid frontmatter.`,
|
|
45
|
-
...issues.map((issue) => `- ${issue.message}`),
|
|
46
|
-
].join('\n'));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return parseYamlMapping(frontmatterBody);
|
|
23
|
+
return parseYamlConfig(source, siteConfigLabel, {
|
|
24
|
+
validateStructure: validateConfigYamlStructure,
|
|
25
|
+
});
|
|
50
26
|
};
|
|
51
27
|
|
|
52
28
|
const siteConfig = await readSiteConfig();
|
|
@@ -207,7 +183,7 @@ const readSiteUrl = (config) => {
|
|
|
207
183
|
const localeLabels = Object.freeze({
|
|
208
184
|
en: Object.freeze({
|
|
209
185
|
dismissBanner: 'Dismiss notice',
|
|
210
|
-
|
|
186
|
+
images: 'Images',
|
|
211
187
|
note: 'Note',
|
|
212
188
|
pageNavigation: 'On this page',
|
|
213
189
|
siteBanners: 'Site notices',
|
|
@@ -216,7 +192,7 @@ const localeLabels = Object.freeze({
|
|
|
216
192
|
}),
|
|
217
193
|
sv: Object.freeze({
|
|
218
194
|
dismissBanner: 'Stäng meddelande',
|
|
219
|
-
|
|
195
|
+
images: 'Bilder',
|
|
220
196
|
note: 'Not',
|
|
221
197
|
pageNavigation: 'På den här sidan',
|
|
222
198
|
siteBanners: 'Meddelanden',
|
|
@@ -246,10 +222,10 @@ const readLocale = (config) => {
|
|
|
246
222
|
});
|
|
247
223
|
};
|
|
248
224
|
|
|
249
|
-
const rawConfig = assertObject(siteConfig, 'config
|
|
250
|
-
const rawTheme = assertObject(themeConfig, 'theme
|
|
225
|
+
const rawConfig = assertObject(siteConfig, 'config YAML');
|
|
226
|
+
const rawTheme = assertObject(themeConfig, 'theme YAML', siteThemeLabel);
|
|
251
227
|
const siteUrl = readSiteUrl(rawConfig);
|
|
252
|
-
const scrollBehaviorNames = ['instant', '
|
|
228
|
+
const scrollBehaviorNames = ['instant', 'smooth'];
|
|
253
229
|
|
|
254
230
|
const defaultFontFamily = "Arial, 'Helvetica Neue', Helvetica, sans-serif";
|
|
255
231
|
const layoutDensityNames = ['compact', 'normal', 'airy'];
|
|
@@ -337,7 +313,7 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
|
|
|
337
313
|
const rawThemeConfig = assertObject(resolveThemeConfig(theme, sourceLabel), 'theme frontmatter', sourceLabel);
|
|
338
314
|
const rawLayoutConfig = assertObject(rawThemeConfig.layout ?? {}, 'layout', sourceLabel);
|
|
339
315
|
const rawLayoutSpacingConfig = assertObject(rawLayoutConfig.spacing ?? {}, 'layout.spacing', sourceLabel);
|
|
340
|
-
const
|
|
316
|
+
const rawImagesConfig = assertObject(rawThemeConfig.images ?? {}, 'images', sourceLabel);
|
|
341
317
|
const rawTypographyConfig = assertObject(rawThemeConfig.typography ?? {}, 'typography', sourceLabel);
|
|
342
318
|
const resolvedLayoutDensity = readEnum(rawLayoutConfig, 'density', 'layout', layoutDensityNames, 'normal', sourceLabel);
|
|
343
319
|
const resolvedLayoutSpacingDefaults = layoutDensityProfiles[resolvedLayoutDensity];
|
|
@@ -359,16 +335,16 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
|
|
|
359
335
|
sectionGap: readResponsiveCssLength(rawLayoutSpacingConfig, 'sectionGap', 'layout.spacing', resolvedLayoutSpacingDefaults.sectionGap, sourceLabel),
|
|
360
336
|
}),
|
|
361
337
|
}),
|
|
362
|
-
|
|
363
|
-
maxAvailableHeightPercent: readResponsivePercent(
|
|
338
|
+
images: Object.freeze({
|
|
339
|
+
maxAvailableHeightPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableHeightPercent', 'images', Object.freeze({
|
|
364
340
|
desktop: 74,
|
|
365
341
|
mobile: 68,
|
|
366
342
|
}), sourceLabel),
|
|
367
|
-
maxAvailableWidthPercent: readResponsivePercent(
|
|
343
|
+
maxAvailableWidthPercent: readResponsivePercent(rawImagesConfig, 'maxAvailableWidthPercent', 'images', Object.freeze({
|
|
368
344
|
desktop: 100,
|
|
369
345
|
mobile: 100,
|
|
370
346
|
}), sourceLabel),
|
|
371
|
-
width: readCssLength(
|
|
347
|
+
width: readCssLength(rawImagesConfig, 'width', 'images', '900px', sourceLabel),
|
|
372
348
|
}),
|
|
373
349
|
typography: Object.freeze({
|
|
374
350
|
fontFamily: readFontFamily(rawTypographyConfig, 'fontFamily', 'typography', defaultFontFamily, sourceLabel),
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const logoAssetFilenames = Object.freeze([
|
|
4
|
+
'logo.svg',
|
|
5
|
+
'logo.png',
|
|
6
|
+
'logo.jpg',
|
|
7
|
+
'logo.jpeg',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const browserIconAssetDefinitions = Object.freeze([
|
|
11
|
+
Object.freeze({ filename: 'favicon.svg', rel: 'icon', type: 'image/svg+xml' }),
|
|
12
|
+
Object.freeze({ filename: 'favicon.ico', rel: 'icon', sizes: 'any' }),
|
|
13
|
+
Object.freeze({ filename: 'favicon.png', rel: 'icon', type: 'image/png' }),
|
|
14
|
+
Object.freeze({ filename: 'apple-touch-icon.png', rel: 'apple-touch-icon' }),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const browserIconAssetFilenames = Object.freeze(
|
|
18
|
+
browserIconAssetDefinitions.map(({ filename }) => filename),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const logoExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg']);
|
|
22
|
+
const faviconExtensions = new Set(['.svg', '.png', '.ico']);
|
|
23
|
+
const exactLogoNames = new Set(logoAssetFilenames);
|
|
24
|
+
const exactBrowserIconNames = new Set(browserIconAssetFilenames);
|
|
25
|
+
|
|
26
|
+
export const isLogoAssetFilename = (filename) => exactLogoNames.has(filename);
|
|
27
|
+
|
|
28
|
+
const editDistance = (left, right) => {
|
|
29
|
+
const rows = Array.from({ length: left.length + 1 }, (_, index) => [index]);
|
|
30
|
+
for (let column = 1; column <= right.length; column += 1) rows[0][column] = column;
|
|
31
|
+
|
|
32
|
+
for (let row = 1; row <= left.length; row += 1) {
|
|
33
|
+
for (let column = 1; column <= right.length; column += 1) {
|
|
34
|
+
rows[row][column] = Math.min(
|
|
35
|
+
rows[row - 1][column] + 1,
|
|
36
|
+
rows[row][column - 1] + 1,
|
|
37
|
+
rows[row - 1][column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return rows[left.length][right.length];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getSuspiciousAsset = (filename) => {
|
|
46
|
+
const lowerFilename = filename.toLowerCase();
|
|
47
|
+
const parsed = path.parse(lowerFilename);
|
|
48
|
+
|
|
49
|
+
if (exactLogoNames.has(lowerFilename) && filename !== lowerFilename) {
|
|
50
|
+
return {
|
|
51
|
+
code: 'public-asset-case',
|
|
52
|
+
message: `Norna does not recognize "${filename}" as the navigation logo on case-sensitive file systems. Rename it to "${lowerFilename}".`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (exactBrowserIconNames.has(lowerFilename) && filename !== lowerFilename) {
|
|
57
|
+
return {
|
|
58
|
+
code: 'public-asset-case',
|
|
59
|
+
message: `Norna does not recognize "${filename}" as a browser icon on case-sensitive file systems. Rename it to "${lowerFilename}".`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed.name === 'logo' && !logoExtensions.has(parsed.ext)) {
|
|
64
|
+
return {
|
|
65
|
+
code: 'unsupported-logo-file',
|
|
66
|
+
message: `Norna does not recognize "${filename}" as the navigation logo. Use exactly one of ${logoAssetFilenames.join(', ')}.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (parsed.name === 'favicon' && !faviconExtensions.has(parsed.ext)) {
|
|
71
|
+
return {
|
|
72
|
+
code: 'unsupported-favicon-file',
|
|
73
|
+
message: `Norna does not link "${filename}" as a browser icon. Use favicon.svg, favicon.ico, or favicon.png.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (/^favicon[-_.]/.test(lowerFilename) && !exactBrowserIconNames.has(lowerFilename)) {
|
|
78
|
+
return {
|
|
79
|
+
code: 'unrecognized-favicon-file',
|
|
80
|
+
message: `Norna does not link "${filename}" automatically. Use favicon.svg, favicon.ico, favicon.png, or apple-touch-icon.png for convention-based browser icons.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (logoExtensions.has(parsed.ext) && editDistance(parsed.name, 'logo') === 1) {
|
|
85
|
+
return {
|
|
86
|
+
code: 'possible-logo-typo',
|
|
87
|
+
message: `"${filename}" looks like a misspelled navigation logo filename. Norna recognizes ${logoAssetFilenames.join(', ')}.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (faviconExtensions.has(parsed.ext) && editDistance(parsed.name, 'favicon') <= 2) {
|
|
92
|
+
return {
|
|
93
|
+
code: 'possible-favicon-typo',
|
|
94
|
+
message: `"${filename}" looks like a misspelled browser icon filename. Norna recognizes favicon.svg, favicon.ico, and favicon.png.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const inspectPublicAssetFilenames = (filenames) => {
|
|
102
|
+
const files = [...filenames].sort((left, right) => left.localeCompare(right, 'en'));
|
|
103
|
+
const logos = files.filter(isLogoAssetFilename);
|
|
104
|
+
const browserIcons = files.filter((filename) => exactBrowserIconNames.has(filename));
|
|
105
|
+
const suspicious = files.flatMap((filename) => {
|
|
106
|
+
if (isLogoAssetFilename(filename) || exactBrowserIconNames.has(filename)) return [];
|
|
107
|
+
const issue = getSuspiciousAsset(filename);
|
|
108
|
+
return issue ? [{ filename, ...issue }] : [];
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return { browserIcons, files, logos, suspicious };
|
|
112
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
import { themePresetNames } from './theme-presets.mjs';
|
|
3
|
+
|
|
4
|
+
const isDateOnly = (value) => {
|
|
5
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
6
|
+
|
|
7
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
8
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const textAlign = z.enum(['left', 'center', 'right']).describe('Text alignment. Omit an override to keep the active typography profile.');
|
|
12
|
+
const textSize = z.enum(['small', 'medium', 'large', 'xlarge']).describe('Text size from the active typography system. Omit an override to keep the profile value.');
|
|
13
|
+
const textWidth = z.enum(['narrow', 'normal', 'wide']).describe('Maximum width of body text. Omit an override to keep the profile value.');
|
|
14
|
+
const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).describe('CSS font weight.');
|
|
15
|
+
const typographyProfile = z.enum(['restrained', 'dense', 'reading', 'statement']).describe('Coordinated typography defaults. Omit this to use the selected preset.');
|
|
16
|
+
const themePreset = z.enum(themePresetNames).describe('Complete Norna visual preset. Start here and add overrides only when needed.');
|
|
17
|
+
const presentationPalette = z.enum(['dark', 'light', 'paper']).describe('Coordinated site color palette. Omit this to use the selected preset.');
|
|
18
|
+
const sectionSurface = z.enum(['base', 'soft', 'emphasis']).describe('Semantic section surface from the active palette.');
|
|
19
|
+
const spacingDensity = z.enum(['compact', 'normal', 'airy']).describe('Coordinated spacing density. Omit this to use the selected preset.');
|
|
20
|
+
const lineHeight = z.number()
|
|
21
|
+
.min(1, 'Use a unitless line height of at least 1.')
|
|
22
|
+
.max(3, 'Use a unitless line height of at most 3.')
|
|
23
|
+
.describe('Unitless line height between 1 and 3.');
|
|
24
|
+
const cssLength = z.string().regex(
|
|
25
|
+
/^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|lh))$/,
|
|
26
|
+
'Use a CSS length such as "0", "0.8em", "1rem", or "12px".',
|
|
27
|
+
).describe('CSS length using px, rem, em, ch or lh.');
|
|
28
|
+
const visualCssLengthUnit = String.raw`(?:px|rem|em|vw|vh|vmin|vmax|ch|%)`;
|
|
29
|
+
const visualCssLengthValue = String.raw`(?:\d+(?:\.\d+)?|\.\d+)${visualCssLengthUnit}`;
|
|
30
|
+
const visualCssLength = z.string().regex(
|
|
31
|
+
new RegExp(`^(?:0|${visualCssLengthValue}|clamp\\(\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*\\))$`),
|
|
32
|
+
'Use a CSS length such as "0", "900px", "56rem", "90%", or "clamp(1rem, 4vw, 3rem)".',
|
|
33
|
+
).describe('Responsive CSS length or clamp() expression.');
|
|
34
|
+
const dateOnly = z.string()
|
|
35
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format.')
|
|
36
|
+
.refine(isDateOnly, 'Use a real calendar date.')
|
|
37
|
+
.describe('Calendar date using YYYY-MM-DD.');
|
|
38
|
+
const visibilityWindow = z.object({
|
|
39
|
+
from: dateOnly.optional().describe('First date on which the content is visible.'),
|
|
40
|
+
until: dateOnly.optional().describe('First date on which the content is no longer visible.'),
|
|
41
|
+
}).strict().refine(
|
|
42
|
+
(value) => value.from !== undefined || value.until !== undefined,
|
|
43
|
+
'Specify from, until, or both.',
|
|
44
|
+
).refine(
|
|
45
|
+
(value) => value.from === undefined || value.until === undefined || value.from < value.until,
|
|
46
|
+
'visible.until must be later than visible.from.',
|
|
47
|
+
).describe('Optional visibility interval.');
|
|
48
|
+
const overrideResponsiveTextAlign = z.object({
|
|
49
|
+
desktop: textAlign.optional().describe('Text alignment on wider screens.'),
|
|
50
|
+
mobile: textAlign.optional().describe('Text alignment on narrow screens.'),
|
|
51
|
+
}).strict().refine(
|
|
52
|
+
(value) => value.desktop !== undefined || value.mobile !== undefined,
|
|
53
|
+
'Specify desktop, mobile, or both.',
|
|
54
|
+
);
|
|
55
|
+
const commonTextPresentationOverride = {
|
|
56
|
+
align: overrideResponsiveTextAlign.optional().describe('Responsive text alignment override.'),
|
|
57
|
+
size: textSize.optional(),
|
|
58
|
+
lineHeight: lineHeight.optional(),
|
|
59
|
+
};
|
|
60
|
+
const headingPresentationOverride = z.object({
|
|
61
|
+
...commonTextPresentationOverride,
|
|
62
|
+
weight: headingWeight.optional().describe('Heading font weight. Omit this to keep the profile value.'),
|
|
63
|
+
spacingBefore: cssLength.optional().describe('Vertical space before the heading.'),
|
|
64
|
+
spacingAfter: cssLength.optional().describe('Vertical space after the heading.'),
|
|
65
|
+
}).strict().describe('Focused overrides for one heading level.');
|
|
66
|
+
const headingLevelsPresentationOverride = z.object({
|
|
67
|
+
h1: headingPresentationOverride.optional().describe('Overrides for Markdown level 1 headings.'),
|
|
68
|
+
h2: headingPresentationOverride.optional().describe('Overrides for Markdown level 2 section headings.'),
|
|
69
|
+
h3: headingPresentationOverride.optional().describe('Overrides for Markdown level 3 headings.'),
|
|
70
|
+
h4: headingPresentationOverride.optional().describe('Overrides for Markdown level 4 headings.'),
|
|
71
|
+
}).strict().describe('Heading overrides by Markdown level.');
|
|
72
|
+
const bodyPresentationOverride = z.object({
|
|
73
|
+
...commonTextPresentationOverride,
|
|
74
|
+
width: textWidth.optional().describe('Maximum body-text line length.'),
|
|
75
|
+
paragraphSpacing: cssLength.optional().describe('Vertical space between body paragraphs.'),
|
|
76
|
+
}).strict().describe('Focused overrides for body text.');
|
|
77
|
+
const captionPresentationOverride = z.object({
|
|
78
|
+
...commonTextPresentationOverride,
|
|
79
|
+
spacingBefore: cssLength.optional().describe('Vertical space between an image and its caption.'),
|
|
80
|
+
}).strict().describe('Focused overrides for image captions.');
|
|
81
|
+
const typographyOverrides = z.object({
|
|
82
|
+
headings: headingLevelsPresentationOverride.optional().describe('Overrides for heading levels.'),
|
|
83
|
+
body: bodyPresentationOverride.optional().describe('Overrides for body text.'),
|
|
84
|
+
caption: captionPresentationOverride.optional().describe('Overrides for image captions.'),
|
|
85
|
+
}).strict().describe('Fine-grained typography overrides applied after profile and rhythm.');
|
|
86
|
+
const themeTypography = z.object({
|
|
87
|
+
fontFamily: z.string()
|
|
88
|
+
.min(1)
|
|
89
|
+
.refine((value) => !/[\n\r;{}]/.test(value), 'Do not use semicolons, braces, or line breaks.')
|
|
90
|
+
.optional()
|
|
91
|
+
.describe('CSS font-family stack.'),
|
|
92
|
+
profile: typographyProfile.optional().describe('Typography profile. Omit this to keep the selected preset.'),
|
|
93
|
+
rhythm: spacingDensity.optional().describe('Typography spacing rhythm. Omit this to keep the selected preset.'),
|
|
94
|
+
overrides: typographyOverrides.optional().describe('Focused typography overrides applied after the profile.'),
|
|
95
|
+
}).strict().refine(
|
|
96
|
+
(value) => value.fontFamily !== undefined || value.profile !== undefined || value.rhythm !== undefined || value.overrides !== undefined,
|
|
97
|
+
'Specify fontFamily, profile, rhythm, overrides, or a combination of them.',
|
|
98
|
+
).describe('Typography profile, rhythm, font family, and focused overrides.');
|
|
99
|
+
const responsiveCssLength = z.union([
|
|
100
|
+
visualCssLength,
|
|
101
|
+
z.object({
|
|
102
|
+
desktop: visualCssLength,
|
|
103
|
+
mobile: visualCssLength,
|
|
104
|
+
}).strict(),
|
|
105
|
+
]);
|
|
106
|
+
const responsivePercent = z.union([
|
|
107
|
+
z.number().positive().max(100),
|
|
108
|
+
z.object({
|
|
109
|
+
desktop: z.number().positive().max(100).describe('Percentage used on wider screens.'),
|
|
110
|
+
mobile: z.number().positive().max(100).describe('Percentage used on narrow screens.'),
|
|
111
|
+
}).strict(),
|
|
112
|
+
]);
|
|
113
|
+
const themeLayoutSpacing = z.object({
|
|
114
|
+
blockGap: responsiveCssLength.optional().describe('Default vertical gap between structured content blocks.'),
|
|
115
|
+
finalSectionBottom: responsiveCssLength.optional().describe('Bottom space after the final section.'),
|
|
116
|
+
firstSectionTop: responsiveCssLength.optional().describe('Top space before the first section.'),
|
|
117
|
+
headingToBlock: responsiveCssLength.optional().describe('Gap from a section heading to a following structured block.'),
|
|
118
|
+
imageGap: responsiveCssLength.optional().describe('Gap between images in an image stack.'),
|
|
119
|
+
sectionGap: responsiveCssLength.optional().describe('Vertical separation between page sections.'),
|
|
120
|
+
}).strict().describe('Fine-grained spacing overrides applied after layout density.');
|
|
121
|
+
const themeLayout = z.object({
|
|
122
|
+
density: spacingDensity.optional().describe('Overall spacing density. Omit this to keep the selected preset.'),
|
|
123
|
+
pageWidth: visualCssLength.optional().describe('Maximum width of the site layout. Omit this to keep the selected preset.'),
|
|
124
|
+
gutter: responsiveCssLength.optional().describe('Horizontal page gutter as one value or separate desktop and mobile values.'),
|
|
125
|
+
spacing: themeLayoutSpacing.optional().describe('Fine-grained spacing overrides.'),
|
|
126
|
+
}).strict().describe('Optional layout overrides applied after the preset.');
|
|
127
|
+
const themeImages = z.object({
|
|
128
|
+
width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
|
|
129
|
+
maxAvailableWidthPercent: responsivePercent.optional().describe('Maximum percentage of available horizontal space used by managed images.'),
|
|
130
|
+
maxAvailableHeightPercent: responsivePercent.optional().describe('Maximum percentage of viewport height used by managed images.'),
|
|
131
|
+
}).strict().describe('Optional defaults for managed image presentation.');
|
|
132
|
+
const sectionSurfaces = z.array(sectionSurface).min(1).max(3).refine(
|
|
133
|
+
(value) => new Set(value).size === value.length,
|
|
134
|
+
'Each section surface may appear only once.',
|
|
135
|
+
).describe('Surface sequence cycled across page sections.');
|
|
136
|
+
const pageNavigation = z.object({
|
|
137
|
+
include: z.boolean().optional().describe('Include this page in site navigation. Pages are included when omitted.'),
|
|
138
|
+
label: z.string().optional().describe('Navigation label when it should differ from the page title.'),
|
|
139
|
+
}).strict();
|
|
140
|
+
const sitewideNavigation = z.object({
|
|
141
|
+
label: z.string().min(1).optional().describe('Accessible site label used for navigation text and logo alternative text.'),
|
|
142
|
+
logo: z.object({
|
|
143
|
+
height: visualCssLength.optional().describe('Displayed logo height. Width follows the intrinsic aspect ratio.'),
|
|
144
|
+
}).strict().optional().describe('Optional display settings for a convention-based navigation logo.'),
|
|
145
|
+
}).strict().describe('Site-wide identity shown in navigation.');
|
|
146
|
+
|
|
147
|
+
const sectionMetadata = z.object({
|
|
148
|
+
visible: visibilityWindow.optional().describe('Optional date window for this section.'),
|
|
149
|
+
}).strict().describe('Metadata keyed by the explicit Markdown section id. Markdown order remains authoritative.');
|
|
150
|
+
const banner = z.object({
|
|
151
|
+
id: z.string().regex(/^[a-z0-9-]+$/).describe('Stable banner identifier used for dismissal state.'),
|
|
152
|
+
tone: z.enum(['warning']).default('warning').describe('Semantic banner tone.'),
|
|
153
|
+
visible: visibilityWindow.optional().describe('Optional date window for the banner.'),
|
|
154
|
+
title: z.string().min(1).describe('Short banner heading.'),
|
|
155
|
+
text: z.string().min(1).describe('Concise banner message.'),
|
|
156
|
+
}).strict().describe('Dismissible, site-wide one-line notice.');
|
|
157
|
+
const banners = z.array(banner).optional().default([]).refine(
|
|
158
|
+
(values) => new Set(values.map((value) => value.id)).size === values.length,
|
|
159
|
+
'Banner ids must be unique.',
|
|
160
|
+
);
|
|
161
|
+
const dateTimeFormat = z.object({
|
|
162
|
+
locale: z.string().min(1).describe('Intl locale such as en-GB or sv-SE.'),
|
|
163
|
+
timeZone: z.string().min(1).describe('IANA time zone such as Europe/Stockholm.'),
|
|
164
|
+
dateStyle: z.enum(['short', 'medium', 'long', 'full']).describe('Intl dateStyle for the build timestamp.'),
|
|
165
|
+
timeStyle: z.enum(['short', 'medium', 'long', 'full']).describe('Intl timeStyle for the build timestamp.'),
|
|
166
|
+
}).strict().refine((value) => {
|
|
167
|
+
try {
|
|
168
|
+
new Intl.DateTimeFormat(value.locale, {
|
|
169
|
+
dateStyle: value.dateStyle,
|
|
170
|
+
timeStyle: value.timeStyle,
|
|
171
|
+
timeZone: value.timeZone,
|
|
172
|
+
});
|
|
173
|
+
return true;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}, 'Use a valid Intl.DateTimeFormat configuration.');
|
|
178
|
+
const sitewideFooter = z.object({
|
|
179
|
+
copyrightMessage: z.string().min(1).optional().describe('Copyright or ownership text shown in the site footer.'),
|
|
180
|
+
buildInfo: z.object({
|
|
181
|
+
enabled: z.boolean().default(true).describe('Show generated build information in the footer.'),
|
|
182
|
+
text: z.string().min(1).describe('Label shown before the generated build timestamp.'),
|
|
183
|
+
dateTimeFormat: dateTimeFormat.describe('Formatting used for the generated build timestamp.'),
|
|
184
|
+
}).strict().optional().describe('Optional generated build timestamp.'),
|
|
185
|
+
}).strict().describe('Site-wide footer content.');
|
|
186
|
+
|
|
187
|
+
export const configSchema = z.object({
|
|
188
|
+
url: z.string().url().describe('Absolute public URL for the built site.'),
|
|
189
|
+
language: z.string().regex(/^(?:en|sv)(?:-[a-zA-Z0-9]+)*$/).optional().describe('Site language tag using Norna\'s English or Swedish interface text; the default is en.'),
|
|
190
|
+
scrollBehavior: z.enum(['instant', 'smooth']).optional().default('instant').describe('Use instant anchors by default or the browser\'s native smooth scrolling.'),
|
|
191
|
+
}).strict().describe('Technical settings for one Norna site. Routes cannot provide technical configuration.');
|
|
192
|
+
|
|
193
|
+
export const siteSchema = z.object({
|
|
194
|
+
title: z.string().describe('Page title used in metadata and route navigation.'),
|
|
195
|
+
description: z.string().describe('Page description used in metadata.'),
|
|
196
|
+
navigation: pageNavigation.optional().describe('Optional navigation metadata for this page.'),
|
|
197
|
+
sections: z.record(z.string().regex(/^[a-z0-9-]+$/), sectionMetadata).optional().default({}).describe('Optional metadata keyed by Markdown section id. Do not duplicate section order here.'),
|
|
198
|
+
}).strict().describe('Frontmatter for a homepage or route content.md file.');
|
|
199
|
+
|
|
200
|
+
export const themeVisualSchema = z.object({
|
|
201
|
+
preset: themePreset.optional().describe('Complete visual starting point. Add only the overrides the site actually needs.'),
|
|
202
|
+
layout: themeLayout.optional(),
|
|
203
|
+
images: themeImages.optional(),
|
|
204
|
+
typography: themeTypography.optional(),
|
|
205
|
+
palette: presentationPalette.optional(),
|
|
206
|
+
sectionSurfaces: sectionSurfaces.optional().describe('Surface sequence cycled through page sections. Omit this to keep the selected preset.'),
|
|
207
|
+
}).strict().describe('Visual settings for a site or route. A route theme replaces the inherited visual theme; site identity remains site-wide.');
|
|
208
|
+
|
|
209
|
+
export const sitewideSchema = z.object({
|
|
210
|
+
navigation: sitewideNavigation.optional().describe('Site-wide navigation identity.'),
|
|
211
|
+
banners: banners.describe('Site-wide dismissible notices.'),
|
|
212
|
+
footer: sitewideFooter.optional().describe('Site-wide footer content.'),
|
|
213
|
+
}).strict().describe('Editorial content and identity shared by every page.');
|