@janga/norna 0.7.9 → 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 (198) hide show
  1. package/README.md +1 -1
  2. package/docs/README.md +14 -11
  3. package/docs/configuration.md +9 -54
  4. package/docs/content.md +6 -7
  5. package/docs/design/norna-diagram-design.md +2 -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 +0 -717
  120. package/examples/typography-presets/.astro/collections/sitewide.schema.json +114 -0
  121. package/examples/typography-presets/.astro/collections/theme.schema.json +161 -59
  122. package/examples/typography-presets/.astro/content.d.ts +8 -0
  123. package/examples/typography-presets/.astro/data-store.json +1 -1
  124. package/examples/typography-presets/.astro/dev-local.json +2 -2
  125. package/examples/typography-presets/.astro/dev.json +2 -2
  126. package/examples/typography-presets/.astro/dev.log +37 -16
  127. package/examples/typography-presets/site/.norna/generated-images.json +76 -76
  128. package/examples/typography-presets/site/config.mjs +0 -13
  129. package/examples/typography-presets/site/content.md +8 -126
  130. package/examples/typography-presets/site/routes/010-quiet-gallery/route-content.md +34 -0
  131. package/examples/typography-presets/site/routes/010-quiet-gallery/theme.md +4 -0
  132. package/examples/typography-presets/site/routes/020-compact-gallery/route-content.md +33 -0
  133. package/examples/typography-presets/site/routes/020-compact-gallery/theme.md +4 -0
  134. package/examples/typography-presets/site/routes/030-text-forward/route-content.md +34 -0
  135. package/examples/typography-presets/site/routes/030-text-forward/theme.md +4 -0
  136. package/examples/typography-presets/site/routes/040-statement/route-content.md +34 -0
  137. package/examples/typography-presets/site/routes/040-statement/theme.md +4 -0
  138. package/examples/typography-presets/site/sitewide-content.md +14 -0
  139. package/examples/typography-presets/site/theme.md +9 -9
  140. package/fixtures/basic/site/config.mjs +0 -13
  141. package/fixtures/basic/site/sitewide-content.md +12 -0
  142. package/fixtures/basic/site/theme.md +2 -2
  143. package/fixtures/content-model-v2/site/content.md +2 -5
  144. package/fixtures/content-model-v2/site/theme.md +1 -3
  145. package/package.json +3 -2
  146. package/scripts/check-config.mjs +22 -1
  147. package/scripts/lib/frontmatter-yaml.mjs +28 -0
  148. package/scripts/lib/logo-assets.mjs +35 -0
  149. package/scripts/lib/norna-markdown-blocks.mjs +166 -0
  150. package/scripts/lib/presentation.mjs +104 -46
  151. package/scripts/lib/project-config.mjs +67 -105
  152. package/scripts/lib/site-content.mjs +26 -118
  153. package/scripts/lib/site-paths.mjs +2 -0
  154. package/scripts/lib/sitewide-config.mjs +34 -0
  155. package/scripts/lib/theme-config.mjs +72 -8
  156. package/scripts/lib/typography.mjs +20 -26
  157. package/scripts/show-typography.mjs +40 -147
  158. package/scripts/sync-content-sections.mjs +19 -17
  159. package/scripts/test-content-check.mjs +59 -27
  160. package/scripts/test-content-model-v2.mjs +154 -0
  161. package/scripts/test-engine-commands.mjs +3 -1
  162. package/scripts/test-package-check.mjs +66 -24
  163. package/scripts/test-temporary-visibility.mjs +19 -2
  164. package/src/components/CardList.astro +5 -2
  165. package/src/components/GalleryGrid.astro +37 -16
  166. package/src/components/SiteBanners.astro +100 -0
  167. package/src/components/SiteNavigation.astro +48 -31
  168. package/src/components/SitePage.astro +55 -13
  169. package/src/components/SiteSection.astro +193 -35
  170. package/src/content.config.ts +78 -48
  171. package/src/layouts/BaseLayout.astro +64 -14
  172. package/src/lib/routeThemes.ts +61 -0
  173. package/src/lib/sectionContent.ts +235 -51
  174. package/src/lib/sitePublicAssets.ts +1 -0
  175. package/src/styles/global.css +282 -93
  176. package/starters/basic/.github/workflows/deploy.yml +1 -1
  177. package/starters/basic/README.md +7 -6
  178. package/starters/basic/site/config.mjs +0 -13
  179. package/starters/basic/site/content.md +1 -1
  180. package/starters/basic/site/sitewide-content.md +14 -0
  181. package/starters/basic/site/theme.md +13 -24
  182. package/starters/project/.github/workflows/deploy.yml +1 -1
  183. package/starters/project/README.md +10 -8
  184. package/starters/project/site/config.mjs +0 -13
  185. package/starters/project/site/sitewide-content.md +14 -0
  186. package/starters/project/site/theme.md +3 -11
  187. /package/examples/typography-presets/site/{images → routes/010-quiet-gallery/images}/quiet-gallery/puppy-portrait.jpg +0 -0
  188. /package/examples/typography-presets/site/{images → routes/010-quiet-gallery/images}/quiet-gallery/shepherd-portrait.jpg +0 -0
  189. /package/examples/typography-presets/site/{images → routes/010-quiet-gallery/images}/quiet-gallery/white-puppy.jpg +0 -0
  190. /package/examples/typography-presets/site/{images → routes/020-compact-gallery/images}/compact-gallery/black-schnauzer.jpg +0 -0
  191. /package/examples/typography-presets/site/{images → routes/020-compact-gallery/images}/compact-gallery/retriever-portrait.jpg +0 -0
  192. /package/examples/typography-presets/site/{images → routes/020-compact-gallery/images}/compact-gallery/schnauzer-portrait.jpg +0 -0
  193. /package/examples/typography-presets/site/{images → routes/030-text-forward/images}/text-forward/boxer-portrait.jpg +0 -0
  194. /package/examples/typography-presets/site/{images → routes/030-text-forward/images}/text-forward/grass-puppy.jpg +0 -0
  195. /package/examples/typography-presets/site/{images → routes/030-text-forward/images}/text-forward/grey-street-dog.jpg +0 -0
  196. /package/examples/typography-presets/site/{images → routes/040-statement/images}/statement/black-pedigree.jpg +0 -0
  197. /package/examples/typography-presets/site/{images → routes/040-statement/images}/statement/sheepdog-grass.jpg +0 -0
  198. /package/examples/typography-presets/site/{images → routes/040-statement/images}/statement/sheepdog-wide.jpg +0 -0
