@janga/norna 0.7.5 → 0.7.7

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 (142) hide show
  1. package/README.md +5 -5
  2. package/astro.config.mjs +76 -0
  3. package/bin/norna-cli.mjs +2 -2
  4. package/docs/README.md +3 -3
  5. package/docs/commands.md +6 -6
  6. package/docs/configuration.md +3 -2
  7. package/docs/content.md +154 -95
  8. package/docs/design/site-examples-structure.md +1 -1
  9. package/docs/engine-development.md +4 -4
  10. package/docs/getting-started.md +6 -4
  11. package/docs/images-and-metadata.md +112 -20
  12. package/docs/routes.md +52 -33
  13. package/docs/site-structure.md +7 -6
  14. package/docs/theme.md +8 -7
  15. package/docs/typography.md +5 -5
  16. package/examples/dog-gallery/site/content.md +41 -46
  17. package/examples/dog-gallery/site/routes/{dog-care → 010-dog-care}/route-content.md +1 -4
  18. package/fixtures/basic/site/content.md +2 -4
  19. package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
  20. package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
  21. package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
  22. package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
  23. package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
  24. package/fixtures/content-model-v2/.astro/data-store.json +1 -0
  25. package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
  26. package/fixtures/content-model-v2/.astro/dev.json +13 -0
  27. package/fixtures/content-model-v2/.astro/dev.log +231 -0
  28. package/fixtures/content-model-v2/.astro/settings.json +5 -0
  29. package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
  30. package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  31. package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
  32. package/fixtures/content-model-v2/dist/guide/index.html +3 -0
  33. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
  34. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
  35. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
  36. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
  37. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
  38. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
  39. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
  40. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
  41. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
  42. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
  43. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
  44. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
  45. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
  46. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
  47. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
  48. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
  49. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
  50. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
  51. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
  52. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
  53. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
  54. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
  55. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
  56. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
  57. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
  58. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
  59. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
  60. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
  61. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
  62. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
  63. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
  64. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
  65. package/fixtures/content-model-v2/dist/index.html +8 -0
  66. package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
  67. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  68. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  69. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  70. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  71. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  72. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  73. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  74. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  75. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  76. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  77. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  78. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  79. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
  80. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
  81. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  82. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  83. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  84. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  85. package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
  86. package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  87. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  88. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  89. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  90. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  91. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  92. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  93. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  94. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  95. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  96. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  97. package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
  98. package/fixtures/content-model-v2/site/config.mjs +11 -0
  99. package/fixtures/content-model-v2/site/content.md +84 -0
  100. package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
  101. package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
  102. package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
  103. package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
  104. package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
  105. package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
  106. package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
  107. package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
  108. package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
  109. package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
  110. package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
  111. package/fixtures/content-model-v2/site/theme.md +9 -0
  112. package/package.json +4 -2
  113. package/scripts/deploy-site.mjs +12 -5
  114. package/scripts/dev-local.mjs +6 -0
  115. package/scripts/generate-images.mjs +132 -56
  116. package/scripts/lib/image-dimensions.mjs +26 -0
  117. package/scripts/lib/norna-markdown-blocks.mjs +643 -0
  118. package/scripts/lib/route-model.mjs +17 -0
  119. package/scripts/lib/site-content.mjs +45 -37
  120. package/scripts/show-typography.mjs +18 -10
  121. package/scripts/sync-content-sections.mjs +280 -423
  122. package/scripts/test-content-check.mjs +59 -45
  123. package/scripts/test-content-model-v2.mjs +998 -0
  124. package/scripts/test-engine-commands.mjs +1 -1
  125. package/scripts/test-package-check.mjs +38 -11
  126. package/scripts/test-route-model.mjs +32 -0
  127. package/scripts/test-temporary-visibility.mjs +3 -6
  128. package/src/components/CardList.astro +77 -0
  129. package/src/components/GalleryGrid.astro +22 -12
  130. package/src/components/SiteNavigation.astro +5 -4
  131. package/src/components/SitePage.astro +12 -6
  132. package/src/components/SiteSection.astro +35 -13
  133. package/src/content.config.ts +8 -23
  134. package/src/lib/generatedImages.ts +50 -10
  135. package/src/lib/sectionContent.ts +130 -35
  136. package/src/lib/sitePages.ts +36 -15
  137. package/src/pages/[slug].astro +3 -3
  138. package/src/styles/global.css +187 -0
  139. package/starters/basic/site/content.md +10 -6
  140. package/starters/project/README.md +2 -2
  141. package/starters/project/site/content.md +0 -10
  142. package/starters/project/site/routes/{guide → 010-guide}/route-content.md +0 -6
