@janga/norna 0.7.8 → 0.7.10

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 (234) hide show
  1. package/README.md +1 -1
  2. package/docs/README.md +18 -11
  3. package/docs/configuration.md +9 -54
  4. package/docs/content.md +6 -7
  5. package/docs/design/norna-diagram-design.md +158 -0
  6. package/docs/getting-started.md +18 -16
  7. package/docs/routes.md +7 -6
  8. package/docs/site-structure.md +4 -2
  9. package/docs/sitewide-content.md +74 -0
  10. package/docs/theme.md +61 -128
  11. package/docs/typography.md +45 -33
  12. package/examples/001-dog-shelter-single-page/README.md +9 -0
  13. package/examples/001-dog-shelter-single-page/site/config.mjs +27 -0
  14. package/examples/001-dog-shelter-single-page/site/content.md +38 -0
  15. package/examples/001-dog-shelter-single-page/site/images/what-we-do/dog-house.svg +25 -0
  16. package/examples/001-dog-shelter-single-page/site/images/you-can-help/heart.svg +13 -0
  17. package/examples/001-dog-shelter-single-page/site/public/favicon.svg +10 -0
  18. package/examples/001-dog-shelter-single-page/site/public/robots.txt +2 -0
  19. package/examples/001-dog-shelter-single-page/site/sitewide-content.md +6 -0
  20. package/examples/001-dog-shelter-single-page/site/theme.md +25 -0
  21. package/examples/002-dog-shelter-multi-page/README.md +9 -0
  22. package/examples/002-dog-shelter-multi-page/site/config.mjs +27 -0
  23. package/examples/002-dog-shelter-multi-page/site/content.md +12 -0
  24. package/examples/002-dog-shelter-multi-page/site/public/favicon.svg +10 -0
  25. package/examples/002-dog-shelter-multi-page/site/public/robots.txt +2 -0
  26. package/examples/002-dog-shelter-multi-page/site/routes/010-dogs/images/dogs/rex.svg +19 -0
  27. package/examples/002-dog-shelter-multi-page/site/routes/010-dogs/images/dogs/rover.svg +19 -0
  28. package/examples/002-dog-shelter-multi-page/site/routes/010-dogs/route-content.md +30 -0
  29. package/examples/002-dog-shelter-multi-page/site/routes/020-adopt/route-content.md +12 -0
  30. package/examples/002-dog-shelter-multi-page/site/sitewide-content.md +6 -0
  31. package/examples/002-dog-shelter-multi-page/site/theme.md +25 -0
  32. package/examples/dog-gallery/.astro/collections/site.schema.json +776 -0
  33. package/examples/dog-gallery/.astro/collections/theme.schema.json +672 -0
  34. package/examples/dog-gallery/.astro/content-assets.mjs +4 -0
  35. package/examples/dog-gallery/.astro/content-modules.mjs +1 -0
  36. package/examples/dog-gallery/.astro/content.d.ts +175 -0
  37. package/examples/dog-gallery/.astro/data-store.json +1 -0
  38. package/examples/dog-gallery/.astro/dev-local.json +8 -0
  39. package/examples/dog-gallery/.astro/dev.json +13 -0
  40. package/examples/dog-gallery/.astro/dev.log +95 -0
  41. package/examples/dog-gallery/.astro/settings.json +5 -0
  42. package/examples/dog-gallery/.astro/types.d.ts +2 -0
  43. package/examples/dog-gallery/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  44. package/examples/dog-gallery/dist/_astro/SitePage.DicGh_5N.css +1 -0
  45. package/examples/dog-gallery/dist/dog-care/index.html +15 -0
  46. package/examples/dog-gallery/dist/dog-shelter-logo.svg +14 -0
  47. package/examples/dog-gallery/dist/favicon.svg +7 -0
  48. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1080.webp +0 -0
  49. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1440.webp +0 -0
  50. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1536.webp +0 -0
  51. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-480.webp +0 -0
  52. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-768.webp +0 -0
  53. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-480.webp +0 -0
  54. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-768.webp +0 -0
  55. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-960.webp +0 -0
  56. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1080.webp +0 -0
  57. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1440.webp +0 -0
  58. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1920.webp +0 -0
  59. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-3939.webp +0 -0
  60. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-480.webp +0 -0
  61. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-768.webp +0 -0
  62. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1080.webp +0 -0
  63. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1440.webp +0 -0
  64. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1920.webp +0 -0
  65. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-2400.webp +0 -0
  66. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-480.webp +0 -0
  67. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-768.webp +0 -0
  68. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1080.webp +0 -0
  69. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1440.webp +0 -0
  70. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1600.webp +0 -0
  71. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-480.webp +0 -0
  72. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-768.webp +0 -0
  73. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1080.webp +0 -0
  74. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1440.webp +0 -0
  75. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1920.webp +0 -0
  76. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-2576.webp +0 -0
  77. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-480.webp +0 -0
  78. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-768.webp +0 -0
  79. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1080.webp +0 -0
  80. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1277.webp +0 -0
  81. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-480.webp +0 -0
  82. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-768.webp +0 -0
  83. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1080.webp +0 -0
  84. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1440.webp +0 -0
  85. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1536.webp +0 -0
  86. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-480.webp +0 -0
  87. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-768.webp +0 -0
  88. package/examples/dog-gallery/dist/index.html +45 -0
  89. package/examples/dog-gallery/dist/robots.txt +2 -0
  90. package/examples/dog-gallery/node_modules/.vite/deps/_metadata.json +7 -7
  91. package/examples/dog-gallery/site/.norna/generated-images.json +8 -8
  92. package/examples/dog-gallery/site/config.mjs +0 -20
  93. package/examples/dog-gallery/site/content.md +4 -5
  94. package/examples/dog-gallery/site/public/logo.svg +14 -0
  95. package/examples/dog-gallery/site/sitewide-content.md +16 -0
  96. package/examples/dog-gallery/site/theme.md +13 -24
  97. package/examples/presentation/README.md +15 -0
  98. package/examples/presentation/site/.norna/generated-images.json +66 -0
  99. package/examples/presentation/site/config.mjs +29 -0
  100. package/examples/presentation/site/content.md +67 -0
  101. package/examples/presentation/site/public/robots.txt +2 -0
  102. package/examples/presentation/site/routes/010-presets/route-content.md +63 -0
  103. package/examples/presentation/site/routes/010-presets/theme.md +4 -0
  104. package/examples/presentation/site/routes/020-media/images/cards/card-carousel.svg +11 -0
  105. package/examples/presentation/site/routes/020-media/images/cards/card-stack.svg +9 -0
  106. package/examples/presentation/site/routes/020-media/images/cards/card-surfaces.svg +8 -0
  107. package/examples/presentation/site/routes/020-media/images/carousel/carousel-one.svg +8 -0
  108. package/examples/presentation/site/routes/020-media/images/carousel/carousel-three.svg +13 -0
  109. package/examples/presentation/site/routes/020-media/images/carousel/carousel-two.svg +8 -0
  110. package/examples/presentation/site/routes/020-media/images/stack/stack-one.svg +8 -0
  111. package/examples/presentation/site/routes/020-media/images/stack/stack-two.svg +9 -0
  112. package/examples/presentation/site/routes/020-media/route-content.md +77 -0
  113. package/examples/presentation/site/routes/020-media/theme.md +4 -0
  114. package/examples/presentation/site/routes/030-surfaces/route-content.md +44 -0
  115. package/examples/presentation/site/routes/030-surfaces/theme.md +7 -0
  116. package/examples/presentation/site/sitewide-content.md +6 -0
  117. package/examples/presentation/site/theme.md +43 -0
  118. package/examples/typography-presets/.astro/collections/routeTheme.schema.json +746 -0
  119. package/examples/typography-presets/.astro/collections/site.schema.json +59 -0
  120. package/examples/typography-presets/.astro/collections/sitewide.schema.json +114 -0
  121. package/examples/typography-presets/.astro/collections/theme.schema.json +770 -0
  122. package/examples/typography-presets/.astro/content-assets.mjs +4 -0
  123. package/examples/typography-presets/.astro/content-modules.mjs +1 -0
  124. package/examples/typography-presets/.astro/content.d.ts +183 -0
  125. package/examples/typography-presets/.astro/data-store.json +1 -0
  126. package/examples/typography-presets/.astro/dev-local.json +8 -0
  127. package/examples/typography-presets/.astro/dev.json +15 -0
  128. package/examples/typography-presets/.astro/dev.log +45 -0
  129. package/examples/typography-presets/.astro/settings.json +5 -0
  130. package/examples/typography-presets/.astro/types.d.ts +2 -0
  131. package/examples/typography-presets/node_modules/.vite/deps/_metadata.json +80 -0
  132. package/examples/typography-presets/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  133. package/examples/typography-presets/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  134. package/examples/typography-presets/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  135. package/examples/typography-presets/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  136. package/examples/typography-presets/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  137. package/examples/typography-presets/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  138. package/examples/typography-presets/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  139. package/examples/typography-presets/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  140. package/examples/typography-presets/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  141. package/examples/typography-presets/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  142. package/examples/typography-presets/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  143. package/examples/typography-presets/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  144. package/examples/typography-presets/node_modules/.vite/deps/embla-carousel.js +1389 -0
  145. package/examples/typography-presets/node_modules/.vite/deps/embla-carousel.js.map +1 -0
  146. package/examples/typography-presets/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  147. package/examples/typography-presets/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  148. package/examples/typography-presets/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  149. package/examples/typography-presets/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  150. package/examples/typography-presets/node_modules/.vite/deps/package.json +3 -0
  151. package/examples/typography-presets/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  152. package/examples/typography-presets/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  153. package/examples/typography-presets/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  154. package/examples/typography-presets/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  155. package/examples/typography-presets/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  156. package/examples/typography-presets/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  157. package/examples/typography-presets/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  158. package/examples/typography-presets/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  159. package/examples/typography-presets/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  160. package/examples/typography-presets/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  161. package/examples/typography-presets/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  162. package/examples/typography-presets/site/.norna/generated-images.json +290 -0
  163. package/examples/typography-presets/site/config.mjs +29 -0
  164. package/examples/typography-presets/site/content.md +16 -0
  165. package/examples/typography-presets/site/public/robots.txt +2 -0
  166. package/examples/typography-presets/site/routes/010-quiet-gallery/images/quiet-gallery/puppy-portrait.jpg +0 -0
  167. package/examples/typography-presets/site/routes/010-quiet-gallery/images/quiet-gallery/shepherd-portrait.jpg +0 -0
  168. package/examples/typography-presets/site/routes/010-quiet-gallery/images/quiet-gallery/white-puppy.jpg +0 -0
  169. package/examples/typography-presets/site/routes/010-quiet-gallery/route-content.md +34 -0
  170. package/examples/typography-presets/site/routes/010-quiet-gallery/theme.md +4 -0
  171. package/examples/typography-presets/site/routes/020-compact-gallery/images/compact-gallery/black-schnauzer.jpg +0 -0
  172. package/examples/typography-presets/site/routes/020-compact-gallery/images/compact-gallery/retriever-portrait.jpg +0 -0
  173. package/examples/typography-presets/site/routes/020-compact-gallery/images/compact-gallery/schnauzer-portrait.jpg +0 -0
  174. package/examples/typography-presets/site/routes/020-compact-gallery/route-content.md +33 -0
  175. package/examples/typography-presets/site/routes/020-compact-gallery/theme.md +4 -0
  176. package/examples/typography-presets/site/routes/030-text-forward/images/text-forward/boxer-portrait.jpg +0 -0
  177. package/examples/typography-presets/site/routes/030-text-forward/images/text-forward/grass-puppy.jpg +0 -0
  178. package/examples/typography-presets/site/routes/030-text-forward/images/text-forward/grey-street-dog.jpg +0 -0
  179. package/examples/typography-presets/site/routes/030-text-forward/route-content.md +34 -0
  180. package/examples/typography-presets/site/routes/030-text-forward/theme.md +4 -0
  181. package/examples/typography-presets/site/routes/040-statement/images/statement/black-pedigree.jpg +0 -0
  182. package/examples/typography-presets/site/routes/040-statement/images/statement/sheepdog-grass.jpg +0 -0
  183. package/examples/typography-presets/site/routes/040-statement/images/statement/sheepdog-wide.jpg +0 -0
  184. package/examples/typography-presets/site/routes/040-statement/route-content.md +34 -0
  185. package/examples/typography-presets/site/routes/040-statement/theme.md +4 -0
  186. package/examples/typography-presets/site/sitewide-content.md +14 -0
  187. package/examples/typography-presets/site/theme.md +43 -0
  188. package/fixtures/basic/site/config.mjs +0 -13
  189. package/fixtures/basic/site/sitewide-content.md +12 -0
  190. package/fixtures/basic/site/theme.md +2 -2
  191. package/fixtures/content-model-v2/site/content.md +2 -5
  192. package/fixtures/content-model-v2/site/theme.md +1 -3
  193. package/package.json +3 -2
  194. package/scripts/check-config.mjs +22 -1
  195. package/scripts/lib/frontmatter-yaml.mjs +28 -0
  196. package/scripts/lib/logo-assets.mjs +35 -0
  197. package/scripts/lib/norna-markdown-blocks.mjs +166 -0
  198. package/scripts/lib/presentation.mjs +104 -46
  199. package/scripts/lib/project-config.mjs +67 -105
  200. package/scripts/lib/site-content.mjs +26 -42
  201. package/scripts/lib/site-paths.mjs +2 -0
  202. package/scripts/lib/sitewide-config.mjs +34 -0
  203. package/scripts/lib/theme-config.mjs +72 -6
  204. package/scripts/lib/typography.mjs +20 -26
  205. package/scripts/show-typography.mjs +40 -147
  206. package/scripts/sync-content-sections.mjs +19 -14
  207. package/scripts/test-content-check.mjs +76 -14
  208. package/scripts/test-content-model-v2.mjs +154 -0
  209. package/scripts/test-engine-commands.mjs +3 -1
  210. package/scripts/test-package-check.mjs +78 -19
  211. package/scripts/test-temporary-visibility.mjs +19 -2
  212. package/src/components/CardList.astro +5 -2
  213. package/src/components/GalleryGrid.astro +37 -16
  214. package/src/components/SiteBanners.astro +100 -0
  215. package/src/components/SiteNavigation.astro +48 -31
  216. package/src/components/SitePage.astro +55 -13
  217. package/src/components/SiteSection.astro +194 -36
  218. package/src/content.config.ts +78 -48
  219. package/src/layouts/BaseLayout.astro +64 -14
  220. package/src/lib/routeThemes.ts +61 -0
  221. package/src/lib/sectionContent.ts +236 -44
  222. package/src/lib/sitePublicAssets.ts +1 -0
  223. package/src/styles/global.css +282 -93
  224. package/starters/basic/.github/workflows/deploy.yml +1 -1
  225. package/starters/basic/README.md +7 -6
  226. package/starters/basic/site/config.mjs +0 -13
  227. package/starters/basic/site/content.md +1 -1
  228. package/starters/basic/site/sitewide-content.md +14 -0
  229. package/starters/basic/site/theme.md +13 -24
  230. package/starters/project/.github/workflows/deploy.yml +1 -1
  231. package/starters/project/README.md +10 -8
  232. package/starters/project/site/config.mjs +0 -13
  233. package/starters/project/site/sitewide-content.md +14 -0
  234. package/starters/project/site/theme.md +3 -11
