@janga/norna 0.7.9 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +1 -1
  2. package/docs/README.md +14 -11
  3. package/docs/configuration.md +9 -54
  4. package/docs/content.md +6 -7
  5. package/docs/design/norna-diagram-design.md +2 -0
  6. package/docs/getting-started.md +18 -16
  7. package/docs/routes.md +7 -6
  8. package/docs/site-structure.md +4 -2
  9. package/docs/sitewide-content.md +74 -0
  10. package/docs/theme.md +61 -128
  11. package/docs/typography.md +45 -33
  12. package/examples/001-dog-shelter-single-page/README.md +9 -0
  13. package/examples/001-dog-shelter-single-page/site/config.mjs +27 -0
  14. package/examples/001-dog-shelter-single-page/site/content.md +38 -0
  15. package/examples/001-dog-shelter-single-page/site/images/what-we-do/dog-house.svg +25 -0
  16. package/examples/001-dog-shelter-single-page/site/images/you-can-help/heart.svg +13 -0
  17. package/examples/001-dog-shelter-single-page/site/public/favicon.svg +10 -0
  18. package/examples/001-dog-shelter-single-page/site/public/robots.txt +2 -0
  19. package/examples/001-dog-shelter-single-page/site/sitewide-content.md +6 -0
  20. package/examples/001-dog-shelter-single-page/site/theme.md +25 -0
  21. package/examples/002-dog-shelter-multi-page/README.md +9 -0
  22. package/examples/002-dog-shelter-multi-page/site/config.mjs +27 -0
  23. package/examples/002-dog-shelter-multi-page/site/content.md +12 -0
  24. package/examples/002-dog-shelter-multi-page/site/public/favicon.svg +10 -0
  25. package/examples/002-dog-shelter-multi-page/site/public/robots.txt +2 -0
  26. package/examples/002-dog-shelter-multi-page/site/routes/010-dogs/images/dogs/rex.svg +19 -0
  27. package/examples/002-dog-shelter-multi-page/site/routes/010-dogs/images/dogs/rover.svg +19 -0
  28. package/examples/002-dog-shelter-multi-page/site/routes/010-dogs/route-content.md +30 -0
  29. package/examples/002-dog-shelter-multi-page/site/routes/020-adopt/route-content.md +12 -0
  30. package/examples/002-dog-shelter-multi-page/site/sitewide-content.md +6 -0
  31. package/examples/002-dog-shelter-multi-page/site/theme.md +25 -0
  32. package/examples/dog-gallery/.astro/collections/site.schema.json +776 -0
  33. package/examples/dog-gallery/.astro/collections/theme.schema.json +672 -0
  34. package/examples/dog-gallery/.astro/content-assets.mjs +4 -0
  35. package/examples/dog-gallery/.astro/content-modules.mjs +1 -0
  36. package/examples/dog-gallery/.astro/content.d.ts +175 -0
  37. package/examples/dog-gallery/.astro/data-store.json +1 -0
  38. package/examples/dog-gallery/.astro/dev-local.json +8 -0
  39. package/examples/dog-gallery/.astro/dev.json +13 -0
  40. package/examples/dog-gallery/.astro/dev.log +95 -0
  41. package/examples/dog-gallery/.astro/settings.json +5 -0
  42. package/examples/dog-gallery/.astro/types.d.ts +2 -0
  43. package/examples/dog-gallery/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  44. package/examples/dog-gallery/dist/_astro/SitePage.DicGh_5N.css +1 -0
  45. package/examples/dog-gallery/dist/dog-care/index.html +15 -0
  46. package/examples/dog-gallery/dist/dog-shelter-logo.svg +14 -0
  47. package/examples/dog-gallery/dist/favicon.svg +7 -0
  48. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1080.webp +0 -0
  49. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1440.webp +0 -0
  50. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-1536.webp +0 -0
  51. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-480.webp +0 -0
  52. package/examples/dog-gallery/dist/images/generated/black-dogs/black-puppy-meadow-7255e3d9-768.webp +0 -0
  53. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-480.webp +0 -0
  54. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-768.webp +0 -0
  55. package/examples/dog-gallery/dist/images/generated/black-dogs/photo-of-a-black-dog-5cf614d2-960.webp +0 -0
  56. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1080.webp +0 -0
  57. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1440.webp +0 -0
  58. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-1920.webp +0 -0
  59. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-3939.webp +0 -0
  60. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-480.webp +0 -0
  61. package/examples/dog-gallery/dist/images/generated/brown-dogs/brown-dog-50ef114e-768.webp +0 -0
  62. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1080.webp +0 -0
  63. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1440.webp +0 -0
  64. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-1920.webp +0 -0
  65. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-2400.webp +0 -0
  66. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-480.webp +0 -0
  67. package/examples/dog-gallery/dist/images/generated/brown-dogs/dog-accompanies-master-7ef804d5-768.webp +0 -0
  68. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1080.webp +0 -0
  69. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1440.webp +0 -0
  70. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-1600.webp +0 -0
  71. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-480.webp +0 -0
  72. package/examples/dog-gallery/dist/images/generated/golden-dogs/golden-retriever-f797fd88-768.webp +0 -0
  73. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1080.webp +0 -0
  74. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1440.webp +0 -0
  75. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-1920.webp +0 -0
  76. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-2576.webp +0 -0
  77. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-480.webp +0 -0
  78. package/examples/dog-gallery/dist/images/generated/golden-dogs/toller-puppy-a2a8fbc0-768.webp +0 -0
  79. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1080.webp +0 -0
  80. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-1277.webp +0 -0
  81. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-480.webp +0 -0
  82. package/examples/dog-gallery/dist/images/generated/white-dogs/white-cute-dog-077a0689-768.webp +0 -0
  83. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1080.webp +0 -0
  84. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1440.webp +0 -0
  85. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-1536.webp +0 -0
  86. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-480.webp +0 -0
  87. package/examples/dog-gallery/dist/images/generated/white-dogs/white-puppy-garden-d3ab2ca0-768.webp +0 -0
  88. package/examples/dog-gallery/dist/index.html +45 -0
  89. package/examples/dog-gallery/dist/robots.txt +2 -0
  90. package/examples/dog-gallery/node_modules/.vite/deps/_metadata.json +7 -7
  91. package/examples/dog-gallery/site/.norna/generated-images.json +8 -8
  92. package/examples/dog-gallery/site/config.mjs +0 -20
  93. package/examples/dog-gallery/site/content.md +4 -5
  94. package/examples/dog-gallery/site/public/logo.svg +14 -0
  95. package/examples/dog-gallery/site/sitewide-content.md +16 -0
  96. package/examples/dog-gallery/site/theme.md +13 -24
  97. package/examples/presentation/README.md +15 -0
  98. package/examples/presentation/site/.norna/generated-images.json +66 -0
  99. package/examples/presentation/site/config.mjs +29 -0
  100. package/examples/presentation/site/content.md +67 -0
  101. package/examples/presentation/site/public/robots.txt +2 -0
  102. package/examples/presentation/site/routes/010-presets/route-content.md +63 -0
  103. package/examples/presentation/site/routes/010-presets/theme.md +4 -0
  104. package/examples/presentation/site/routes/020-media/images/cards/card-carousel.svg +11 -0
  105. package/examples/presentation/site/routes/020-media/images/cards/card-stack.svg +9 -0
  106. package/examples/presentation/site/routes/020-media/images/cards/card-surfaces.svg +8 -0
  107. package/examples/presentation/site/routes/020-media/images/carousel/carousel-one.svg +8 -0
  108. package/examples/presentation/site/routes/020-media/images/carousel/carousel-three.svg +13 -0
  109. package/examples/presentation/site/routes/020-media/images/carousel/carousel-two.svg +8 -0
  110. package/examples/presentation/site/routes/020-media/images/stack/stack-one.svg +8 -0
  111. package/examples/presentation/site/routes/020-media/images/stack/stack-two.svg +9 -0
  112. package/examples/presentation/site/routes/020-media/route-content.md +77 -0
  113. package/examples/presentation/site/routes/020-media/theme.md +4 -0
  114. package/examples/presentation/site/routes/030-surfaces/route-content.md +44 -0
  115. package/examples/presentation/site/routes/030-surfaces/theme.md +7 -0
  116. package/examples/presentation/site/sitewide-content.md +6 -0
  117. package/examples/presentation/site/theme.md +43 -0
  118. package/examples/typography-presets/.astro/collections/routeTheme.schema.json +746 -0
  119. package/examples/typography-presets/.astro/collections/site.schema.json +0 -717
  120. package/examples/typography-presets/.astro/collections/sitewide.schema.json +114 -0
  121. package/examples/typography-presets/.astro/collections/theme.schema.json +161 -59
  122. package/examples/typography-presets/.astro/content.d.ts +8 -0
  123. package/examples/typography-presets/.astro/data-store.json +1 -1
  124. package/examples/typography-presets/.astro/dev-local.json +2 -2
  125. package/examples/typography-presets/.astro/dev.json +2 -2
  126. package/examples/typography-presets/.astro/dev.log +37 -16
  127. package/examples/typography-presets/site/.norna/generated-images.json +76 -76
  128. package/examples/typography-presets/site/config.mjs +0 -13
  129. package/examples/typography-presets/site/content.md +8 -126
  130. package/examples/typography-presets/site/routes/010-quiet-gallery/route-content.md +34 -0
  131. package/examples/typography-presets/site/routes/010-quiet-gallery/theme.md +4 -0
  132. package/examples/typography-presets/site/routes/020-compact-gallery/route-content.md +33 -0
  133. package/examples/typography-presets/site/routes/020-compact-gallery/theme.md +4 -0
  134. package/examples/typography-presets/site/routes/030-text-forward/route-content.md +34 -0
  135. package/examples/typography-presets/site/routes/030-text-forward/theme.md +4 -0
  136. package/examples/typography-presets/site/routes/040-statement/route-content.md +34 -0
  137. package/examples/typography-presets/site/routes/040-statement/theme.md +4 -0
  138. package/examples/typography-presets/site/sitewide-content.md +14 -0
  139. package/examples/typography-presets/site/theme.md +9 -9
  140. package/fixtures/basic/site/config.mjs +0 -13
  141. package/fixtures/basic/site/sitewide-content.md +12 -0
  142. package/fixtures/basic/site/theme.md +2 -2
  143. package/fixtures/content-model-v2/site/content.md +2 -5
  144. package/fixtures/content-model-v2/site/theme.md +1 -3
  145. package/package.json +3 -2
  146. package/scripts/check-config.mjs +22 -1
  147. package/scripts/lib/frontmatter-yaml.mjs +28 -0
  148. package/scripts/lib/logo-assets.mjs +35 -0
  149. package/scripts/lib/norna-markdown-blocks.mjs +166 -0
  150. package/scripts/lib/presentation.mjs +104 -46
  151. package/scripts/lib/project-config.mjs +67 -105
  152. package/scripts/lib/site-content.mjs +26 -118
  153. package/scripts/lib/site-paths.mjs +2 -0
  154. package/scripts/lib/sitewide-config.mjs +34 -0
  155. package/scripts/lib/theme-config.mjs +72 -8
  156. package/scripts/lib/typography.mjs +20 -26
  157. package/scripts/show-typography.mjs +40 -147
  158. package/scripts/sync-content-sections.mjs +19 -17
  159. package/scripts/test-content-check.mjs +59 -27
  160. package/scripts/test-content-model-v2.mjs +154 -0
  161. package/scripts/test-engine-commands.mjs +3 -1
  162. package/scripts/test-package-check.mjs +66 -24
  163. package/scripts/test-temporary-visibility.mjs +19 -2
  164. package/src/components/CardList.astro +5 -2
  165. package/src/components/GalleryGrid.astro +37 -16
  166. package/src/components/SiteBanners.astro +100 -0
  167. package/src/components/SiteNavigation.astro +48 -31
  168. package/src/components/SitePage.astro +55 -13
  169. package/src/components/SiteSection.astro +193 -35
  170. package/src/content.config.ts +78 -48
  171. package/src/layouts/BaseLayout.astro +64 -14
  172. package/src/lib/routeThemes.ts +61 -0
  173. package/src/lib/sectionContent.ts +235 -51
  174. package/src/lib/sitePublicAssets.ts +1 -0
  175. package/src/styles/global.css +282 -93
  176. package/starters/basic/.github/workflows/deploy.yml +1 -1
  177. package/starters/basic/README.md +7 -6
  178. package/starters/basic/site/config.mjs +0 -13
  179. package/starters/basic/site/content.md +1 -1
  180. package/starters/basic/site/sitewide-content.md +14 -0
  181. package/starters/basic/site/theme.md +13 -24
  182. package/starters/project/.github/workflows/deploy.yml +1 -1
  183. package/starters/project/README.md +10 -8
  184. package/starters/project/site/config.mjs +0 -13
  185. package/starters/project/site/sitewide-content.md +14 -0
  186. package/starters/project/site/theme.md +3 -11
  187. /package/examples/typography-presets/site/{images → routes/010-quiet-gallery/images}/quiet-gallery/puppy-portrait.jpg +0 -0
  188. /package/examples/typography-presets/site/{images → routes/010-quiet-gallery/images}/quiet-gallery/shepherd-portrait.jpg +0 -0
  189. /package/examples/typography-presets/site/{images → routes/010-quiet-gallery/images}/quiet-gallery/white-puppy.jpg +0 -0
  190. /package/examples/typography-presets/site/{images → routes/020-compact-gallery/images}/compact-gallery/black-schnauzer.jpg +0 -0
  191. /package/examples/typography-presets/site/{images → routes/020-compact-gallery/images}/compact-gallery/retriever-portrait.jpg +0 -0
  192. /package/examples/typography-presets/site/{images → routes/020-compact-gallery/images}/compact-gallery/schnauzer-portrait.jpg +0 -0
  193. /package/examples/typography-presets/site/{images → routes/030-text-forward/images}/text-forward/boxer-portrait.jpg +0 -0
  194. /package/examples/typography-presets/site/{images → routes/030-text-forward/images}/text-forward/grass-puppy.jpg +0 -0
  195. /package/examples/typography-presets/site/{images → routes/030-text-forward/images}/text-forward/grey-street-dog.jpg +0 -0
  196. /package/examples/typography-presets/site/{images → routes/040-statement/images}/statement/black-pedigree.jpg +0 -0
  197. /package/examples/typography-presets/site/{images → routes/040-statement/images}/statement/sheepdog-grass.jpg +0 -0
  198. /package/examples/typography-presets/site/{images → routes/040-statement/images}/statement/sheepdog-wide.jpg +0 -0
