@janga/norna 0.7.13 → 0.7.15

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 (146) hide show
  1. package/README.md +15 -9
  2. package/bin/norna-cli.mjs +11 -4
  3. package/docs/README.md +71 -43
  4. package/docs/commands.md +34 -20
  5. package/docs/configuration.md +43 -62
  6. package/docs/content.md +34 -7
  7. package/docs/design/command-organization.md +15 -8
  8. package/docs/design/norna-diagram-design.md +6 -6
  9. package/docs/design/site-examples-structure.md +1 -1
  10. package/docs/engine-development.md +22 -9
  11. package/docs/getting-started.md +29 -16
  12. package/docs/how-to/embedded-site.md +1 -1
  13. package/docs/images-and-metadata.md +3 -3
  14. package/docs/local-development.md +11 -3
  15. package/docs/public-files.md +105 -0
  16. package/docs/publishing.md +4 -8
  17. package/docs/routes.md +6 -7
  18. package/docs/site-files.md +144 -0
  19. package/docs/sitewide-content.md +47 -33
  20. package/docs/theme.md +37 -49
  21. package/docs/typography.md +25 -31
  22. package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
  23. package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
  24. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
  25. package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
  26. package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
  27. package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
  28. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
  29. package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
  30. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  31. package/examples/feature-demos/media-and-surfaces/README.md +1 -1
  32. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  33. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  35. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  36. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  37. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  38. package/examples/feature-demos/sitewide-content/README.md +8 -0
  39. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  40. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  41. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  43. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  44. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  45. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  46. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  48. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  50. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  51. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  52. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  53. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  54. package/fixtures/basic/site/content.md +3 -3
  55. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  56. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  57. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  58. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  59. package/package.json +10 -3
  60. package/schemas/config.schema.json +77 -0
  61. package/schemas/content-frontmatter.schema.json +81 -0
  62. package/schemas/manifest.json +9 -0
  63. package/schemas/sitewide-content.schema.json +215 -0
  64. package/schemas/theme.schema.json +1208 -0
  65. package/scripts/check-config.mjs +18 -11
  66. package/scripts/export-theme-preset.mjs +1 -1
  67. package/scripts/generate-schemas.mjs +283 -0
  68. package/scripts/init-site.mjs +22 -27
  69. package/scripts/lib/documentation-links.mjs +8 -0
  70. package/scripts/lib/editor-language-service.mjs +609 -0
  71. package/scripts/lib/example-sites.mjs +2 -2
  72. package/scripts/lib/git-status.mjs +7 -0
  73. package/scripts/lib/logo-assets.mjs +15 -10
  74. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  75. package/scripts/lib/presentation.mjs +23 -12
  76. package/scripts/lib/project-config.mjs +17 -41
  77. package/scripts/lib/public-asset-conventions.mjs +112 -0
  78. package/scripts/lib/schema-definitions.mjs +213 -0
  79. package/scripts/lib/schema-value-definitions.mjs +86 -0
  80. package/scripts/lib/site-content.mjs +24 -18
  81. package/scripts/lib/site-paths.mjs +4 -8
  82. package/scripts/lib/sitewide-content.mjs +24 -0
  83. package/scripts/lib/theme-config.mjs +15 -35
  84. package/scripts/lib/theme-presets.mjs +76 -66
  85. package/scripts/lib/typography.mjs +15 -15
  86. package/scripts/lib/yaml-config.mjs +50 -0
  87. package/scripts/list-theme-presets.mjs +15 -0
  88. package/scripts/release.mjs +84 -8
  89. package/scripts/show-typography.mjs +22 -46
  90. package/scripts/sync-content-sections.mjs +2 -2
  91. package/scripts/test-client-javascript.mjs +28 -28
  92. package/scripts/test-content-check.mjs +21 -26
  93. package/scripts/test-content-model-v2.mjs +72 -40
  94. package/scripts/test-documentation.mjs +70 -4
  95. package/scripts/test-editor-language-service.mjs +158 -0
  96. package/scripts/test-engine-commands.mjs +65 -34
  97. package/scripts/test-package-check.mjs +37 -30
  98. package/scripts/test-project-config.mjs +15 -20
  99. package/scripts/test-schemas.mjs +102 -0
  100. package/scripts/test-temporary-visibility.mjs +5 -13
  101. package/scripts/test-theme-presets.mjs +64 -37
  102. package/src/components/CardList.astro +2 -5
  103. package/src/components/ImageCarousel.astro +21 -21
  104. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  105. package/src/components/SectionNavigationScript.astro +28 -0
  106. package/src/components/SiteNavigation.astro +6 -5
  107. package/src/components/SitePage.astro +8 -8
  108. package/src/components/SiteSection.astro +18 -21
  109. package/src/content.config.ts +17 -209
  110. package/src/layouts/BaseLayout.astro +9 -9
  111. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  112. package/src/lib/routeThemes.ts +9 -29
  113. package/src/lib/sectionContent.ts +3 -3
  114. package/src/lib/sitePublicAssets.ts +5 -21
  115. package/src/styles/global.css +73 -73
  116. package/starters/basic/.github/workflows/deploy.yml +1 -1
  117. package/starters/basic/README.md +16 -13
  118. package/starters/basic/package.json +3 -2
  119. package/starters/basic/site/{config.md → config.yaml} +0 -2
  120. package/starters/basic/site/content.md +1 -1
  121. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  122. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  123. package/starters/project/.github/workflows/deploy.yml +1 -1
  124. package/starters/project/README.md +22 -15
  125. package/starters/project/package.json +3 -2
  126. package/starters/project/site/{config.md → config.yaml} +0 -2
  127. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  128. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  129. package/docs/site-structure.md +0 -98
  130. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  131. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  132. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  133. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  134. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  135. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  136. package/fixtures/content-model-v2/site/theme.md +0 -7
  137. package/scripts/lib/sitewide-config.mjs +0 -34
  138. package/src/components/MobileSectionNavigationScript.astro +0 -14
  139. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  140. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  142. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  143. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  144. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  145. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  146. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -3,10 +3,12 @@ import { spawnSync } from 'node:child_process';