@@ -0,0 +1,61 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseYamlMapping } from '../../scripts/lib/frontmatter-yaml.mjs';
4
+ import {
5
+ splitSiteFile,
6
+ validateFrontmatterIndentation,
7
+ validateRouteThemeFrontmatterStructure,
8
+ } from '../../scripts/lib/site-content.mjs';
9
+ import { siteRoutesDir, siteRoutesLabel, sitewideContentLabel } from '../../scripts/lib/site-paths.mjs';
10
+ import { themeVisualSchema } from '../content.config';
11
+
12
+ type RouteTheme = {
13
+ id: string;
14
+ data: Record<string, unknown>;
15
+ };
16
+
17
+ export const getRouteTheme = async (routeDirectory: string | null): Promise<RouteTheme | null> => {
18
+ if (!routeDirectory) return null;
19
+
20
+ const segments = routeDirectory.split(/[\\/]/).filter(Boolean);
21
+ for (let length = segments.length; length > 0; length -= 1) {
22
+ const themeSegments = segments.slice(0, length);
23
+ const themePath = path.join(siteRoutesDir, ...themeSegments, 'theme.md');
24
+ const themeLabel = `${siteRoutesLabel}/${themeSegments.join('/')}/theme.md`;
25
+ const source = await readFile(themePath, 'utf8').catch((error) => {
26
+ if (error?.code === 'ENOENT') return null;
27
+ throw error;
28
+ });
29
+ if (!source) continue;
30
+
31
+ const { frontmatter, frontmatterBody } = splitSiteFile(source, themeLabel);
32
+ const issues: Array<{ message: string }> = [];
33
+ validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
34
+ validateRouteThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
35
+ if (issues.length > 0) {
36
+ throw new Error([
37
+ `${themeLabel} has invalid frontmatter.`,
38
+ ...issues.map((issue) => `- ${issue.message}`),
39
+ ].join('\n'));
40
+ }
41
+
42
+ const data = parseYamlMapping(frontmatterBody);
43
+ if (Object.hasOwn(data, 'navigation')) {
44
+ throw new Error(`${themeLabel} may not define navigation. Brand and logo belong in ${sitewideContentLabel}.`);
45
+ }
46
+ const parsed = themeVisualSchema.safeParse(data);
47
+ if (!parsed.success) {
48
+ throw new Error([
49
+ `${themeLabel} has invalid values.`,
50
+ ...parsed.error.issues.map((issue) => `- ${issue.path.join('.') || '(root)'}: ${issue.message}`),
51
+ ].join('\n'));
52
+ }
53
+
54
+ return {
55
+ id: `routes/${themeSegments.join('/')}/theme`,
56
+ data: parsed.data,
57
+ };
58
+ }
59
+
60
+ return null;
61
+ };
@@ -1,6 +1,8 @@
1
1
  import type { CollectionEntry } from 'astro:content';