@@ -129,6 +129,99 @@ test('content model v2 fixture checks and builds', async () => {
129
129
  }
130
130
  });
131
131
 
132
+ test('route theme replaces visual theme while site identity stays at the root', async () => {
133
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
134
+ try {
135
+ await writeFile(path.join(siteDir, 'theme.md'), `---
136
+ typography:
137
+ preset: text-forward
138
+ ---
139
+ `);
140
+ await writeFile(path.join(siteDir, 'sitewide-content.md'), `---
141
+ navigation:
142
+ brand: Root Brand
143
+ ---
144
+ `);
145
+ await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
146
+ await writeFile(path.join(siteDir, 'content.md'), `---
147
+ title: Root
148
+ description: Root page
149
+ ---
150
+
151
+ ## Home {#home}
152
+
153
+ Root content.
154
+ `);
155
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'route-content.md'), `---
156
+ title: Guide
157
+ description: Guide page
158
+ navigation:
159
+ label: Guide
160
+ ---
161
+
162
+ ## Guide {#guide}
163
+
164
+ Route content.
165
+ `);
166
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.md'), `---
167
+ typography:
168
+ preset: statement
169
+ presentation:
170
+ palette: light
171
+ ---
172
+ `);
173
+
174
+ await runNorna(['--site-dir', siteDir, 'build']);
175
+ const rootHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'index.html'), 'utf8');
176
+ const routeHtml = await readFile(path.join(path.dirname(siteDir), 'dist', 'guide', 'index.html'), 'utf8');
177
+ assert.match(rootHtml, /--color-page: #000000/);
178
+ assert.match(routeHtml, /--color-page: #ffffff/);
179
+ assert.match(routeHtml, /<a class="site-brand" href="\/">Root Brand<\/a>/);
180
+ } finally {
181
+ await rm(root, { recursive: true, force: true });
182
+ }
183
+ });
184
+
185
+ test('route theme cannot define site identity', async () => {
186
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
187
+ try {
188
+ await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
189
+ await writeFile(path.join(siteDir, 'content.md'), `---
190
+ title: Root
191
+ description: Root page
192
+ ---
193
+
194
+ ## Home {#home}
195
+
196
+ Root content.
197
+ `);
198
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'route-content.md'), `---
199
+ title: Guide
200
+ description: Guide page
201
+ ---
202
+
203
+ ## Guide {#guide}
204
+
205
+ Route content.
206
+ `);
207
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.md'), `---
208
+ navigation:
209
+ brand: Route Brand
210
+ ---
211
+ `);
212
+
213
+ await assert.rejects(
214
+ runNorna(['--site-dir', siteDir, 'config:check']),
215
+ (error) => {
216
+ assert.match(error.output, /may not define navigation\. Brand and logo belong in site\/sitewide-content\.md/);
217
+ return true;
218
+ },
219
+ );
220
+ } finally {
221
+ await rm(root, { recursive: true, force: true });
222
+ }
223
+ });
224
+
132
225
  test('content:check warns for local Markdown images but not external Markdown images', async () => {
133
226
  const { root, siteDir } = await createTempSite();
134
227
  try {
@@ -189,6 +282,40 @@ description: Fixture
189
282
  }
190
283
  });
191
284
 
285
+ test('inline notes render as linked numbered margin notes', async () => {
286
+ const { root, siteDir } = await createTempSite({ underRepoCache: true });
287
+ try {
288
+ await writeFile(path.join(siteDir, 'content.md'), `---
289
+ title: Inline notes
290
+ description: Fixture
291
+ ---
292
+
293
+ ## Intro {#intro}
294
+
295
+ The first paragraph points to an explanation.{note-ref}
296
+
297
+ {note: This explanation names \`sitewide-content.md\` and stays beside the paragraph on wide screens.}
298
+
299
+ The second paragraph has its own explanation.{note-ref}
300
+
301
+ {note: This is the second explanation.}
302
+ `);
303
+
304
+ await runNorna(['--site-dir', siteDir, 'build']);
305
+ const html = await readFile(path.join(path.dirname(siteDir), 'dist', 'index.html'), 'utf8');
306
+ assert.match(html, /<sup class="section-note-ref"><a id="note-ref-home-intro-1" href="#note-home-intro-1" aria-label="Note 1" aria-describedby="note-home-intro-1" data-note-id="note-home-intro-1">1<\/a><\/sup>/);
307
+ assert.match(html, /<aside class="section-note section-note-margin" id="note-home-intro-1" aria-label="Note 1" role="note" data-note-id="note-home-intro-1" style="grid-row: 1;">/);
308
+ assert.match(html, /<a class="section-note-number" href="#note-ref-home-intro-1" aria-label="Note 1">\s*1\s*<\/a>/);
309
+ assert.match(html, /This explanation names <code>sitewide-content\.md<\/code> and stays beside the paragraph on wide screens\./);
310
+ assert.match(html, /<sup class="section-note-ref"><a id="note-ref-home-intro-2" href="#note-home-intro-2" aria-label="Note 2" aria-describedby="note-home-intro-2" data-note-id="note-home-intro-2">2<\/a><\/sup>/);
311
+ assert.match(html, /<aside class="section-note section-note-margin" id="note-home-intro-2" aria-label="Note 2" role="note" data-note-id="note-home-intro-2" style="grid-row: 2;">/);
312
+ assert.match(html, /This is the second explanation\./);
313
+ assert.doesNotMatch(html, /\{note:/);
314
+ } finally {
315
+ await rm(root, { recursive: true, force: true });
316
+ }
317
+ });
318
+
192
319
  test('updated managed SVG images get updated static output', async () => {
193
320
  const { root, siteDir } = await createTempSite();
194
321
  try {
@@ -842,6 +969,33 @@ description: Fixture
842
969
  }
843
970
  });