@@ -27,7 +27,7 @@ try {
27
27
  const directSiteRoot = path.join(tempRoot, 'current-directory-site');
28
28
  await mkdir(directSiteRoot, { recursive: true });
29
29
  await writeFile(path.join(directSiteRoot, 'config.mjs'), 'export default { site: { url: "https://example.com/" } };\n');
30
- await writeFile(path.join(directSiteRoot, 'content.md'), '---\ntitle: Direct Site\nsections: []\n---\n');
30
+ await writeFile(path.join(directSiteRoot, 'content.md'), '---\ntitle: Direct Site\ndescription: Direct site fixture.\n---\n\n## Intro {#intro}\n\nText.\n');
31
31
  const directSiteProjectRoot = await realpath(tempRoot);
32
32
  const directSiteDoctorResult = runCli(['doctor'], {
33
33
  cwd: directSiteRoot,
@@ -190,15 +190,35 @@ try {
190
190
  packageCheckThemePath,
191
191
  packageCheckTheme.replace('brand: Example Site', 'brand: Package Check Brand'),
192
192
  );
193
- await mkdir(path.join(siteProjectRoot, 'site', 'routes', 'about'), { recursive: true });
194
- await writeFile(path.join(siteProjectRoot, 'site', 'routes', 'about', 'route-content.md'), `---
193
+ await writeFile(path.join(siteProjectRoot, 'site', 'content.md'), `---
194
+ title: Package Check Site
195
+ description: Site used by package checks.
196
+ sections:
197
+ intro: {}
198
+ work: {}
199
+ ---
200
+
201
+ ## Intro {#intro}
202
+
203
+ This page verifies that [packaged norna sites]{.highlight} can build with the current content model.
204
+
205
+ <!-- norna-image-provenance:
206
+ image: package-check.svg
207
+ source: package check fixture
208
+ -->
209
+
210
+ ## Work {#work}
211
+
212
+ This section keeps the package check independent from user-facing starter copy.
213
+ `);
214
+ await mkdir(path.join(siteProjectRoot, 'site', 'routes', '010-about'), { recursive: true });
215
+ await writeFile(path.join(siteProjectRoot, 'site', 'routes', '010-about', 'route-content.md'), `---
195
216
  title: About the site
196
217
  description: Route used by package checks.
197
218
  navigation:
198
219
  label: About
199
- order: 20
200
220
  sections:
201
- - id: about
221
+ about: {}
202
222
  ---
203
223
 
204
224
  ## About {#about}
@@ -273,6 +293,10 @@ This route verifies that packaged norna sites can build route pages.
273
293
  path.join(siteProjectRoot, 'dist', 'index.html'),
274
294
  '--section-body-line-height: 1.5',
275
295
  );
296
+ await assertFileExcludes(
297
+ path.join(siteProjectRoot, 'dist', 'index.html'),
298
+ 'norna-image-provenance',
299
+ );
276
300
  await assertFileIncludes(
277
301
  path.join(siteProjectRoot, 'dist', 'index.html'),
278
302
  '--section-body-paragraph-spacing: 0.85em',
@@ -373,8 +397,8 @@ This route verifies that packaged norna sites can build route pages.
373
397
  await writeFile(
374
398
  siteContentPath,
375
399
  siteContent.replace(
376
- 'description: Minimal norna starter site.',
377
- 'description: Minimal norna starter site.\npresentation:\n typography:\n overrides:\n body:\n lineHeight: tight',
400
+ 'description: Site used by package checks.',
401
+ 'description: Site used by package checks.\npresentation:\n typography:\n overrides:\n body:\n lineHeight: tight',
378
402
  ),
379
403
  );
380
404
  await runExpectFailure(
@@ -386,25 +410,28 @@ This route verifies that packaged norna sites can build route pages.
386
410
  await writeFile(
387
411
  siteContentPath,
388
412
  siteContent.replace(
389
- ' typography:\n preset: statement',
390
- ' typography:\n overrides:\n caption:\n spacingBefore: wide',
413
+ ' intro: {}',
414
+ ' intro:\n presentation:\n typography:\n overrides:\n caption:\n spacingBefore: wide',
391
415
  ),
392
416
  );
393
417
  await runExpectFailure(
394
418
  npxBin,
395
419
  ['norna', 'build'],
396
420
  { cwd: siteProjectRoot, env: npmEnv },
397
- 'sections.0.presentation.typography.overrides.caption.spacingBefore',
421
+ 'sections.intro.presentation.typography.overrides.caption.spacingBefore',
398
422
  );
399
423
  await writeFile(
400
424
  siteContentPath,
401
- siteContent.replace(' preset: statement', ' preset: dramatic'),
425
+ siteContent.replace(
426
+ ' intro: {}',
427
+ ' intro:\n presentation:\n typography:\n preset: dramatic',
428
+ ),
402
429
  );
403
430
  await runExpectFailure(
404
431
  npxBin,
405
432
  ['norna', 'build'],
406
433
  { cwd: siteProjectRoot, env: npmEnv },
407
- 'sections.0.presentation.typography.preset',
434
+ 'sections.intro.presentation.typography.preset',
408
435
  );
409
436
  await writeFile(
410
437
  siteThemePath,
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ import { parseRouteDirectory } from './lib/route-model.mjs';
3
+
4
+ assert.deepEqual(parseRouteDirectory('010-getting-started'), {
5
+ routeDirectory: '010-getting-started',
6
+ routeOrder: 10,
7
+ routeId: 'getting-started',
8
+ });
9
+
10
+ assert.deepEqual(parseRouteDirectory('120-api-reference'), {
11
+ routeDirectory: '120-api-reference',
12
+ routeOrder: 120,
13
+ routeId: 'api-reference',
14
+ });
15
+
16
+ for (const routeDirectory of [
17
+ '000-home',
18
+ '10-about',
19
+ '010_About',
20
+ '010-About',
21
+ '010-about-',
22
+ '010-about--team',
23
+ '010-om-oss!',
24
+ ]) {
25
+ assert.throws(
26
+ () => parseRouteDirectory(routeDirectory),
27
+ /Route directories must use the form NNN-route-id/,
28
+ `${routeDirectory} should be rejected`,
29
+ );
30
+ }
31
+
32
+ console.log('Route model test passed.');
@@ -59,17 +59,14 @@ frame:
59
59
  title: Temporary Visibility Test
60
60
  description: Test site for temporary sections.
61
61
  sections:
62
- - id: expired
62
+ expired:
63
63
  visible:
64
64
  until: "2026-01-01"
65
- gallery: []
66
- - id: active
65
+ active:
67
66
  visible:
68
67
  from: "2026-01-01"
69
68
  until: "2026-12-31"
70
- gallery: []
71
- - id: always
72
- gallery: []
69
+ always: {}
73
70
 
74
71
  ---
75
72
  ## Expired {#expired}
@@ -0,0 +1,77 @@
1
+ ---
2
+ import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import { withBasePath } from '../lib/basePath';
4
+ import { getImageAttributes } from '../lib/generatedImages';
5
+
6
+ type Card = {
7
+ title: string;
8
+ text?: string;
9
+ image?: string;
10
+ src?: string;
11
+ link?: string;
12
+ 'badge-text'?: string;
13
+ };
14
+ type CardListLayout = 'image-top' | 'image-left' | 'image-right';
15
+ type CardListFlow = 'grid' | 'stack';
16
+ type CardListSize = 's' | 'm' | 'l' | 'xl';
17
+ type CardListWidth = 'text' | 'narrow' | 'normal' | 'wide';
18
+
19
+ interface Props {
20
+ cards: Card[];
21
+ layout?: CardListLayout;
22
+ flow?: CardListFlow;
23
+ size?: CardListSize;
24
+ width?: CardListWidth;
25
+ }
26
+
27
+ const { cards, layout = 'image-top', flow = 'grid', size = 'm', width = 'normal' } = Astro.props;
28
+ const cardImageSizes = [
29
+ '(max-width: 700px) 100vw',
30
+ `min(20rem, ${projectConfig.gallery.maxAvailableWidthPercent.desktop}vw)`,
31
+ ].join(', ');
32
+ const displayLink = (link: string) => withBasePath(projectConfig.site.basePath, link);
33
+ const cardListClasses = [
34
+ 'card-list',
35
+ `card-list-layout-${layout}`,
36
+ `card-list-flow-${flow}`,
37
+ `card-list-size-${size}`,
38
+ `card-list-width-${width}`,
39
+ ];
40
+ ---
41
+
42
+ <div class:list={cardListClasses}>
43
+ {cards.map((card) => {
44
+ const content = (
45
+ <>
46
+ {card.src && (
47
+ <div class="card-list-image-frame">
48
+ <img
49
+ {...getImageAttributes(card.src, cardImageSizes)}
50
+ class="card-list-image"
51
+ alt=""
52
+ decoding="async"
53
+ fetchpriority="low"
54
+ loading="lazy"
55
+ />
56
+ </div>
57
+ )}
58
+ <div class="card-list-content">
59
+ {card['badge-text'] && <p class="card-list-badge">{card['badge-text']}</p>}
60
+ <h3 class="card-list-title">{card.title}</h3>
61
+ {card.text && <p>{card.text}</p>}
62
+ </div>
63
+ </>
64
+ );
65
+
66
+ return (
67
+ <article class:list={['card-list-item', {
68
+ 'card-list-item-has-image': Boolean(card.src),
69
+ 'card-list-item-linked': Boolean(card.link),
70
+ }]}>
71
+ {card.link
72
+ ? <a class="card-list-link" href={displayLink(card.link)}>{content}</a>
73
+ : content}
74
+ </article>
75
+ );
76
+ })}
77
+ </div>
@@ -1,11 +1,18 @@
1
1
  ---
2
- import type { CollectionEntry } from 'astro:content';
3
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
4
3
  import { getGeneratedImage, getImageAttributes } from '../lib/generatedImages';
5
4
 
6
- type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
7
- type GalleryItem = SiteSection['gallery'][number];
8
- type GalleryImage = Extract<GalleryItem, { image: string }>;
5
+ type GalleryImage = {
6
+ image: string;
7
+ src?: string;
8
+ alt?: string;
9
+ caption?: string;
10
+ };
11
+ type GalleryItem = GalleryImage | { carousel: GalleryImage[] };
12
+ type GeneratedImageWithDimensions = NonNullable<ReturnType<typeof getGeneratedImage>> & {
13
+ width: number;
14
+ height: number;
15
+ };
9
16
 
10
17
  interface Props {
11
18
  title: string;
@@ -18,9 +25,12 @@ const galleryImageSizes = [
18
25
  `(max-width: 700px) ${projectConfig.gallery.maxAvailableWidthPercent.mobile}vw`,
19
26
  `${projectConfig.gallery.maxAvailableWidthPercent.desktop}vw`,
20
27
  ].join(', ');
28
+ const hasDimensions = (image: ReturnType<typeof getGeneratedImage>): image is GeneratedImageWithDimensions => (
29
+ Boolean(image) && Number.isFinite(image.width) && Number.isFinite(image.height)
30
+ );
21
31
  const getImageClass = (image: GalleryImage) => {
22
- const generatedImage = getGeneratedImage(image.image);
23
- return generatedImage && generatedImage.width > generatedImage.height
32
+ const generatedImage = getGeneratedImage(image.src ?? image.image);
33
+ return hasDimensions(generatedImage) && generatedImage.width > generatedImage.height
24
34
  ? 'gallery-image-landscape'
25
35
  : undefined;
26
36
  };
@@ -31,8 +41,8 @@ const getImageLoadingProps = (isPriority: boolean) => ({
31
41
  });
32
42
  const getCarouselAspectRatio = (images: GalleryImage[]) => {
33
43
  const ratios = images
34
- .map((image) => getGeneratedImage(image.image))
35
- .filter((image): image is NonNullable<typeof image> => Boolean(image))
44
+ .map((image) => getGeneratedImage(image.src ?? image.image))
45
+ .filter(hasDimensions)
36
46
  .map((image) => image.width / image.height);
37
47
 
38
48
  return ratios.length > 0 ? Math.min(...ratios) : null;
@@ -48,9 +58,9 @@ const getCarouselAspectRatio = (images: GalleryImage[]) => {
48
58
  <figure class="gallery-item">
49
59
  <div class="gallery-image-frame">
50
60
  <img
51
- {...getImageAttributes(item.image, galleryImageSizes)}
61
+ {...getImageAttributes(item.src ?? item.image, galleryImageSizes)}
52
62
  class:list={['gallery-image', getImageClass(item)]}
53
- alt={item.alt}
63
+ alt={item.alt ?? ''}
54
64
  {...getImageLoadingProps(isPriorityItem)}
55
65
  />
56
66
  </div>
@@ -87,9 +97,9 @@ const getCarouselAspectRatio = (images: GalleryImage[]) => {
87
97
  <figure class="gallery-carousel-slide" data-carousel-slide>
88
98
  <div class="gallery-carousel-image-frame">
89
99
  <img
90
- {...getImageAttributes(image.image, galleryImageSizes)}
100
+ {...getImageAttributes(image.src ?? image.image, galleryImageSizes)}
91
101
  class:list={['gallery-image', getImageClass(image)]}
92
- alt={image.alt}
102
+ alt={image.alt ?? ''}
93
103
  {...getImageLoadingProps(isPriorityItem && imageIndex === 0)}
94
104
  />
95
105
  </div>
@@ -1,11 +1,9 @@
1
1
  ---
2
- import type { CollectionEntry } from 'astro:content';
3
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
4
3
  import { withBasePath } from '../lib/basePath';
4
+ import type { ResolvedSection } from '../lib/sectionContent';
5
5
  import type { SitePage } from '../lib/sitePages';
6
6
 
7
- type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
8
- type ResolvedSection = SiteSection & { title: string };
9
7
  type FrameColors = {
10
8
  backgroundColor: string;
11
9
  textColor: string;
@@ -21,8 +19,11 @@ interface Props {
21
19
 
22
20
  const { brandLabel, currentPage, pages, sections, frameColors } = Astro.props;
23
21
  const smoothScrollConfig = projectConfig.navigation.smoothScroll;
22
+ const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
23
+ ? 'var(--color-page)'
24
+ : frameColors.backgroundColor;
24
25
  const navigationStyle = [
25
- `--site-top-background-color: ${frameColors.backgroundColor}`,
26
+ `--site-top-background-color: ${siteTopBackgroundColor}`,
26
27
  `--site-top-text-color: ${frameColors.textColor}`,
27
28
  ].join('; ');
28
29
  const showSiteNavigation = pages.length > 1;
@@ -1,11 +1,14 @@
1
1
  ---
2
2
  import { getCollection, type CollectionEntry } from 'astro:content';
3
+ import path from 'node:path';
3
4
  import BaseLayout from '../layouts/BaseLayout.astro';
4
5
  import SiteNavigation from './SiteNavigation.astro';
5
6
  import SiteSection from './SiteSection.astro';
6
7
  import { getSectionsContent } from '../lib/sectionContent';
7
8
  import { getNavigationPages, getSitePage, getSitePages } from '../lib/sitePages';
8
9
  import { getToday, isVisibleToday } from '../lib/visibility';
10
+ import { readSiteFile } from '../../scripts/lib/site-content.mjs';
11
+ import { siteContentPath, siteRoutesDir } from '../../scripts/lib/site-paths.mjs';
9
12
  import { resolveFrameColors, resolvePagePresentation } from '../../scripts/lib/presentation.mjs';
10
13
 
11
14
  type SiteEntry = CollectionEntry<'site'>;
@@ -16,13 +19,16 @@ interface Props {
16
19
 
17
20
  const { entry } = Astro.props;
18
21
  const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
22
+ const sitePages = getSitePages(await getCollection('site'));
23
+ const currentPage = getSitePage(entry);
19
24
  const siteContentLabel = entry.id.includes('-route-')
20
- ? `${siteDirectory || 'site'}/routes/${entry.id.split('-route-').at(-1)}/route-content.md`
25
+ ? `${siteDirectory || 'site'}/routes/${currentPage.routeDirectory}/route-content.md`
21
26
  : `${siteDirectory || 'site'}/content.md`;
27
+ const pageContentPath = currentPage.routeDirectory
28
+ ? path.join(siteRoutesDir, currentPage.routeDirectory, 'route-content.md')
29
+ : siteContentPath;
30
+ const rawMarkdown = (await readSiteFile(pageContentPath, siteContentLabel)).body;
22
31
  const [theme] = await getCollection('theme');
23
- const allEntries = await getCollection('site');
24
- const sitePages = getSitePages(allEntries);
25
- const currentPage = getSitePage(entry);
26
32
  const navigationPages = getNavigationPages(sitePages);
27
33
  const homePage = sitePages.find((page) => page.isHome) ?? currentPage;
28
34
  const themeData = theme?.data ?? {};
@@ -35,12 +41,12 @@ const frameColors = resolveFrameColors({
35
41
  });
36
42
  const sections = entry.data.sections;
37
43
  const siteHtml = entry.rendered?.html ?? '';
38
- const resolvedSections = getSectionsContent(siteHtml, sections, pagePresentation.inlineStyles);
44
+ const resolvedSections = getSectionsContent(siteHtml, rawMarkdown, sections, currentPage, pagePresentation.inlineStyles);
39
45
  const today = getToday();
40
46
  const visibleSections = resolvedSections.filter((section) => isVisibleToday(section.visible, today));
41
47
 
42
48
  if (visibleSections.length === 0) {
43
- throw new Error(`No sections are visible for ${today}. Check sections[].visible in ${siteContentLabel}.`);
49
+ throw new Error(`No sections are visible for ${today}. Check sections.<section-id>.visible in ${siteContentLabel}.`);
44
50
  }
45
51
  ---
46
52
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  import type { CollectionEntry } from 'astro:content';
3
3
  import { resolveTypographyOverride } from '../../scripts/lib/typography.mjs';
4
+ import CardList from './CardList.astro';
4
5
  import GalleryGrid from './GalleryGrid.astro';
6
+ import type { ResolvedSection } from '../lib/sectionContent';
5
7
 
6
- type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
7
- type ResolvedSection = SiteSection & { title: string; contentHtml: string };
8
8
  type ResponsiveValue<T> = { desktop: T; mobile: T };
9
9
  type ResponsiveOverride<T> = { desktop?: T; mobile?: T };
10
10
  type TextAlign = 'left' | 'center' | 'right';
@@ -12,7 +12,7 @@ type TextSize = 'small' | 'medium' | 'large' | 'xlarge';
12
12
  type SectionPresentationOverride = {
13
13
  backgroundColor?: string;
14
14
  textColor?: string;
15
- typography?: NonNullable<SiteSection['presentation']>['typography'];
15
+ typography?: NonNullable<ResolvedSection['presentation']>['typography'];
16
16
  };
17
17
  type ResolvedPagePresentation = {
18
18
  backgroundColor: string;
@@ -197,7 +197,11 @@ const getAlignedTextWidth = (
197
197
  });
198
198
  const headingWidth = getAlignedTextWidth(headingAlign, '760px');
199
199
  const bodyWidth = getAlignedTextWidth(bodyAlign, 'var(--text-width)');
200
- const hasGallery = section.gallery.length > 0;
200
+ const hasMedia = section.contentBlocks.some((block) => (
201
+ block.type === 'image-stack'
202
+ || block.type === 'image-carousel'
203
+ || (block.type === 'card-list' && block.cards.some((card) => card.image))
204
+ ));
201
205
  const markdownHeadingStyle = (level: 'h3' | 'h4') => {
202
206
  const headingConfig = markdownHeadings[level];
203
207
  const fontSize = getHeadingSizeValue(level, headingConfig.size);
@@ -255,16 +259,34 @@ const sectionStyle = [
255
259
  <HeadingTag>{section.title}</HeadingTag>
256
260
  </header>
257
261
 
258
- <div class:list={['section-body', { 'section-body-has-gallery': hasGallery, 'section-body-text-only': !hasGallery }]}>
259
- {section.contentHtml && <div class="section-markdown" set:html={section.contentHtml} />}
262
+ <div class:list={['section-body', { 'section-body-has-gallery': hasMedia, 'section-body-text-only': !hasMedia }]}>
263
+ {section.contentBlocks.map((block, blockIndex) => {
264
+ if (block.type === 'html') {
265
+ return block.html && <div class="section-markdown" set:html={block.html} />;
266
+ }
260
267
 
261
- {hasGallery && (
262
- <GalleryGrid
263
- title={section.title}
264
- items={section.gallery}
265
- priorityFirstImage={priorityGalleryImage}
266
- />
267
- )}
268
+ if (block.type === 'image-carousel') {
269
+ return (
270
+ <GalleryGrid
271
+ title={section.title}
272
+ items={[{ carousel: block.images }]}
273
+ priorityFirstImage={priorityGalleryImage && blockIndex === 0}
274
+ />
275
+ );
276
+ }
277
+
278
+ if (block.type === 'card-list') {
279
+ return <CardList cards={block.cards} layout={block.layout} flow={block.flow} size={block.size} width={block.width} />;
280
+ }
281
+
282
+ return (
283
+ <GalleryGrid
284
+ title={section.title}
285
+ items={block.images}
286
+ priorityFirstImage={priorityGalleryImage && blockIndex === 0}
287
+ />
288
+ );
289
+ })}
268
290
  </div>
269
291
  </div>
270
292
  </section>
@@ -3,14 +3,13 @@ import { glob } from 'astro/loaders';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { z } from 'astro/zod';
5
5
  import { siteDir, siteDirLabel } from '../scripts/lib/site-paths.mjs';
6
+ import { parseRouteDirectory } from '../scripts/lib/route-model.mjs';
6
7
  import { isDateOnly } from './lib/visibility';
7
8
 
8
9
  const siteEntryId = `${siteDirLabel
9
10
  .replace(/^[./\\]+/, '')
10
11
  .replace(/[^a-zA-Z0-9-]+/g, '-')
11
12
  .replace(/^-+|-+$/g, '') || 'site'}-content`;
12
- const contentImageName = z.string().regex(/^[a-z0-9][a-z0-9.-]*\.(jpe?g|png)$/i);
13
- const routeSlug = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'Use lowercase letters, numbers, and hyphens.');
14
13
  const colorValue = z.string().regex(
15
14
  /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
16
15
  'Use a hex color such as "#000000".',
@@ -163,37 +162,23 @@ const frame = z.object({
163
162
  const pageNavigation = z.object({
164
163
  include: z.boolean().optional(),
165
164
  label: z.string().optional(),
166
- order: z.number().int().optional(),
167
165
  }).strict();
168
166
  const themeNavigation = z.object({
169
167
  brand: z.string().min(1).optional(),
170
168
  }).strict();
171
169
 
172
- const galleryImage = z.object({
173
- image: contentImageName,
174
- alt: z.string(),
175
- caption: z.string().optional(),
170
+ const sectionMetadata = z.object({
171
+ visible: visibilityWindow.optional(),
172
+ presentation: sectionPresentationOverride.optional(),
176
173
  }).strict();
177
- const galleryCarousel = z.object({
178
- carousel: z.array(galleryImage).min(2, 'A carousel must contain at least two images.'),
179
- }).strict();
180
- const galleryItem = z.union([galleryImage, galleryCarousel]);
181
174
 
182
175
  const siteSchema = z.object({
183
176
  title: z.string(),
184
177
  description: z.string(),
185
- slug: routeSlug.optional(),
186
178
  navigation: pageNavigation.optional(),
187
179
  presentation: pagePresentation.optional(),
188
180
  frame: frame.optional(),
189
- sections: z.array(
190
- z.object({
191
- id: z.string().regex(/^[a-z0-9-]+$/),
192
- visible: visibilityWindow.optional(),
193
- presentation: sectionPresentationOverride.optional(),
194
- gallery: z.array(galleryItem).optional().default([]),
195
- }).strict(),
196
- ).min(1),
181
+ sections: z.record(z.string().regex(/^[a-z0-9-]+$/), sectionMetadata).optional().default({}),
197
182
  });
198
183
 
199
184
  const themeSchema = z.object({
@@ -214,9 +199,9 @@ const site = defineCollection({
214
199
  return siteEntryId;
215
200
  }
216
201
 
217
- const routeFolder = entry.match(/^routes\/([^/]+)\/route-content\.md$/)?.[1];
218
- return routeFolder
219
- ? `${siteEntryId.replace(/-content$/, '')}-route-${routeFolder}`
202
+ const routeDirectory = entry.match(/^routes\/([^/]+)\/route-content\.md$/)?.[1];
203
+ return routeDirectory
204
+ ? `${siteEntryId.replace(/-content$/, '')}-route-${parseRouteDirectory(routeDirectory, `route directory routes/${routeDirectory}`).routeDirectory}`
220
205
  : entry.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
221
206
  },
222
207
  }),
@@ -1,45 +1,65 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, statSync } from 'node:fs';
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { generatedImagesManifestPath } from '../../scripts/lib/site-paths.mjs';
4
4
  import { withBasePath } from './basePath';
5
5
 
6
6
  type GeneratedImage = {
7
+ kind?: 'static';
7
8
  outputVersion?: number;
8
9
  sourceHash?: string;
9
- width: number;
10
- height: number;
11
- variants: Array<{
10
+ src?: string;
11
+ width?: number;
12
+ height?: number;
13
+ variants?: Array<{
12
14
  src: string;
13
15
  width: number;
14
16
  }>;
15
17
  };
16
18
 
19
+ let cachedImagesSignature: string | null = null;
20
+ let cachedImages: Record<string, GeneratedImage | undefined> = {};
21
+
17
22
  const readGeneratedImages = () => {
18
23
  try {
19
- return JSON.parse(readFileSync(generatedImagesManifestPath, 'utf8')) as Record<string, GeneratedImage | undefined>;
24
+ const manifestStats = statSync(generatedImagesManifestPath);
25
+ const signature = `${manifestStats.mtimeMs}:${manifestStats.size}`;
26
+
27
+ if (signature === cachedImagesSignature) {
28
+ return cachedImages;
29
+ }
30
+
31
+ cachedImages = JSON.parse(readFileSync(generatedImagesManifestPath, 'utf8')) as Record<string, GeneratedImage | undefined>;
32
+ cachedImagesSignature = signature;
33
+ return cachedImages;
20
34
  } catch {
35
+ cachedImagesSignature = null;
36
+ cachedImages = {};
21
37
  return {};
22
38
  }
23
39
  };
24
40
 
25
- const images = readGeneratedImages();
26
41
  const maxDisplayImageWidth = 1920;
27
42
  const fallbackDisplayImageWidth = 1440;
28
43
 
29
- export const getGeneratedImage = (src: string) => images[src];
44
+ export const getGeneratedImage = (src: string) => readGeneratedImages()[src];
30
45
 
31
46
  const displaySrc = (src: string) => withBasePath(projectConfig.site.basePath, src);
32
47
 
33
- export const getLinkedImageSrc = (src: string) => displaySrc(getGeneratedImage(src)?.variants.at(-1)?.src ?? src);
48
+ export const getLinkedImageSrc = (src: string) => {
49
+ const image = getGeneratedImage(src);
50
+ return displaySrc(image?.kind === 'static'
51
+ ? image.src ?? src
52
+ : image?.variants?.at(-1)?.src ?? src);
53
+ };
34
54
 
35
- const getDisplayVariants = (variants: GeneratedImage['variants']) => {
55
+ const getDisplayVariants = (variants: NonNullable<GeneratedImage['variants']>) => {
36
56
  const sortedVariants = [...variants].sort((a, b) => a.width - b.width);
37
57
  const displayVariants = sortedVariants.filter((variant) => variant.width <= maxDisplayImageWidth);
38
58
 
39
59
  return displayVariants.length > 0 ? displayVariants : sortedVariants;
40
60
  };
41
61
 
42
- const getFallbackVariant = (variants: GeneratedImage['variants']) => (
62
+ const getFallbackVariant = (variants: NonNullable<GeneratedImage['variants']>) => (
43
63
  variants.filter((variant) => variant.width <= fallbackDisplayImageWidth).at(-1) ?? variants[0]
44
64
  );
45
65
 
@@ -53,6 +73,26 @@ export const getImageAttributes = (src: string, sizes: string) => {
53
73
  };
54
74
  }
55
75
 
76
+ if (image.kind === 'static') {
77
+ return {
78
+ src: displaySrc(image.src ?? src),
79
+ ...(Number.isFinite(image.width) && Number.isFinite(image.height)
80
+ ? {
81
+ style: `aspect-ratio: ${image.width} / ${image.height};`,
82
+ width: image.width,
83
+ height: image.height,
84
+ }
85
+ : {}),
86
+ };
87
+ }
88
+
89
+ if (!image.variants) {
90
+ return {
91
+ src: displaySrc(src),
92
+ sizes,
93
+ };
94
+ }
95
+
56
96
  const displayVariants = getDisplayVariants(image.variants);
57
97
  const fallbackVariant = getFallbackVariant(displayVariants);
58
98