@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
@@ -9,12 +9,11 @@ import {
9
9
  nornaMarkdownBlockDefinitions,
10
10
  } from './norna-markdown-blocks.mjs';
11
11
  import { inspectPublicAssetFilenames, logoAssetFilenames } from './public-asset-conventions.mjs';
12
+ import { getHeadingIdentifierIssues, getMarkdownHeadings } from './heading-ids.mjs';
12
13
  import { siteSchema } from './schema-definitions.mjs';
13
14
 
14
15
  const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png', '.svg']);
15
16
  const siteConfigNames = ['config.yaml'];
16
- const headingIdPattern = /^##\s+.+?\s*\{#([a-z0-9-]+)\}\s*$/;
17
-
18
17
  const fileExists = (filePath) => access(filePath).then(() => true, () => false);
19
18
  const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
20
19
 
@@ -37,7 +36,7 @@ export const findNornaSiteRoot = async (documentPath) => {
37
36
  }
38
37
  };
39
38
 
40
- const readSitewideNavigationForEditor = async (siteRoot) => {
39
+ const readSitewideLogoForEditor = async (siteRoot) => {
41
40
  const filename = await findFile(siteRoot, ['sitewide-content.yaml']);
42
41
  if (!filename) return null;
43
42
  const absolutePath = path.join(siteRoot, filename);
@@ -45,15 +44,16 @@ const readSitewideNavigationForEditor = async (siteRoot) => {
45
44
 
46
45
  try {
47
46
  const data = load(source) ?? {};
48
- const navigation = data && typeof data === 'object' && data.navigation && typeof data.navigation === 'object'
49
- ? data.navigation
50
- : {};
47
+ const logoConfigured = Boolean(
48
+ data
49
+ && typeof data === 'object'
50
+ && Object.hasOwn(data, 'logo'),
51
+ );
51
52
  const logoLine = source.replace(/\r\n?/g, '\n').split('\n')
52
- .findIndex((line) => /^\s+logo:\s*(?:#.*)?$/.test(line)) + 1;
53
+ .findIndex((line) => /^logo:\s*(?:#.*)?$/.test(line)) + 1;
53
54
  return {
54
55
  absolutePath,
55
- label: typeof navigation.label === 'string' ? navigation.label : null,
56
- logoConfigured: Object.hasOwn(navigation, 'logo'),
56
+ logoConfigured,
57
57
  logoLine: logoLine > 0 ? logoLine : 1,
58
58
  };
59
59
  } catch {
@@ -73,7 +73,7 @@ export const getSitePublicAssetStatus = async (documentPath) => {
73
73
  const inspection = inspectPublicAssetFilenames(
74
74
  entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
75
75
  );
76
- const navigation = await readSitewideNavigationForEditor(siteRoot);
76
+ const sitewideLogo = await readSitewideLogoForEditor(siteRoot);
77
77
  const issues = inspection.suspicious.map((issue) => ({
78
78
  ...issue,
79
79
  absolutePath: path.join(publicDirectory, issue.filename),
@@ -92,14 +92,14 @@ export const getSitePublicAssetStatus = async (documentPath) => {
92
92
  });
93
93
  }
94
94
  }
95
- if (inspection.logos.length === 0 && navigation?.logoConfigured) {
95
+ if (inspection.logos.length === 0 && sitewideLogo?.logoConfigured) {
96
96
  const publicLabel = toPosixPath(path.relative(path.dirname(siteRoot), publicDirectory));
97
97
  issues.push({
98
- absolutePath: navigation.absolutePath,
98
+ absolutePath: sitewideLogo.absolutePath,
99
99
  code: 'missing-logo-file',
100
- filename: path.basename(navigation.absolutePath),
101
- line: navigation.logoLine,
102
- message: `Navigation logo settings are configured, but no logo file was found. Add exactly one of ${logoAssetFilenames.map((filename) => `${publicLabel}/${filename}`).join(', ')}, or remove navigation.logo.`,
100
+ filename: path.basename(sitewideLogo.absolutePath),
101
+ line: sitewideLogo.logoLine,
102
+ message: `Logo display settings are configured, but no logo file was found. Add exactly one of ${logoAssetFilenames.map((filename) => `${publicLabel}/${filename}`).join(', ')}, or remove logo.`,
103
103
  severity: 'error',
104
104
  });
105
105
  }
@@ -107,7 +107,7 @@ export const getSitePublicAssetStatus = async (documentPath) => {
107
107
  return {
108
108
  ...inspection,
109
109
  issues,
110
- navigation,
110
+ sitewideLogo,
111
111
  publicDirectory,
112
112
  siteRoot,
113
113
  };
@@ -121,34 +121,22 @@ const getPageContext = (siteRoot, documentPath) => {
121
121
  contentPath: absoluteDocumentPath,
122
122
  imagesRoot: path.join(siteRoot, 'images'),
123
123
  pageLabel: 'Home',
124
- routeDirectory: null,
124
+ pageDirectory: null,
125
125
  };
126
126
  }
127
127
 
128
- const match = relativePath.match(/^routes\/(.+)\/content\.md$/);
128
+ const match = relativePath.match(/^pages\/(.+)\/content\.md$/);
129
129
  if (!match) return null;
130
130
 
131
- const routeDirectory = match[1];
131
+ const pageDirectory = match[1];
132
132
  return {
133
133
  contentPath: absoluteDocumentPath,
134
- imagesRoot: path.join(siteRoot, 'routes', ...routeDirectory.split('/'), 'images'),
135
- pageLabel: routeDirectory,
136
- routeDirectory,
134
+ imagesRoot: path.join(siteRoot, 'pages', ...pageDirectory.split('/'), 'images'),
135
+ pageLabel: pageDirectory,
136
+ pageDirectory,
137
137
  };
138
138
  };
139
139
 
140
- const getSectionAtLine = (source, lineIndex) => {
141
- const lines = source.replace(/\r\n?/g, '\n').split('\n');
142
- let section = null;
143
-
144
- for (let index = 0; index <= Math.min(lineIndex, lines.length - 1); index += 1) {
145
- const match = lines[index].match(headingIdPattern);
146
- if (match) section = { id: match[1], line: index };
147
- }
148
-
149
- return section;
150
- };
151
-
152
140
  const getOpenFenceAtLine = (source, lineIndex) => {
153
141
  const lines = source.replace(/\r\n?/g, '\n').split('\n');
154
142
  let open = null;
@@ -304,12 +292,11 @@ const collectImageFiles = async (directory, imageRoot, pageLabel, files) => {
304
292
  filename: entry.name,
305
293
  pageLabel,
306
294
  relativeToImages,
307
- sectionId: relativeToImages.split('/')[0] ?? '',
308
295
  });
309
296
  }
310
297
  };
311
298
 
312
- const collectRouteImageRoots = async (directory, siteRoot, roots) => {
299
+ const collectPageImageRoots = async (directory, siteRoot, roots) => {
313
300
  const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
314
301
  if (error?.code === 'ENOENT') return [];
315
302
  throw error;
@@ -319,11 +306,11 @@ const collectRouteImageRoots = async (directory, siteRoot, roots) => {
319
306
  if (!entry.isDirectory()) continue;
320
307
  const absolutePath = path.join(directory, entry.name);
321
308
  if (entry.name === 'images') {
322
- const routeDirectory = toPosixPath(path.relative(path.join(siteRoot, 'routes'), path.dirname(absolutePath)));
323
- roots.push({ imageRoot: absolutePath, pageLabel: routeDirectory });
309
+ const pageDirectory = toPosixPath(path.relative(path.join(siteRoot, 'pages'), path.dirname(absolutePath)));
310
+ roots.push({ imageRoot: absolutePath, pageLabel: pageDirectory });
324
311
  continue;
325
312
  }
326
- await collectRouteImageRoots(absolutePath, siteRoot, roots);
313
+ await collectPageImageRoots(absolutePath, siteRoot, roots);
327
314
  }
328
315
  };
329
316
 
@@ -344,7 +331,7 @@ const getContentFiles = async (siteRoot) => {
344
331
  }
345
332
  };
346
333
 
347
- await visit(path.join(siteRoot, 'routes'));
334
+ await visit(path.join(siteRoot, 'pages'));
348
335
  return files;
349
336
  };
350
337
 
@@ -366,9 +353,9 @@ export const createSiteImageIndex = async (siteRoot) => {
366
353
  const homeImageRoot = path.join(siteRoot, 'images');
367
354
  await collectImageFiles(homeImageRoot, homeImageRoot, 'Home', files);
368
355
 
369
- const routeRoots = [];
370
- await collectRouteImageRoots(path.join(siteRoot, 'routes'), siteRoot, routeRoots);
371
- for (const root of routeRoots) {
356
+ const pageRoots = [];
357
+ await collectPageImageRoots(path.join(siteRoot, 'pages'), siteRoot, pageRoots);
358
+ for (const root of pageRoots) {
372
359
  await collectImageFiles(root.imageRoot, root.imageRoot, root.pageLabel, files);
373
360
  }
374
361
 
@@ -397,9 +384,7 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
397
384
  const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
398
385
  if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
399
386
 
400
- const section = getSectionAtLine(source, line);
401
- if (!section) return null;
402
- const expectedDirectory = path.join(page.imagesRoot, section.id);
387
+ const expectedDirectory = page.imagesRoot;
403
388
  const index = await createSiteImageIndex(siteRoot);
404
389
  const candidates = index.files.map((file) => {
405
390
  const isExpected = path.dirname(file.absolutePath) === expectedDirectory;
@@ -421,7 +406,6 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
421
406
  candidates,
422
407
  expectedDirectory,
423
408
  page,
424
- section,
425
409
  siteRoot,
426
410
  };
427
411
  };
@@ -434,10 +418,9 @@ export const getImageDefinitionContext = async ({ documentPath, source, line })
434
418
  const filename = match[1].replace(/^['"]|['"]$/g, '');
435
419
  const siteRoot = await findNornaSiteRoot(documentPath);
436
420
  const page = siteRoot ? getPageContext(siteRoot, documentPath) : null;
437
- const section = getSectionAtLine(source, line);
438
- if (!siteRoot || !page || !section) return null;
421
+ if (!siteRoot || !page) return null;
439
422
 
440
- const expectedPath = path.join(page.imagesRoot, section.id, filename);
423
+ const expectedPath = path.join(page.imagesRoot, filename);
441
424
  if (await fileExists(expectedPath)) return { files: [expectedPath], filename, siteRoot };
442
425
 
443
426
  const index = await createSiteImageIndex(siteRoot);
@@ -480,7 +463,9 @@ const getImageReferenceLines = (source) => {
480
463
  const getContentFrontmatterDiagnostics = (source) => {
481
464
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
482
465
  if (!match) {
483
- return [{ severity: 'error', line: 1, message: 'Norna content files must start with YAML frontmatter delimited by ---.' }];
466
+ return /^---(?:\r?\n|$)/.test(source)
467
+ ? [{ severity: 'error', line: 1, message: 'YAML frontmatter starts with ---, but no closing --- delimiter was found.' }]
468
+ : [];
484
469
  }
485
470
 
486
471
  let data;
@@ -504,21 +489,98 @@ const getContentFrontmatterDiagnostics = (source) => {
504
489
  }));
505
490
  };
506
491
 
507
- const getSectionHeadingDiagnostics = (source) => source.replace(/\r\n?/g, '\n').split('\n')
508
- .flatMap((line, index) => {
509
- if (!/^##(?:\s|$)/.test(line) || headingIdPattern.test(line)) return [];
510
- return [{
511
- code: 'missing-section-id',
492
+ const getMarkdownStructure = (source) => {
493
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
494
+ let bodyStart = 0;
495
+ if (lines[0]?.trim() === '---') {
496
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
497
+ if (closingIndex < 0) return { bodyStart: lines.length, headings: [], lines };
498
+ bodyStart = closingIndex + 1;
499
+ }
500
+
501
+ const headings = [];
502
+ let fence = null;
503
+ for (let index = bodyStart; index < lines.length; index += 1) {
504
+ const line = lines[index];
505
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)$/);
506
+ if (fenceMatch) {
507
+ if (!fence) {
508
+ fence = { character: fenceMatch[1][0], length: fenceMatch[1].length };
509
+ } else if (
510
+ fenceMatch[1][0] === fence.character
511
+ && fenceMatch[1].length >= fence.length
512
+ && !fenceMatch[2].trim()
513
+ ) {
514
+ fence = null;
515
+ }
516
+ continue;
517
+ }
518
+ if (fence) continue;
519
+
520
+ const match = line.match(/^(#{1,2})\s+(.+?)\s*$/);
521
+ if (match) headings.push({ level: match[1].length, line: index + 1, source: line });
522
+ }
523
+
524
+ return { bodyStart, headings, lines };
525
+ };
526
+
527
+ const getPageTitleDiagnostics = (structure) => {
528
+ const { bodyStart, headings, lines } = structure;
529
+ const pageHeadings = headings.filter((heading) => heading.level === 1);
530
+ const diagnostics = [];
531
+
532
+ if (pageHeadings.length === 0) {
533
+ diagnostics.push({
534
+ code: 'missing-page-title',
512
535
  severity: 'error',
513
- line: index + 1,
514
- message: 'Level 2 Norna section headings require an explicit id, for example "## Introduction {#introduction}".',
515
- }];
516
- });
536
+ line: 1,
537
+ message: 'Norna pages require exactly one Markdown H1 page title, for example "# About".',
538
+ });
539
+ } else if (pageHeadings.length > 1) {
540
+ for (const heading of pageHeadings.slice(1)) {
541
+ diagnostics.push({
542
+ code: 'duplicate-page-title',
543
+ severity: 'error',
544
+ line: heading.line,
545
+ message: 'A Norna page can contain only one Markdown H1. Use a level 2 heading with an explicit id for a section.',
546
+ });
547
+ }
548
+ }
549
+
550
+ if (headings[0] && headings[0].level !== 1) {
551
+ diagnostics.push({
552
+ code: 'page-title-order',
553
+ severity: 'error',
554
+ line: headings[0].line,
555
+ message: 'The Markdown H1 page title must come before every level 2 section.',
556
+ });
557
+ }
558
+
559
+ const firstHeadingLineIndex = (headings[0]?.line ?? lines.length + 1) - 1;
560
+ if (lines.slice(bodyStart, firstHeadingLineIndex).some((line) => line.trim())) {
561
+ diagnostics.push({
562
+ code: 'page-title-order',
563
+ severity: 'error',
564
+ line: bodyStart + 1,
565
+ message: 'The Markdown H1 page title must be the first content after optional frontmatter.',
566
+ });
567
+ }
568
+
569
+ return diagnostics;
570
+ };
517
571
 
518
572
  export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
573
+ const markdownStructure = getMarkdownStructure(source);
574
+ const { headings } = await getMarkdownHeadings(source);
519
575
  const diagnostics = [
520
576
  ...getContentFrontmatterDiagnostics(source),
521
- ...getSectionHeadingDiagnostics(source),
577
+ ...getPageTitleDiagnostics(markdownStructure),
578
+ ...getHeadingIdentifierIssues(headings).map((issue) => ({
579
+ code: issue.code,
580
+ severity: 'error',
581
+ line: issue.heading.line,
582
+ message: `${issue.message} ${issue.fix}`,
583
+ })),
522
584
  ];
523
585
  const blockResult = extractNornaMarkdownBlockDiagnostics(source, { label: path.basename(documentPath) });
524
586
  for (const error of blockResult.errors) {
@@ -547,9 +609,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
547
609
 
548
610
  const index = await createSiteImageIndex(siteRoot);
549
611
  for (const reference of getImageReferenceLines(source)) {
550
- const section = getSectionAtLine(source, reference.line - 1);
551
- if (!section) continue;
552
- const expectedPath = path.join(page.imagesRoot, section.id, reference.filename);
612
+ const expectedPath = path.join(page.imagesRoot, reference.filename);
553
613
  if (await fileExists(expectedPath)) continue;
554
614
 
555
615
  const candidates = index.filesByName.get(reference.filename) ?? [];
@@ -558,7 +618,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
558
618
  code: 'missing-image',
559
619
  severity: 'error',
560
620
  line: reference.line,
561
- message: `Image "${reference.filename}" was not found in the expected section folder or elsewhere in this Norna site.`,
621
+ message: `Image "${reference.filename}" was not found in this page's images folder or elsewhere in this Norna site.`,
562
622
  });
563
623
  continue;
564
624
  }
@@ -586,7 +646,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
586
646
  line: reference.line,
587
647
  message: otherReferences.length > 0
588
648
  ? `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))} and is still referenced by ${otherReferences.join(', ')}. Norna cannot relocate it safely.`
589
- : `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))}. Run "norna content:sync" to relocate it to this section.`,
649
+ : `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))}. Run "norna content:sync" to relocate it to this page's images folder.`,
590
650
  });
591
651
  }
592
652
 
@@ -0,0 +1,161 @@
1
+ import { markdownToMdast } from 'satteri';
2
+
3
+ export const explicitHeadingIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4
+ export const reservedHeadingIds = new Set(['page-title']);
5
+
6
+ const explicitHeadingIdSuffixPattern = /\s*\{#([^{}]*)\}\s*$/;
7
+ const transliterations = new Map([
8
+ ['æ', 'ae'],
9
+ ['đ', 'd'],
10
+ ['ð', 'd'],
11
+ ['ħ', 'h'],
12
+ ['ı', 'i'],
13
+ ['ł', 'l'],
14
+ ['ø', 'o'],
15
+ ['œ', 'oe'],
16
+ ['ß', 'ss'],
17
+ ['þ', 'th'],
18
+ ]);
19
+
20
+ const transliterateToAscii = (value) => Array.from(value)
21
+ .map((character) => transliterations.get(character) ?? character)
22
+ .join('');
23
+
24
+ export const slugifyHeadingText = (value) => transliterateToAscii(value
25
+ .normalize('NFKD')
26
+ .toLowerCase())
27
+ .replace(/\p{Mark}+/gu, '')
28
+ .replace(/['’]/g, '')
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/^-+|-+$/g, '');
31
+
32
+ const getNodeText = (node) => {
33
+ if (!node || typeof node !== 'object') return '';
34
+ if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? '';
35
+ if (node.type === 'image') return node.alt ?? '';
36
+ if (!Array.isArray(node.children)) return '';
37
+ return node.children.map(getNodeText).join('');
38
+ };
39
+
40
+ export const resolveHeadingIdentifier = (headingText, depth) => {
41
+ const explicitMatch = headingText.match(explicitHeadingIdSuffixPattern);
42
+ const explicitSource = explicitMatch?.[1];
43
+ const title = explicitMatch
44
+ ? headingText.slice(0, explicitMatch.index).trim()
45
+ : headingText.trim();
46
+ const explicitId = explicitSource && explicitHeadingIdPattern.test(explicitSource)
47
+ ? explicitSource
48
+ : null;
49
+
50
+ return {
51
+ depth,
52
+ explicitId,
53
+ explicitSource: explicitSource ?? null,
54
+ id: depth === 1 ? null : explicitId ?? slugifyHeadingText(title),
55
+ title,
56
+ };
57
+ };
58
+
59
+ export const getMarkdownHeadings = async (source) => {
60
+ const normalizedSource = source.replace(/\r\n?/g, '\n');
61
+ const tree = await markdownToMdast(normalizedSource, {
62
+ features: {
63
+ frontmatter: true,
64
+ gfm: true,
65
+ },
66
+ });
67
+
68
+ const headings = tree.children
69
+ .filter((node) => node.type === 'heading' && node.depth >= 1 && node.depth <= 3)
70
+ .map((node) => ({
71
+ ...resolveHeadingIdentifier(getNodeText(node), node.depth),
72
+ index: node.position?.start.offset ?? 0,
73
+ line: node.position?.start.line ?? 1,
74
+ source: normalizedSource.slice(
75
+ node.position?.start.offset ?? 0,
76
+ node.position?.end.offset ?? node.position?.start.offset ?? 0,
77
+ ),
78
+ }));
79
+
80
+ return { headings, source: normalizedSource };
81
+ };
82
+
83
+ export const getHeadingIdentifierIssues = (headings) => {
84
+ const issues = [];
85
+ const headingsById = new Map();
86
+
87
+ for (const heading of headings) {
88
+ if (heading.depth === 1) {
89
+ if (heading.explicitSource !== null) {
90
+ issues.push({
91
+ code: 'page-title-id',
92
+ heading,
93
+ message: 'The Markdown H1 is the page title and must not have an id.',
94
+ fix: 'Remove the {#...} suffix. Heading ids apply to H2 and H3.',
95
+ });
96
+ }
97
+ continue;
98
+ }
99
+
100
+ if (heading.explicitSource !== null && !heading.explicitId) {
101
+ issues.push({
102
+ code: 'invalid-heading-id',
103
+ heading,
104
+ message: `Heading "${heading.title}" has an invalid explicit id "${heading.explicitSource}".`,
105
+ fix: 'Use lowercase ASCII letters, numbers, and single hyphens, for example {#getting-started}.',
106
+ });
107
+ continue;
108
+ }
109
+
110
+ if (!heading.id) {
111
+ issues.push({
112
+ code: 'empty-heading-id',
113
+ heading,
114
+ message: `Heading "${heading.title}" cannot produce an automatic ASCII id.`,
115
+ fix: `Add an explicit id, for example ${'#'.repeat(heading.depth)} ${heading.title} {#section-id}.`,
116
+ });
117
+ continue;
118
+ }
119
+
120
+ if (reservedHeadingIds.has(heading.id)) {
121
+ issues.push({
122
+ code: 'reserved-heading-id',
123
+ heading,
124
+ message: `Heading id "${heading.id}" is reserved by Norna.`,
125
+ fix: 'Choose another explicit id.',
126
+ });
127
+ continue;
128
+ }
129
+
130
+ const existing = headingsById.get(heading.id);
131
+ if (existing) {
132
+ issues.push({
133
+ code: 'duplicate-heading-id',
134
+ heading,
135
+ otherHeading: existing,
136
+ message: `Two headings resolve to id "${heading.id}".`,
137
+ fix: `Add a unique explicit id to at least one heading, for example {#${heading.id}-details}.`,
138
+ });
139
+ continue;
140
+ }
141
+
142
+ headingsById.set(heading.id, heading);
143
+ }
144
+
145
+ return issues;
146
+ };
147
+
148
+ export const formatHeadingIdentifierIssue = (issue, sourceLabel = 'Markdown') => {
149
+ const lines = [`${sourceLabel}: ${issue.message}`];
150
+ if (issue.otherHeading) {
151
+ lines.push(
152
+ `- line ${issue.otherHeading.line}: "${issue.otherHeading.title}"`,
153
+ `- line ${issue.heading.line}: "${issue.heading.title}"`,
154
+ );
155
+ } else {
156
+ lines.push(`- line ${issue.heading.line}: "${issue.heading.title}"`);
157
+ }
158
+ if (issue.fix) lines.push(issue.fix);
159
+
160
+ return lines.join('\n');
161
+ };
@@ -0,0 +1,51 @@
1
+ export const navigationModeNames = Object.freeze([
2
+ 'automatic',
3
+ 'sections',
4
+ 'top',
5
+ 'tree',
6
+ ]);
7
+
8
+ const assertNavigationMode = (mode) => {
9
+ if (!navigationModeNames.includes(mode)) {
10
+ throw new Error(`Unknown navigation mode "${mode}". Use one of: ${navigationModeNames.join(', ')}.`);
11
+ }
12
+
13
+ return mode;
14
+ };
15
+
16
+ const getListedPages = (pages) => pages.filter((page) => page.isHome || page.listed !== false);
17
+
18
+ const getPageNavigationDepth = (page) => {
19
+ const pageDepth = page.depth ?? 1;
20
+ return (page.headings ?? []).reduce((maximum, heading) => (
21
+ Math.max(maximum, pageDepth + heading.depth - 1)
22
+ ), pageDepth);
23
+ };
24
+
25
+ export const getAutomaticNavigationMode = (pages) => {
26
+ const listedPages = getListedPages(pages);
27
+ if (listedPages.length <= 1) return 'sections';
28
+
29
+ const maximumDepth = listedPages.reduce((maximum, page) => (
30
+ Math.max(maximum, getPageNavigationDepth(page))
31
+ ), 1);
32
+
33
+ return maximumDepth <= 2 ? 'top' : 'tree';
34
+ };
35
+
36
+ export const resolveNavigationModel = ({ mode = 'automatic', pages }) => {
37
+ const requestedMode = assertNavigationMode(mode);
38
+ const listedPages = getListedPages(pages);
39
+ const maximumDepth = listedPages.reduce((maximum, page) => (
40
+ Math.max(maximum, getPageNavigationDepth(page))
41
+ ), 1);
42
+
43
+ return Object.freeze({
44
+ mode: requestedMode === 'automatic'
45
+ ? getAutomaticNavigationMode(listedPages)
46
+ : requestedMode,
47
+ requestedMode,
48
+ listedPageCount: listedPages.length,
49
+ maximumDepth,
50
+ });
51
+ };
@@ -3,12 +3,11 @@ import { documentationLink } from './documentation-links.mjs';
3
3
 
4
4
  const field = (description, options = {}) => Object.freeze({ description, ...options });
5
5
  const value = (title, description) => Object.freeze({ title, description });
6
- const nornaBlocksDocumentation = documentationLink('Norna blocks reference', 'content.md', 'norna-blocks');
7
6
 
8
7
  export const nornaMarkdownBlockDefinitions = Object.freeze({
9
8
  'norna-image-stack': Object.freeze({
10
9
  description: 'Display one or more managed images in a vertical stack.',
11
- documentation: nornaBlocksDocumentation,
10
+ documentation: documentationLink('Image stack reference', 'content.md', 'image-stack'),
12
11
  item: Object.freeze({
13
12
  start: field('Start another managed image.', { key: 'image', prefix: '- ' }),
14
13
  fields: Object.freeze({
@@ -19,7 +18,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
19
18
  }),
20
19
  'norna-image-carousel': Object.freeze({
21
20
  description: 'Display two or more managed images in an interactive carousel.',
22
- documentation: nornaBlocksDocumentation,
21
+ documentation: documentationLink('Image carousel reference', 'content.md', 'image-carousel'),
23
22
  item: Object.freeze({
24
23
  start: field('Start another managed carousel image.', { key: 'image', prefix: '- ' }),
25
24
  fields: Object.freeze({
@@ -30,7 +29,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
30
29
  }),
31
30
  'norna-card-list': Object.freeze({
32
31
  description: 'Display a structured list of cards.',
33
- documentation: nornaBlocksDocumentation,
32
+ documentation: documentationLink('Card list reference', 'content.md', 'card-list'),
34
33
  options: Object.freeze({
35
34
  layout: field('Place each card image above, left, or right of its text. Defaults to image-top.', {
36
35
  default: 'image-top',
@@ -0,0 +1,83 @@
1
+ export const pageDirectoryPattern = /^(?!000)(\d{3})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
2
+
3
+ const pageEntryPathSeparator = '--';
4
+
5
+ export const parsePageDirectory = (pageDirectory, label = 'page directory') => {
6
+ const match = pageDirectory.match(pageDirectoryPattern);
7
+
8
+ if (!match) {
9
+ throw new Error(
10
+ `Invalid ${label} "${pageDirectory}". Page directories must use the form NNN-page-id, for example 010-getting-started. The prefix must be 001-999 and page-id may contain only lowercase letters, numbers, and single hyphens.`,
11
+ );
12
+ }
13
+
14
+ return {
15
+ pageDirectory,
16
+ pageOrder: Number.parseInt(match[1], 10),
17
+ pageId: match[2],
18
+ };
19
+ };
20
+
21
+ const normalizePageDirectoryPath = (pageDirectoryPath) => pageDirectoryPath.replaceAll('\\', '/');
22
+
23
+ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page directory path') => {
24
+ const normalizedPath = normalizePageDirectoryPath(pageDirectoryPath);
25
+ const segments = normalizedPath.split('/').filter(Boolean);
26
+
27
+ if (!normalizedPath || normalizedPath.startsWith('/') || normalizedPath.endsWith('/') || segments.length % 2 === 0) {
28
+ throw new Error(
29
+ `Invalid ${label} "${pageDirectoryPath}". Nested pages must use the form NNN-page-id/pages/NNN-child-id.`,
30
+ );
31
+ }
32
+
33
+ const pageDirectories = [];
34
+ for (const [index, segment] of segments.entries()) {
35
+ if (index % 2 === 1) {
36
+ if (segment !== 'pages') {
37
+ throw new Error(
38
+ `Invalid ${label} "${pageDirectoryPath}". Use a pages directory between nested page directories.`,
39
+ );
40
+ }
41
+ continue;
42
+ }
43
+
44
+ pageDirectories.push(parsePageDirectory(segment, `${label} segment ${segment}`));
45
+ }
46
+
47
+ const pageIds = pageDirectories.map(({ pageId }) => pageId);
48
+ const pageOrders = pageDirectories.map(({ pageOrder }) => pageOrder);
49
+ const currentPage = pageDirectories.at(-1);
50
+
51
+ return {
52
+ pageDirectory: normalizedPath,
53
+ pageDirectories: pageDirectories.map(({ pageDirectory }) => pageDirectory),
54
+ pageId: currentPage.pageId,
55
+ pageIds,
56
+ pageOrder: currentPage.pageOrder,
57
+ pageOrders,
58
+ pagePath: pageIds.join('/'),
59
+ parentPagePath: pageIds.length > 1 ? pageIds.slice(0, -1).join('/') : null,
60
+ depth: pageIds.length,
61
+ };
62
+ };
63
+
64
+ export const encodePageDirectoryPath = (pageDirectoryPath) => {
65
+ const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
66
+ return pageDirectories.join(pageEntryPathSeparator);
67
+ };
68
+
69
+ export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path') => {
70
+ const pageDirectories = pageEntryPath.split(pageEntryPathSeparator);
71
+ if (pageDirectories.some((pageDirectory) => !pageDirectory)) {
72
+ throw new Error(`Invalid ${label} "${pageEntryPath}".`);
73
+ }
74
+
75
+ return pageDirectories.join('/pages/');
76
+ };
77
+
78
+ export const getPageDirectoryAncestors = (pageDirectoryPath) => {
79
+ const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
80
+ return pageDirectories.map((_, index) => (
81
+ pageDirectories.slice(0, index + 1).join('/pages/')
82
+ ));
83
+ };