@janga/norna 0.7.6 → 0.7.8

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 (139) hide show
  1. package/README.md +8 -7
  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 +7 -6
  6. package/docs/configuration.md +3 -2
  7. package/docs/content.md +202 -91
  8. package/docs/design/site-examples-structure.md +1 -1
  9. package/docs/engine-development.md +3 -3
  10. package/docs/getting-started.md +50 -8
  11. package/docs/images-and-metadata.md +117 -20
  12. package/docs/routes.md +20 -12
  13. package/docs/site-structure.md +9 -5
  14. package/docs/theme.md +12 -8
  15. package/docs/typography.md +9 -10
  16. package/examples/dog-gallery/site/content.md +38 -77
  17. package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +0 -10
  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 +3 -2
  113. package/scripts/deploy-site.mjs +11 -4
  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/site-content.mjs +31 -29
  119. package/scripts/show-typography.mjs +17 -9
  120. package/scripts/sync-content-sections.mjs +414 -412
  121. package/scripts/test-content-check.mjs +59 -45
  122. package/scripts/test-content-model-v2.mjs +1230 -0
  123. package/scripts/test-engine-commands.mjs +1 -1
  124. package/scripts/test-package-check.mjs +36 -8
  125. package/scripts/test-temporary-visibility.mjs +3 -6
  126. package/src/components/CardList.astro +77 -0
  127. package/src/components/GalleryGrid.astro +22 -12
  128. package/src/components/SiteNavigation.astro +5 -4
  129. package/src/components/SitePage.astro +9 -2
  130. package/src/components/SiteSection.astro +35 -13
  131. package/src/content.config.ts +4 -17
  132. package/src/lib/generatedImages.ts +50 -10
  133. package/src/lib/sectionContent.ts +130 -35
  134. package/src/styles/global.css +187 -0
  135. package/starters/basic/README.md +4 -2
  136. package/starters/basic/site/content.md +9 -11
  137. package/starters/project/README.md +7 -3
  138. package/starters/project/site/content.md +0 -9
  139. package/starters/project/site/routes/010-guide/route-content.md +0 -5
@@ -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,6 +190,27 @@ try {
190
190
  packageCheckThemePath,
191
191
  packageCheckTheme.replace('brand: Example Site', 'brand: Package Check Brand'),
192
192
  );
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
+ `);
193
214
  await mkdir(path.join(siteProjectRoot, 'site', 'routes', '010-about'), { recursive: true });
194
215
  await writeFile(path.join(siteProjectRoot, 'site', 'routes', '010-about', 'route-content.md'), `---
195
216
  title: About the site
@@ -197,7 +218,7 @@ description: Route used by package checks.
197
218
  navigation:
198
219
  label: About
199
220
  sections:
200
- - id: about
221
+ about: {}
201
222
  ---
202
223
 
203
224
  ## About {#about}
@@ -272,6 +293,10 @@ This route verifies that packaged norna sites can build route pages.
272
293
  path.join(siteProjectRoot, 'dist', 'index.html'),
273
294
  '--section-body-line-height: 1.5',
274
295
  );
