@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
@@ -4,26 +4,30 @@ import {
4
4
  siteContentLabel,
5
5
  siteContentPath,
6
6
  siteDir,
7
+ siteDirLabel,
7
8
  siteImagesDir,
8
9
  siteImagesLabel,
9
- siteRoutesDir,
10
- siteRoutesLabel,
10
+ sitePagesDir,
11
+ sitePagesLabel,
12
+ sitewideContentLabel,
11
13
  } from './site-paths.mjs';
12
- import { parseRouteDirectory } from './route-model.mjs';
14
+ import { parsePageDirectoryPath } from './page-model.mjs';
15
+ import { getMarkdownHeadings } from './heading-ids.mjs';
16
+ import { schemaTopLevelKeys } from './schema-definitions.mjs';
13
17
 
14
18
  export const rasterImageExtensions = new Set(['.jpg', '.jpeg', '.png']);
15
19
  export const staticImageExtensions = new Set(['.svg']);
16
20
  export const supportedImageExtensions = new Set([...rasterImageExtensions, ...staticImageExtensions]);
17
21
 
18
- const h2Regex = /^##\s+.*$/gm;
19
- const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
20
22
  const deprecatedInlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
21
23
  const frontmatterDelimiterRegex = /^---\s*$/;
22
- const knownConfigTopLevelFrontmatterKeys = new Set(['url', 'language', 'scrollBehavior']);
23
- const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'navigation', 'sections']);
24
- const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'preset', 'layout', 'images', 'typography', 'palette', 'sectionSurfaces']);
25
- const knownRouteThemeTopLevelFrontmatterKeys = new Set(['navigation', 'preset', 'layout', 'images', 'typography', 'palette', 'sectionSurfaces']);
26
- const knownSitewideTopLevelFrontmatterKeys = new Set(['navigation', 'banners', 'footer']);
24
+ const knownConfigTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.config);
25
+ const knownContentTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.content);
26
+ const knownThemeTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.theme);
27
+ const knownPageThemeTopLevelFrontmatterKeys = new Set(
28
+ [...knownThemeTopLevelFrontmatterKeys].filter((key) => key !== 'navigation'),
29
+ );
30
+ const knownSitewideTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.sitewide);
27
31
  const knownNestedFrontmatterKeys = new Set([
28
32
  'align',
29
33
  'alt',
@@ -45,11 +49,10 @@ const knownNestedFrontmatterKeys = new Set([
45
49
  'heading',
46
50
  'headings',
47
51
  'id',
48
- 'include',
49
52
  'image',
50
53
  'imageGap',
51
54
  'images',
52
- 'label',
55
+ 'listed',
53
56
  'logo',
54
57
  'lineHeight',
55
58
  'maxAvailableHeightPercent',
@@ -63,7 +66,6 @@ const knownNestedFrontmatterKeys = new Set([
63
66
  'preset',
64
67
  'profile',
65
68
  'rhythm',
66
- 'sections',
67
69
  'sectionGap',
68
70
  'sectionSurfaces',
69
71
  'size',
@@ -81,46 +83,80 @@ export const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
81
83
  const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
82
84
 
83
85
  export const getContentFiles = async () => {
86
+ const legacyRoutesDir = path.join(siteDir, 'routes');
87
+ const legacyRoutes = await readdir(legacyRoutesDir).catch((error) => {
88
+ if (error?.code === 'ENOENT') return null;
89
+ throw error;
90
+ });
91
+ if (legacyRoutes !== null) {
92
+ throw new Error(`${siteDirLabel}/routes is no longer supported. Rename it to ${sitePagesLabel} and use NNN-page-id directory names.`);
93
+ }
94
+
84
95
  const contentFiles = [{
85
96
  contentLabel: siteContentLabel,
86
97
  contentPath: siteContentPath,
87
98
  imagesDir: siteImagesDir,
88
99
  imagesLabel: siteImagesLabel,
89
100
  isHome: true,
90
- routeDirectory: null,
91
- routeId: null,
92
- routeOrder: 0,
101
+ pageDirectory: null,
102
+ pageDirectories: [],
103
+ pageId: null,
104
+ pageIds: [],
105
+ pageOrder: 0,
106
+ pageOrders: [],
107
+ pagePath: '',
108
+ parentPagePath: null,
109
+ depth: 0,
93
110
  }];
94
- const routeEntries = await readdir(siteRoutesDir, { withFileTypes: true }).catch((error) => {
111
+ const collectPageContentFiles = async (pagesDir, pagesLabel, parentPageDirectory = '') => {
112
+ const pageEntries = await readdir(pagesDir, { withFileTypes: true }).catch((error) => {
95
113
  if (error?.code === 'ENOENT') {
96
114
  return [];
97
115
  }
98
116
 
99
117
  throw error;
100
- });
101
-
102
- for (const entry of routeEntries) {
103
- if (!entry.isDirectory()) continue;
104
-
105
- const routeDirectory = entry.name;
106
- const routeDir = path.join(siteRoutesDir, routeDirectory);
107
- const routeContentPath = path.join(routeDir, 'content.md');
118
+ });
108
119
 
109
- if (!(await fileExists(routeContentPath))) continue;
120
+ for (const entry of pageEntries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
121
+ if (!entry.isDirectory()) continue;
122
+
123
+ const pageDirectory = parentPageDirectory
124
+ ? `${parentPageDirectory}/pages/${entry.name}`
125
+ : entry.name;
126
+ const pageLabel = `${pagesLabel}/${entry.name}`;
127
+ const pageDir = path.join(pagesDir, entry.name);
128
+ const pageContentPath = path.join(pageDir, 'content.md');
129
+
130
+ if (!(await fileExists(pageContentPath))) {
131
+ const childPageEntries = await readdir(path.join(pageDir, 'pages'), { withFileTypes: true }).catch((error) => {
132
+ if (error?.code === 'ENOENT') return [];
133
+ throw error;
134
+ });
135
+ if (childPageEntries.some((childEntry) => childEntry.isDirectory())) {
136
+ throw new Error(`${pageLabel} contains nested pages but has no content.md of its own.`);
137
+ }
138
+ continue;
139
+ }
140
+ const pageMetadata = parsePageDirectoryPath(pageDirectory, pageLabel);
141
+
142
+ contentFiles.push({
143
+ contentLabel: `${pageLabel}/content.md`,
144
+ contentPath: pageContentPath,
145
+ imagesDir: path.join(pageDir, 'images'),
146
+ imagesLabel: `${pageLabel}/images`,
147
+ isHome: false,
148
+ ...pageMetadata,
149
+ });
110
150
 
111
- const { routeId, routeOrder } = parseRouteDirectory(routeDirectory, `${siteRoutesLabel}/${routeDirectory}`);
151
+ await collectPageContentFiles(
152
+ path.join(pageDir, 'pages'),
153
+ `${pageLabel}/pages`,
154
+ pageDirectory,
155
+ );
156
+ }
157
+ };
112
158
 
113
- contentFiles.push({
114
- contentLabel: `${siteRoutesLabel}/${routeDirectory}/content.md`,
115
- contentPath: routeContentPath,
116
- imagesDir: path.join(routeDir, 'images'),
117
- imagesLabel: `${siteRoutesLabel}/${routeDirectory}/images`,
118
- isHome: false,
119
- routeDirectory,
120
- routeId,
121
- routeOrder,
122
- });
123
- }
159
+ await collectPageContentFiles(sitePagesDir, sitePagesLabel);
124
160
 
125
161
  return contentFiles;
126
162
  };
@@ -129,7 +165,15 @@ export const splitSiteFile = (source, label = siteContentLabel) => {
129
165
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
130
166
 
131
167
  if (!match) {
132
- throw new Error(`${label} is missing frontmatter delimited by ---.`);
168
+ if (/^---(?:\r?\n|$)/.test(source)) {
169
+ throw new Error(`${label} starts YAML frontmatter with ---, but no closing --- delimiter was found.`);
170
+ }
171
+
172
+ return {
173
+ frontmatter: '',
174
+ frontmatterBody: '',
175
+ body: source,
176
+ };
133
177
  }
134
178
 
135
179
  return {
@@ -250,10 +294,24 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
250
294
  if (knownTopLevelFrontmatterKeys.has(key)) continue;
251
295
 
252
296
  let fix;
253
- if (fileKind === 'content' && key === 'images') {
297
+ let message = `Frontmatter line ${lineNumber} defines "${key}" at the top level, but it is not a valid top-level ${fileKind} field.`;
298
+ if (fileKind === 'content' && key === 'title') {
299
+ fix = 'Remove "title:" and write the page title as the single Markdown H1 after frontmatter, for example "# About".';
300
+ } else if (fileKind === 'content' && key === 'description') {
301
+ fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
302
+ } else if (fileKind === 'content' && key === 'images') {
254
303
  fix = 'Put local image references in norna-image-stack or norna-image-carousel blocks in the Markdown body.';
255
304
  } else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
256
305
  fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
306
+ } else if (fileKind === 'page theme' && key === 'navigation') {
307
+ message = `Frontmatter line ${lineNumber}: page themes may not define navigation.`;
308
+ fix = 'Set the site-wide navigation mode under "navigation:" in the root theme.yaml.';
309
+ } else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
310
+ message = `Frontmatter line ${lineNumber}: ${fileKind} may not define navigation logo settings. Optional logo display settings belong under "logo:" in ${sitewideContentLabel}.`;
311
+ fix = `Move only the logo display settings under "logo:" in ${sitewideContentLabel}; the homepage Markdown H1 supplies navigation text and logo alternative text.`;
312
+ } else if (fileKind === 'sitewide content' && (key === 'navigation' || key === 'site')) {
313
+ message = `Frontmatter line ${lineNumber}: "${key}:" no longer defines site identity.`;
314
+ fix = 'Use the homepage Markdown H1 for navigation text and logo alternative text. Move only an optional logo height to top-level "logo:".';
257
315
  } else if (knownNestedFrontmatterKeys.has(key)) {
258
316
  fix = `Indent "${key}:" under the object it belongs to.`;
259
317
  } else {
@@ -262,7 +320,7 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
262
320
 
263
321
  addIssue({
264
322
  severity: 'error',
265
- message: `Frontmatter line ${lineNumber} defines "${key}" at the top level, but it is not a valid top-level ${fileKind} field.`,
323
+ message,
266
324
  fix,
267
325
  });
268
326
  }
@@ -286,10 +344,10 @@ export const validateThemeYamlStructure = (frontmatter, addIssue) =>
286
344
  fileKind: 'theme',
287
345
  });
288
346
 
289
- export const validateRouteThemeYamlStructure = (frontmatter, addIssue) =>
347
+ export const validatePageThemeYamlStructure = (frontmatter, addIssue) =>
290
348
  validateFrontmatterStructure(frontmatter, addIssue, {
291
- knownTopLevelFrontmatterKeys: knownRouteThemeTopLevelFrontmatterKeys,
292
- fileKind: 'route theme',
349
+ knownTopLevelFrontmatterKeys: knownPageThemeTopLevelFrontmatterKeys,
350
+ fileKind: 'page theme',
293
351
  });
294
352
 
295
353
  export const validateSitewideYamlStructure = (frontmatter, addIssue) =>
@@ -303,53 +361,32 @@ export const readThemeFile = async (sitePath) => {
303
361
  return { frontmatter: source, frontmatterBody: source, body: '' };
304
362
  };
305
363
 
306
- export const getFrontmatterSections = (frontmatter) => {
307
- const sections = [];
308
- const lines = frontmatter.split(/\r?\n/);
309
- let inSections = false;
310
-
311
- for (const [index, line] of lines.entries()) {
312
- if (/^sections:\s*$/.test(line)) {
313
- inSections = true;
314
- continue;
315
- }
316
-
317
- if (!inSections) continue;
318
- if (/^[a-zA-Z0-9_-]+:/.test(line)) break;
319
-
320
- const sectionMatch = line.match(/^\s{2}([a-z0-9-]+):(?:\s+.*)?\s*$/);
321
- if (sectionMatch) {
322
- sections.push({ id: sectionMatch[1], line: index + 1 });
323
- }
324
- }
325
-
326
- return sections;
327
- };
328
-
329
- export const getHeadingId = (heading) => heading.match(explicitHeadingIdRegex)?.[1];
330
-
331
364
  export const getDeprecatedInlineStyleReferences = (body) => Array.from(body.matchAll(deprecatedInlineStyleReferenceRegex))
332
365
  .map((match) => match[1]);
333
366
 
334
- export const getBodySections = (body) => {
335
- const matches = Array.from(body.matchAll(h2Regex));
336
- const prelude = matches.length > 0 ? body.slice(0, matches[0].index) : body;
337
- const sections = [];
338
-
339
- for (let index = 0; index < matches.length; index += 1) {
340
- const match = matches[index];
341
- const start = match.index ?? 0;
342
- const next = matches[index + 1];
343
- const end = next?.index ?? body.length;
344
- const text = body.slice(start, end).trimEnd();
345
- const heading = match[0];
346
- const id = getHeadingId(heading);
347
- const line = body.slice(0, start).split(/\r?\n/).length;
348
-
349
- sections.push({ id, heading, line, text });
350
- }
367
+ export const getBodySections = async (body) => {
368
+ const { headings, source } = await getMarkdownHeadings(body);
369
+ const structuralHeadings = headings.filter((heading) => heading.depth <= 2);
370
+ const prelude = structuralHeadings.length > 0 ? source.slice(0, structuralHeadings[0].index) : source;
371
+ const sections = structuralHeadings.map((heading, index) => {
372
+ const next = structuralHeadings[index + 1];
373
+ const end = next?.index ?? source.length;
351
374
 
352
- return { prelude, sections };
375
+ return {
376
+ ...heading,
377
+ heading: heading.source,
378
+ isPageTitle: heading.depth === 1,
379
+ level: heading.depth,
380
+ text: source.slice(heading.index, end).trimEnd(),
381
+ };
382
+ });
383
+
384
+ return {
385
+ headings,
386
+ pageHeadings: sections.filter((section) => section.isPageTitle),
387
+ prelude,
388
+ sections,
389
+ };
353
390
  };
354
391
 
355
392
  export const getImageFiles = async (directory) => {
@@ -119,7 +119,7 @@ export const siteThemePath = path.join(siteDir, 'theme.yaml');
119
119
  export const siteContentPath = path.join(siteDir, 'content.md');
120
120
  export const sitewideContentPath = path.join(siteDir, 'sitewide-content.yaml');
121
121
  export const siteImagesDir = path.join(siteDir, 'images');
122
- export const siteRoutesDir = path.join(siteDir, 'routes');
122
+ export const sitePagesDir = path.join(siteDir, 'pages');
123
123
  export const sitePublicDir = path.join(siteDir, 'public');
124
124
  export const siteStateDir = path.join(siteDir, '.norna');
125
125
  export const astroPublicDir = path.join(siteStateDir, 'public');
@@ -138,7 +138,7 @@ export const siteThemeLabel = getPathLabel(siteThemePath);
138
138
  export const siteContentLabel = getPathLabel(siteContentPath);
139
139
  export const sitewideContentLabel = getPathLabel(sitewideContentPath);
140
140
  export const siteImagesLabel = getPathLabel(siteImagesDir);
141
- export const siteRoutesLabel = getPathLabel(siteRoutesDir);
141
+ export const sitePagesLabel = getPathLabel(sitePagesDir);
142
142
  export const sitePublicLabel = getPathLabel(sitePublicDir);
143
143
  export const astroPublicLabel = getPathLabel(astroPublicDir);
144
144
  export const generatedImagesManifestLabel = getPathLabel(generatedImagesManifestPath);
@@ -1,15 +1,14 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
- validateRouteThemeYamlStructure,
4
+ validatePageThemeYamlStructure,
5
5
  validateThemeYamlStructure,
6
6
  } from './site-content.mjs';
7
7
  import {
8
- siteRoutesDir,
9
- siteRoutesLabel,
8
+ sitePagesDir,
9
+ sitePagesLabel,
10
10
  siteThemeLabel,
11
11
  siteThemePath,
12
- sitewideContentLabel,
13
12
  } from './site-paths.mjs';
14
13
  import { resolveThemePresentation } from './presentation.mjs';
15
14
  import { resolveThemeConfig } from './theme-presets.mjs';
@@ -26,16 +25,13 @@ export const readThemeConfig = async () => {
26
25
  const config = parseYamlConfig(themeFile, siteThemeLabel, {
27
26
  validateStructure: validateThemeYamlStructure,
28
27
  });
29
- if (Object.hasOwn(config, 'navigation')) {
30
- throw new Error(`${siteThemeLabel} may not define navigation. The site label and logo settings belong in ${sitewideContentLabel}.`);
31
- }
32
28
  resolveThemeConfig(config, siteThemeLabel);
33
29
  resolveThemePresentation(config, siteThemeLabel);
34
30
 
35
31
  return config;
36
32
  };
37
33
 
38
- const getRouteThemeFiles = async (directory, relativeDirectory = '') => {
34
+ const getPageThemeFiles = async (directory, relativeDirectory = '') => {
39
35
  const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
40
36
  if (error?.code === 'ENOENT') return [];
41
37
  throw error;
@@ -46,14 +42,14 @@ const getRouteThemeFiles = async (directory, relativeDirectory = '') => {
46
42
  const relativePath = path.join(relativeDirectory, entry.name);
47
43
  const absolutePath = path.join(directory, entry.name);
48
44
  if (entry.isDirectory()) {
49
- files.push(...await getRouteThemeFiles(absolutePath, relativePath));
45
+ files.push(...await getPageThemeFiles(absolutePath, relativePath));
50
46
  continue;
51
47
  }
52
48
 
53
49
  if (entry.name === 'theme.yaml') {
54
50
  files.push({
55
51
  path: absolutePath,
56
- label: `${siteRoutesLabel}/${relativePath.split(path.sep).join('/')}`,
52
+ label: `${sitePagesLabel}/${relativePath.split(path.sep).join('/')}`,
57
53
  });
58
54
  }
59
55
  }
@@ -61,18 +57,15 @@ const getRouteThemeFiles = async (directory, relativeDirectory = '') => {
61
57
  return files;
62
58
  };
63
59
 
64
- export const validateRouteThemeFiles = async () => {
65
- const files = await getRouteThemeFiles(siteRoutesDir);
60
+ export const validatePageThemeFiles = async () => {
61
+ const files = await getPageThemeFiles(sitePagesDir);
66
62
  const configs = [];
67
63
 
68
64
  for (const file of files) {
69
65
  const source = await readFile(file.path, 'utf8');
70
66
  const config = parseYamlConfig(source, file.label, {
71
- validateStructure: validateRouteThemeYamlStructure,
67
+ validateStructure: validatePageThemeYamlStructure,
72
68
  });
73
- if (Object.hasOwn(config, 'navigation')) {
74
- throw new Error(`${file.label} may not define navigation. The site label and logo settings belong in ${sitewideContentLabel}.`);
75
- }
76
69
  resolveThemeConfig(config, file.label);
77
70
  resolveThemePresentation(config, file.label);
78
71
 
@@ -2,6 +2,7 @@ import { mergeDeep } from './typography.mjs';
2
2
 
3
3
  const themePresetConfigurations = Object.freeze({
4
4
  portfolio: Object.freeze({
5
+ navigation: { mode: 'automatic' },
5
6
  layout: {
6
7
  density: 'normal',
7
8
  pageWidth: '1240px',
@@ -24,6 +25,7 @@ const themePresetConfigurations = Object.freeze({
24
25
  sectionSurfaces: ['base'],
25
26
  }),
26
27
  documentation: Object.freeze({
28
+ navigation: { mode: 'automatic' },
27
29
  layout: {
28
30
  density: 'compact',
29
31
  pageWidth: '1240px',
@@ -46,6 +48,7 @@ const themePresetConfigurations = Object.freeze({
46
48
  sectionSurfaces: ['base', 'soft'],
47
49
  }),
48
50
  project: Object.freeze({
51
+ navigation: { mode: 'automatic' },
49
52
  layout: {
50
53
  density: 'compact',
51
54
  pageWidth: '1120px',
@@ -68,6 +71,7 @@ const themePresetConfigurations = Object.freeze({
68
71
  sectionSurfaces: ['base', 'soft'],
69
72
  }),
70
73
  statement: Object.freeze({
74
+ navigation: { mode: 'automatic' },
71
75
  layout: {
72
76
  density: 'airy',
73
77
  pageWidth: '1280px',
@@ -167,7 +171,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
167
171
  export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
168
172
  const preset = getThemePreset(presetName, sourceLabel);
169
173
  const metadata = getThemePresetMetadata(presetName);
170
- const { layout, images, typography, palette, sectionSurfaces } = preset;
174
+ const { navigation, layout, images, typography, palette, sectionSurfaces } = preset;
171
175
 
172
176
  return [
173
177
  `# Original values for Norna's "${presetName}" theme preset.`,
@@ -177,6 +181,10 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
177
181
  `# Available theme presets: ${themePresetNames.join(', ')}.`,
178
182
  `preset: ${presetName}`,
179
183
  '',
184
+ 'navigation:',
185
+ ' # Alternatives: automatic, sections, top, tree.',
186
+ ` mode: ${navigation.mode}`,
187
+ '',
180
188
  'layout:',
181
189
  ' # Alternatives: compact, normal, airy.',
182
190
  ` density: ${layout.density}`,
@@ -129,7 +129,7 @@ if (showHelp || !releaseType) {
129
129
  await run('git', ['add', ...releasePaths]);
130
130
  await run('git', ['commit', '-m', `Release ${releaseTag}`]);
131
131
  releaseCommitCreated = true;
132
- await run('git', ['tag', releaseTag]);
132
+ await run('git', ['tag', '-a', releaseTag, '-m', `Release ${releaseTag}`]);
133
133
  releaseTagCreated = true;
134
134
  await run(npmBin, ['run', 'release:publish']);
135
135
  await run('git', ['push', '--follow-tags']);
@@ -13,15 +13,17 @@ import {
13
13
  readSiteFile,
14
14
  validateContentFrontmatterStructure,
15
15
  validateFrontmatterIndentation,
16
- validateRouteThemeYamlStructure,
16
+ validatePageThemeYamlStructure,
17
17
  } from './lib/site-content.mjs';
18
18
  import {
19
19
  siteContentLabel,
20
20
  siteContentPath,
21
- siteRoutesDir,
21
+ sitePagesDir,
22
+ sitePagesLabel,
22
23
  siteThemeLabel,
23
24
  siteThemePath,
24
25
  } from './lib/site-paths.mjs';
26
+ import { getPageDirectoryAncestors } from './lib/page-model.mjs';
25
27
  import { readThemeConfig } from './lib/theme-config.mjs';
26
28
  import { resolveThemeConfig } from './lib/theme-presets.mjs';
27
29
  import { parseYamlConfig } from './lib/yaml-config.mjs';
@@ -197,22 +199,26 @@ const readThemeTypography = async () => {
197
199
  return resolveAnnotatedTypographyConfig(themeTypographyConfig ?? defaultTypography, siteThemeLabel);
198
200
  };
199
201
 
200
- const readRouteThemeTypography = async (contentFile, themeTypography) => {
202
+ const readPageThemeTypography = async (contentFile, themeTypography) => {
201
203
  if (contentFile.isHome) return themeTypography;
202
204
 
203
- const routeThemePath = path.join(siteRoutesDir, contentFile.routeDirectory, 'theme.yaml');
204
- const routeThemeFile = await readFile(routeThemePath, 'utf8').catch((error) => {
205
- if (error?.code === 'ENOENT') return null;
206
- throw error;
207
- });
208
- if (!routeThemeFile) return themeTypography;
209
- const routeThemeLabel = `${contentFile.contentLabel.replace(/\/content\.md$/, '')}/theme.yaml`;
210
-
211
- const routeThemeConfig = resolveThemeConfig(parseYamlConfig(routeThemeFile, routeThemeLabel, {
212
- validateStructure: validateRouteThemeYamlStructure,
213
- }), routeThemeLabel);
214
- const typographyConfig = routeThemeConfig.typography;
215
- return resolveAnnotatedTypographyConfig(typographyConfig ?? defaultTypography, routeThemeLabel);
205
+ for (const pageDirectory of getPageDirectoryAncestors(contentFile.pageDirectory).reverse()) {
206
+ const pageThemePath = path.join(sitePagesDir, pageDirectory, 'theme.yaml');
207
+ const pageThemeFile = await readFile(pageThemePath, 'utf8').catch((error) => {
208
+ if (error?.code === 'ENOENT') return null;
209
+ throw error;
210
+ });
211
+ if (!pageThemeFile) continue;
212
+ const pageThemeLabel = `${sitePagesLabel}/${pageDirectory}/theme.yaml`;
213
+
214
+ const pageThemeConfig = resolveThemeConfig(parseYamlConfig(pageThemeFile, pageThemeLabel, {
215
+ validateStructure: validatePageThemeYamlStructure,
216
+ }), pageThemeLabel);
217
+ const typographyConfig = pageThemeConfig.typography;
218
+ return resolveAnnotatedTypographyConfig(typographyConfig ?? defaultTypography, pageThemeLabel);
219
+ }
220
+
221
+ return themeTypography;
216
222
  };
217
223
 
218
224
  const readPageTypography = async (contentFile, siteThemeTypography) => {
@@ -227,8 +233,8 @@ const readPageTypography = async (contentFile, siteThemeTypography) => {
227
233
  ].join('\n'));
228
234
  }
229
235
 
230
- const pageTypography = await readRouteThemeTypography(contentFile, siteThemeTypography);
231
- const sections = getBodySections(body).sections
236
+ const pageTypography = await readPageThemeTypography(contentFile, siteThemeTypography);
237
+ const sections = (await getBodySections(body)).sections
232
238
  .filter((section) => section.id)
233
239
  .map((section) => {
234
240
  return {
@@ -239,7 +245,7 @@ const readPageTypography = async (contentFile, siteThemeTypography) => {
239
245
 
240
246
  return {
241
247
  source: contentFile.contentLabel,
242
- route: contentFile.isHome ? '/' : `/${contentFile.routeId}/`,
248
+ pathname: contentFile.isHome ? '/' : `/${contentFile.pagePath}/`,
243
249
  pageTypography,
244
250
  sections,
245
251
  };
@@ -259,7 +265,7 @@ if (mode === 'profiles') {
259
265
  typography: formatAnnotatedTypography(themeTypography),
260
266
  },
261
267
  pages: Object.fromEntries(pages.map((page) => [
262
- page.route,
268
+ page.pathname,
263
269
  {
264
270
  source: page.source,
265
271
  typography: formatAnnotatedTypography(page.pageTypography),