3
3
  import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { parseYamlMapping } from './lib/frontmatter-yaml.mjs';
7
- import { splitSiteFile } from './lib/site-content.mjs';
6
+ import { load } from 'js-yaml';
7
+ import { resolveThemePresentation } from './lib/presentation.mjs';
8
8
  import {
9
+ getThemePresetMetadata,
9
10
  resolveThemeConfig,
11
+ themePresetDefinitions,
10
12
  themePresetNames,
11
13
  themePresets,
12
14
  } from './lib/theme-presets.mjs';
@@ -25,36 +27,68 @@ const runCli = (args) => spawnSync(process.execPath, [cliPath, '--site-dir', sit
25
27
 
26
28
  try {
27
29
  assert.deepEqual(Object.keys(themePresets), themePresetNames);
30
+ assert.deepEqual(Object.keys(themePresetDefinitions), themePresetNames);
28
31
  for (const presetName of themePresetNames) {
32
+ const metadata = getThemePresetMetadata(presetName);
33
+ assert.equal(metadata.name, presetName);
34
+ assert.ok(metadata.title);
35
+ assert.ok(metadata.description);
29
36
  const resolved = resolveThemeConfig({ preset: presetName }, 'test theme');
30
37
  assert.equal(resolved.preset, presetName);
31
38
  assert.ok(resolved.layout?.density);
32
- assert.ok(resolved.gallery?.width);
39
+ assert.ok(resolved.images?.width);
33
40
  assert.ok(resolved.typography?.fontFamily);
34
- assert.ok(resolved.typography?.preset);
35
- assert.ok(resolved.presentation?.palette);
41
+ assert.ok(resolved.typography?.profile);
42
+ assert.ok(resolved.palette);
43
+ assert.ok(resolved.sectionSurfaces);
36
44
  }
37
45
 
38
46
  const overridden = resolveThemeConfig({
39
47
  preset: 'documentation',
40
48
  layout: { pageWidth: '1300px' },
41
- presentation: { palette: 'dark' },
49
+ palette: 'dark',
42
50
  }, 'test theme');
43
51
  assert.equal(overridden.layout.density, 'compact');
44
52
  assert.equal(overridden.layout.pageWidth, '1300px');
45
- assert.equal(overridden.gallery.width, '920px');
46
- assert.equal(overridden.presentation.palette, 'dark');
47
- assert.deepEqual(overridden.presentation.sectionSurfaces.sequence, ['base', 'soft']);
53
+ assert.equal(overridden.images.width, '920px');
54
+ assert.equal(overridden.palette, 'dark');
55
+ assert.deepEqual(overridden.sectionSurfaces, ['base', 'soft']);
48
56
  assert.throws(
49
- () => resolveThemeConfig({ preset: 'unknown' }, 'test/theme.md'),
50
- /Unknown theme preset "unknown" in test\/theme\.md.*portfolio, documentation, project, statement/,
57
+ () => resolveThemeConfig({ preset: 'unknown' }, 'test/theme.yaml'),
58
+ /Unknown theme preset "unknown" in test\/theme\.yaml.*portfolio, documentation, project, statement/,
59
+ );
60
+ assert.throws(
61
+ () => resolveThemePresentation({ sectionSurfaces: ['base', 'glowing'] }, 'test/theme.yaml'),
62
+ /Unknown section surface "glowing" in test\/theme\.yaml.*base, soft, emphasis/,
63
+ );
64
+ assert.throws(
65
+ () => resolveThemePresentation({ sectionSurfaces: ['base', 'base'] }, 'test/theme.yaml'),
66
+ /Each section surface may appear only once in test\/theme\.yaml/,
51
67
  );
52
68
 
69
+ const listResult = runCli(['theme:presets']);
70
+ assert.equal(listResult.status, 0, listResult.stderr || listResult.stdout);
71
+ for (const presetName of themePresetNames) {
72
+ const metadata = getThemePresetMetadata(presetName);
73
+ assert.ok(listResult.stdout.includes(`${presetName}\n ${metadata.description}`));
74
+ }
75
+ const invalidListResult = runCli(['theme:presets', 'documentation']);
76
+ assert.notEqual(invalidListResult.status, 0);
77
+ assert.match(invalidListResult.stderr, /Usage: norna theme:presets/);
78
+
79
+ const themeSchema = JSON.parse(await readFile(path.join(repoRoot, 'schemas', 'theme.schema.json'), 'utf8'));
80
+ assert.deepEqual(
81
+ themeSchema.properties.preset.oneOf.map((entry) => entry.const),
82
+ themePresetNames,
83
+ );
84
+ for (const entry of themeSchema.properties.preset.oneOf) {
85
+ const metadata = getThemePresetMetadata(entry.const);
86
+ assert.equal(entry.title, metadata.title);
87
+ assert.equal(entry.description, metadata.description);
88
+ }
89
+
53
90
  await mkdir(path.join(siteDir, 'routes', '010-guide'), { recursive: true });
54
- await writeFile(path.join(siteDir, 'config.md'), `---
55
- url: https://example.com/
56
- ---
57
- `);
91
+ await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\n');
58
92
  await writeFile(path.join(siteDir, 'content.md'), `---
59
93
  title: Theme preset test
60
94
  description: Root page
@@ -64,15 +98,12 @@ description: Root page
64
98
 
65
99
  Root content.
66
100
  `);
67
- await writeFile(path.join(siteDir, 'theme.md'), `---
68
- preset: documentation
101
+ await writeFile(path.join(siteDir, 'theme.yaml'), `preset: documentation
69
102
  layout:
70
103
  pageWidth: 1300px
71
- presentation:
72
- palette: dark
73
- ---
104
+ palette: dark
74
105
  `);
75
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'route-content.md'), `---
106
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'content.md'), `---
76
107
  title: Guide
77
108
  description: Route page
78
109
  navigation:
@@ -83,13 +114,10 @@ navigation:
83
114
 
84
115
  Route content.
85
116
  `);
86
- await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.md'), `---
87
- preset: portfolio
117
+ await writeFile(path.join(siteDir, 'routes', '010-guide', 'theme.yaml'), `preset: portfolio
88
118
  layout:
89
119
  pageWidth: 1010px
90
- presentation:
91
- palette: light
92
- ---
120
+ palette: light
93
121
  `);
94
122
 
95
123
  const buildResult = runCli(['build']);
@@ -105,31 +133,30 @@ presentation:
105
133
 
106
134
  const typographyResult = runCli(['typography', 'show']);
107
135
  assert.equal(typographyResult.status, 0, typographyResult.stderr || typographyResult.stdout);
108
- assert.match(typographyResult.stdout, /value: text-forward/);
109
- assert.match(typographyResult.stdout, /value: quiet-gallery/);
136
+ assert.match(typographyResult.stdout, /value: reading/);
137
+ assert.match(typographyResult.stdout, /value: restrained/);
110
138
 
111
- const routeThemePath = path.join(siteDir, 'routes', '010-guide', 'theme.md');
139
+ const routeThemePath = path.join(siteDir, 'routes', '010-guide', 'theme.yaml');
112
140
  const routeThemeSource = await readFile(routeThemePath, 'utf8');
113
- await writeFile(routeThemePath, `---\npreset: unknown\n---\n`);
141
+ await writeFile(routeThemePath, 'preset: unknown\n');
114
142
  const invalidRoutePresetResult = runCli(['config:check']);
115
143
  assert.notEqual(invalidRoutePresetResult.status, 0);
116
- assert.match(invalidRoutePresetResult.stderr, /Unknown theme preset "unknown" in .*routes\/010-guide\/theme\.md/);
144
+ assert.match(invalidRoutePresetResult.stderr, /Unknown theme preset "unknown" in .*routes\/010-guide\/theme\.yaml/);
117
145
  await writeFile(routeThemePath, routeThemeSource);
118
146
 
119
147
  const exportResult = runCli(['theme:export', 'documentation']);
120
148
  assert.equal(exportResult.status, 0, exportResult.stderr || exportResult.stdout);
121
- assert.match(exportResult.stdout, /orig-documentation-theme\.md/);
122
- const exportedPath = path.join(siteDir, 'orig-documentation-theme.md');
149
+ assert.match(exportResult.stdout, /orig-documentation-theme\.yaml/);
150
+ const exportedPath = path.join(siteDir, 'orig-documentation-theme.yaml');
123
151
  const exportedSource = await readFile(exportedPath, 'utf8');
124
- assert.match(exportedSource, /This is a reference file\. Norna only loads theme\.md\./);
152
+ assert.match(exportedSource, /This is a reference file\. Norna only loads theme\.yaml\./);
125
153
  assert.match(exportedSource, /Available theme presets: portfolio, documentation, project, statement\./);
126
154
  assert.match(exportedSource, /# Alternatives: dark, light, paper\./);
127
- const { frontmatterBody } = splitSiteFile(exportedSource, 'exported theme reference');
128
- const exportedConfig = parseYamlMapping(frontmatterBody);
155
+ const exportedConfig = load(exportedSource);
129
156
  assert.equal(exportedConfig.preset, 'documentation');
130
157
  assert.equal(exportedConfig.layout.pageWidth, themePresets.documentation.layout.pageWidth);
131
158
  assert.equal(exportedConfig.typography.fontFamily, themePresets.documentation.typography.fontFamily);
132
- assert.deepEqual(exportedConfig.presentation.sectionSurfaces.sequence, ['base', 'soft']);
159
+ assert.deepEqual(exportedConfig.sectionSurfaces, ['base', 'soft']);
133
160
 
134
161
  const exportAgainResult = runCli(['theme:export', 'documentation']);
135
162
  assert.notEqual(exportAgainResult.status, 0);
@@ -18,19 +18,16 @@ type CardListWidth = 'text' | 'narrow' | 'normal' | 'wide';
18
18
 
19
19
  interface Props {
20
20
  cards: Card[];
21
- maxAvailableWidthPercent: {
22
- desktop: number;
23
- };
24
21
  layout?: CardListLayout;
25
22
  flow?: CardListFlow;
26
23
  size?: CardListSize;
27
24
  width?: CardListWidth;
28
25
  }
29
26
 
30
- const { cards, maxAvailableWidthPercent, layout = 'image-top', flow = 'grid', size = 'm', width = 'normal' } = Astro.props;
27
+ const { cards, layout = 'image-top', flow = 'grid', size = 'm', width = 'normal' } = Astro.props;
31
28
  const cardImageSizes = [
32
29
  '(max-width: 700px) 100vw',
33
- `min(20rem, ${maxAvailableWidthPercent.desktop}vw)`,
30
+ 'min(20rem, 100vw)',
34
31
  ].join(', ');
35
32
  const displayLink = (link: string) => withBasePath(projectConfig.site.basePath, link);
36
33
  const cardListClasses = [
@@ -4,15 +4,15 @@ import { getImageAttributes } from '../lib/generatedImages';
4
4
  import {
5
5
  getCaptionId,
6
6
  getCarouselAspectRatio,
7
- getGalleryImageSizes,
7
+ getManagedImageSizes,
8
8
  getImageClass,
9
9
  getImageLoadingProps,
10
- type GalleryImage,
11
- } from '../lib/galleryImages';
10
+ type ManagedImage,
11
+ } from '../lib/imagePresentation';
12
12
 
13
13
  interface Props {
14
14
  title: string;
15
- images: GalleryImage[];
15
+ images: ManagedImage[];
16
16
  maxAvailableWidthPercent: {
17
17
  desktop: number;
18
18
  mobile: number;
@@ -21,16 +21,16 @@ interface Props {
21
21
  }
22
22
 
23
23
  const { title, images, maxAvailableWidthPercent, priorityFirstImage = false } = Astro.props;
24
- const galleryImageSizes = getGalleryImageSizes(maxAvailableWidthPercent);
24
+ const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
25
25
  const carouselAspectRatio = getCarouselAspectRatio(images);
26
26
  const carouselStyle = carouselAspectRatio
27
- ? `--gallery-carousel-aspect-ratio: ${carouselAspectRatio}`
27
+ ? `--image-carousel-aspect-ratio: ${carouselAspectRatio}`
28
28
  : undefined;
29
29
  ---
30
30
 
31
- <div class="gallery" aria-label={`${projectConfig.locale.labels.gallery}: ${title}`}>
31
+ <div class="managed-images" aria-label={`${projectConfig.locale.labels.images}: ${title}`}>
32
32
  <section
33
- class="gallery-item gallery-carousel"
33
+ class="managed-image-item image-carousel"
34
34
  data-carousel
35
35
  tabindex="0"
36
36
  role="group"
@@ -38,18 +38,18 @@ const carouselStyle = carouselAspectRatio
38
38
  aria-label={`${title}, bildkarusell`}
39
39
  style={carouselStyle}
40
40
  >
41
- <div class="gallery-carousel-stage">
42
- <div class="gallery-carousel-viewport" data-carousel-viewport>
43
- <div class="gallery-carousel-slides">
41
+ <div class="image-carousel-stage">
42
+ <div class="image-carousel-viewport" data-carousel-viewport>
43
+ <div class="image-carousel-slides">
44
44
  {images.map((image, imageIndex) => {
45
45
  const captionId = image.caption ? getCaptionId(image, 0, imageIndex) : undefined;
46
46
 
47
47
  return (
48
- <figure class="gallery-carousel-slide" data-carousel-slide>
49
- <div class="gallery-carousel-image-frame">
48
+ <figure class="image-carousel-slide" data-carousel-slide>
49
+ <div class="image-carousel-frame">
50
50
  <img
51
- {...getImageAttributes(image.src ?? image.image, galleryImageSizes)}
52
- class:list={['gallery-image', getImageClass(image)]}
51
+ {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
52
+ class:list={['managed-image', getImageClass(image)]}
53
53
  alt={image.alt ?? ''}
54
54
  aria-describedby={captionId}
55
55
  {...getImageLoadingProps(priorityFirstImage && imageIndex === 0)}
@@ -61,7 +61,7 @@ const carouselStyle = carouselAspectRatio
61
61
  </div>
62
62
  </div>
63
63
  <button
64
- class="gallery-carousel-button gallery-carousel-button-previous"
64
+ class="image-carousel-button image-carousel-button-previous"
65
65
  type="button"
66
66
  data-carousel-previous
67
67
  aria-label="Föregående bild"
@@ -70,7 +70,7 @@ const carouselStyle = carouselAspectRatio
70
70
  <span aria-hidden="true">‹</span>
71
71
  </button>
72
72
  <button
73
- class="gallery-carousel-button gallery-carousel-button-next"
73
+ class="image-carousel-button image-carousel-button-next"
74
74
  type="button"
75
75
  data-carousel-next
76
76
  aria-label="Nästa bild"
@@ -78,20 +78,20 @@ const carouselStyle = carouselAspectRatio
78
78
  >
79
79
  <span aria-hidden="true">›</span>
80
80
  </button>
81
- <output class="gallery-carousel-position" data-carousel-position aria-live="polite">
81
+ <output class="image-carousel-position" data-carousel-position aria-live="polite">
82
82
  1 / {images.length}
83
83
  </output>
84
84
  </div>
85
- <div class="gallery-carousel-captions" data-carousel-captions>
85
+ <div class="image-carousel-captions" data-carousel-captions>
86
86
  {images.map((image, imageIndex) => (
87
87
  image.caption && (
88
88
  <p
89
- class="gallery-meta gallery-carousel-caption"
89
+ class="image-meta image-carousel-caption"
90
90
  id={getCaptionId(image, 0, imageIndex)}
91
91
  data-carousel-caption
92
92
  data-carousel-caption-index={imageIndex}
93
93
  >
94
- <span class="gallery-details">
94
+ <span class="image-details">
95
95
  <span>{image.caption}</span>
96
96
  </span>
97
97
  </p>
@@ -3,15 +3,15 @@ import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { getImageAttributes } from '../lib/generatedImages';
4
4
  import {
5
5
  getCaptionId,
6
- getGalleryImageSizes,
6
+ getManagedImageSizes,
7
7
  getImageClass,
8
8
  getImageLoadingProps,
9
- type GalleryImage,
10
- } from '../lib/galleryImages';
9
+ type ManagedImage,
10
+ } from '../lib/imagePresentation';
11
11
 
12
12
  interface Props {
13
13
  title: string;
14
- images: GalleryImage[];
14
+ images: ManagedImage[];
15
15
  maxAvailableWidthPercent: {
16
16
  desktop: number;
17
17
  mobile: number;
@@ -20,28 +20,28 @@ interface Props {
20
20
  }
21
21
 
22
22
  const { title, images, maxAvailableWidthPercent, priorityFirstImage = false } = Astro.props;
23
- const galleryImageSizes = getGalleryImageSizes(maxAvailableWidthPercent);
23
+ const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
24
24
  ---
25
25
 
26
- <div class="gallery" aria-label={`${projectConfig.locale.labels.gallery}: ${title}`}>
26
+ <div class="image-stack" aria-label={`${projectConfig.locale.labels.images}: ${title}`}>
27
27
  {images.map((image, itemIndex) => {
28
28
  const isPriorityItem = priorityFirstImage && itemIndex === 0;
29
29
  const captionId = image.caption ? getCaptionId(image, itemIndex) : undefined;
30
30
 
31
31
  return (
32
- <figure class="gallery-item">
33
- <div class="gallery-image-frame">
32
+ <figure class="managed-image-item">
33
+ <div class="managed-image-frame">
34
34
  <img
35
- {...getImageAttributes(image.src ?? image.image, galleryImageSizes)}
36
- class:list={['gallery-image', getImageClass(image)]}
35
+ {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
36
+ class:list={['managed-image', getImageClass(image)]}
37
37
  alt={image.alt ?? ''}
38
38
  aria-describedby={captionId}
39
39
  {...getImageLoadingProps(isPriorityItem)}
40
40
  />
41
41
  </div>
42
42
  {image.caption && (
43
- <figcaption class="gallery-meta" id={captionId}>
44
- <div class="gallery-details">
43
+ <figcaption class="image-meta" id={captionId}>
44
+ <div class="image-details">
45
45
  <span>{image.caption}</span>
46
46
  </div>
47
47
  </figcaption>
@@ -0,0 +1,28 @@
1
+ <script>
2
+ const siteTop = document.querySelector<HTMLElement>('.site-top');
3
+ const mobileMenu = document.querySelector<HTMLDetailsElement>('.mobile-nav-menu');
4
+ const mobileSectionLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>('.mobile-page-nav a'));
5
+
6
+ const setAnchorOffset = () => {
7
+ if (!siteTop) return;
8
+
9
+ const offset = Math.ceil(siteTop.getBoundingClientRect().height);
10
+ document.documentElement.style.setProperty('--site-top-anchor-offset', `${offset}px`);
11
+ };
12
+
13
+ mobileSectionLinks.forEach((link) => {
14
+ link.addEventListener('click', (event) => {
15
+ const linkUrl = new URL(link.href, window.location.href);
16
+ if (linkUrl.pathname !== window.location.pathname) return;
17
+
18
+ if (mobileMenu) mobileMenu.open = false;
19
+ if (event.detail > 0) link.blur();
20
+ });
21
+ });
22
+
23
+ setAnchorOffset();
24
+
25
+ if (siteTop && 'ResizeObserver' in window) {
26
+ new ResizeObserver(setAnchorOffset).observe(siteTop);
27
+ }
28
+ </script>
@@ -3,7 +3,7 @@ import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { withBasePath } from '../lib/basePath';
4
4
  import type { SectionNavigation } from '../lib/sectionContent';
5
5
  import type { SitePage } from '../lib/sitePages';
6
- import MobileSectionNavigationScript from './MobileSectionNavigationScript.astro';
6
+ import SectionNavigationScript from './SectionNavigationScript.astro';
7
7
 
8
8
  type FrameColors = {
9
9
  backgroundColor: string;
@@ -11,7 +11,7 @@ type FrameColors = {
11
11
  };
12
12
 
13
13
  interface Props {
14
- brandLabel: string;
14
+ navigationLabel: string;
15
15
  logo?: {
16
16
  src: string;
17
17
  alt: string;
@@ -25,7 +25,7 @@ interface Props {
25
25
  frameColors: FrameColors;
26
26
  }
27
27
 
28
- const { brandLabel, logo, currentPage, pageNavigation, frameColors } = Astro.props;
28
+ const { navigationLabel, logo, currentPage, pageNavigation, frameColors } = Astro.props;
29
29
  const siteTopBackgroundColor = frameColors.backgroundColor === 'transparent'
30
30
  ? 'var(--color-page)'
31
31
  : frameColors.backgroundColor;
@@ -48,6 +48,7 @@ const showSinglePageSectionNavigation = Boolean(
48
48
  const hasCurrentPageSectionMenu = Boolean(
49
49
  showSiteNavigation && currentPageNavigation && hasSectionMenu(currentPageNavigation.sections),
50
50
  );
51
+ const hasSectionNavigation = showSinglePageSectionNavigation || hasCurrentPageSectionMenu;
51
52
  ---
52
53
 
53
54
  <a class="skip-link" href="#main-content">{projectConfig.locale.labels.skipToContent}</a>
@@ -59,7 +60,7 @@ const hasCurrentPageSectionMenu = Boolean(
59
60
  {showNavigationRow && (
60
61
  <div class="site-nav-row">
61
62
  <a class="site-brand" href={pageHref(homePage)}>
62
- {logo ? <img class="site-brand-logo" src={withBasePath(projectConfig.site.basePath, logo.src)} alt={logo.alt} /> : brandLabel}
63
+ {logo ? <img class="site-brand-logo" src={withBasePath(projectConfig.site.basePath, logo.src)} alt={logo.alt} /> : navigationLabel}
63
64
  </a>
64
65
 
65
66
  {showSiteNavigation && (
@@ -140,4 +141,4 @@ const hasCurrentPageSectionMenu = Boolean(
140
141
 
141
142
  </header>
142
143
 
143
- {hasCurrentPageSectionMenu && <MobileSectionNavigationScript />}
144
+ {hasSectionNavigation && <SectionNavigationScript />}
@@ -27,15 +27,15 @@ const siteDirectory = (process.env.NORNA_SITE_DIR ?? 'site').trim();
27
27
  const sitePages = getSitePages(await getCollection('site'));
28
28
  const currentPage = getSitePage(entry);
29
29
  const siteContentLabel = entry.id.includes('-route-')
30
- ? `${siteDirectory || 'site'}/routes/${currentPage.routeDirectory}/route-content.md`
30
+ ? `${siteDirectory || 'site'}/routes/${currentPage.routeDirectory}/content.md`
31
31
  : `${siteDirectory || 'site'}/content.md`;
32
32
  const pageContentPath = currentPage.routeDirectory
33
- ? path.join(siteRoutesDir, currentPage.routeDirectory, 'route-content.md')
33
+ ? path.join(siteRoutesDir, currentPage.routeDirectory, 'content.md')
34
34
  : siteContentPath;
35
35
  const rawMarkdown = (await readSiteFile(pageContentPath, siteContentLabel)).body;
36
36
  const [siteTheme] = await getCollection('theme');
37
37
  if (!siteTheme) {
38
- throw new Error('site/theme.md is required. Add a root theme before building the site.');
38
+ throw new Error('site/theme.yaml is required. Add a root theme before building the site.');
39
39
  }
40
40
  const [sitewide] = await getCollection('sitewide');
41
41
  const sitewideData = sitewide?.data ?? {};
@@ -46,15 +46,15 @@ const routeTheme = await getRouteTheme(currentPage.routeDirectory);
46
46
  const themeData = routeTheme?.data ?? siteThemeData;
47
47
  const themeLabel = routeTheme
48
48
  ? `${siteDirectory || 'site'}/${routeTheme.id}`
49
- : `${siteDirectory || 'site'}/theme.md`;
49
+ : `${siteDirectory || 'site'}/${siteTheme.id}`;
50
50
  const visualTheme = resolveThemeVisualConfig(themeData, themeLabel);
51
- const brandLabel = sitewideData.navigation?.brand ?? homePage.title;
51
+ const navigationLabel = sitewideData.navigation?.label ?? homePage.title;
52
52
  const logoAsset = getLogoAsset();
53
53
  const logoSettings = sitewideData.navigation?.logo;
54
54
  const logo = logoAsset
55
55
  ? {
56
56
  src: logoAsset.href,
57
- alt: logoSettings?.alt ?? brandLabel,
57
+ alt: navigationLabel,
58
58
  height: logoSettings?.height,
59
59
  }
60
60
  : undefined;
@@ -98,7 +98,7 @@ if (visibleSections.length === 0) {
98
98
  footer={sitewideData.footer}
99
99
  >
100
100
  <SiteNavigation
101
- brandLabel={brandLabel}
101
+ navigationLabel={navigationLabel}
102
102
  logo={logo}
103
103
  currentPage={currentPage}
104
104
  pageNavigation={pageNavigation}
@@ -114,7 +114,7 @@ if (visibleSections.length === 0) {
114
114
  visualTheme={visualTheme}
115
115
  sectionIndex={index}
116
116
  headingLevel={index === 0 ? 1 : 2}
117
- priorityGalleryImage={index === 0}
117
+ prioritizeFirstImage={index === 0}
118
118
  />
119
119
  ))}
120
120
  </main>
@@ -2,8 +2,8 @@
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import { resolveSectionSurface } from '../../scripts/lib/presentation.mjs';
4
4
  import CardList from './CardList.astro';
5
- import GalleryGrid from './GalleryGrid.astro';
6
5
  import ImageCarousel from './ImageCarousel.astro';
6
+ import ImageStack from './ImageStack.astro';
7
7
  import type { ResolvedSection } from '../lib/sectionContent';
8
8
 
9
9
  type ResponsiveValue<T> = { desktop: T; mobile: T };
@@ -15,17 +15,14 @@ type ResolvedPagePresentation = {
15
15
  palette: {
16
16
  surfaces: Record<'base' | 'soft' | 'emphasis', { backgroundColor: string; textColor: string }>;
17
17
  };
18
- sectionSurfaces: {
19
- mode: 'none' | 'cycle';
20
- sequence: Array<'base' | 'soft' | 'emphasis'>;
21
- };
18
+ sectionSurfaces: Array<'base' | 'soft' | 'emphasis'>;
22
19
  typography: {
23
- preset: string;
20
+ profile: string;
24
21
  values: any;
25
22
  };
26
23
  };
27
24
  type VisualTheme = {
28
- gallery: {
25
+ images: {
29
26
  maxAvailableWidthPercent: {
30
27
  desktop: number;
31
28
  mobile: number;
@@ -39,10 +36,10 @@ interface Props {
39
36
  visualTheme: VisualTheme;
40
37
  sectionIndex: number;
41
38
  headingLevel: 1 | 2;
42
- priorityGalleryImage?: boolean;
39
+ prioritizeFirstImage?: boolean;
43
40
  }
44
41
 
45
- const { section, pagePresentation, visualTheme, sectionIndex, headingLevel, priorityGalleryImage = false } = Astro.props;
42
+ const { section, pagePresentation, visualTheme, sectionIndex, headingLevel, prioritizeFirstImage = false } = Astro.props;
46
43
  const HeadingTag = headingLevel === 1 ? 'h1' : 'h2';
47
44
  // Sections are authored with Markdown ## headings even when the first one renders as HTML h1.
48
45
  const sectionHeadingVisualLevel = 'h2';
@@ -169,7 +166,7 @@ const getHeadingSizeValue = (level: HeadingLevel, size: TextSize) => (
169
166
  const textWidthValues: Record<TextWidth, string> = {
170
167
  narrow: 'min(60ch, var(--text-width))',
171
168
  normal: 'min(72ch, var(--text-width))',
172
- wide: 'min(72ch, var(--gallery-layout-width))',
169
+ wide: 'min(72ch, var(--image-layout-width))',
173
170
  };
174
171
  const surface = resolveSectionSurface(pagePresentation, sectionIndex);
175
172
  const typography = pagePresentation.typography.values;
@@ -213,7 +210,7 @@ const bodyWidth = {
213
210
  desktop: bodyWidthValue,
214
211
  mobile: bodyWidthValue,
215
212
  };
216
- const isMediaBlock = (block: ResolvedSection['contentBlocks'][number]) => (
213
+ const isStructuredBlock = (block: ResolvedSection['contentBlocks'][number]) => (
217
214
  block.type === 'image-stack'
218
215
  || block.type === 'image-carousel'
219
216
  || block.type === 'card-list'
@@ -243,7 +240,7 @@ const flushReadingBlocks = () => {
243
240
  };
244
241
 
245
242
  for (const block of section.contentBlocks) {
246
- if (isMediaBlock(block)) {
243
+ if (isStructuredBlock(block)) {
247
244
  flushReadingBlocks();
248
245
  contentGroups.push({ type: 'media', block });
249
246
  } else {
@@ -301,7 +298,7 @@ const sectionStyle = [
301
298
  ---
302
299
 
303
300
  <section
304
- id={section.id}
301
+ aria-labelledby={section.id}
305
302
  class:list={[
306
303
  'site-section',
307
304
  {
@@ -314,10 +311,10 @@ const sectionStyle = [
314
311
  >
315
312
  <div class="section-card">
316
313
  <header class="section-header">
317
- <HeadingTag set:html={section.titleHtml} />
314
+ <HeadingTag id={section.id} set:html={section.titleHtml} />
318
315
  </header>
319
316
 
320
- <div class:list={['section-body', { 'section-body-has-gallery': hasMedia, 'section-body-text-only': !hasMedia }]}>
317
+ <div class:list={['section-body', { 'section-body-has-media': hasMedia, 'section-body-text-only': !hasMedia }]}>
321
318
  {contentGroups.map((group, groupIndex) => {
322
319
  if (group.type === 'reading') {
323
320
  if (hasMarginNotes && group.blocks.some((block) => block.type === 'note')) {
@@ -383,22 +380,22 @@ const sectionStyle = [
383
380
  <ImageCarousel
384
381
  title={section.title}
385
382
  images={block.images}
386
- maxAvailableWidthPercent={visualTheme.gallery.maxAvailableWidthPercent}
387
- priorityFirstImage={priorityGalleryImage && groupIndex === 0}
383
+ maxAvailableWidthPercent={visualTheme.images.maxAvailableWidthPercent}
384
+ priorityFirstImage={prioritizeFirstImage && groupIndex === 0}
388
385
  />
389
386
  );
390
387
  }
391
388
 
392
389
  if (block.type === 'card-list') {
393
- return <CardList cards={block.cards} maxAvailableWidthPercent={visualTheme.gallery.maxAvailableWidthPercent} layout={block.layout} flow={block.flow} size={block.size} width={block.width} />;
390
+ return <CardList cards={block.cards} layout={block.layout} flow={block.flow} size={block.size} width={block.width} />;
394
391
  }
395
392
 
396
393
  return (
397
- <GalleryGrid
394
+ <ImageStack
398
395
  title={section.title}
399
396
  images={block.images}
400
- maxAvailableWidthPercent={visualTheme.gallery.maxAvailableWidthPercent}
401
- priorityFirstImage={priorityGalleryImage && groupIndex === 0}
397
+ maxAvailableWidthPercent={visualTheme.images.maxAvailableWidthPercent}
398
+ priorityFirstImage={prioritizeFirstImage && groupIndex === 0}
402
399
  />
403
400
  );
404
401
  })}