2
+ import { markdownToHtml } from 'satteri';
2
3
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
4
  import {
5
+ extractInlineNoteDiagnostics,
4
6
  extractNornaMarkdownBlocks,
5
7
  splitNornaMarkdownBlockMarkers,
6
8
  splitNornaRenderedCodeBlocks,
@@ -10,8 +12,6 @@ import type { SitePage } from './sitePages';
10
12
 
11
13
  type SiteSectionMetadataMap = CollectionEntry<'site'>['data']['sections'];
12
14
  type SiteSectionMetadata = SiteSectionMetadataMap[string];
13
- type ThemePresentation = CollectionEntry<'theme'>['data']['presentation'];
14
- type InlineStyles = NonNullable<NonNullable<ThemePresentation>['inlineStyles']>;
15
15
  type GalleryImage = {
16
16
  image: string;
17
17
  src: string;
@@ -30,20 +30,32 @@ type CardListLayout = 'image-top' | 'image-left' | 'image-right';
30
30
  type CardListFlow = 'grid' | 'stack';
31
31
  type CardListSize = 's' | 'm' | 'l' | 'xl';
32
32
  type CardListWidth = 'text' | 'narrow' | 'normal' | 'wide';
33
+ type InlineNote = {
34
+ markdown: string;
35
+ number: number;
36
+ id: string;
37
+ referenceId: string;
38
+ };
33
39
  type SectionContentBlock =
34
40
  | { type: 'html'; html: string }
35
41
  | { type: 'image-stack'; images: GalleryImage[] }
36
42
  | { type: 'image-carousel'; images: GalleryImage[] }
37
- | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width: CardListWidth; cards: CardListItem[] };
43
+ | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width: CardListWidth; cards: CardListItem[] }
44
+ | { type: 'note'; html: string; number?: number; id?: string; referenceId?: string };
38
45
  export type ResolvedSection = SiteSectionMetadata & {
39
46
  id: string;
40
47
  title: string;
48
+ titleHtml: string;
41
49
  contentBlocks: SectionContentBlock[];
42
50
  };
