@janga/norna 0.7.16 → 0.7.17

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 (200) hide show
  1. package/README.md +6 -6
  2. package/astro.config.mjs +35 -3
  3. package/docs/README.md +10 -10
  4. package/docs/commands.md +2 -2
  5. package/docs/content.md +82 -102
  6. package/docs/design/navigation-and-theme-plan.md +115 -0
  7. package/docs/design/norna-diagram-design.md +9 -9
  8. package/docs/design/site-examples-structure.md +9 -9
  9. package/docs/engine-development.md +9 -9
  10. package/docs/getting-started.md +20 -15
  11. package/docs/images-and-metadata.md +10 -9
  12. package/docs/pages.md +134 -0
  13. package/docs/public-files.md +49 -36
  14. package/docs/publishing.md +19 -3
  15. package/docs/site-files.md +29 -28
  16. package/docs/sitewide-content.md +20 -36
  17. package/docs/theme.md +20 -20
  18. package/docs/typography.md +10 -10
  19. package/examples/README.md +2 -2
  20. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/site.schema.json +17 -41
  21. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/sitewide.schema.json +26 -67
  22. package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/theme.schema.json +185 -88
  23. package/examples/complete-sites/dog-shelter-multi-page/.astro/data-store.json +1 -1
  24. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev-local.json +4 -4
  25. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.json +5 -5
  26. package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.log +33 -23
  27. package/examples/complete-sites/dog-shelter-multi-page/README.md +1 -1
  28. package/examples/complete-sites/dog-shelter-multi-page/node_modules/.vite/deps/_metadata.json +8 -8
  29. package/examples/complete-sites/dog-shelter-multi-page/site/.norna/generated-images.json +20 -20
  30. package/examples/complete-sites/dog-shelter-multi-page/site/content.md +3 -4
  31. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/010-dogs/content.md +3 -4
  32. package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/020-adopt/content.md +3 -4
  33. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +2 -4
  34. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/site.schema.json +17 -41
  35. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/sitewide.schema.json +26 -67
  36. package/examples/complete-sites/dog-shelter-single-page/.astro/collections/theme.schema.json +185 -88
  37. package/examples/complete-sites/dog-shelter-single-page/.astro/data-store.json +1 -1
  38. package/examples/complete-sites/dog-shelter-single-page/.astro/dev-local.json +8 -0
  39. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.json +5 -5
  40. package/examples/complete-sites/dog-shelter-single-page/.astro/dev.log +16 -12
  41. package/examples/complete-sites/dog-shelter-single-page/README.md +1 -1
  42. package/examples/complete-sites/dog-shelter-single-page/node_modules/.vite/deps/_metadata.json +8 -8
  43. package/examples/complete-sites/dog-shelter-single-page/site/.norna/generated-images.json +12 -12
  44. package/examples/complete-sites/dog-shelter-single-page/site/content.md +5 -2
  45. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +2 -4
  46. package/examples/feature-demos/media-and-surfaces/.astro/collections/site.schema.json +17 -41
  47. package/examples/feature-demos/media-and-surfaces/.astro/collections/sitewide.schema.json +26 -67
  48. package/examples/feature-demos/media-and-surfaces/.astro/collections/theme.schema.json +185 -88
  49. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  50. package/examples/feature-demos/media-and-surfaces/.astro/dev.log +4 -14
  51. package/examples/feature-demos/media-and-surfaces/README.md +2 -2
  52. package/examples/feature-demos/media-and-surfaces/node_modules/.vite/deps/_metadata.json +9 -9
  53. package/examples/feature-demos/media-and-surfaces/site/.norna/generated-images.json +16 -16
  54. package/examples/feature-demos/media-and-surfaces/site/content.md +4 -7
  55. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/010-media/content.md +6 -5
  56. package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/content.md +7 -6
  57. package/examples/feature-demos/media-and-surfaces/site/sitewide-content.yaml +0 -2
  58. package/examples/feature-demos/sitewide-content/README.md +3 -3
  59. package/examples/feature-demos/sitewide-content/site/content.md +8 -7
  60. package/examples/feature-demos/sitewide-content/site/pages/010-logo-and-navigation/content.md +17 -0
  61. package/examples/feature-demos/sitewide-content/site/{routes → pages}/020-notices/content.md +7 -6
  62. package/examples/feature-demos/sitewide-content/site/{routes → pages}/030-footer/content.md +6 -5
  63. package/examples/feature-demos/sitewide-content/site/sitewide-content.yaml +4 -13
  64. package/examples/feature-demos/theme-presets/README.md +1 -1
  65. package/examples/feature-demos/theme-presets/site/.norna/generated-images.json +60 -60
  66. package/examples/feature-demos/theme-presets/site/content.md +4 -7
  67. package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/content.md +4 -7
  68. package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/content.md +4 -7
  69. package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/content.md +4 -7
  70. package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/content.md +4 -7
  71. package/examples/feature-demos/theme-presets/site/sitewide-content.yaml +1 -10
  72. package/fixtures/basic/site/content.md +5 -6
  73. package/fixtures/basic/site/sitewide-content.yaml +1 -8
  74. package/fixtures/content-model-v2/site/content.md +11 -13
  75. package/fixtures/content-model-v2/site/pages/010-guide/content.md +18 -0
  76. package/fixtures/nested-pages/site/.norna/generated-images.json +10 -0
  77. package/fixtures/nested-pages/site/config.yaml +1 -0
  78. package/fixtures/nested-pages/site/content.md +15 -0
  79. package/fixtures/nested-pages/site/pages/010-guides/content.md +15 -0
  80. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/content.md +21 -0
  81. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/images/diagram.svg +21 -0
  82. package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/pages/010-macos/content.md +14 -0
  83. package/fixtures/nested-pages/site/pages/010-guides/pages/020-workflows/content.md +11 -0
  84. package/fixtures/nested-pages/site/pages/010-guides/theme.yaml +3 -0
  85. package/fixtures/nested-pages/site/pages/020-reference/content.md +10 -0
  86. package/fixtures/nested-pages/site/pages/020-reference/pages/010-installation/content.md +11 -0
  87. package/fixtures/nested-pages/site/theme.yaml +3 -0
  88. package/package.json +7 -4
  89. package/schemas/config.schema.json +5 -5
  90. package/schemas/content-frontmatter.schema.json +28 -63
  91. package/schemas/manifest.json +1 -0
  92. package/schemas/sitewide-content.schema.json +58 -132
  93. package/schemas/theme.schema.json +396 -86
  94. package/scripts/check-config.mjs +6 -5
  95. package/scripts/deploy-site.mjs +6 -8
  96. package/scripts/doctor.mjs +2 -2
  97. package/scripts/generate-images.mjs +11 -18
  98. package/scripts/generate-schemas.mjs +3 -217
  99. package/scripts/lib/base-path-redirect.mjs +9 -0
  100. package/scripts/lib/editor-language-service.mjs +126 -66
  101. package/scripts/lib/heading-ids.mjs +161 -0
  102. package/scripts/lib/navigation-model.mjs +51 -0
  103. package/scripts/lib/norna-markdown-blocks.mjs +3 -4
  104. package/scripts/lib/page-model.mjs +83 -0
  105. package/scripts/lib/project-config.mjs +28 -0
  106. package/scripts/lib/schema-definitions.mjs +41 -46
  107. package/scripts/lib/schema-editor-metadata.mjs +475 -0
  108. package/scripts/lib/schema-value-definitions.mjs +6 -6
  109. package/scripts/lib/site-content.mjs +124 -87
  110. package/scripts/lib/site-paths.mjs +2 -2
  111. package/scripts/lib/theme-config.mjs +9 -16
  112. package/scripts/lib/theme-presets.mjs +9 -1
  113. package/scripts/release.mjs +1 -1
  114. package/scripts/show-typography.mjs +26 -20
  115. package/scripts/sync-content-sections.mjs +106 -65
  116. package/scripts/{test-temporary-visibility.mjs → test-banner-visibility.mjs} +7 -24
  117. package/scripts/test-client-javascript.mjs +39 -42
  118. package/scripts/test-content-check.mjs +138 -54
  119. package/scripts/test-content-model-v2.mjs +445 -228
  120. package/scripts/test-documentation.mjs +6 -1
  121. package/scripts/test-editor-language-service.mjs +168 -24
  122. package/scripts/test-engine-commands.mjs +2 -1
  123. package/scripts/test-heading-ids.mjs +68 -0
  124. package/scripts/test-navigation-model.mjs +63 -0
  125. package/scripts/test-nested-pages.mjs +110 -0
  126. package/scripts/test-package-check.mjs +31 -40
  127. package/scripts/test-page-model.mjs +80 -0
  128. package/scripts/test-project-config.mjs +25 -1
  129. package/scripts/test-schemas.mjs +66 -14
  130. package/scripts/test-theme-presets.mjs +33 -24
  131. package/src/components/NavigationPageTree.astro +68 -0
  132. package/src/components/SectionNavigationScript.astro +110 -5
  133. package/src/components/SiteBreadcrumbs.astro +29 -0
  134. package/src/components/SiteNavigation.astro +65 -69
  135. package/src/components/SitePage.astro +70 -46
  136. package/src/components/SiteSection.astro +27 -20
  137. package/src/content.config.ts +9 -5
  138. package/src/layouts/BaseLayout.astro +11 -20
  139. package/src/lib/pageThemes.ts +42 -0
  140. package/src/lib/sectionContent.ts +109 -106
  141. package/src/lib/siteNavigation.ts +48 -0
  142. package/src/lib/sitePages.ts +85 -38
  143. package/src/pages/{[slug].astro → [...slug].astro} +2 -2
  144. package/src/styles/global.css +373 -55
  145. package/starters/basic/README.md +5 -7
  146. package/starters/basic/site/content.md +7 -4
  147. package/starters/basic/site/sitewide-content.yaml +1 -10
  148. package/starters/project/README.md +8 -8
  149. package/starters/project/site/content.md +5 -4
  150. package/starters/project/site/{routes → pages}/010-guide/content.md +5 -4
  151. package/starters/project/site/sitewide-content.yaml +1 -10
  152. package/docs/routes.md +0 -121
  153. package/examples/feature-demos/sitewide-content/site/routes/010-identity/content.md +0 -20
  154. package/fixtures/content-model-v2/site/routes/010-guide/content.md +0 -17
  155. package/scripts/lib/route-model.mjs +0 -17
  156. package/scripts/test-route-model.mjs +0 -32
  157. package/src/lib/routeThemes.ts +0 -41
  158. /package/examples/complete-sites/dog-shelter-multi-page/site/images/{intro/shelter-dog.jpg → shelter-dog.jpg} +0 -0
  159. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rex.jpg +0 -0
  160. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rover.jpg +0 -0
  161. /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/020-adopt/images/adopt → pages/020-adopt/images}/adoption-dog.jpg +0 -0
  162. /package/examples/complete-sites/dog-shelter-single-page/site/images/{you-can-help/foster-dog.jpg → foster-dog.jpg} +0 -0
  163. /package/examples/complete-sites/dog-shelter-single-page/site/images/{what-we-do/rescue-dog.jpg → rescue-dog.jpg} +0 -0
  164. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-carousel.svg +0 -0
  165. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-stack.svg +0 -0
  166. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-surfaces.svg +0 -0
  167. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-one.svg +0 -0
  168. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-three.svg +0 -0
  169. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-two.svg +0 -0
  170. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-one.svg +0 -0
  171. /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-two.svg +0 -0
  172. /package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/theme.yaml +0 -0
  173. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/puppy-portrait.jpg +0 -0
  174. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/shepherd-portrait.jpg +0 -0
  175. /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/white-puppy.jpg +0 -0
  176. /package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/theme.yaml +0 -0
  177. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/black-schnauzer.jpg +0 -0
  178. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/retriever-portrait.jpg +0 -0
  179. /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/schnauzer-portrait.jpg +0 -0
  180. /package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/theme.yaml +0 -0
  181. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/boxer-portrait.jpg +0 -0
  182. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grass-puppy.jpg +0 -0
  183. /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grey-street-dog.jpg +0 -0
  184. /package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/theme.yaml +0 -0
  185. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/black-pedigree.jpg +0 -0
  186. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-grass.jpg +0 -0
  187. /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-wide.jpg +0 -0
  188. /package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/theme.yaml +0 -0
  189. /package/fixtures/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
  190. /package/fixtures/content-model-v2/site/images/{card-section/adopt.svg → adopt.svg} +0 -0
  191. /package/fixtures/content-model-v2/site/images/{intro/detail.jpg → detail.jpg} +0 -0
  192. /package/fixtures/content-model-v2/site/images/{intro/duplicate.jpg → duplicate.jpg} +0 -0
  193. /package/fixtures/content-model-v2/site/images/{card-section/foster.svg → foster.svg} +0 -0
  194. /package/fixtures/content-model-v2/site/images/{intro/hero.jpg → hero.jpg} +0 -0
  195. /package/fixtures/content-model-v2/site/images/{plain/image.jpg → image.jpg} +0 -0
  196. /package/fixtures/content-model-v2/site/images/{timed/slide-one.jpg → slide-one.jpg} +0 -0
  197. /package/fixtures/content-model-v2/site/images/{timed/slide-three.jpg → slide-three.jpg} +0 -0
  198. /package/fixtures/content-model-v2/site/images/{timed/slide-two.jpg → slide-two.jpg} +0 -0
  199. /package/fixtures/content-model-v2/site/{routes/010-guide/images/intro → pages/010-guide/images}/duplicate.jpg +0 -0
  200. /package/starters/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