844
971
 
972
+ test('content:check rejects the removed norna-note block', async () => {
973
+ const { root, siteDir } = await createTempSite();
974
+ try {
975
+ await writeFile(path.join(siteDir, 'content.md'), `---
976
+ title: Removed note block
977
+ description: Fixture
978
+ ---
979
+
980
+ ## Intro {#intro}
981
+
982
+ \`\`\`norna-note
983
+ This block is no longer supported.
984
+ \`\`\`
985
+ `);
986
+
987
+ await assert.rejects(
988
+ () => runContentScript(siteDir, ['--check']),
989
+ (error) => {
990
+ assert.match(error.output, /Unknown Norna block "norna-note"\. Use one of: norna-image-stack, norna-image-carousel, norna-card-list\./);
991
+ return true;
992
+ },
993
+ );
994
+ } finally {
995
+ await rm(root, { recursive: true, force: true });
996
+ }
997
+ });
998
+
845
999
  test('content:check supports tilde fenced Norna blocks', async () => {
846
1000
  const { root, siteDir } = await createTempSite();
847
1001
  try {
@@ -57,8 +57,10 @@ try {
57
57
  for (const [presetName, preset] of Object.entries(typographyPresets)) {
58
58
  for (const [level, heading] of Object.entries(preset.headings)) {
59
59
  assert.equal(heading.size, 'medium', `${presetName} ${level} should use medium size`);
60
+ assert.ok([400, 500, 600, 700].includes(heading.weight), `${presetName} ${level} should use a supported weight`);
60
61
  }
61
62
  assert.equal(preset.body.size, 'medium', `${presetName} body should use medium size`);
63
+ assert.ok(['narrow', 'normal', 'wide'].includes(preset.body.width), `${presetName} body should use a supported width`);
62
64
  assert.equal(preset.caption.size, 'medium', `${presetName} caption should use medium size`);
63
65
  }
64
66
  for (const [rhythmName, rhythm] of Object.entries(typographyRhythms)) {
@@ -76,7 +78,7 @@ try {
76
78
  assert.match(showResult.stdout, /pages:/);
77
79
  assert.match(showResult.stdout, /\s+\/:/);
78
80
  assert.match(showResult.stdout, /value: quiet-gallery/);
79
- assert.match(showResult.stdout, /inherited: true/);
81
+ assert.match(showResult.stdout, /source: "site\/theme\.md"/);
80
82
  assert.match(showResult.stdout, /intro:/);
81
83
 
82
84
  const initAgainResult = runCli(['init', initializedSiteRoot]);
@@ -160,6 +160,7 @@ try {
160
160
  assertFileExists(path.join(packagedStarterRoot, 'README.md')),
161
161
  assertFileExists(path.join(packagedStarterRoot, 'site', 'config.mjs')),
162
162
  assertFileExists(path.join(packagedStarterRoot, 'site', 'theme.md')),
163
+ assertFileExists(path.join(packagedStarterRoot, 'site', 'sitewide-content.md')),
163
164
  assertFileExists(path.join(packagedStarterRoot, 'site', 'content.md')),
164
165
  assertFileExists(path.join(packagedStarterRoot, 'site', 'images', 'work', '.gitkeep')),
165
166
  assertFileExists(path.join(packagedStarterRoot, 'site', 'public', 'robots.txt')),
@@ -184,11 +185,15 @@ try {
184
185
  .replace("url: 'https://example.com/'", "url: 'https://example.com/site/'")
185
186
  .replace("basePath: '/'", "basePath: '/site/'"),
186
187
  );
187
- const packageCheckThemePath = path.join(siteProjectRoot, 'site', 'theme.md');
188
- const packageCheckTheme = await readFile(packageCheckThemePath, 'utf8');
188
+ const packageCheckSitewidePath = path.join(siteProjectRoot, 'site', 'sitewide-content.md');
189
+ const packageCheckSitewide = await readFile(packageCheckSitewidePath, 'utf8');
189
190
  await writeFile(
190
- packageCheckThemePath,
191
- packageCheckTheme.replace('brand: Example Site', 'brand: Package Check Brand'),
191
+ packageCheckSitewidePath,
192
+ packageCheckSitewide.replace('brand: Example Site', 'brand: Package Check Brand\n logo:\n alt: Package Check Logo\n height: 2.6rem'),
193
+ );
194
+ await writeFile(
195
+ path.join(siteProjectRoot, 'site', 'public', 'logo.svg'),
196
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="black"/></svg>\n',
192
197
  );
193
198
  await writeFile(path.join(siteProjectRoot, 'site', 'content.md'), `---
194
199
  title: Package Check Site
@@ -201,14 +206,14 @@ sections:
201
206
 
202
207
  ## Intro {#intro}
203
208
 
204
- This page verifies that [packaged norna sites]{.highlight} can build with the current content model.
209
+ This page verifies that **packaged norna sites** can build with the current content model.
205
210
 
206
211
  <!-- norna-image-provenance:
207
212
  image: package-check.svg
208
213
  source: package check fixture
209
214
  -->
210
215
 
211
- ## [**Warning**]{.highlight} {#warning}
216
+ ## **Warning** {#warning}
212
217
 
213
218
  This section verifies styled section headings.
214
219
 
@@ -262,6 +267,10 @@ This route verifies that packaged norna sites can build route pages.
262
267
  path.join(siteProjectRoot, 'dist', 'index.html'),
263
268
  '--section-heading-font-size-desktop: clamp(1.35rem, 1.1rem + 0.9vw, 2rem)',
264
269
  );
270
+ await assertFileIncludes(
271
+ path.join(siteProjectRoot, 'dist', 'index.html'),
272
+ '--section-heading-font-weight: 400',
273
+ );
265
274
  await assertFileIncludes(
266
275
  path.join(siteProjectRoot, 'dist', 'index.html'),
267
276
  '--section-heading-spacing-after: 0.55em',
@@ -288,7 +297,15 @@ This route verifies that packaged norna sites can build route pages.
288
297
  );
289
298
  await assertFileIncludes(
290
299
  path.join(siteProjectRoot, 'dist', 'index.html'),
291
- '<a class="site-brand" href="/site/">Package Check Brand</a>',
300
+ '<a class="site-brand" href="/site/"><img class="site-brand-logo" src="/site/logo.svg" alt="Package Check Logo">',
301
+ );
302
+ await assertFileIncludes(
303
+ path.join(siteProjectRoot, 'dist', 'index.html'),
304
+ '<div class="site-nav-submenu">',
305
+ );
306
+ await assertFileIncludes(
307
+ path.join(siteProjectRoot, 'dist', 'index.html'),
308
+ 'href="/site/#intro">Intro</a>',
292
309
  );
293
310
  await assertFileIncludes(
294
311
  path.join(siteProjectRoot, 'dist', 'index.html'),
@@ -298,6 +315,10 @@ This route verifies that packaged norna sites can build route pages.
298
315
  path.join(siteProjectRoot, 'dist', 'index.html'),
299
316
  '--section-body-line-height: 1.5',
300
317
  );
318
+ await assertFileIncludes(
319
+ path.join(siteProjectRoot, 'dist', 'index.html'),
320
+ '--section-body-width-desktop: min(72ch, var(--text-width))',
321
+ );
301
322
  await assertFileExcludes(
302
323
  path.join(siteProjectRoot, 'dist', 'index.html'),
303
324
  'norna-image-provenance',
@@ -318,25 +339,29 @@ This route verifies that packaged norna sites can build route pages.
318
339
  path.join(siteProjectRoot, 'dist', 'index.html'),
319
340
  '--section-caption-font-size-desktop: clamp(0.86rem, 0.84rem + 0.08vw, 0.92rem)',
320
341
  );
342
+ await assertFileExcludes(
343
+ path.join(siteProjectRoot, 'dist', 'index.html'),
344
+ 'class="inline-style',
345
+ );
321
346
  await assertFileIncludes(
322
347
  path.join(siteProjectRoot, 'dist', 'index.html'),
323
- 'class="inline-style inline-style-highlight" style="--inline-style-color: #ffd84d"',
348
+ 'data-section-title="Warning"',
324
349
  );
325
350
  await assertFileIncludes(
326
351
  path.join(siteProjectRoot, 'dist', 'index.html'),
327
- '<h2><span class="inline-style inline-style-highlight" style="--inline-style-color: #ffd84d"><strong>Warning</strong></span></h2>',
352
+ 'href="/site/#warning">Warning</a>',
328
353
  );
329
354
  await assertFileIncludes(
330
355
  path.join(siteProjectRoot, 'dist', 'index.html'),
331
- 'data-section-title="Warning"',
356
+ '--section-background-color: #000000',
332
357
  );
333
358
  await assertFileIncludes(
334
359
  path.join(siteProjectRoot, 'dist', 'index.html'),
335
- 'href="#warning">Warning</a>',
360
+ '--section-background-color: #171717',
336
361
  );
337
362
  await assertFileIncludes(
338
363
  path.join(siteProjectRoot, 'dist', 'index.html'),
339
- '--section-background-color: #000000',
364
+ '--section-background-color: #252525',
340
365
  );
341
366
  await assertFileIncludes(
342
367
  path.join(siteProjectRoot, 'dist', 'index.html'),
@@ -344,7 +369,7 @@ This route verifies that packaged norna sites can build route pages.
344
369
  );
345
370
  await assertFileIncludes(
346
371
  path.join(siteProjectRoot, 'dist', 'index.html'),
347
- '--site-top-text-color: #f7f4ee',
372
+ '--site-top-text-color: #f2eee6',
348
373
  );
349
374
  await assertFileIncludes(
350
375
  path.join(siteProjectRoot, 'dist', 'index.html'),
@@ -352,11 +377,11 @@ This route verifies that packaged norna sites can build route pages.
352
377
  );
353
378
  await assertFileIncludes(
354
379
  path.join(siteProjectRoot, 'dist', 'index.html'),
355
- '--site-footer-text-color: #f7f4ee',
380
+ '--site-footer-text-color: #f2eee6',
356
381
  );
357
382
  await assertFileIncludes(
358
383
  path.join(siteProjectRoot, 'dist', 'index.html'),
359
- '--section-text-color: #f7f4ee',
384
+ '--section-text-color: #f2eee6',
360
385
  );
361
386
  await writeFile(path.join(siteProjectRoot, 'site', 'public', 'favicon.ico'), 'fake icon');
362
387
  await runInherit(npxBin, ['norna', 'build'], { cwd: siteProjectRoot, env: npmEnv });
@@ -382,35 +407,52 @@ This route verifies that packaged norna sites can build route pages.
382
407
  );
383
408
  await writeFile(
384
409
  siteThemePath,
385
- siteTheme.replace('backgroundColor: "#000000"', 'backgroundColor: blue'),
410
+ siteTheme.replace('\n palette: dark', '\n palette: neon'),
386
411
  );
387
412
  await runExpectFailure(
388
413
  npxBin,
389
414
  ['norna', 'build'],
390
415
  { cwd: siteProjectRoot, env: npmEnv },
391
- 'presentation.backgroundColor',
416
+ 'presentation.palette',
392
417
  );
393
418
  await writeFile(
394
419
  siteThemePath,
395
- siteTheme.replace('textColor: "#f7f4ee"', 'textColor: white'),
420
+ siteTheme.replace(' sectionSurfaces:\n mode: cycle', ' sectionSurfaces:\n mode: glowing'),
396
421
  );
397
422
  await runExpectFailure(
398
423
  npxBin,
399
424
  ['norna', 'build'],
400
425
  { cwd: siteProjectRoot, env: npmEnv },
401
- 'presentation.textColor',
426
+ 'presentation.sectionSurfaces.mode',
402
427
  );
403
428
  await writeFile(
404
429
  siteThemePath,
405
- siteTheme.replace(' color: "#ffd84d"', ' color: yellow'),
430
+ siteTheme.replace(
431
+ ' sectionSurfaces:\n mode: cycle',
432
+ ' sectionSurfaces:\n mode: cycle\n sequence: [base, base]',
433
+ ),
406
434
  );
407
435
  await runExpectFailure(
408
436
  npxBin,
409
437
  ['norna', 'build'],
410
438
  { cwd: siteProjectRoot, env: npmEnv },
411
- 'presentation.inlineStyles.highlight.color',
439
+ 'presentation.sectionSurfaces',
412
440
  );
413
441
  await writeFile(siteThemePath, siteTheme);
442
+ await writeFile(
443
+ siteContentPath,
444
+ siteContent.replace(
445
+ 'This page verifies that **packaged norna sites** can build with the current content model.',
446
+ 'This page verifies that [packaged norna sites]{.highlight} can build with the current content model.',
447
+ ),
448
+ );
449
+ await runExpectFailure(
450
+ npxBin,
451
+ ['norna', 'build'],
452
+ { cwd: siteProjectRoot, env: npmEnv },
453
+ 'Inline color style ".highlight" is no longer supported',
454
+ );
455
+ await writeFile(siteContentPath, siteContent);
414
456
  await writeFile(
415
457
  siteContentPath,
416
458
  siteContent.replace(
@@ -422,7 +464,7 @@ This route verifies that packaged norna sites can build route pages.
422
464
  npxBin,
423
465
  ['norna', 'build'],
424
466
  { cwd: siteProjectRoot, env: npmEnv },
425
- 'presentation.typography.overrides.body.lineHeight',
467
+ 'presentation',
426
468
  );
427
469
  await writeFile(
428
470
  siteContentPath,
@@ -435,7 +477,7 @@ This route verifies that packaged norna sites can build route pages.
435
477
  npxBin,
436
478
  ['norna', 'build'],
437
479
  { cwd: siteProjectRoot, env: npmEnv },
438
- 'sections.intro.presentation.typography.overrides.caption.spacingBefore',
480
+ 'sections.intro: Unrecognized key: "presentation"',
439
481
  );
440
482
  await writeFile(
441
483
  siteContentPath,
@@ -448,7 +490,7 @@ This route verifies that packaged norna sites can build route pages.
448
490
  npxBin,
449
491
  ['norna', 'build'],
450
492
  { cwd: siteProjectRoot, env: npmEnv },
451
- 'sections.intro.presentation.typography.preset',
493
+ 'sections.intro: Unrecognized key: "presentation"',
452
494
  );
453
495
  await writeFile(
454
496
  siteThemePath,
@@ -51,8 +51,23 @@ try {
51
51
  await writeFile(path.join(siteDir, 'theme.md'), `---
52
52
  typography:
53
53
  preset: quiet-gallery
54
- frame:
55
- colors: presentation
54
+ presentation:
55
+ palette: dark
56
+ ---
57
+ `);
58
+ await writeFile(path.join(siteDir, 'sitewide-content.md'), `---
59
+ banners:
60
+ - id: expired-banner
61
+ visible:
62
+ until: "2026-01-01"
63
+ title: Expired banner
64
+ text: Expired banner text.
65
+ - id: active-banner
66
+ visible:
67
+ from: "2026-01-01"
68
+ until: "2026-12-31"
69
+ title: Active banner
70
+ text: Active banner text.
56
71
  ---
57
72
  `);
58
73
  await writeFile(path.join(siteDir, 'content.md'), `---
@@ -88,6 +103,8 @@ Always visible section text.
88
103
  assert.match(html, /Active section text/);
89
104
  assert.match(html, /Always visible section text/);
90
105
  assert.doesNotMatch(html, /Expired section text/);
106
+ assert.match(html, /Active banner text/);
107
+ assert.doesNotMatch(html, /Expired banner text/);
91
108
 
92
109
  console.log('Temporary visibility test passed.');
93
110
  } finally {
@@ -18,16 +18,19 @@ type CardListWidth = 'text' | 'narrow' | 'normal' | 'wide';
18
18
 
19
19
  interface Props {
20
20
  cards: Card[];
21
+ maxAvailableWidthPercent: {
22
+ desktop: number;
23
+ };
21
24
  layout?: CardListLayout;
22
25
  flow?: CardListFlow;
23
26
  size?: CardListSize;
24
27
  width?: CardListWidth;
25
28
  }
26
29
 
27
- const { cards, layout = 'image-top', flow = 'grid', size = 'm', width = 'normal' } = Astro.props;
30
+ const { cards, maxAvailableWidthPercent, layout = 'image-top', flow = 'grid', size = 'm', width = 'normal' } = Astro.props;
28
31
  const cardImageSizes = [
29
32
  '(max-width: 700px) 100vw',
30
- `min(20rem, ${projectConfig.gallery.maxAvailableWidthPercent.desktop}vw)`,
33
+ `min(20rem, ${maxAvailableWidthPercent.desktop}vw)`,
31
34
  ].join(', ');
32
35
  const displayLink = (link: string) => withBasePath(projectConfig.site.basePath, link);
33
36
  const cardListClasses = [
@@ -17,13 +17,25 @@ type GeneratedImageWithDimensions = NonNullable<ReturnType<typeof getGeneratedIm
17
17
  interface Props {
18
18
  title: string;
19
19
  items: GalleryItem[];
20
+ maxAvailableWidthPercent: {
21
+ desktop: number;
22
+ mobile: number;
23
+ };
20
24
  priorityFirstImage?: boolean;
21
25
  }
22
26
 
23
- const { title, items, priorityFirstImage = false } = Astro.props;
27
+ const { title, items, maxAvailableWidthPercent, priorityFirstImage = false } = Astro.props;
28
+ const getCaptionId = (image: GalleryImage, itemIndex: number, imageIndex = 0) => {
29
+ const source = (image.src ?? image.image)
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-|-$/g, '');
33
+
34
+ return `gallery-caption-${source}-${itemIndex}-${imageIndex}`;
35
+ };
24
36
  const galleryImageSizes = [
25
- `(max-width: 700px) ${projectConfig.gallery.maxAvailableWidthPercent.mobile}vw`,
26
- `${projectConfig.gallery.maxAvailableWidthPercent.desktop}vw`,
37
+ `(max-width: 700px) ${maxAvailableWidthPercent.mobile}vw`,
38
+ `${maxAvailableWidthPercent.desktop}vw`,
27
39
  ].join(', ');
28
40
  const hasDimensions = (image: ReturnType<typeof getGeneratedImage>): image is GeneratedImageWithDimensions => (
29
41
  Boolean(image) && Number.isFinite(image.width) && Number.isFinite(image.height)
@@ -54,6 +66,8 @@ const getCarouselAspectRatio = (images: GalleryImage[]) => {
54
66
  const isPriorityItem = priorityFirstImage && itemIndex === 0;
55
67
 
56
68
  if ('image' in item) {
69
+ const captionId = item.caption ? getCaptionId(item, itemIndex) : undefined;
70
+
57
71
  return (
58
72
  <figure class="gallery-item">
59
73
  <div class="gallery-image-frame">
@@ -61,11 +75,12 @@ const getCarouselAspectRatio = (images: GalleryImage[]) => {
61
75
  {...getImageAttributes(item.src ?? item.image, galleryImageSizes)}
62
76
  class:list={['gallery-image', getImageClass(item)]}
63
77
  alt={item.alt ?? ''}
78
+ aria-describedby={captionId}
64
79
  {...getImageLoadingProps(isPriorityItem)}
65
80
  />
66
81
  </div>
67
82
  {item.caption && (
68
- <figcaption class="gallery-meta">
83
+ <figcaption class="gallery-meta" id={captionId}>
69
84
  <div class="gallery-details">
70
85
  <span>{item.caption}</span>
71
86
  </div>
@@ -93,18 +108,23 @@ const getCarouselAspectRatio = (images: GalleryImage[]) => {
93
108
  <div class="gallery-carousel-stage">
94
109
  <div class="gallery-carousel-viewport" data-carousel-viewport>
95
110
  <div class="gallery-carousel-slides">
96
- {item.carousel.map((image, imageIndex) => (
97
- <figure class="gallery-carousel-slide" data-carousel-slide>
98
- <div class="gallery-carousel-image-frame">
99
- <img
100
- {...getImageAttributes(image.src ?? image.image, galleryImageSizes)}
101
- class:list={['gallery-image', getImageClass(image)]}
102
- alt={image.alt ?? ''}
103
- {...getImageLoadingProps(isPriorityItem && imageIndex === 0)}
104
- />
105
- </div>
106
- </figure>
107
- ))}
111
+ {item.carousel.map((image, imageIndex) => {
112
+ const captionId = image.caption ? getCaptionId(image, itemIndex, imageIndex) : undefined;
113
+
114
+ return (
115
+ <figure class="gallery-carousel-slide" data-carousel-slide>
116
+ <div class="gallery-carousel-image-frame">
117
+ <img
118
+ {...getImageAttributes(image.src ?? image.image, galleryImageSizes)}
119
+ class:list={['gallery-image', getImageClass(image)]}
120
+ alt={image.alt ?? ''}
121
+ aria-describedby={captionId}
122
+ {...getImageLoadingProps(isPriorityItem && imageIndex === 0)}
123
+ />
124
+ </div>
125
+ </figure>
126
+ );
127
+ })}
108
128
  </div>
109
129
  </div>
110
130
  <button
@@ -134,6 +154,7 @@ const getCarouselAspectRatio = (images: GalleryImage[]) => {
134
154
  image.caption && (
135
155
  <p
136
156
  class="gallery-meta gallery-carousel-caption"
157
+ id={getCaptionId(image, itemIndex, imageIndex)}
137
158
  data-carousel-caption
138
159
  data-carousel-caption-index={imageIndex}
139
160
  >
@@ -0,0 +1,100 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+
4
+ type Banner = {
5
+ id: string;
6
+ tone: 'warning';
7
+ title: string;
8
+ text: string;
9
+ visible?: {
10
+ from?: string;
11
+ until?: string;
12
+ };
13
+ };
14
+
15
+ interface Props {
16
+ banners: Banner[];
17
+ }
18
+
19
+ const { banners } = Astro.props;
20
+ const hashBanner = (banner: Banner) => {
21
+ const value = [
22
+ banner.id,
23
+ banner.tone,
24
+ banner.title,
25
+ banner.text,
26
+ banner.visible?.from ?? '',
27
+ banner.visible?.until ?? '',
28
+ ].join('\u001f');
29
+ let hash = 0;
30
+
31
+ for (const character of value) {
32
+ hash = ((hash << 5) - hash + character.charCodeAt(0)) | 0;
33
+ }
34
+
35
+ return (hash >>> 0).toString(36);
36
+ };
37
+ const getStorageKey = (banner: Banner) => `norna-banner:${banner.id}:${hashBanner(banner)}`;
38
+ const dismissBannerLabel = projectConfig.locale.labels.dismissBanner;
39
+ const siteBannersLabel = projectConfig.locale.labels.siteBanners;
40
+ ---
41
+
42
+ {banners.length > 0 && (
43
+ <aside class="site-banners" aria-label={siteBannersLabel}>
44
+ {banners.map((banner) => {
45
+ const storageKey = getStorageKey(banner);
46
+ const dismissLabel = `${dismissBannerLabel}: ${banner.title}`;
47
+
48
+ return (
49
+ <article
50
+ class:list={['site-banner', `site-banner-${banner.tone}`]}
51
+ data-norna-banner
52
+ data-banner-key={storageKey}
53
+ role="status"
54
+ >
55
+ <div class="site-banner-copy">
56
+ <p class="site-banner-title">{banner.title}</p>
57
+ <p class="site-banner-text">{banner.text}</p>
58
+ </div>
59
+ <button class="site-banner-dismiss" type="button" data-dismiss-banner aria-label={dismissLabel} title={dismissLabel}>
60
+ <span aria-hidden="true">&times;</span>
61
+ </button>
62
+ </article>
63
+ );
64
+ })}
65
+ </aside>
66
+ )}
67
+
68
+ <script is:inline>
69
+ (() => {
70
+ const storagePrefix = 'norna-banner:';
71
+ const banners = Array.from(document.querySelectorAll('[data-norna-banner]'));
72
+
73
+ banners.forEach((banner) => {
74
+ const storageKey = banner.getAttribute('data-banner-key');
75
+ const dismissButton = banner.querySelector('[data-dismiss-banner]');
76
+
77
+ if (!storageKey || !(dismissButton instanceof HTMLButtonElement)) return;
78
+
79
+ try {
80
+ if (localStorage.getItem(storageKey) === 'dismissed') {
81
+ banner.hidden = true;
82
+ }
83
+ } catch {
84
+ // Private browsing modes may deny storage; the banner remains usable.
85
+ }
86
+
87
+ dismissButton.addEventListener('click', () => {
88
+ try {
89
+ if (storageKey.startsWith(storagePrefix)) {
90
+ localStorage.setItem(storageKey, 'dismissed');
91
+ }
92
+ } catch {
93
+ // Dismissing still works for the current page when storage is unavailable.
94
+ }
95
+
96
+ banner.hidden = true;
97
+ });
98
+ });
99
+ })();
100
+ </script>