296
+ await assertFileExcludes(
297
+ path.join(siteProjectRoot, 'dist', 'index.html'),
298
+ 'norna-image-provenance',
299
+ );
275
300
  await assertFileIncludes(
276
301
  path.join(siteProjectRoot, 'dist', 'index.html'),
277
302
  '--section-body-paragraph-spacing: 0.85em',
@@ -372,8 +397,8 @@ This route verifies that packaged norna sites can build route pages.
372
397
  await writeFile(
373
398
  siteContentPath,
374
399
  siteContent.replace(
375
- 'description: Minimal norna starter site.',
376
- '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',
377
402
  ),
378
403
  );
379
404
  await runExpectFailure(
@@ -385,25 +410,28 @@ This route verifies that packaged norna sites can build route pages.
385
410
  await writeFile(
386
411
  siteContentPath,
387
412
  siteContent.replace(
388
- ' typography:\n preset: statement',
389
- ' typography:\n overrides:\n caption:\n spacingBefore: wide',
413
+ ' intro: {}',
414
+ ' intro:\n presentation:\n typography:\n overrides:\n caption:\n spacingBefore: wide',
390
415
  ),
391
416
  );
392
417
  await runExpectFailure(
393
418
  npxBin,
394
419
  ['norna', 'build'],
395
420
  { cwd: siteProjectRoot, env: npmEnv },
396
- 'sections.0.presentation.typography.overrides.caption.spacingBefore',
421
+ 'sections.intro.presentation.typography.overrides.caption.spacingBefore',
397
422
  );
398
423
  await writeFile(
399
424
  siteContentPath,
400
- siteContent.replace(' preset: statement', ' preset: dramatic'),
425
+ siteContent.replace(
426
+ ' intro: {}',
427
+ ' intro:\n presentation:\n typography:\n preset: dramatic',
428
+ ),
401
429
  );
402
430
  await runExpectFailure(
403
431
  npxBin,
404
432
  ['norna', 'build'],
405
433
  { cwd: siteProjectRoot, env: npmEnv },
406
- 'sections.0.presentation.typography.preset',
434
+ 'sections.intro.presentation.typography.preset',
407
435
  );
408
436
  await writeFile(
409
437
  siteThemePath,
@@ -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'>;
@@ -21,6 +24,10 @@ const currentPage = getSitePage(entry);
21
24
  const siteContentLabel = entry.id.includes('-route-')
22
25
  ? `${siteDirectory || 'site'}/routes/${currentPage.routeDirectory}/route-content.md`
23
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;
24
31
  const [theme] = await getCollection('theme');
25
32
  const navigationPages = getNavigationPages(sitePages);
26
33
  const homePage = sitePages.find((page) => page.isHome) ?? currentPage;
@@ -34,12 +41,12 @@ const frameColors = resolveFrameColors({
34
41
  });
35
42
  const sections = entry.data.sections;
36
43
  const siteHtml = entry.rendered?.html ?? '';
37
- const resolvedSections = getSectionsContent(siteHtml, sections, pagePresentation.inlineStyles);
44
+ const resolvedSections = getSectionsContent(siteHtml, rawMarkdown, sections, currentPage, pagePresentation.inlineStyles);
38
45
  const today = getToday();
39
46
  const visibleSections = resolvedSections.filter((section) => isVisibleToday(section.visible, today));
40
47
 
41
48
  if (visibleSections.length === 0) {
42
- 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}.`);
43
50
  }
44
51
  ---
45
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>
@@ -10,7 +10,6 @@ const siteEntryId = `${siteDirLabel
10
10
  .replace(/^[./\\]+/, '')
11
11
  .replace(/[^a-zA-Z0-9-]+/g, '-')
12
12
  .replace(/^-+|-+$/g, '') || 'site'}-content`;
13
- const contentImageName = z.string().regex(/^[a-z0-9][a-z0-9.-]*\.(jpe?g|png)$/i);
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".',
@@ -168,15 +167,10 @@ const themeNavigation = z.object({
168
167
  brand: z.string().min(1).optional(),
169
168
  }).strict();
170
169
 
171
- const galleryImage = z.object({
172
- image: contentImageName,
173
- alt: z.string(),
174
- caption: z.string().optional(),
170
+ const sectionMetadata = z.object({
171
+ visible: visibilityWindow.optional(),
172
+ presentation: sectionPresentationOverride.optional(),
175
173
  }).strict();
176
- const galleryCarousel = z.object({
177
- carousel: z.array(galleryImage).min(2, 'A carousel must contain at least two images.'),
178
- }).strict();
179
- const galleryItem = z.union([galleryImage, galleryCarousel]);
180
174
 
181
175
  const siteSchema = z.object({
182
176
  title: z.string(),
@@ -184,14 +178,7 @@ const siteSchema = z.object({
184
178
  navigation: pageNavigation.optional(),
185
179
  presentation: pagePresentation.optional(),
186
180
  frame: frame.optional(),
187
- sections: z.array(
188
- z.object({
189
- id: z.string().regex(/^[a-z0-9-]+$/),
190
- visible: visibilityWindow.optional(),
191
- presentation: sectionPresentationOverride.optional(),
192
- gallery: z.array(galleryItem).optional().default([]),
193
- }).strict(),
194
- ).min(1),
181
+ sections: z.record(z.string().regex(/^[a-z0-9-]+$/), sectionMetadata).optional().default({}),
195
182
  });
196
183
 
197
184
  const themeSchema = z.object({
@@ -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