@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.
Files changed (146) hide show
  1. package/README.md +15 -9
  2. package/bin/norna-cli.mjs +11 -4
  3. package/docs/README.md +71 -43
  4. package/docs/commands.md +34 -20
  5. package/docs/configuration.md +43 -62
  6. package/docs/content.md +34 -7
  7. package/docs/design/command-organization.md +15 -8
  8. package/docs/design/norna-diagram-design.md +6 -6
  9. package/docs/design/site-examples-structure.md +1 -1
  10. package/docs/engine-development.md +22 -9
  11. package/docs/getting-started.md +29 -16
  12. package/docs/how-to/embedded-site.md +1 -1
  13. package/docs/images-and-metadata.md +3 -3
  14. package/docs/local-development.md +11 -3
  15. package/docs/public-files.md +105 -0
  16. package/docs/publishing.md +4 -8
  17. package/docs/routes.md +6 -7
  18. package/docs/site-files.md +144 -0
  19. package/docs/sitewide-content.md +47 -33
  20. package/docs/theme.md +37 -49
  21. package/docs/typography.md +25 -31
  22. package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
  23. package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
  24. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
  25. package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
  26. package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
  27. package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
  28. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
  29. package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
  30. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  31. package/examples/feature-demos/media-and-surfaces/README.md +1 -1
  32. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  33. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  35. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  36. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  37. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  38. package/examples/feature-demos/sitewide-content/README.md +8 -0
  39. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  40. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  41. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  43. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  44. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  45. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  46. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  48. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  50. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  51. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  52. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  53. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  54. package/fixtures/basic/site/content.md +3 -3
  55. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  56. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  57. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  58. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  59. package/package.json +10 -3
  60. package/schemas/config.schema.json +77 -0
  61. package/schemas/content-frontmatter.schema.json +81 -0
  62. package/schemas/manifest.json +9 -0
  63. package/schemas/sitewide-content.schema.json +215 -0
  64. package/schemas/theme.schema.json +1208 -0
  65. package/scripts/check-config.mjs +18 -11
  66. package/scripts/export-theme-preset.mjs +1 -1
  67. package/scripts/generate-schemas.mjs +283 -0
  68. package/scripts/init-site.mjs +22 -27
  69. package/scripts/lib/documentation-links.mjs +8 -0
  70. package/scripts/lib/editor-language-service.mjs +609 -0
  71. package/scripts/lib/example-sites.mjs +2 -2
  72. package/scripts/lib/git-status.mjs +7 -0
  73. package/scripts/lib/logo-assets.mjs +15 -10
  74. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  75. package/scripts/lib/presentation.mjs +23 -12
  76. package/scripts/lib/project-config.mjs +17 -41
  77. package/scripts/lib/public-asset-conventions.mjs +112 -0
  78. package/scripts/lib/schema-definitions.mjs +213 -0
  79. package/scripts/lib/schema-value-definitions.mjs +86 -0
  80. package/scripts/lib/site-content.mjs +24 -18
  81. package/scripts/lib/site-paths.mjs +4 -8
  82. package/scripts/lib/sitewide-content.mjs +24 -0
  83. package/scripts/lib/theme-config.mjs +15 -35
  84. package/scripts/lib/theme-presets.mjs +76 -66
  85. package/scripts/lib/typography.mjs +15 -15
  86. package/scripts/lib/yaml-config.mjs +50 -0
  87. package/scripts/list-theme-presets.mjs +15 -0
  88. package/scripts/release.mjs +84 -8
  89. package/scripts/show-typography.mjs +22 -46
  90. package/scripts/sync-content-sections.mjs +2 -2
  91. package/scripts/test-client-javascript.mjs +28 -28
  92. package/scripts/test-content-check.mjs +21 -26
  93. package/scripts/test-content-model-v2.mjs +72 -40
  94. package/scripts/test-documentation.mjs +70 -4
  95. package/scripts/test-editor-language-service.mjs +158 -0
  96. package/scripts/test-engine-commands.mjs +65 -34
  97. package/scripts/test-package-check.mjs +37 -30
  98. package/scripts/test-project-config.mjs +15 -20
  99. package/scripts/test-schemas.mjs +102 -0
  100. package/scripts/test-temporary-visibility.mjs +5 -13
  101. package/scripts/test-theme-presets.mjs +64 -37
  102. package/src/components/CardList.astro +2 -5
  103. package/src/components/ImageCarousel.astro +21 -21
  104. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  105. package/src/components/SectionNavigationScript.astro +28 -0
  106. package/src/components/SiteNavigation.astro +6 -5
  107. package/src/components/SitePage.astro +8 -8
  108. package/src/components/SiteSection.astro +18 -21
  109. package/src/content.config.ts +17 -209
  110. package/src/layouts/BaseLayout.astro +9 -9
  111. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  112. package/src/lib/routeThemes.ts +9 -29
  113. package/src/lib/sectionContent.ts +3 -3
  114. package/src/lib/sitePublicAssets.ts +5 -21
  115. package/src/styles/global.css +73 -73
  116. package/starters/basic/.github/workflows/deploy.yml +1 -1
  117. package/starters/basic/README.md +16 -13
  118. package/starters/basic/package.json +3 -2
  119. package/starters/basic/site/{config.md → config.yaml} +0 -2
  120. package/starters/basic/site/content.md +1 -1
  121. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  122. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  123. package/starters/project/.github/workflows/deploy.yml +1 -1
  124. package/starters/project/README.md +22 -15
  125. package/starters/project/package.json +3 -2
  126. package/starters/project/site/{config.md → config.yaml} +0 -2
  127. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  128. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  129. package/docs/site-structure.md +0 -98
  130. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  131. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  132. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  133. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  134. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  135. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  136. package/fixtures/content-model-v2/site/theme.md +0 -7
  137. package/scripts/lib/sitewide-config.mjs +0 -34
  138. package/src/components/MobileSectionNavigationScript.astro +0 -14
  139. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  140. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  142. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  143. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  144. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  145. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  146. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { siteConfigLabel, sitePublicLabel } from './lib/site-paths.mjs';
