@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
@@ -1,228 +1,36 @@
1
1
  import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
+ import { basename } from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
4
- import { z } from 'astro/zod';
5
- import { siteDir, siteDirLabel } from '../scripts/lib/site-paths.mjs';
5
+ import {
6
+ siteDir,
7
+ siteDirLabel,
8
+ siteThemePath,
9
+ sitewideContentPath,
10
+ } from '../scripts/lib/site-paths.mjs';
6
11
  import { parseRouteDirectory } from '../scripts/lib/route-model.mjs';
7
- import { isDateOnly } from './lib/visibility';
12
+ import {
13
+ siteSchema,
14
+ sitewideSchema,
15
+ themeVisualSchema,
16
+ } from '../scripts/lib/schema-definitions.mjs';
8
17
 
9
18
  const siteEntryId = `${siteDirLabel
10
19
  .replace(/^[./\\]+/, '')
11
20
  .replace(/[^a-zA-Z0-9-]+/g, '-')
12
21
  .replace(/^-+|-+$/g, '') || 'site'}-content`;
13
- const textAlign = z.enum(['left', 'center', 'right']);
14
- const textSize = z.enum(['small', 'medium', 'large', 'xlarge']);
15
- const textWidth = z.enum(['narrow', 'normal', 'wide']);
16
- const headingWeight = z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]);
17
- const typographyPreset = z.enum(['quiet-gallery', 'compact-gallery', 'text-forward', 'statement']);
18
- const themePreset = z.enum(['portfolio', 'documentation', 'project', 'statement']);
19
- const presentationPalette = z.enum(['dark', 'light', 'paper']);
20
- const sectionSurfaceMode = z.enum(['none', 'cycle']);
21
- const sectionSurface = z.enum(['base', 'soft', 'emphasis']);
22
- const spacingDensity = z.enum(['compact', 'normal', 'airy']);
23
- const lineHeight = z.number()
24
- .min(1, 'Use a unitless line height of at least 1.')
25
- .max(3, 'Use a unitless line height of at most 3.');
26
- const cssLength = z.string().regex(
27
- /^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|lh))$/,
28
- 'Use a CSS length such as "0", "0.8em", "1rem", or "12px".',
29
- );
30
- const visualCssLengthUnit = String.raw`(?:px|rem|em|vw|vh|vmin|vmax|ch|%)`;
31
- const visualCssLengthValue = String.raw`(?:\d+(?:\.\d+)?|\.\d+)${visualCssLengthUnit}`;
32
- const visualCssLength = z.string().regex(
33
- new RegExp(`^(?:0|${visualCssLengthValue}|clamp\\(\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*\\))$`),
34
- 'Use a CSS length such as "0", "900px", "56rem", "90%", or "clamp(1rem, 4vw, 3rem)".',
35
- );
36
- const dateOnly = z.string()
37
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format.')
38
- .refine(isDateOnly, 'Use a real calendar date.');
39
- const visibilityWindow = z.object({
40
- from: dateOnly.optional(),
41
- until: dateOnly.optional(),
42
- }).strict().refine(
43
- (value) => value.from !== undefined || value.until !== undefined,
44
- 'Specify from, until, or both.',
45
- ).refine(
46
- (value) => value.from === undefined || value.until === undefined || value.from < value.until,
47
- 'visible.until must be later than visible.from.',
48
- );
49
- const overrideResponsiveTextAlign = z.object({
50
- desktop: textAlign.optional(),
51
- mobile: textAlign.optional(),
52
- }).strict().refine(
53
- (value) => value.desktop !== undefined || value.mobile !== undefined,
54
- 'Specify desktop, mobile, or both.',
55
- );
56
- const commonTextPresentationOverride = {
57
- align: overrideResponsiveTextAlign.optional(),
58
- size: textSize.optional(),
59
- lineHeight: lineHeight.optional(),
60
- };
61
- const headingPresentationOverride = z.object({
62
- ...commonTextPresentationOverride,
63
- weight: headingWeight.optional(),
64
- spacingBefore: cssLength.optional(),
65
- spacingAfter: cssLength.optional(),
66
- }).strict();
67
- const headingLevelsPresentationOverride = z.object({
68
- h1: headingPresentationOverride.optional(),
69
- h2: headingPresentationOverride.optional(),
70
- h3: headingPresentationOverride.optional(),
71
- h4: headingPresentationOverride.optional(),
72
- }).strict();
73
- const bodyPresentationOverride = z.object({
74
- ...commonTextPresentationOverride,
75
- width: textWidth.optional(),
76
- paragraphSpacing: cssLength.optional(),
77
- }).strict();
78
- const captionPresentationOverride = z.object({
79
- ...commonTextPresentationOverride,
80
- spacingBefore: cssLength.optional(),
81
- }).strict();
82
- const typographyOverrides = z.object({
83
- headings: headingLevelsPresentationOverride.optional(),
84
- body: bodyPresentationOverride.optional(),
85
- caption: captionPresentationOverride.optional(),
86
- }).strict();
87
- const themeTypography = z.object({
88
- fontFamily: z.string()
89
- .min(1)
90
- .refine((value) => !/[\n\r;{}]/.test(value), 'Do not use semicolons, braces, or line breaks.').optional(),
91
- preset: typographyPreset.optional(),
92
- rhythm: spacingDensity.optional(),
93
- overrides: typographyOverrides.optional(),
94
- }).strict().refine(
95
- (value) => value.fontFamily !== undefined || value.preset !== undefined || value.rhythm !== undefined || value.overrides !== undefined,
96
- 'Specify fontFamily, preset, rhythm, overrides, or both.',
97
- );
98
- const responsiveCssLength = z.union([
99
- visualCssLength,
100
- z.object({
101
- desktop: visualCssLength,
102
- mobile: visualCssLength,
103
- }).strict(),
104
- ]);
105
- const responsivePercent = z.union([
106
- z.number().positive().max(100),
107
- z.object({
108
- desktop: z.number().positive().max(100),
109
- mobile: z.number().positive().max(100),
110
- }).strict(),
111
- ]);
112
- const themeLayoutSpacing = z.object({
113
- blockGap: responsiveCssLength.optional(),
114
- finalSectionBottom: responsiveCssLength.optional(),
115
- firstSectionTop: responsiveCssLength.optional(),
116
- headingToBlock: responsiveCssLength.optional(),
117
- imageGap: responsiveCssLength.optional(),
118
- sectionGap: responsiveCssLength.optional(),
119
- }).strict();
120
- const themeLayout = z.object({
121
- density: spacingDensity.optional(),
122
- pageWidth: visualCssLength.optional(),
123
- gutter: responsiveCssLength.optional(),
124
- spacing: themeLayoutSpacing.optional(),
125
- }).strict();
126
- const themeGallery = z.object({
127
- width: visualCssLength.optional(),
128
- maxAvailableWidthPercent: responsivePercent.optional(),
129
- maxAvailableHeightPercent: responsivePercent.optional(),
130
- }).strict();
131
- const themePresentation = z.object({
132
- palette: presentationPalette.optional(),
133
- sectionSurfaces: z.object({
134
- mode: sectionSurfaceMode,
135
- sequence: z.array(sectionSurface).min(1).max(3).optional(),
136
- }).strict().refine(
137
- (value) => !value.sequence || new Set(value.sequence).size === value.sequence.length,
138
- 'Each section surface may appear only once in a sequence.',
139
- ).optional(),
140
- }).strict();
141
- const pageNavigation = z.object({
142
- include: z.boolean().optional(),
143
- label: z.string().optional(),
144
- }).strict();
145
- const themeNavigation = z.object({
146
- brand: z.string().min(1).optional(),
147
- logo: z.object({
148
- alt: z.string().min(1).optional(),
149
- height: visualCssLength.optional(),
150
- }).strict().optional(),
151
- }).strict();
152
-
153
- const sectionMetadata = z.object({
154
- visible: visibilityWindow.optional(),
155
- }).strict();
156
- const banner = z.object({
157
- id: z.string().regex(/^[a-z0-9-]+$/),
158
- tone: z.enum(['warning']).default('warning'),
159
- visible: visibilityWindow.optional(),
160
- title: z.string().min(1),
161
- text: z.string().min(1),
162
- }).strict();
163
- const banners = z.array(banner).optional().default([]).refine(
164
- (values) => new Set(values.map((value) => value.id)).size === values.length,
165
- 'Banner ids must be unique.',
166
- );
167
- const dateTimeFormat = z.object({
168
- locale: z.string().min(1),
169
- timeZone: z.string().min(1),
170
- dateStyle: z.string().min(1),
171
- timeStyle: z.string().min(1),
172
- }).strict().refine((value) => {
173
- try {
174
- new Intl.DateTimeFormat(value.locale, {
175
- dateStyle: value.dateStyle as Intl.DateTimeFormatOptions['dateStyle'],
176
- timeStyle: value.timeStyle as Intl.DateTimeFormatOptions['timeStyle'],
177
- timeZone: value.timeZone,
178
- });
179
- return true;
180
- } catch {
181
- return false;
182
- }
183
- }, 'Use a valid Intl.DateTimeFormat configuration.');
184
- const sitewideFooter = z.object({
185
- copyrightMessage: z.string().min(1).optional(),
186
- buildInfo: z.object({
187
- enabled: z.boolean().default(true),
188
- text: z.string().min(1),
189
- dateTimeFormat,
190
- }).strict().optional(),
191
- }).strict();
192
-
193
- const siteSchema = z.object({
194
- title: z.string(),
195
- description: z.string(),
196
- navigation: pageNavigation.optional(),
197
- sections: z.record(z.string().regex(/^[a-z0-9-]+$/), sectionMetadata).optional().default({}),
198
- });
199
-
200
- export const themeVisualSchema = z.object({
201
- preset: themePreset.optional(),
202
- layout: themeLayout.optional(),
203
- gallery: themeGallery.optional(),
204
- typography: themeTypography.optional(),
205
- presentation: themePresentation.optional(),
206
- }).strict();
207
-
208
22
  const siteThemeSchema = themeVisualSchema;