@@ -10,13 +10,14 @@ const siteEntryId = `${siteDirLabel
10
10
  .replace(/^[./\\]+/, '')
11
11
  .replace(/[^a-zA-Z0-9-]+/g, '-')
12
12
  .replace(/^-+|-+$/g, '') || 'site'}-content`;
13
- const colorValue = z.string().regex(
14
- /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
15
- 'Use a hex color such as "#000000".',
16
- );
17
13
  const textAlign = z.enum(['left', 'center', 'right']);
18
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)]);
19
17
  const typographyPreset = z.enum(['quiet-gallery', 'compact-gallery', 'text-forward', 'statement']);
18
+ const presentationPalette = z.enum(['dark', 'light', 'paper']);
19
+ const sectionSurfaceMode = z.enum(['none', 'cycle']);
20
+ const sectionSurface = z.enum(['base', 'soft', 'emphasis']);
20
21
  const spacingDensity = z.enum(['compact', 'normal', 'airy']);
21
22
  const lineHeight = z.number()
22
23
  .min(1, 'Use a unitless line height of at least 1.')
@@ -31,7 +32,6 @@ const visualCssLength = z.string().regex(
31
32
  new RegExp(`^(?:0|${visualCssLengthValue}|clamp\\(\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*,\\s*${visualCssLengthValue}\\s*\\))$`),
32
33
  'Use a CSS length such as "0", "900px", "56rem", "90%", or "clamp(1rem, 4vw, 3rem)".',
33
34
  );