2
- import { getLogoAssets } from './lib/logo-assets.mjs';
3
- import { readSitewideConfig } from './lib/sitewide-config.mjs';
2
+ import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
3
+ import { logoAssetFilenames } from './lib/public-asset-conventions.mjs';
4
+ import { readSitewideContent } from './lib/sitewide-content.mjs';
4
5
  import { readThemeConfig, validateRouteThemeFiles } from './lib/theme-config.mjs';
5
6
 
6
7
  const formatErrorMessage = (error) => {
@@ -14,22 +15,28 @@ const formatErrorMessage = (error) => {
14
15
  try {
15
16
  const { projectConfig } = await import('./lib/project-config.mjs');
16
17
  const themeConfig = await readThemeConfig();
17
- const sitewideConfig = await readSitewideConfig();
18
+ const sitewideContent = await readSitewideContent();
18
19
  await validateRouteThemeFiles();
19
20
  const logoAssets = getLogoAssets();
21
+ const publicAssetInspection = getPublicAssetInspection();
22
+ const logoAssetPaths = logoAssetFilenames.map((filename) => `${sitePublicLabel}/${filename}`);
23
+ for (const issue of publicAssetInspection.suspicious) {
24
+ console.warn(`Warning: ${sitePublicLabel}/${issue.filename}: ${issue.message}`);
25
+ }
20
26
 
21
27
  if (logoAssets.length > 1) {
22
28
  throw new Error([
23
- `Found multiple logo files in ${sitePublicLabel}. Keep exactly one of logo.svg, logo.png, logo.jpg, or logo.jpeg.`,
29
+ `Found multiple logo files in ${sitePublicLabel}. Keep exactly one of ${logoAssetFilenames.join(', ')}.`,
24
30
  ...logoAssets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
25
31
  ].join('\n'));
26
32
  }
27
33
 
28
34
  if (logoAssets.length === 0) {
29
- console.warn(`Warning: No logo file found in ${sitePublicLabel}. Norna will use sitewide navigation.brand or the homepage title as the navigation label.`);
30
- console.warn(`Add exactly one of ${sitePublicLabel}/logo.svg, logo.png, logo.jpg, or logo.jpeg when the site should have a logo.`);
31
- } else if (sitewideConfig.navigation?.brand) {
32
- console.warn('Warning: Both sitewide navigation.brand and a logo file are configured. The logo is used; navigation.brand is only the text fallback.');
35
+ if (sitewideContent.navigation?.logo) {
36
+ throw new Error(`Site-wide navigation.logo is configured, but no logo file was found. Add exactly one of ${logoAssetPaths.join(', ')}, or remove navigation.logo.`);
37
+ }
38
+ console.warn(`Warning: No logo file found in ${sitePublicLabel}. Norna will use sitewide navigation.label or the homepage title as the navigation label.`);
39
+ console.warn(`Add exactly one of ${logoAssetPaths.join(', ')} when the site should have a logo.`);
33
40
  }
34
41
 
35
42
  console.log('Config check passed.');
@@ -39,9 +46,9 @@ try {
39
46
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
40
47
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
41
48
  console.log(`Layout density: ${projectConfig.layout.density}`);
42
- console.log(`Image area width: ${projectConfig.gallery.width}`);
43
- console.log(`Image max available width: desktop ${projectConfig.gallery.maxAvailableWidthPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableWidthPercent.mobile}%`);
44
- console.log(`Image max available height: desktop ${projectConfig.gallery.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.gallery.maxAvailableHeightPercent.mobile}%`);
49
+ console.log(`Image area width: ${projectConfig.images.width}`);
50
+ console.log(`Image max available width: desktop ${projectConfig.images.maxAvailableWidthPercent.desktop}%, mobile ${projectConfig.images.maxAvailableWidthPercent.mobile}%`);
51
+ console.log(`Image max available height: desktop ${projectConfig.images.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.images.maxAvailableHeightPercent.mobile}%`);
45
52
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
46
53
  console.log(`Language: ${projectConfig.locale.lang}`);
47
54
  console.log(`Scroll behavior: ${projectConfig.navigation.scrollBehavior}`);
@@ -12,7 +12,7 @@ if (!presetName || extraArgs.length > 0) {
12
12
  throw new Error(`Usage: norna theme:export <preset>\nAvailable presets: ${themePresetNames.join(', ')}`);
13
13
  }
14
14
 
15
- const filename = `orig-${presetName}-theme.md`;
15
+ const filename = `orig-${presetName}-theme.yaml`;
16
16
  const outputPath = path.join(siteDir, filename);
17
17
  const outputLabel = path.posix.join(path.posix.dirname(siteThemeLabel), filename);
18
18
  const source = renderThemePresetReference(presetName, siteThemeLabel);
@@ -0,0 +1,283 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { z } from 'astro/zod';
5
+ import {
6
+ configSchema,
7
+ siteSchema,
8
+ sitewideSchema,
9
+ themeVisualSchema,
10
+ } from './lib/schema-definitions.mjs';
11
+ import { documentationLink } from './lib/documentation-links.mjs';
12
+ import { getSchemaValueDefinition } from './lib/schema-value-definitions.mjs';
13
+
14
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const schemaDirectory = path.join(root, 'schemas');
16
+ const definitions = [
17
+ ['config.schema.json', 'Norna site configuration', configSchema],
18
+ ['theme.schema.json', 'Norna visual theme', themeVisualSchema],
19
+ ['sitewide-content.schema.json', 'Norna site-wide content', sitewideSchema],
20
+ ['content-frontmatter.schema.json', 'Norna page frontmatter', siteSchema],
21
+ ];
22
+ const yamlExample = (source) => `\`\`\`yaml\n${source}\n\`\`\``;
23
+
24
+ const schemaProperty = (schema, propertyPath) => {
25
+ let current = schema;
26
+ for (const name of propertyPath.split('.')) {
27
+ current = current?.properties?.[name];
28
+ if (!current) throw new Error(`Generated schema has no property at ${propertyPath}.`);
29
+ }
30
+ return current;
31
+ };
32
+
33
+ const addHelp = (schema, propertyPath, paragraphs, examples = []) => {
34
+ const property = schemaProperty(schema, propertyPath);
35
+ property.markdownDescription = paragraphs.join('\n\n');
36
+ if (examples.length > 0) property.examples = examples;
37
+ };
38
+
39
+ const getLiteralValues = (schema) => {
40
+ if (Array.isArray(schema.enum)) return schema.enum;
41
+ if (
42
+ Array.isArray(schema.anyOf)
43
+ && schema.anyOf.length > 0
44
+ && schema.anyOf.every((candidate) => Object.hasOwn(candidate, 'const'))
45
+ ) {
46
+ return schema.anyOf.map((candidate) => candidate.const);
47
+ }
48
+ return null;
49
+ };
50
+
51
+ const addValueDescriptions = (schema) => {
52
+ if (!schema || typeof schema !== 'object') return;
53
+ if (Array.isArray(schema)) {
54
+ for (const value of schema) addValueDescriptions(value);
55
+ return;
56
+ }
57
+
58
+ const values = getLiteralValues(schema);
59
+ const definition = values ? getSchemaValueDefinition(values) : null;
60
+ if (definition) {
61
+ delete schema.enum;
62
+ delete schema.anyOf;
63
+ schema.oneOf = values.map((value) => ({
64
+ const: value,
65
+ title: definition.options[value].title,
66
+ description: definition.options[value].description,
67
+ }));
68
+ }
69
+
70
+ for (const value of Object.values(schema)) addValueDescriptions(value);
71
+ };
72
+
73
+ const addLanguageSuggestions = (jsonSchema) => {
74
+ const language = jsonSchema.properties?.language;
75
+ if (!language) throw new Error('Generated config schema has no language property.');
76
+ const definition = getSchemaValueDefinition(['en', 'sv']);
77
+ language.default = 'en';
78
+ language.examples = ['en', 'sv', 'en-GB', 'sv-SE'];
79
+ language.oneOf = [
80
+ ...definition.values.map((value) => ({
81
+ const: value,
82
+ title: definition.options[value].title,
83
+ description: definition.options[value].description,
84
+ })),
85
+ {
86
+ type: 'string',
87
+ pattern: '^(?:en|sv)-[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$',
88
+ title: 'Regional language tag',
89
+ description: 'Use an English or Swedish regional language tag such as en-GB or sv-SE.',
90
+ },
91
+ ];
92
+ };
93
+
94
+ const addSitewideNavigationHelp = (jsonSchema) => {
95
+ const navigation = jsonSchema.properties?.navigation;
96
+ const label = navigation?.properties?.label;
97
+ const logo = navigation?.properties?.logo;
98
+ if (!navigation || !label || !logo) throw new Error('Generated site-wide schema has no navigation label/logo properties.');
99
+
100
+ navigation.markdownDescription = [
101
+ yamlExample('navigation:\n label: Example Site'),
102
+ '`navigation` defines the identity shared by the homepage and every route. Routes cannot replace it.',
103
+ documentationLink('Navigation identity reference', 'sitewide-content.md', 'navigation-identity'),
104
+ ].join('\n\n');
105
+ label.markdownDescription = [
106
+ yamlExample('navigation:\n label: Example Site'),
107
+ '- Without a logo, this text is shown in the navigation.',
108
+ '- With a logo, this text becomes the image alternative text.',
109
+ '- When omitted, Norna uses the homepage title.',
110
+ documentationLink('Navigation identity reference', 'sitewide-content.md', 'navigation-identity'),
111
+ ].join('\n\n');
112
+ label.examples = ['Example Site'];
113
+ logo.markdownDescription = [
114
+ yamlExample('navigation:\n label: Example Site\n logo:\n height: 2rem'),
115
+ '`logo` optionally overrides the displayed height of the logo file that Norna discovers automatically. It does not enable or select the file.',
116
+ documentationLink('Navigation logo filenames and placement', 'public-files.md', 'navigation-logo'),
117
+ ].join('\n\n');
118
+ logo.properties.height.examples = ['2rem'];
119
+ };
120
+
121
+ const addConfigHelp = (jsonSchema) => {
122
+ jsonSchema.markdownDescription = [
123
+ yamlExample('url: https://example.com/'),
124
+ '`config.yaml` contains the few technical settings shared by the complete site.',
125
+ documentationLink('Configuration reference', 'configuration.md'),
126
+ ].join('\n\n');
127
+ addHelp(jsonSchema, 'url', [
128
+ yamlExample('url: https://example.com/'),
129
+ 'The absolute public URL. Its path also determines the base path for generated links and assets.',
130
+ documentationLink('Public URL reference', 'configuration.md', 'url'),
131
+ ], ['https://example.com/', 'https://owner.github.io/repository-name/']);
132
+ addHelp(jsonSchema, 'language', [
133
+ yamlExample('language: en-GB'),
134
+ 'Sets the page language and selects Norna\'s built-in English or Swedish interface text. The default is `en`.',
135
+ documentationLink('Language reference', 'configuration.md', 'language'),
136
+ ], ['en', 'sv', 'en-GB', 'sv-SE']);
137
+ addHelp(jsonSchema, 'scrollBehavior', [
138
+ yamlExample('scrollBehavior: smooth'),
139
+ 'Controls same-page anchor movement. `instant` is the default; `smooth` uses the browser\'s native smooth scrolling.',
140
+ documentationLink('Scroll behavior reference', 'configuration.md', 'scrollbehavior'),
141
+ ], ['instant', 'smooth']);
142
+ };
143
+
144
+ const addThemeHelp = (jsonSchema) => {
145
+ jsonSchema.markdownDescription = [
146
+ yamlExample('preset: documentation'),
147
+ 'Choose one complete preset first. Add focused overrides only when the site needs to differ from it.',
148
+ documentationLink('Theme reference', 'theme.md'),
149
+ ].join('\n\n');
150
+ addHelp(jsonSchema, 'preset', [
151
+ yamlExample('preset: documentation'),
152
+ 'Selects coordinated layout, image sizing, typography, palette and section surfaces. This is the normal starting point for a theme.',
153
+ documentationLink('Compare theme presets', 'theme.md', 'theme-presets'),
154
+ ]);
155
+ addHelp(jsonSchema, 'layout', [
156
+ yamlExample('layout:\n density: compact\n pageWidth: 72rem'),
157
+ 'Overrides page width, gutters and structural spacing after the selected preset.',
158
+ documentationLink('Layout reference', 'theme.md', 'layout'),
159
+ ]);
160
+ addHelp(jsonSchema, 'images', [
161
+ yamlExample('images:\n width: 900px\n maxAvailableHeightPercent: 74'),
162
+ 'Overrides the available width and height used by Norna-managed images.',
163
+ documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
164
+ ]);
165
+ addHelp(jsonSchema, 'typography', [
166
+ yamlExample('typography:\n profile: reading\n rhythm: normal'),
167
+ 'Overrides the preset\'s font stack, typography profile, rhythm or individual text settings.',
168
+ documentationLink('Typography configuration reference', 'typography.md', 'configuration-shape'),
169
+ ]);
170
+ addHelp(jsonSchema, 'palette', [
171
+ yamlExample('palette: paper'),
172
+ 'Chooses a coordinated color system for the page frame, navigation, footer and section surfaces.',
173
+ documentationLink('Palette and section surfaces', 'theme.md', 'palette-and-section-surfaces'),
174
+ ], ['dark', 'light', 'paper']);
175
+ addHelp(jsonSchema, 'sectionSurfaces', [
176
+ yamlExample('sectionSurfaces: [base, soft, emphasis]'),
177
+ 'Lists one to three semantic surfaces. Norna cycles through them in section order; omit this field to keep the preset sequence.',
178
+ documentationLink('Palette and section surfaces', 'theme.md', 'palette-and-section-surfaces'),
179
+ ], [['base'], ['base', 'soft'], ['base', 'soft', 'emphasis']]);
180
+ };
181
+
182
+ const addSitewideHelp = (jsonSchema) => {
183
+ jsonSchema.markdownDescription = [
184
+ yamlExample('navigation:\n label: Example Site'),
185
+ '`sitewide-content.yaml` optionally defines editorial content and identity shared by every page.',
186
+ documentationLink('Site-wide content reference', 'sitewide-content.md'),
187
+ ].join('\n\n');
188
+ addSitewideNavigationHelp(jsonSchema);
189
+ addHelp(jsonSchema, 'banners', [
190
+ yamlExample('banners:\n - id: project-status\n tone: warning\n title: Experimental code\n text: Not for production use.'),
191
+ 'Adds dismissible notices above page content. List order controls their presentation order; each `id` must be unique.',
192
+ documentationLink('Banner reference', 'sitewide-content.md', 'banners'),
193
+ ]);
194
+ addHelp(jsonSchema, 'footer', [
195
+ yamlExample('footer:\n copyrightMessage: Copyright Example Owner.'),
196
+ 'Adds shared footer content. Generated build information is optional.',
197
+ documentationLink('Footer reference', 'sitewide-content.md', 'footer'),
198
+ ]);
199
+ };
200
+
201
+ const addContentHelp = (jsonSchema) => {
202
+ jsonSchema.markdownDescription = [
203
+ yamlExample('---\ntitle: About\ndescription: About this project.\n---'),
204
+ 'Every homepage and route `content.md` starts with page metadata, followed by Markdown sections.',
205
+ documentationLink('Page frontmatter reference', 'content.md', 'page-frontmatter'),
206
+ ].join('\n\n');
207
+ addHelp(jsonSchema, 'title', [
208
+ yamlExample('title: About'),
209
+ 'The page title used in metadata and, by default, route navigation.',
210
+ documentationLink('Page frontmatter reference', 'content.md', 'page-frontmatter'),
211
+ ], ['About']);
212
+ addHelp(jsonSchema, 'description', [
213
+ yamlExample('description: About this project.'),
214
+ 'A concise page description used in metadata.',
215
+ documentationLink('Page frontmatter reference', 'content.md', 'page-frontmatter'),
216
+ ], ['About this project.']);
217
+ addHelp(jsonSchema, 'navigation', [
218
+ yamlExample('navigation:\n include: true\n label: About us'),
219
+ 'Controls whether a route appears in site navigation and optionally replaces its visible title there. Homepage navigation is section-based.',
220
+ documentationLink('Route navigation reference', 'routes.md', 'navigation'),
221
+ ]);
222
+ addHelp(jsonSchema, 'sections', [
223
+ yamlExample('sections:\n announcement:\n visible:\n until: "2026-12-01"'),
224
+ 'Adds optional metadata keyed by explicit Markdown section id. Markdown headings remain the source of section content and order.',
225
+ documentationLink('Section metadata reference', 'content.md', 'section-metadata'),
226
+ ]);
227
+ };
228
+
229
+ const schemaFiles = new Map();
230
+
231
+ for (const [filename, title, schema] of definitions) {
232
+ const jsonSchema = z.toJSONSchema(schema, { target: 'draft-07', io: 'input' });
233
+ jsonSchema.$id = `https://janga.github.io/norna/schemas/${filename}`;
234
+ jsonSchema.title = title;
235
+ addValueDescriptions(jsonSchema);
236
+ if (filename === 'config.schema.json') {
237
+ addLanguageSuggestions(jsonSchema);
238
+ addConfigHelp(jsonSchema);
239
+ }
240
+ if (filename === 'theme.schema.json') addThemeHelp(jsonSchema);
241
+ if (filename === 'sitewide-content.schema.json') addSitewideHelp(jsonSchema);
242
+ if (filename === 'content-frontmatter.schema.json') addContentHelp(jsonSchema);
243
+ schemaFiles.set(filename, `${JSON.stringify(jsonSchema, null, 2)}\n`);
244
+ }
245
+
246
+ schemaFiles.set('manifest.json', `${JSON.stringify({
247
+ schemaVersion: 1,
248
+ files: {
249
+ config: 'config.schema.json',
250
+ contentFrontmatter: 'content-frontmatter.schema.json',
251
+ sitewideContent: 'sitewide-content.schema.json',
252
+ theme: 'theme.schema.json',
253
+ },
254
+ }, null, 2)}\n`);
255
+
256
+ const args = process.argv.slice(2);
257
+ const checkOnly = args.length === 1 && args[0] === '--check';
258
+ if (args.length > 0 && !checkOnly) {
259
+ throw new Error('Usage: node scripts/generate-schemas.mjs [--check]');
260
+ }
261
+
262
+ if (checkOnly) {
263
+ const stale = [];
264
+ for (const [filename, expected] of schemaFiles) {
265
+ const actual = await readFile(path.join(schemaDirectory, filename), 'utf8').catch((error) => (
266
+ error?.code === 'ENOENT' ? null : Promise.reject(error)
267
+ ));
268
+ if (actual !== expected) stale.push(filename);
269
+ }
270
+
271
+ if (stale.length > 0) {
272
+ throw new Error(`Generated Norna schemas are stale: ${stale.join(', ')}. Run "npm run schemas:generate".`);
273
+ }
274
+
275
+ console.log(`Norna schemas are up to date (${definitions.length} schemas).`);
276
+ } else {
277
+ await mkdir(schemaDirectory, { recursive: true });
278
+ for (const [filename, source] of schemaFiles) {
279
+ await writeFile(path.join(schemaDirectory, filename), source);
280
+ }
281
+
282
+ console.log(`Generated ${definitions.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
283
+ }
@@ -11,14 +11,14 @@ const packageName = '@janga/norna';
11
11
  const cliExecutableName = 'norna';
12
12
 
13
13
  const usage = `
14
- Usage: norna init <target-dir> [--type pure|embedded] [--site-dir <path>]
14
+ Usage: norna init <target-dir> [--type standalone|embedded] [--site-dir <path>]
15
15
 
16
16
  Creates a new site project or adds a Norna site directory to an existing
17
17
  project.
18
18
 
19
19
  Examples:
20
20
  norna init my-site
21
- norna init my-site --type pure
21
+ norna init my-site --type standalone
22
22
  norna init . --type embedded --site-dir presentation
23
23
  `.trim();
24
24
 
@@ -27,7 +27,7 @@ const parseArgs = (rawArgs) => {
27
27
  const options = {
28
28
  siteDirectory: process.env[siteDirectoryEnv] ?? 'site',
29
29
  targetDirectory: null,
30
- type: 'pure',
30
+ type: 'standalone',
31
31
  };
32
32
 
33
33
  for (let index = 0; index < rawArgs.length; index += 1) {
@@ -36,7 +36,7 @@ const parseArgs = (rawArgs) => {
36
36
  if (arg === '--type') {
37
37
  const value = rawArgs[index + 1];
38
38
  if (!value || value.startsWith('-')) {
39
- throw new Error('--type requires "pure" or "embedded".');
39
+ throw new Error('--type requires "standalone" or "embedded".');
40
40
  }
41
41
  options.type = value;
42
42
  index += 1;
@@ -87,8 +87,8 @@ if (!targetDirectory) {
87
87
  throw new Error(usage);
88
88
  }
89
89
 
90
- if (!['pure', 'embedded'].includes(type)) {
91
- throw new Error(`Invalid --type "${type}". Use "pure" or "embedded".`);
90
+ if (!['standalone', 'embedded'].includes(type)) {
91
+ throw new Error(`Invalid --type "${type}". Use "standalone" or "embedded".`);
92
92
  }
93
93
 
94
94
  if (!siteDirectory || path.isAbsolute(siteDirectory) || siteDirectory.split(/[\\/]/).includes('..')) {
@@ -122,19 +122,20 @@ const readJsonFile = async (filePath) => JSON.parse(await readFile(filePath, 'ut
122
122
 
123
123
  const siteDirArg = siteDirectory === 'site' ? [] : ['--site-dir', siteDirectory];
124
124
  const cliCommand = (command) => [cliExecutableName, ...siteDirArg, command].join(' ');
125
- const galleryScripts = {
125
+ const nornaScripts = {
126
126
  'norna:dev': cliCommand('dev:local'),
127
127
  'norna:dev:lan': cliCommand('dev:lan'),
128
128
  'norna:dev:restart': cliCommand('dev:restart'),
129
129
  'norna:dev:status': cliCommand('dev:status'),
130
130
  'norna:dev:logs': cliCommand('dev:logs'),
131
131
  'norna:dev:stop': cliCommand('dev:stop'),
132
- 'norna:check': 'npm run norna:config:check && npm run norna:content:check',
132
+ 'norna:check': cliCommand('check'),
133
133
  'norna:config:check': cliCommand('config:check'),
134
134
  'norna:content:check': cliCommand('content:check'),
135
135
  'norna:sync': cliCommand('content:sync'),
136
+ 'norna:theme:presets': cliCommand('theme:presets'),
136
137
  'norna:theme:export': cliCommand('theme:export'),
137
- 'norna:typography:presets': cliCommand('typography presets'),
138
+ 'norna:typography:profiles': cliCommand('typography profiles'),
138
139
  'norna:typography:show': cliCommand('typography show'),
139
140
  'norna:public': cliCommand('site:public'),
140
141
  'norna:images': cliCommand('images'),
@@ -149,17 +150,17 @@ const galleryScripts = {
149
150
  'norna:engine:version': cliCommand('engine:version'),
150
151
  };
151
152
 
152
- const addGalleryDependency = (packageJson, version) => {
153
+ const addNornaDependency = (packageJson, version) => {
153
154
  packageJson.dependencies ??= {};
154
155
  packageJson.dependencies[packageName] ??= version;
155
156
  };
156
157
 
157
- const addGalleryScripts = (packageJson, scripts, { includePureAliases }) => {
158
+ const addNornaScripts = (packageJson, scripts, { includeStandaloneAliases }) => {
158
159
  packageJson.scripts ??= {};
159
160
  const wantedScripts = {
160
- ...(includePureAliases ? { dev: 'npm run norna:dev --' } : {}),
161
+ ...(includeStandaloneAliases ? { dev: 'npm run norna:dev --' } : {}),
161
162
  ...scripts,
162
- ...(includePureAliases ? { build: 'npm run norna:build' } : {}),
163
+ ...(includeStandaloneAliases ? { build: 'npm run norna:build' } : {}),
163
164
  };
164
165
  const conflicts = Object.entries(wantedScripts).filter(([name, value]) => (
165
166
  packageJson.scripts[name] !== undefined && packageJson.scripts[name] !== value
@@ -178,12 +179,12 @@ const addGalleryScripts = (packageJson, scripts, { includePureAliases }) => {
178
179
  const enginePackageJson = await readJsonFile(enginePackageJsonPath);
179
180
  const existingEntries = await readDirectoryEntries(targetRoot);
180
181
 
181
- if (type === 'pure') {
182
+ if (type === 'standalone') {
182
183
  if (existingEntries && existingEntries.length > 0) {
183
184
  const existingPackageJson = existingEntries.includes('package.json')
184
185
  ? ' The target already contains package.json; use --type embedded to add a Norna site to an existing project.'
185
186
  : '';
186
- throw new Error(`Target directory must be empty for --type pure: ${targetRoot}.${existingPackageJson}`);
187
+ throw new Error(`Target directory must be empty for --type standalone: ${targetRoot}.${existingPackageJson}`);
187
188
  }
188
189
 
189
190
  await mkdir(path.dirname(targetRoot), { recursive: true });
@@ -199,10 +200,8 @@ if (type === 'pure') {
199
200
 
200
201
  const targetPackageJson = await readJsonFile(targetPackageJsonPath);
201
202
  targetPackageJson.dependencies[packageName] = enginePackageJson.version;
202
- if (siteDirectory !== 'site') {
203
- targetPackageJson.scripts = {};
204
- addGalleryScripts(targetPackageJson, galleryScripts, { includePureAliases: true });
205
- }
203
+ if (siteDirectory !== 'site') targetPackageJson.scripts = {};
204
+ addNornaScripts(targetPackageJson, nornaScripts, { includeStandaloneAliases: true });
206
205
  await writeFile(targetPackageJsonPath, `${JSON.stringify(targetPackageJson, null, 2)}\n`);
207
206
  } else {
208
207
  if (!existingEntries) {
@@ -222,8 +221,8 @@ if (type === 'pure') {
222
221
  throw new Error(`Norna site directory must be empty for --type embedded: ${targetSiteDir}`);
223
222
  }
224
223
 
225
- addGalleryDependency(targetPackageJson, enginePackageJson.version);
226
- addGalleryScripts(targetPackageJson, galleryScripts, { includePureAliases: false });
224
+ addNornaDependency(targetPackageJson, enginePackageJson.version);
225
+ addNornaScripts(targetPackageJson, nornaScripts, { includeStandaloneAliases: false });
227
226
  await mkdir(path.dirname(targetSiteDir), { recursive: true });
228
227
  await cp(starterSiteRoot, targetSiteDir, {
229
228
  filter: (source) => path.basename(source) !== '.DS_Store',
@@ -244,9 +243,5 @@ if (isInsideEngineRoot(targetRoot)) {
244
243
  console.log('');
245
244
  console.log('Next steps:');
246
245
  console.log(` cd ${targetRoot}`);
247
- console.log(type === 'embedded'
248
- ? ' npm install'
249
- : ' npm install');
250
- console.log(type === 'embedded'
251
- ? ' npm run norna:dev'
252
- : ' npm run dev');
246
+ console.log(' npm install');
247
+ console.log(' npm run norna:dev');
@@ -0,0 +1,8 @@
1
+ import packageJson from '../../package.json' with { type: 'json' };
2
+
3
+ export const documentationRef = `v${packageJson.version}`;
4
+ export const documentationRoot = `https://github.com/janga/norna/blob/${documentationRef}/docs`;
5
+
6
+ export const documentationLink = (label, file, anchor) => (
7
+ `[${label}](${documentationRoot}/${file}${anchor ? `#${anchor}` : ''})`
8
+ );