209
23
 
210
- const sitewideSchema = z.object({
211
- navigation: themeNavigation.optional(),
212
- banners,
213
- footer: sitewideFooter.optional(),
214
- }).strict();
215
-
216
24
  const site = defineCollection({
217
25
  loader: glob({
218
- pattern: ['content.md', 'routes/*/route-content.md'],
26
+ pattern: ['content.md', 'routes/*/content.md'],
219
27
  base: pathToFileURL(siteDir),
220
28
  generateId: ({ entry }) => {
221
29
  if (entry === 'content.md') {
222
30
  return siteEntryId;
223
31
  }
224
32
 
225
- const routeDirectory = entry.match(/^routes\/([^/]+)\/route-content\.md$/)?.[1];
33
+ const routeDirectory = entry.match(/^routes\/([^/]+)\/content\.md$/)?.[1];
226
34
  return routeDirectory
227
35
  ? `${siteEntryId.replace(/-content$/, '')}-route-${parseRouteDirectory(routeDirectory, `route directory routes/${routeDirectory}`).routeDirectory}`
228
36
  : entry.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
@@ -233,16 +41,16 @@ const site = defineCollection({
233
41
 
234
42
  const theme = defineCollection({
235
43
  loader: glob({
236
- pattern: 'theme.md',
44
+ pattern: basename(siteThemePath),
237
45
  base: pathToFileURL(siteDir),
238
- generateId: ({ entry }) => entry,
46
+ generateId: () => `${siteEntryId.replace(/-content$/, '')}-theme`,
239
47
  }),
240
48
  schema: siteThemeSchema,
241
49
  });
242
50
 
243
51
  const sitewide = defineCollection({
244
52
  loader: glob({
245
- pattern: 'sitewide-content.md',
53
+ pattern: basename(sitewideContentPath),
246
54
  base: pathToFileURL(siteDir),
247
55
  generateId: () => `${siteEntryId.replace(/-content$/, '')}-sitewide`,
248
56
  }),
@@ -30,7 +30,7 @@ type VisualTheme = {
30
30
  finalSectionBottom: { desktop: string; mobile: string };
31
31
  };
32
32
  };
33
- gallery: {
33
+ images: {
34
34
  width: string;
35
35
  maxAvailableWidthPercent: { desktop: number; mobile: number };
36
36
  maxAvailableHeightPercent: { desktop: number; mobile: number };
@@ -73,7 +73,7 @@ const {
73
73
  const documentTitle = title;
74
74
  const canonicalUrl = new URL(withBasePath(projectConfig.site.basePath, pathname), projectConfig.site.url).href;
75
75
  const iconLinks = getIconLinks();
76
- const scrollBehavior = projectConfig.navigation.scrollBehavior === 'browser-smooth' ? 'smooth' : 'auto';
76
+ const scrollBehavior = projectConfig.navigation.scrollBehavior === 'smooth' ? 'smooth' : 'auto';
77
77
  const formatPercent = (value: number) => `${value}%`;
78
78
  const formatViewportHeightPercent = (value: number) => `${value}svh`;
79
79
  const layoutSpacing = visualTheme.layout.spacing;
@@ -90,17 +90,17 @@ const documentStyle = [
90
90
  `--space-content-block-gap-mobile: ${layoutSpacing.blockGap.mobile}`,
91
91
  `--space-heading-to-block-desktop: ${layoutSpacing.headingToBlock.desktop}`,
92
92
  `--space-heading-to-block-mobile: ${layoutSpacing.headingToBlock.mobile}`,
93
- `--space-gallery-item-desktop: ${layoutSpacing.imageGap.desktop}`,
94
- `--space-gallery-item-mobile: ${layoutSpacing.imageGap.mobile}`,
93
+ `--space-image-item-desktop: ${layoutSpacing.imageGap.desktop}`,
94
+ `--space-image-item-mobile: ${layoutSpacing.imageGap.mobile}`,
95
95
  `--space-section-to-section-desktop: ${layoutSpacing.sectionGap.desktop}`,
96
96
  `--space-section-to-section-mobile: ${layoutSpacing.sectionGap.mobile}`,
97
97
  `--space-final-section-bottom-desktop: ${layoutSpacing.finalSectionBottom.desktop}`,
98
98
  `--space-final-section-bottom-mobile: ${layoutSpacing.finalSectionBottom.mobile}`,
99
- `--gallery-width: ${visualTheme.gallery.width}`,
100
- `--gallery-max-available-width-desktop: ${formatPercent(visualTheme.gallery.maxAvailableWidthPercent.desktop)}`,
101
- `--gallery-max-available-width-mobile: ${formatPercent(visualTheme.gallery.maxAvailableWidthPercent.mobile)}`,
102
- `--gallery-max-image-height-desktop: ${formatViewportHeightPercent(visualTheme.gallery.maxAvailableHeightPercent.desktop)}`,
103
- `--gallery-max-image-height-mobile: ${formatViewportHeightPercent(visualTheme.gallery.maxAvailableHeightPercent.mobile)}`,
99
+ `--image-width: ${visualTheme.images.width}`,
100
+ `--image-max-available-width-desktop: ${formatPercent(visualTheme.images.maxAvailableWidthPercent.desktop)}`,
101
+ `--image-max-available-width-mobile: ${formatPercent(visualTheme.images.maxAvailableWidthPercent.mobile)}`,
102
+ `--image-max-height-desktop: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.desktop)}`,
103
+ `--image-max-height-mobile: ${formatViewportHeightPercent(visualTheme.images.maxAvailableHeightPercent.mobile)}`,
104
104
  ].join('; ');
105
105
  const { buildInfo, copyrightMessage } = footer ?? {};
106
106
  const showBuildInfo = Boolean(buildInfo?.enabled);
@@ -1,6 +1,6 @@
1
1
  import { getGeneratedImage } from './generatedImages';
2
2
 
3
- export type GalleryImage = {
3
+ export type ManagedImage = {
4
4
  image: string;
5
5
  src?: string;
6
6
  alt?: string;
@@ -16,24 +16,24 @@ const hasDimensions = (image: ReturnType<typeof getGeneratedImage>): image is Ge
16
16
  Boolean(image) && Number.isFinite(image.width) && Number.isFinite(image.height)
17
17
  );
18
18
 
19
- export const getCaptionId = (image: GalleryImage, itemIndex: number, imageIndex = 0) => {
19
+ export const getCaptionId = (image: ManagedImage, itemIndex: number, imageIndex = 0) => {
20
20
  const source = (image.src ?? image.image)
21
21
  .toLowerCase()
22
22
  .replace(/[^a-z0-9]+/g, '-')
23
23
  .replace(/^-|-$/g, '');
24
24
 
25
- return `gallery-caption-${source}-${itemIndex}-${imageIndex}`;
25
+ return `image-caption-${source}-${itemIndex}-${imageIndex}`;
26
26
  };
27
27
 
28
- export const getGalleryImageSizes = (maxAvailableWidthPercent: { desktop: number; mobile: number }) => [
28
+ export const getManagedImageSizes = (maxAvailableWidthPercent: { desktop: number; mobile: number }) => [
29
29
  `(max-width: 700px) ${maxAvailableWidthPercent.mobile}vw`,
30
30
  `${maxAvailableWidthPercent.desktop}vw`,
31
31
  ].join(', ');
32
32
 
33
- export const getImageClass = (image: GalleryImage) => {
33
+ export const getImageClass = (image: ManagedImage) => {
34
34
  const generatedImage = getGeneratedImage(image.src ?? image.image);
35
35
  return hasDimensions(generatedImage) && generatedImage.width > generatedImage.height
36
- ? 'gallery-image-landscape'
36
+ ? 'managed-image-landscape'
37
37
  : undefined;
38
38
  };
39
39
 
@@ -43,7 +43,7 @@ export const getImageLoadingProps = (isPriority: boolean) => ({
43
43
  loading: isPriority ? 'eager' as const : 'lazy' as const,
44
44
  });
45
45
 
46
- export const getCarouselAspectRatio = (images: GalleryImage[]) => {
46
+ export const getCarouselAspectRatio = (images: ManagedImage[]) => {
47
47
  const ratios = images
48
48
  .map((image) => getGeneratedImage(image.src ?? image.image))
49
49
  .filter(hasDimensions)
@@ -1,13 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { parseYamlMapping } from '../../scripts/lib/frontmatter-yaml.mjs';
4
3
  import {
5
- splitSiteFile,
6
- validateFrontmatterIndentation,
7
- validateRouteThemeFrontmatterStructure,
4
+ validateRouteThemeYamlStructure,
8
5
  } from '../../scripts/lib/site-content.mjs';
9
6
  import { siteRoutesDir, siteRoutesLabel, sitewideContentLabel } from '../../scripts/lib/site-paths.mjs';
10
- import { themeVisualSchema } from '../content.config';
7
+ import { parseYamlConfig } from '../../scripts/lib/yaml-config.mjs';
11
8
 
12
9
  type RouteTheme = {
13
10
  id: string;
@@ -20,40 +17,23 @@ export const getRouteTheme = async (routeDirectory: string | null): Promise<Rout
20
17
  const segments = routeDirectory.split(/[\\/]/).filter(Boolean);
21
18
  for (let length = segments.length; length > 0; length -= 1) {
22
19
  const themeSegments = segments.slice(0, length);
23
- const themePath = path.join(siteRoutesDir, ...themeSegments, 'theme.md');
24
- const themeLabel = `${siteRoutesLabel}/${themeSegments.join('/')}/theme.md`;
20
+ const themePath = path.join(siteRoutesDir, ...themeSegments, 'theme.yaml');
25
21
  const source = await readFile(themePath, 'utf8').catch((error) => {
26
22
  if (error?.code === 'ENOENT') return null;
27
23
  throw error;
28
24
  });
29
25
  if (!source) continue;
26
+ const themeLabel = `${siteRoutesLabel}/${themeSegments.join('/')}/theme.yaml`;
30
27
 
31
- const { frontmatter, frontmatterBody } = splitSiteFile(source, themeLabel);
32
- const issues: Array<{ message: string }> = [];
33
- validateFrontmatterIndentation(frontmatter, (issue) => issues.push(issue));
34
- validateRouteThemeFrontmatterStructure(frontmatter, (issue) => issues.push(issue));
35
- if (issues.length > 0) {
36
- throw new Error([
37
- `${themeLabel} has invalid frontmatter.`,
38
- ...issues.map((issue) => `- ${issue.message}`),
39
- ].join('\n'));
40
- }
41
-
42
- const data = parseYamlMapping(frontmatterBody);
28
+ const data = parseYamlConfig(source, themeLabel, {
29
+ validateStructure: validateRouteThemeYamlStructure,
30
+ });
43
31
  if (Object.hasOwn(data, 'navigation')) {
44
- throw new Error(`${themeLabel} may not define navigation. Brand and logo belong in ${sitewideContentLabel}.`);
32
+ throw new Error(`${themeLabel} may not define navigation. The site label and logo settings belong in ${sitewideContentLabel}.`);
45
33
  }
46
- const parsed = themeVisualSchema.safeParse(data);
47
- if (!parsed.success) {
48
- throw new Error([
49
- `${themeLabel} has invalid values.`,
50
- ...parsed.error.issues.map((issue) => `- ${issue.path.join('.') || '(root)'}: ${issue.message}`),
51
- ].join('\n'));
52
- }
53
-
54
34
  return {
55
35
  id: `routes/${themeSegments.join('/')}/theme`,
56
- data: parsed.data,
36
+ data,
57
37
  };
58
38
  }
59
39
 
@@ -12,7 +12,7 @@ import type { SitePage } from './sitePages';
12
12
 
13
13
  type SiteSectionMetadataMap = CollectionEntry<'site'>['data']['sections'];
14
14
  type SiteSectionMetadata = SiteSectionMetadataMap[string];
15
- type GalleryImage = {
15
+ type ManagedImage = {
16
16
  image: string;
17
17
  src: string;
18
18
  alt?: string;
@@ -38,8 +38,8 @@ type InlineNote = {
38
38
  };
39
39
  type SectionContentBlock =
40
40
  | { type: 'html'; html: string }
41
- | { type: 'image-stack'; images: GalleryImage[] }
42
- | { type: 'image-carousel'; images: GalleryImage[] }
41
+ | { type: 'image-stack'; images: ManagedImage[] }
42
+ | { type: 'image-carousel'; images: ManagedImage[] }
43
43
  | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width: CardListWidth; cards: CardListItem[] }
44
44
  | { type: 'note'; html: string; number?: number; id?: string; referenceId?: string };
45
45
  export type ResolvedSection = SiteSectionMetadata & {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { sitePublicDir } from '../../scripts/lib/site-paths.mjs';
4
+ import { browserIconAssetDefinitions } from '../../scripts/lib/public-asset-conventions.mjs';
4
5
  import projectConfig from '../../scripts/lib/project-config.mjs';
5
6
  export { getLogoAsset } from '../../scripts/lib/logo-assets.mjs';
6
7
  import { withBasePath } from './basePath';
@@ -12,27 +13,10 @@ type IconLink = {
12
13
  sizes?: string;
13
14
  };
14
15
 
15
- const faviconCandidates: IconLink[] = [
16
- {
17
- rel: 'icon',
18
- href: '/favicon.svg',
19
- type: 'image/svg+xml',
20
- },
21
- {
22
- rel: 'icon',
23
- href: '/favicon.ico',
24
- sizes: 'any',
25
- },
26
- {
27
- rel: 'icon',
28
- href: '/favicon.png',
29
- type: 'image/png',
30
- },
31
- {
32
- rel: 'apple-touch-icon',
33
- href: '/apple-touch-icon.png',
34
- },
35
- ];
16
+ const faviconCandidates: IconLink[] = browserIconAssetDefinitions.map(({ filename, ...definition }) => ({
17
+ ...definition,
18
+ href: `/${filename}`,
19
+ }));
36
20
 
37
21
  const publicAssetExists = (href: string) => {
38
22
  const relativePath = href.replace(/^\//, '');