34
- const inlineStyleName = z.string().regex(/^[a-z][a-z0-9-]*$/);
35
35
  const dateOnly = z.string()
36
36
  .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD format.')
37
37
  .refine(isDateOnly, 'Use a real calendar date.');
@@ -59,6 +59,7 @@ const commonTextPresentationOverride = {
59
59
  };
60
60
  const headingPresentationOverride = z.object({
61
61
  ...commonTextPresentationOverride,
62
+ weight: headingWeight.optional(),
62
63
  spacingBefore: cssLength.optional(),
63
64
  spacingAfter: cssLength.optional(),
64
65
  }).strict();
@@ -70,6 +71,7 @@ const headingLevelsPresentationOverride = z.object({
70
71
  }).strict();
71
72
  const bodyPresentationOverride = z.object({
72
73
  ...commonTextPresentationOverride,
74
+ width: textWidth.optional(),
73
75
  paragraphSpacing: cssLength.optional(),
74
76
  }).strict();
75
77
  const captionPresentationOverride = z.object({
@@ -81,14 +83,6 @@ const typographyOverrides = z.object({
81
83
  body: bodyPresentationOverride.optional(),
82
84
  caption: captionPresentationOverride.optional(),
83
85
  }).strict();
84
- const typography = z.object({
85
- preset: typographyPreset.optional(),
86
- rhythm: spacingDensity.optional(),
87
- overrides: typographyOverrides.optional(),
88
- }).strict().refine(
89
- (value) => value.preset !== undefined || value.rhythm !== undefined || value.overrides !== undefined,
90
- 'Specify preset, rhythm, overrides, or both.',
91
- );
92
86
  const themeTypography = z.object({
93
87
  fontFamily: z.string()
94
88
  .min(1)
@@ -115,9 +109,10 @@ const responsivePercent = z.union([
115
109
  }).strict(),
116
110
  ]);
117
111
  const themeLayoutSpacing = z.object({
118
- bodyToImages: responsiveCssLength.optional(),
112
+ blockGap: responsiveCssLength.optional(),
119
113
  finalSectionBottom: responsiveCssLength.optional(),
120
114
  firstSectionTop: responsiveCssLength.optional(),
115
+ headingToBlock: responsiveCssLength.optional(),
121
116
  imageGap: responsiveCssLength.optional(),
122
117
  sectionGap: responsiveCssLength.optional(),
123
118
  }).strict();
@@ -132,32 +127,15 @@ const themeGallery = z.object({
132
127
  maxAvailableWidthPercent: responsivePercent.optional(),
133
128
  maxAvailableHeightPercent: responsivePercent.optional(),
134
129
  }).strict();
135
- const sectionPresentationOverride = z.object({
136
- backgroundColor: colorValue.optional(),
137
- textColor: colorValue.optional(),
138
- typography: typography.optional(),
139
- }).strict();
140
130
  const themePresentation = z.object({
141
- backgroundColor: colorValue.optional(),
142
- textColor: colorValue.optional(),
143
- inlineStyles: z.record(inlineStyleName, z.object({
144
- color: colorValue,
145
- }).strict()).optional(),
146
- }).strict();
147
- const pagePresentation = z.object({
148
- backgroundColor: colorValue.optional(),
149
- textColor: colorValue.optional(),
150
- typography: typography.optional(),
151
- }).strict();
152
- const frameColors = z.union([
153
- z.enum(['theme', 'presentation']),
154
- z.object({
155
- backgroundColor: colorValue,
156
- textColor: colorValue,
157
- }).strict(),
158
- ]);
159
- const frame = z.object({
160
- colors: frameColors.optional(),
131
+ palette: presentationPalette.optional(),
132
+ sectionSurfaces: z.object({
133
+ mode: sectionSurfaceMode,
134
+ sequence: z.array(sectionSurface).min(1).max(3).optional(),
135
+ }).strict().refine(
136
+ (value) => !value.sequence || new Set(value.sequence).size === value.sequence.length,
137
+ 'Each section surface may appear only once in a sequence.',
138
+ ).optional(),
161
139
  }).strict();
162
140
  const pageNavigation = z.object({
163
141
  include: z.boolean().optional(),
@@ -165,29 +143,72 @@ const pageNavigation = z.object({
165
143
  }).strict();
166
144
  const themeNavigation = z.object({
167
145
  brand: z.string().min(1).optional(),
146
+ logo: z.object({
147
+ alt: z.string().min(1).optional(),
148
+ height: visualCssLength.optional(),
149
+ }).strict().optional(),
168
150
  }).strict();
169
151
 
170
152
  const sectionMetadata = z.object({
171
153
  visible: visibilityWindow.optional(),
172
- presentation: sectionPresentationOverride.optional(),
154
+ }).strict();
155
+ const banner = z.object({
156
+ id: z.string().regex(/^[a-z0-9-]+$/),
157
+ tone: z.enum(['warning']).default('warning'),
158
+ visible: visibilityWindow.optional(),
159
+ title: z.string().min(1),
160
+ text: z.string().min(1),
161
+ }).strict();
162
+ const banners = z.array(banner).optional().default([]).refine(
163
+ (values) => new Set(values.map((value) => value.id)).size === values.length,
164
+ 'Banner ids must be unique.',
165
+ );
166
+ const dateTimeFormat = z.object({
167
+ locale: z.string().min(1),
168
+ timeZone: z.string().min(1),
169
+ dateStyle: z.string().min(1),
170
+ timeStyle: z.string().min(1),
171
+ }).strict().refine((value) => {
172
+ try {
173
+ new Intl.DateTimeFormat(value.locale, {
174
+ dateStyle: value.dateStyle as Intl.DateTimeFormatOptions['dateStyle'],
175
+ timeStyle: value.timeStyle as Intl.DateTimeFormatOptions['timeStyle'],
176
+ timeZone: value.timeZone,
177
+ });
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }, 'Use a valid Intl.DateTimeFormat configuration.');
183
+ const sitewideFooter = z.object({
184
+ copyrightMessage: z.string().min(1).optional(),
185
+ buildInfo: z.object({
186
+ enabled: z.boolean().default(true),
187
+ text: z.string().min(1),
188
+ dateTimeFormat,
189
+ }).strict().optional(),
173
190
  }).strict();
174
191
 
175
192
  const siteSchema = z.object({
176
193
  title: z.string(),
177
194
  description: z.string(),
178
195
  navigation: pageNavigation.optional(),
179
- presentation: pagePresentation.optional(),
180
- frame: frame.optional(),
181
196
  sections: z.record(z.string().regex(/^[a-z0-9-]+$/), sectionMetadata).optional().default({}),
182
197
  });
183
198
 
184
- const themeSchema = z.object({
185
- navigation: themeNavigation.optional(),
199
+ export const themeVisualSchema = z.object({
186
200
  layout: themeLayout.optional(),
187
201
  gallery: themeGallery.optional(),
188
202
  typography: themeTypography.optional(),
189
203
  presentation: themePresentation.optional(),
190
- frame: frame.optional(),
204
+ }).strict();
205
+
206
+ const siteThemeSchema = themeVisualSchema;
207
+
208
+ const sitewideSchema = z.object({
209
+ navigation: themeNavigation.optional(),
210
+ banners,
211
+ footer: sitewideFooter.optional(),
191
212
  }).strict();
192
213
 
193
214
  const site = defineCollection({
@@ -212,9 +233,18 @@ const theme = defineCollection({
212
233
  loader: glob({
213
234
  pattern: 'theme.md',
214
235
  base: pathToFileURL(siteDir),
215
- generateId: () => `${siteEntryId.replace(/-content$/, '')}-theme`,
236
+ generateId: ({ entry }) => entry,
237
+ }),
238
+ schema: siteThemeSchema,
239
+ });
240
+
241
+ const sitewide = defineCollection({
242
+ loader: glob({
243
+ pattern: 'sitewide-content.md',
244
+ base: pathToFileURL(siteDir),
245
+ generateId: () => `${siteEntryId.replace(/-content$/, '')}-sitewide`,
216
246
  }),
217
- schema: themeSchema,
247
+ schema: sitewideSchema,
218
248
  });
219
249
 
220
- export const collections = { site, theme };
250
+ export const collections = { site, theme, sitewide };
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import { getPresentationCssVariables } from '../../scripts/lib/presentation.mjs';
3
4
  import { getIconLinks } from '../lib/sitePublicAssets';
4
5
  import { withBasePath } from '../lib/basePath';
5
6
  import '../styles/global.css';
@@ -9,18 +10,64 @@ type FrameColors = {
9
10
  textColor: string;
10
11
  };
11
12
 
13
+ type Presentation = {
14
+ palette: {
15
+ page: FrameColors;
16
+ css: Record<string, string>;
17
+ colorScheme: string;
18
+ };
19
+ };
20
+ type VisualTheme = {
21
+ layout: {
22
+ gutter: { desktop: string; mobile: string };
23
+ pageWidth: string;
24
+ spacing: {
25
+ firstSectionTop: { desktop: string; mobile: string };
26
+ blockGap: { desktop: string; mobile: string };
27
+ headingToBlock: { desktop: string; mobile: string };
28
+ imageGap: { desktop: string; mobile: string };
29
+ sectionGap: { desktop: string; mobile: string };
30
+ finalSectionBottom: { desktop: string; mobile: string };
31
+ };
32
+ };
33
+ gallery: {
34
+ width: string;
35
+ maxAvailableWidthPercent: { desktop: number; mobile: number };
36
+ maxAvailableHeightPercent: { desktop: number; mobile: number };
37
+ };
38
+ typography: { fontFamily: string };
39
+ };
40
+
12
41
  interface Props {
13
42
  title?: string;
14
43
  description?: string;
44
+ presentation: Presentation;
45
+ visualTheme: VisualTheme;
15
46
  frameColors: FrameColors;
16
47
  pathname?: string;
48
+ footer?: {
49
+ copyrightMessage?: string;
50
+ buildInfo?: {
51
+ enabled: boolean;
52
+ text: string;
53
+ dateTimeFormat: {
54
+ locale: string;
55
+ timeZone: string;
56
+ dateStyle: string;
57
+ timeStyle: string;
58
+ };
59
+ };
60
+ };
17
61
  }
18
62
 
19
63
  const {
20
64
  title = 'norna',
21
65
  description = 'A norna site.',
66
+ presentation,
67
+ visualTheme,
22
68
  frameColors,
23
69
  pathname = '/',
70
+ footer,
24
71
  } = Astro.props;
25
72
 
26
73
  const documentTitle = title;
@@ -29,30 +76,33 @@ const iconLinks = getIconLinks();
29
76
  const scrollBehavior = projectConfig.navigation.smoothScroll.enabled ? 'smooth' : 'auto';
30
77
  const formatPercent = (value: number) => `${value}%`;
31
78
  const formatViewportHeightPercent = (value: number) => `${value}svh`;
32
- const layoutSpacing = projectConfig.layout.spacing;
79
+ const layoutSpacing = visualTheme.layout.spacing;
33
80
  const documentStyle = [
34
81
  `scroll-behavior: ${scrollBehavior}`,
35
- `--font-sans: ${projectConfig.typography.fontFamily}`,
36
- `--page-width: ${projectConfig.layout.pageWidth}`,
37
- `--content-gutter-desktop: ${projectConfig.layout.gutter.desktop}`,
38
- `--content-gutter-mobile: ${projectConfig.layout.gutter.mobile}`,
82
+ ...Object.entries(getPresentationCssVariables(presentation)).map(([name, value]) => `${name}: ${value}`),
83
+ `--font-sans: ${visualTheme.typography.fontFamily}`,
84
+ `--page-width: ${visualTheme.layout.pageWidth}`,
85
+ `--content-gutter-desktop: ${visualTheme.layout.gutter.desktop}`,
86
+ `--content-gutter-mobile: ${visualTheme.layout.gutter.mobile}`,
39
87
  `--space-nav-to-first-section-desktop: ${layoutSpacing.firstSectionTop.desktop}`,
40
88
  `--space-nav-to-first-section-mobile: ${layoutSpacing.firstSectionTop.mobile}`,
41
- `--space-section-body-to-gallery-desktop: ${layoutSpacing.bodyToImages.desktop}`,
42
- `--space-section-body-to-gallery-mobile: ${layoutSpacing.bodyToImages.mobile}`,
89
+ `--space-content-block-gap-desktop: ${layoutSpacing.blockGap.desktop}`,
90
+ `--space-content-block-gap-mobile: ${layoutSpacing.blockGap.mobile}`,
91
+ `--space-heading-to-block-desktop: ${layoutSpacing.headingToBlock.desktop}`,
92
+ `--space-heading-to-block-mobile: ${layoutSpacing.headingToBlock.mobile}`,
43
93
  `--space-gallery-item-desktop: ${layoutSpacing.imageGap.desktop}`,
44
94
  `--space-gallery-item-mobile: ${layoutSpacing.imageGap.mobile}`,
45
95
  `--space-section-to-section-desktop: ${layoutSpacing.sectionGap.desktop}`,
46
96
  `--space-section-to-section-mobile: ${layoutSpacing.sectionGap.mobile}`,
47
97
  `--space-final-section-bottom-desktop: ${layoutSpacing.finalSectionBottom.desktop}`,
48
98
  `--space-final-section-bottom-mobile: ${layoutSpacing.finalSectionBottom.mobile}`,
49
- `--gallery-width: ${projectConfig.gallery.width}`,
50
- `--gallery-max-available-width-desktop: ${formatPercent(projectConfig.gallery.maxAvailableWidthPercent.desktop)}`,
51
- `--gallery-max-available-width-mobile: ${formatPercent(projectConfig.gallery.maxAvailableWidthPercent.mobile)}`,
52
- `--gallery-max-image-height-desktop: ${formatViewportHeightPercent(projectConfig.gallery.maxAvailableHeightPercent.desktop)}`,
53
- `--gallery-max-image-height-mobile: ${formatViewportHeightPercent(projectConfig.gallery.maxAvailableHeightPercent.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)}`,
54
104
  ].join('; ');
55
- const { buildInfo, copyrightMessage } = projectConfig.footer;
105
+ const { buildInfo, copyrightMessage } = footer ?? {};
56
106
  const showBuildInfo = Boolean(buildInfo?.enabled);
57
107
  const buildTime = showBuildInfo
58
108
  ? new Intl.DateTimeFormat(buildInfo.dateTimeFormat.locale, {
@@ -75,7 +125,7 @@ const footerStyle = [
75
125
  <meta name="viewport" content="width=device-width, initial-scale=1" />
76
126
  <meta name="format-detection" content="telephone=no" />
77
127
  <meta name="description" content={description} />
78
- <meta name="theme-color" content="#000000" />
128
+ <meta name="theme-color" content={presentation.palette.page.backgroundColor} />
79
129
  <link rel="canonical" href={canonicalUrl} />
80
130
  <meta name="generator" content={Astro.generator} />
81
131
  {iconLinks.map((iconLink) => (
@@ -0,0 +1,61 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseYamlMapping } from '../../scripts/lib/frontmatter-yaml.mjs';
4
+ import {
5
+ splitSiteFile,
6
+ validateFrontmatterIndentation,
7
+ validateRouteThemeFrontmatterStructure,
8
+ } from '../../scripts/lib/site-content.mjs';
9
+ import { siteRoutesDir, siteRoutesLabel, sitewideContentLabel } from '../../scripts/lib/site-paths.mjs';
10
+ import { themeVisualSchema } from '../content.config';
11
+
12
+ type RouteTheme = {
13
+ id: string;
14
+ data: Record<string, unknown>;
15
+ };
16
+
17
+ export const getRouteTheme = async (routeDirectory: string | null): Promise<RouteTheme | null> => {
18
+ if (!routeDirectory) return null;
19
+
20
+ const segments = routeDirectory.split(/[\\/]/).filter(Boolean);
21
+ for (let length = segments.length; length > 0; length -= 1) {
22
+ const themeSegments = segments.slice(0, length);
23
+ const themePath = path.join(siteRoutesDir, ...themeSegments, 'theme.md');
24
+ const themeLabel = `${siteRoutesLabel}/${themeSegments.join('/')}/theme.md`;
25
+ const source = await readFile(themePath, 'utf8').catch((error) => {
26
+ if (error?.code === 'ENOENT') return null;
27
+ throw error;
28
+ });
29
+ if (!source) continue;
30
+
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);
43
+ if (Object.hasOwn(data, 'navigation')) {
44
+ throw new Error(`${themeLabel} may not define navigation. Brand and logo belong in ${sitewideContentLabel}.`);
45
+ }
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
+ return {
55
+ id: `routes/${themeSegments.join('/')}/theme`,
56
+ data: parsed.data,
57
+ };
58
+ }
59
+
60
+ return null;
61
+ };