43
51
 
52
+ export type SectionNavigation = {
53
+ id: string;
54
+ title: string;
55
+ };
56
+
44
57
  const headingRegex = /<h2\b([^>]*)>([\s\S]*?)<\/h2>/gi;
45
58
  const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
46
- const inlineStyleReferenceRegex = /\[([^\]<]+)\]\{\.([a-z][a-z0-9-]*)\}/g;
47
59
  const markdownH2Regex = /^##\s+.*$/gm;
48
60
  const imageProvenanceCommentRegex = /<!--\s*norna-image-provenance:[\s\S]*?-->/gi;
49
61
 
@@ -84,25 +96,141 @@ const getExplicitHeadingId = (headingHtml: string) =>
84
96
  const getHeadingTitle = (headingHtml: string) =>
85
97
  decodeHtmlEntities(stripTags(headingHtml)).replace(explicitHeadingIdRegex, '').trim();
86
98
 
87
- const applyInlineStyles = (html: string, inlineStyles: InlineStyles | undefined) =>
88
- html.replace(inlineStyleReferenceRegex, (_match, text: string, styleName: string) => {
89
- const style = inlineStyles?.[styleName];
99
+ const getHeadingTitleHtml = (headingHtml: string) =>
100
+ prepareContentHtml(headingHtml.replace(explicitHeadingIdRegex, '').trim());
90
101
 
91
- if (!style) {
92
- throw new Error(`Markdown uses inline style ".${styleName}", but theme.md presentation.inlineStyles.${styleName} is not defined.`);
102
+ export const getSectionNavigation = (html: string, sectionMetadata: SiteSectionMetadataMap): SectionNavigation[] => {
103
+ const matches = Array.from(html.matchAll(headingRegex));
104
+ const sectionIds = new Set<string>();
105
+ const sections = matches.map((match) => {
106
+ const attributes = match[1] ?? '';
107
+ const headingHtml = match[2] ?? '';
108
+ const explicitId = getExplicitHeadingId(headingHtml);
109
+ const id = getHeadingId(attributes, headingHtml);
110
+ const title = getHeadingTitle(headingHtml);
111
+
112
+ if (!explicitId) {
113
+ throw new Error(`Section heading "${title}" is missing an explicit id. Write it as: ## ${title} {#${id}}`);
93
114
  }
94
115
 
95
- return `<span class="inline-style inline-style-${styleName}" style="--inline-style-color: ${style.color}">${text}</span>`;
116
+ if (sectionIds.has(id)) {
117
+ throw new Error(`Duplicate Markdown section heading id: ${id}`);
118
+ }
119
+
120
+ sectionIds.add(id);
121
+ return { id, title };
96
122
  });
97
123
 
124
+ for (const id of Object.keys(sectionMetadata)) {
125
+ if (!sectionIds.has(id)) {
126
+ throw new Error(
127
+ `Section metadata "${id}" does not match any Markdown section. Add "## Heading {#${id}}" or remove sections.${id}.`,
128
+ );
129
+ }
130
+ }
131
+
132
+ return sections;
133
+ };
134
+
98
135
  const stripImageProvenanceComments = (html: string) => html.replace(imageProvenanceCommentRegex, '');
99
136
 
100
- const prepareContentHtml = (html: string, inlineStyles: InlineStyles | undefined) =>
137
+ const prepareContentHtml = (html: string) =>
101
138
  applyBasePathToHtml(
102
139
  projectConfig.site.basePath,
103
- stripImageProvenanceComments(applyInlineStyles(html, inlineStyles)),
140
+ stripImageProvenanceComments(html),
104
141
  );
105
142
 
143
+ const htmlVoidElements = new Set([
144
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
145
+ 'meta', 'param', 'source', 'track', 'wbr',
146
+ ]);
147
+
148
+ // Keep notes aligned with the Markdown block immediately before them instead of
149
+ // aligning them with a whole run of paragraphs rendered as one HTML fragment.
150
+ const splitHtmlIntoBlocks = (html: string) => {
151
+ const tagRegex = /<!--[\s\S]*?-->|<\/?([a-z][\w:-]*)(?:\s[^<>]*?)?\/?>/gi;
152
+ const blocks: string[] = [];
153
+ let blockStart = -1;
154
+ let depth = 0;
155
+ let cursor = 0;
156
+
157
+ for (const match of html.matchAll(tagRegex)) {
158
+ const start = match.index ?? 0;
159
+ const token = match[0] ?? '';
160
+ const tagName = match[1]?.toLowerCase();
161
+ if (!tagName) continue;
162
+
163
+ const isClosing = /^<\//.test(token);
164
+ const isSelfClosing = /\/\s*>$/.test(token) || htmlVoidElements.has(tagName);
165
+
166
+ if (isClosing) {
167
+ if (depth > 0) depth -= 1;
168
+ if (depth === 0 && blockStart >= 0) {
169
+ blocks.push(html.slice(blockStart, start + token.length));
170
+ cursor = start + token.length;
171
+ blockStart = -1;
172
+ }
173
+ continue;
174
+ }
175
+
176
+ if (depth === 0) {
177
+ if (blockStart < 0) blockStart = start;
178
+ if (isSelfClosing) {
179
+ blocks.push(html.slice(blockStart, start + token.length));
180
+ cursor = start + token.length;
181
+ blockStart = -1;
182
+ }
183
+ }
184
+
185
+ if (!isSelfClosing) depth += 1;
186
+ }
187
+
188
+ if (blockStart >= 0) {
189
+ blocks.push(html.slice(blockStart).trim());
190
+ cursor = html.length;
191
+ }
192
+
193
+ const prefix = html.slice(0, blocks.length > 0 ? html.indexOf(blocks[0]!) : html.length).trim();
194
+ const suffix = html.slice(cursor).trim();
195
+ return [prefix, ...blocks, suffix].filter(Boolean);
196
+ };
197
+
198
+ const inlineNoteMarkerRegex = /^\s*<norna-inline-note\s+data-note-index="(\d+)"\s*><\/norna-inline-note>\s*$/i;
199
+ const noteDeclarationParagraphRegex = /<p>\s*\{note:\s*[\s\S]*?\}\s*<\/p>/gi;
200
+ const htmlCodeRegionRegex = /<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>/gi;
201
+
202
+ const replaceHtmlOutsideCode = (html: string, transform: (value: string) => string) => {
203
+ let result = '';
204
+ let cursor = 0;
205
+
206
+ for (const match of html.matchAll(htmlCodeRegionRegex)) {
207
+ const start = match.index ?? 0;
208
+ result += transform(html.slice(cursor, start));
209
+ result += match[0];
210
+ cursor = start + match[0].length;
211
+ }
212
+
213
+ return result + transform(html.slice(cursor));
214
+ };
215
+
216
+ const applyInlineNoteMarkup = (html: string, notes: InlineNote[]) => {
217
+ let referenceIndex = 0;
218
+ const withReferences = replaceHtmlOutsideCode(html, (value) => value.replace(/\{note-ref\}/g, () => {
219
+ const note = notes[referenceIndex];
220
+ if (!note) return '{note-ref}';
221
+
222
+ referenceIndex += 1;
223
+ return `\u2060<sup class="section-note-ref"><a id="${note.referenceId}" href="#${note.id}" aria-label="Note ${note.number}" aria-describedby="${note.id}" data-note-id="${note.id}">${note.number}</a></sup>`;
224
+ }));
225
+
226
+ let noteIndex = 0;
227
+ return withReferences.replace(noteDeclarationParagraphRegex, () => {
228
+ const marker = `<norna-inline-note data-note-index="${noteIndex}"></norna-inline-note>`;
229
+ noteIndex += 1;
230
+ return marker;
231
+ });
232
+ };
233
+
106
234
  const getRawMarkdownSections = (markdown: string) => {
107
235
  const matches = Array.from(markdown.matchAll(markdownH2Regex));
108
236
  const sections = new Map<string, string>();
@@ -128,61 +256,103 @@ const getImageSourceKey = (page: SitePage, sectionId: string, image: string) =>
128
256
  : `images/${sectionId}/${image}`
129
257
  );
130
258
 
131
- const resolveContentBlocks = (
259
+ const renderInlineNoteMarkdown = async (markdown: string) => {
260
+ const result = await markdownToHtml(markdown, {
261
+ features: {
262
+ gfm: true,
263
+ smartPunctuation: true,
264
+ },
265
+ });
266
+
267
+ if (/<img\b/i.test(result.html)) {
268
+ throw new Error('Inline notes cannot contain images. Use a Norna image block with a caption instead.');
269
+ }
270
+
271
+ return prepareContentHtml(result.html);
272
+ };
273
+
274
+ const resolveContentBlocks = async (
132
275
  html: string,
133
276
  rawMarkdown: string,
134
277
  page: SitePage,
135
278
  sectionId: string,
136
- inlineStyles: InlineStyles | undefined,
279
+ inlineNotes: InlineNote[] = [],
137
280
  ) => {
138
281
  const rawBlocks = extractNornaMarkdownBlocks(rawMarkdown);
282
+ const renderedHtml = applyInlineNoteMarkup(html, inlineNotes);
139
283
  const splitBlocks = rawBlocks.length > 0
140
- ? splitNornaRenderedCodeBlocks(html, rawBlocks)
141
- : splitNornaMarkdownBlockMarkers(html);
284
+ ? splitNornaRenderedCodeBlocks(renderedHtml, rawBlocks)
285
+ : splitNornaMarkdownBlockMarkers(renderedHtml);
286
+ const hasNotes = inlineNotes.length > 0;
287
+ const contentBlocks = hasNotes
288
+ ? splitBlocks.flatMap((block) => block.type === 'html'
289
+ ? splitHtmlIntoBlocks(block.html).map((htmlBlock) => ({ type: 'html' as const, html: htmlBlock }))
290
+ : [block])
291
+ : splitBlocks;
292
+ const resolvedBlocks: SectionContentBlock[] = [];
142
293
 
143
- return splitBlocks.map((block): SectionContentBlock => {
294
+ for (const block of contentBlocks) {
144
295
  if (block.type === 'html') {
145
- return {
146
- type: 'html',
147
- html: prepareContentHtml(block.html, inlineStyles),
148
- };
296
+ const inlineNoteMarker = block.html.match(inlineNoteMarkerRegex);
297
+ if (inlineNoteMarker) {
298
+ const note = inlineNotes[Number(inlineNoteMarker[1])];
299
+ if (!note) {
300
+ throw new Error('Norna inline note markup could not be matched to its note text.');
301
+ }
302
+
303
+ resolvedBlocks.push({
304
+ type: 'note',
305
+ html: await renderInlineNoteMarkdown(note.markdown),
306
+ number: note.number,
307
+ id: note.id,
308
+ referenceId: note.referenceId,
309
+ });
310
+ continue;
311
+ }
312
+
313
+ resolvedBlocks.push({ type: 'html', html: prepareContentHtml(block.html) });
314
+ continue;
315
+ }
316
+
317
+ if (block.type === 'card-list') {
318
+ resolvedBlocks.push({
319
+ type: 'card-list',
320
+ layout: block.layout,
321
+ flow: block.flow,
322
+ size: block.size,
323
+ width: block.width,
324
+ cards: block.cards.map((card: CardListItem) => ({
325
+ ...card,
326
+ ...(card.image ? { src: getImageSourceKey(page, sectionId, card.image) } : {}),
327
+ })),
328
+ });
329
+ continue;
149
330
  }
150
331
 
151
- return {
332
+ resolvedBlocks.push({
152
333
  type: block.type,
153
- ...(block.type === 'card-list'
154
- ? {
155
- layout: block.layout,
156
- flow: block.flow,
157
- size: block.size,
158
- width: block.width,
159
- cards: block.cards.map((card: CardListItem) => ({
160
- ...card,
161
- ...(card.image ? { src: getImageSourceKey(page, sectionId, card.image) } : {}),
162
- })),
163
- }
164
- : {
165
- images: block.images.map((image: { image: string; alt?: string; caption?: string }) => ({
166
- ...image,
167
- src: getImageSourceKey(page, sectionId, image.image),
168
- })),
169
- }),
170
- };
171
- });
334
+ images: block.images.map((image: { image: string; alt?: string; caption?: string }) => ({
335
+ ...image,
336
+ src: getImageSourceKey(page, sectionId, image.image),
337
+ })),
338
+ });
339
+ }
340
+
341
+ return resolvedBlocks;
172
342
  };
173
343
 
174
- export const getSectionsContent = (
344
+ export const getSectionsContent = async (
175
345
  html: string,
176
346
  rawMarkdown: string,
177
347
  sectionMetadata: SiteSectionMetadataMap,
178
348
  page: SitePage,
179
- inlineStyles?: InlineStyles,
180
349
  ) => {
181
350
  const matches = Array.from(html.matchAll(headingRegex));
182
351
  const rawSections = getRawMarkdownSections(rawMarkdown);
183
352
  const metadataIds = new Set(Object.keys(sectionMetadata));
184
353
  const sections: ResolvedSection[] = [];
185
354
  const sectionIds = new Set<string>();
355
+ let nextNoteNumber = 1;
186
356
 
187
357
  for (let index = 0; index < matches.length; index += 1) {
188
358
  const match = matches[index];
@@ -205,11 +375,33 @@ export const getSectionsContent = (
205
375
  }
206
376
 
207
377
  sectionIds.add(id);
378
+ const rawSection = rawSections.get(id) ?? '';
379
+ const inlineNoteDiagnostics = extractInlineNoteDiagnostics(rawSection, {
380
+ label: 'Markdown section',
381
+ });
382
+ if (inlineNoteDiagnostics.errors.length > 0) {
383
+ throw new Error(inlineNoteDiagnostics.errors[0].message);
384
+ }
385
+
386
+ const inlineNotes = inlineNoteDiagnostics.notes.map((note) => {
387
+ const number = nextNoteNumber;
388
+ nextNoteNumber += 1;
389
+ const pageKey = page.routeId || 'home';
390
+ const noteKey = `${pageKey}-${id}-${number}`;
391
+ return {
392
+ ...note,
393
+ number,
394
+ id: `note-${noteKey}`,
395
+ referenceId: `note-ref-${noteKey}`,
396
+ };
397
+ });
398
+
208
399
  sections.push({
209
400
  ...(sectionMetadata[id] ?? {}),
210
401
  id,
211
402
  title,
212
- contentBlocks: resolveContentBlocks(content, rawSections.get(id) ?? '', page, id, inlineStyles),
403
+ titleHtml: getHeadingTitleHtml(headingHtml),
404
+ contentBlocks: await resolveContentBlocks(content, rawSection, page, id, inlineNotes),
213
405
  });
214
406
  }
215
407
 
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { sitePublicDir } from '../../scripts/lib/site-paths.mjs';
4
4
  import projectConfig from '../../scripts/lib/project-config.mjs';
5
+ export { getLogoAsset } from '../../scripts/lib/logo-assets.mjs';
5
6
  import { withBasePath } from './basePath';
6
7
 
7
8
  type IconLink = {