@@ -2,7 +2,7 @@ import { siteConfigLabel, sitePublicLabel } from './lib/site-paths.mjs';
2
2
  import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
3
3
  import { logoAssetFilenames } from './lib/public-asset-conventions.mjs';
4
4
  import { readSitewideContent } from './lib/sitewide-content.mjs';
5
- import { readThemeConfig, validateRouteThemeFiles } from './lib/theme-config.mjs';
5
+ import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
6
6
 
7
7
  const formatErrorMessage = (error) => {
8
8
  if (error instanceof Error) {
@@ -16,7 +16,7 @@ try {
16
16
  const { projectConfig } = await import('./lib/project-config.mjs');
17
17
  const themeConfig = await readThemeConfig();
18
18
  const sitewideContent = await readSitewideContent();
19
- await validateRouteThemeFiles();
19
+ await validatePageThemeFiles();
20
20
  const logoAssets = getLogoAssets();
21
21
  const publicAssetInspection = getPublicAssetInspection();
22
22
  const logoAssetPaths = logoAssetFilenames.map((filename) => `${sitePublicLabel}/${filename}`);
@@ -32,10 +32,10 @@ try {
32
32
  }
33
33
 
34
34
  if (logoAssets.length === 0) {
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.`);
35
+ if (sitewideContent.logo) {
36
+ throw new Error(`Site-wide logo is configured, but no logo file was found. Add exactly one of ${logoAssetPaths.join(', ')}, or remove logo.`);
37
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.`);
38
+ console.warn(`Warning: No logo file found in ${sitePublicLabel}. Norna will use page titles for navigation.`);
39
39
  console.warn(`Add exactly one of ${logoAssetPaths.join(', ')} when the site should have a logo.`);
40
40
  }
41
41
 
@@ -51,6 +51,7 @@ try {
51
51
  console.log(`Image max available height: desktop ${projectConfig.images.maxAvailableHeightPercent.desktop}%, mobile ${projectConfig.images.maxAvailableHeightPercent.mobile}%`);
52
52
  console.log(`Font family: ${projectConfig.typography.fontFamily}`);
53
53
  console.log(`Language: ${projectConfig.locale.lang}`);
54
+ console.log(`Navigation mode: ${projectConfig.navigation.mode}`);
54
55
  console.log(`Scroll behavior: ${projectConfig.navigation.scrollBehavior}`);
55
56
  } catch (error) {
56
57
  console.error('Config check failed.');
@@ -21,7 +21,7 @@ import {
21
21
  siteContentLabel,
22
22
  siteImagesLabel,
23
23
  sitePublicLabel,
24
- siteRoutesLabel,
24
+ sitePagesLabel,
25
25
  siteThemeLabel,
26
26
  } from './lib/site-paths.mjs';
27
27
 
@@ -150,15 +150,13 @@ const getExpectedImagePaths = async () => {
150
150
  for (const contentFile of contentFiles) {
151
151
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
152
152
 
153
- for (const section of getBodySections(body).sections) {
154
- if (!section.id) continue;
155
-
153
+ for (const section of (await getBodySections(body)).sections) {
156
154
  const blocks = extractNornaMarkdownBlocks(section.text, { label: contentFile.contentLabel });
157
155
  for (const { image } of getNornaBlockImageReferences(blocks)) {
158
156
  if (image.includes('/') || image.includes('\\')) continue;
159
157
  if (!supportedImageExtensions.has(path.extname(image).toLowerCase())) continue;
160
158
 
161
- imagePaths.add(toPosixPath(path.join(contentFile.imagesLabel, section.id, image)));
159
+ imagePaths.add(toPosixPath(path.join(contentFile.imagesLabel, image)));
162
160
  }
163
161
  }
164
162
  }
@@ -174,7 +172,7 @@ const isExpectedUntracked = (entry, expectedImagePaths) => (
174
172
  expectedImagePaths.has(entry.path)
175
173
  || entry.path === generatedImagesManifestLabel
176
174
  || entry.path.startsWith(`${sitePublicLabel}/`)
177
- || entry.path.startsWith(`${siteRoutesLabel}/`)
175
+ || entry.path.startsWith(`${sitePagesLabel}/`)
178
176
  )
179
177
  );
180
178
 
@@ -182,7 +180,7 @@ const isAllowedPath = (entry, filePath, expectedImagePaths) => (
182
180
  filePath === siteContentLabel
183
181
  || filePath === siteThemeLabel
184
182
  || (!isUntracked(entry) && filePath.startsWith(`${siteImagesLabel}/`))
185
- || filePath.startsWith(`${siteRoutesLabel}/`)
183
+ || filePath.startsWith(`${sitePagesLabel}/`)
186
184
  || filePath.startsWith(`${sitePublicLabel}/`)
187
185
  || expectedImagePaths.has(filePath)
188
186
  || filePath.startsWith('src/')
@@ -213,7 +211,7 @@ const assertDeployableStatus = async (entries, expectedImagePaths) => {
213
211
  if (unexpectedUntracked.length > 0) {
214
212
  fail([
215
213
  'Refusing to deploy: unexpected untracked files are present.',
216
- `Only new referenced images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<NNN-route-id>/images/<section-id>/ are staged automatically.`,
214
+ `Only new referenced images directly under ${siteImagesLabel}/ or ${sitePagesLabel}/<NNN-page-id>/images/ are staged automatically.`,
217
215
  ...unexpectedUntracked.map((entry) => `- ${formatEntry(entry)}`),
218
216
  ].join('\n'));
219
217
  }
@@ -12,7 +12,7 @@ import {
12
12
  siteImagesDir,
13
13
  siteProjectRoot,
14
14
  sitePublicDir,
15
- siteRoutesDir,
15
+ sitePagesDir,
16
16
  siteThemePath,
17
17
  } from './lib/site-paths.mjs';
18
18
 
@@ -26,7 +26,7 @@ const lines = [
26
26
  `Site theme: ${siteThemePath}`,
27
27
  `Site content: ${siteContentPath}`,
28
28
  `Site images: ${siteImagesDir}`,
29
- `Site routes: ${siteRoutesDir}`,
29
+ `Site pages: ${sitePagesDir}`,
30
30
  `Site public source: ${sitePublicDir}`,
31
31
  `Generated image manifest: ${generatedImagesManifestPath}`,
32
32
  `Astro public output: ${astroPublicDir}`,
@@ -104,22 +104,18 @@ const fail = (message) => {
104
104
  throw new Error(message);
105
105
  };
106
106
 
107
- const getImageSourceKey = (contentFile, sectionId, image) => (
107
+ const getImageSourceKey = (contentFile, image) => (
108
108
  contentFile.isHome
109
- ? `images/${sectionId}/${image}`
110
- : `routes/${contentFile.routeDirectory}/images/${sectionId}/${image}`
109
+ ? `images/${image}`
110
+ : `pages/${contentFile.pageDirectory}/images/${image}`
111
111
  );
112
112
 
113
113
  const getReferencedImages = async (contentFile) => {
114
114
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
115
- const { sections } = getBodySections(body);
115
+ const { sections } = await getBodySections(body);
116
116
  const references = [];
117
117
 
118
118
  for (const section of sections) {
119
- if (!section.id) {
120
- fail(`${contentFile.contentLabel}: section heading "${section.heading}" is missing an explicit id.`);
121
- }
122
-
123
119
  const blocks = extractNornaMarkdownBlocks(section.text, { label: contentFile.contentLabel });
124
120
  for (const { image, line, blockDisplayType } of getNornaBlockImageReferences(blocks)) {
125
121
  references.push({
@@ -127,8 +123,8 @@ const getReferencedImages = async (contentFile) => {
127
123
  image,
128
124
  line,
129
125
  blockDisplayType,
130
- sectionId: section.id,
131
- sourceKey: getImageSourceKey(contentFile, section.id, image),
126
+ sectionLabel: section.id ?? 'page title',
127
+ sourceKey: getImageSourceKey(contentFile, image),
132
128
  });
133
129
  }
134
130
  }
@@ -171,9 +167,8 @@ const getReferencedSources = async () => {
171
167
  fail(`Image file does not exist: ${contentFile.imagesLabel}/${siteImagePath}`);
172
168
  }
173
169
 
174
- const currentDirectory = path.basename(path.dirname(sourcePath));
175
- if (reference.sectionId && currentDirectory !== reference.sectionId) {
176
- fail(`Image "${imageName}" is used in section "${reference.sectionId}" but is located in ${contentFile.imagesLabel}/${currentDirectory}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
170
+ if (path.dirname(sourcePath) !== contentFile.imagesDir) {
171
+ fail(`Image "${imageName}" is used in ${reference.sectionLabel} but is not stored directly in ${contentFile.imagesLabel}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
177
172
  }
178
173
 
179
174
  seen.add(reference.sourceKey);
@@ -194,16 +189,14 @@ const validateCarouselOrientations = async (manifest) => {
194
189
 
195
190
  for (const contentFile of contentFiles) {
196
191
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
197
- const { sections } = getBodySections(body);
192
+ const { sections } = await getBodySections(body);
198
193
 
199
194
  for (const section of sections) {
200
- if (!section.id) continue;
201
-
202
195
  const blocks = extractNornaMarkdownBlocks(section.text, { label: contentFile.contentLabel });
203
196
  for (const block of blocks.filter((candidate) => candidate.type === 'image-carousel')) {
204
197
  const orientations = new Set(
205
198
  block.images
206
- .map((image) => manifest[getImageSourceKey(contentFile, section.id, image.image)])
199
+ .map((image) => manifest[getImageSourceKey(contentFile, image.image)])
207
200
  .filter(Boolean)
208
201
  .filter((entry) => Number.isFinite(entry.width) && Number.isFinite(entry.height))
209
202
  .map(getOrientation)
@@ -212,7 +205,7 @@ const validateCarouselOrientations = async (manifest) => {
212
205
  orientations.delete('square');
213
206
  if (orientations.size > 1) {
214
207
  throw new Error(
215
- `Carousel in section "${section.id}" on line ${block.line} mixes landscape and portrait images. Use landscape images with optional square images, or portrait images with optional square images.`,
208
+ `Carousel in ${section.id ? `section "${section.id}"` : 'the page introduction'} on line ${block.line} mixes landscape and portrait images. Use landscape images with optional square images, or portrait images with optional square images.`,
216
209
  );
217
210
  }
218
211
  }
@@ -8,8 +8,7 @@ import {
8
8
  sitewideSchema,
9
9
  themeVisualSchema,
10
10
  } from './lib/schema-definitions.mjs';
11
- import { documentationLink } from './lib/documentation-links.mjs';
12
- import { getSchemaValueDefinition } from './lib/schema-value-definitions.mjs';
11
+ import { applySchemaEditorMetadata } from './lib/schema-editor-metadata.mjs';
13
12
 
14
13
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
14
  const schemaDirectory = path.join(root, 'schemas');
@@ -19,231 +18,18 @@ const definitions = [
19
18
  ['sitewide-content.schema.json', 'Norna site-wide content', sitewideSchema],
20
19
  ['content-frontmatter.schema.json', 'Norna page frontmatter', siteSchema],
21
20
  ];
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
21
  const schemaFiles = new Map();
230
22
 
231
23
  for (const [filename, title, schema] of definitions) {
232
24
  const jsonSchema = z.toJSONSchema(schema, { target: 'draft-07', io: 'input' });
233
25
  jsonSchema.$id = `https://janga.github.io/norna/schemas/${filename}`;
234
26
  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);
27
+ applySchemaEditorMetadata(filename, jsonSchema);
243
28
  schemaFiles.set(filename, `${JSON.stringify(jsonSchema, null, 2)}\n`);
244
29
  }
245
30
 
246
31
  schemaFiles.set('manifest.json', `${JSON.stringify({
32
+ editorApiVersion: 1,
247
33
  schemaVersion: 1,
248
34
  files: {
249
35
  config: 'config.schema.json',
@@ -0,0 +1,9 @@
1
+ export const getBasePathRedirectLocation = (basePath, requestUrl) => {
2
+ if (basePath === '/') return undefined;
3
+
4
+ const basePathWithoutTrailingSlash = basePath.replace(/\/$/, '');
5
+ const parsedRequestUrl = new URL(requestUrl, 'http://localhost');
6
+ if (parsedRequestUrl.pathname !== basePathWithoutTrailingSlash) return undefined;
7
+
8
+ return `${basePathWithoutTrailingSlash}/${parsedRequestUrl.search}`;
9
+ };