@janga/norna 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +5 -5
  2. package/astro.config.mjs +76 -0
  3. package/bin/norna-cli.mjs +2 -2
  4. package/docs/README.md +3 -3
  5. package/docs/commands.md +6 -6
  6. package/docs/configuration.md +3 -2
  7. package/docs/content.md +154 -95
  8. package/docs/design/site-examples-structure.md +1 -1
  9. package/docs/engine-development.md +4 -4
  10. package/docs/getting-started.md +6 -4
  11. package/docs/images-and-metadata.md +112 -20
  12. package/docs/routes.md +52 -33
  13. package/docs/site-structure.md +7 -6
  14. package/docs/theme.md +8 -7
  15. package/docs/typography.md +5 -5
  16. package/examples/dog-gallery/site/content.md +41 -46
  17. package/examples/dog-gallery/site/routes/{dog-care → 010-dog-care}/route-content.md +1 -4
  18. package/fixtures/basic/site/content.md +2 -4
  19. package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
  20. package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
  21. package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
  22. package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
  23. package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
  24. package/fixtures/content-model-v2/.astro/data-store.json +1 -0
  25. package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
  26. package/fixtures/content-model-v2/.astro/dev.json +13 -0
  27. package/fixtures/content-model-v2/.astro/dev.log +231 -0
  28. package/fixtures/content-model-v2/.astro/settings.json +5 -0
  29. package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
  30. package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
  31. package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
  32. package/fixtures/content-model-v2/dist/guide/index.html +3 -0
  33. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
  34. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
  35. package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
  36. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
  37. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
  38. package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
  39. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
  40. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
  41. package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
  42. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
  43. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
  44. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
  45. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
  46. package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
  47. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
  48. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
  49. package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
  50. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
  51. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
  52. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
  53. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
  54. package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
  55. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
  56. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
  57. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
  58. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
  59. package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
  60. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
  61. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
  62. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
  63. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
  64. package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
  65. package/fixtures/content-model-v2/dist/index.html +8 -0
  66. package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
  67. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
  68. package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
  69. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  70. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  71. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
  72. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  73. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  74. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  75. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  76. package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  77. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
  78. package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
  79. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
  80. package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
  81. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
  82. package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
  83. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
  84. package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
  85. package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
  86. package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
  87. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
  88. package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
  89. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
  90. package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
  91. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
  92. package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
  93. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
  94. package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
  95. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
  96. package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
  97. package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
  98. package/fixtures/content-model-v2/site/config.mjs +11 -0
  99. package/fixtures/content-model-v2/site/content.md +84 -0
  100. package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
  101. package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
  102. package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
  103. package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
  104. package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
  105. package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
  106. package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
  107. package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
  108. package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
  109. package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
  110. package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
  111. package/fixtures/content-model-v2/site/theme.md +9 -0
  112. package/package.json +4 -2
  113. package/scripts/deploy-site.mjs +12 -5
  114. package/scripts/dev-local.mjs +6 -0
  115. package/scripts/generate-images.mjs +132 -56
  116. package/scripts/lib/image-dimensions.mjs +26 -0
  117. package/scripts/lib/norna-markdown-blocks.mjs +643 -0
  118. package/scripts/lib/route-model.mjs +17 -0
  119. package/scripts/lib/site-content.mjs +45 -37
  120. package/scripts/show-typography.mjs +18 -10
  121. package/scripts/sync-content-sections.mjs +280 -423
  122. package/scripts/test-content-check.mjs +59 -45
  123. package/scripts/test-content-model-v2.mjs +998 -0
  124. package/scripts/test-engine-commands.mjs +1 -1
  125. package/scripts/test-package-check.mjs +38 -11
  126. package/scripts/test-route-model.mjs +32 -0
  127. package/scripts/test-temporary-visibility.mjs +3 -6
  128. package/src/components/CardList.astro +77 -0
  129. package/src/components/GalleryGrid.astro +22 -12
  130. package/src/components/SiteNavigation.astro +5 -4
  131. package/src/components/SitePage.astro +12 -6
  132. package/src/components/SiteSection.astro +35 -13
  133. package/src/content.config.ts +8 -23
  134. package/src/lib/generatedImages.ts +50 -10
  135. package/src/lib/sectionContent.ts +130 -35
  136. package/src/lib/sitePages.ts +36 -15
  137. package/src/pages/[slug].astro +3 -3
  138. package/src/styles/global.css +187 -0
  139. package/starters/basic/site/content.md +10 -6
  140. package/starters/project/README.md +2 -2
  141. package/starters/project/site/content.md +0 -10
  142. package/starters/project/site/routes/{guide → 010-guide}/route-content.md +0 -6
@@ -1,14 +1,51 @@
1
1
  import type { CollectionEntry } from 'astro:content';
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
+ import {
4
+ extractNornaMarkdownBlocks,
5
+ splitNornaMarkdownBlockMarkers,
6
+ splitNornaRenderedCodeBlocks,
7
+ } from '../../scripts/lib/norna-markdown-blocks.mjs';
3
8
  import { applyBasePathToHtml } from './basePath';
9
+ import type { SitePage } from './sitePages';
4
10
 
5
- type SiteSection = CollectionEntry<'site'>['data']['sections'][number];
11
+ type SiteSectionMetadataMap = CollectionEntry<'site'>['data']['sections'];
12
+ type SiteSectionMetadata = SiteSectionMetadataMap[string];
6
13
  type ThemePresentation = CollectionEntry<'theme'>['data']['presentation'];
7
14
  type InlineStyles = NonNullable<NonNullable<ThemePresentation>['inlineStyles']>;
15
+ type GalleryImage = {
16
+ image: string;
17
+ src: string;
18
+ alt?: string;
19
+ caption?: string;
20
+ };
21
+ type CardListItem = {
22
+ title: string;
23
+ text?: string;
24
+ image?: string;
25
+ src?: string;
26
+ link?: string;
27
+ 'badge-text'?: string;
28
+ };
29
+ type CardListLayout = 'image-top' | 'image-left' | 'image-right';
30
+ type CardListFlow = 'grid' | 'stack';
31
+ type CardListSize = 's' | 'm' | 'l' | 'xl';
32
+ type CardListWidth = 'text' | 'narrow' | 'normal' | 'wide';
33
+ type SectionContentBlock =
34
+ | { type: 'html'; html: string }
35
+ | { type: 'image-stack'; images: GalleryImage[] }
36
+ | { type: 'image-carousel'; images: GalleryImage[] }
37
+ | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width: CardListWidth; cards: CardListItem[] };
38
+ export type ResolvedSection = SiteSectionMetadata & {
39
+ id: string;
40
+ title: string;
41
+ contentBlocks: SectionContentBlock[];
42
+ };
8
43
 
9
44
  const headingRegex = /<h2\b([^>]*)>([\s\S]*?)<\/h2>/gi;
10
45
  const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
11
46
  const inlineStyleReferenceRegex = /\[([^\]<]+)\]\{\.([a-z][a-z0-9-]*)\}/g;
47
+ const markdownH2Regex = /^##\s+.*$/gm;
48
+ const imageProvenanceCommentRegex = /<!--\s*norna-image-provenance:[\s\S]*?-->/gi;
12
49
 
13
50
  const stripTags = (html: string) => html.replace(/<[^>]*>/g, '');
14
51
 
@@ -58,18 +95,94 @@ const applyInlineStyles = (html: string, inlineStyles: InlineStyles | undefined)
58
95
  return `<span class="inline-style inline-style-${styleName}" style="--inline-style-color: ${style.color}">${text}</span>`;
59
96
  });
60
97
 
98
+ const stripImageProvenanceComments = (html: string) => html.replace(imageProvenanceCommentRegex, '');
99
+
61
100
  const prepareContentHtml = (html: string, inlineStyles: InlineStyles | undefined) =>
62
- applyBasePathToHtml(projectConfig.site.basePath, applyInlineStyles(html, inlineStyles));
101
+ applyBasePathToHtml(
102
+ projectConfig.site.basePath,
103
+ stripImageProvenanceComments(applyInlineStyles(html, inlineStyles)),
104
+ );
105
+
106
+ const getRawMarkdownSections = (markdown: string) => {
107
+ const matches = Array.from(markdown.matchAll(markdownH2Regex));
108
+ const sections = new Map<string, string>();
109
+
110
+ for (let index = 0; index < matches.length; index += 1) {
111
+ const match = matches[index];
112
+ const heading = match[0] ?? '';
113
+ const id = heading.match(explicitHeadingIdRegex)?.[1];
114
+ if (!id) continue;
115
+
116
+ const start = match.index ?? 0;
117
+ const next = matches[index + 1];
118
+ const end = next?.index ?? markdown.length;
119
+ sections.set(id, markdown.slice(start, end));
120
+ }
121
+
122
+ return sections;
123
+ };
124
+
125
+ const getImageSourceKey = (page: SitePage, sectionId: string, image: string) => (
126
+ page.routeDirectory
127
+ ? `routes/${page.routeDirectory}/images/${sectionId}/${image}`
128
+ : `images/${sectionId}/${image}`
129
+ );
130
+
131
+ const resolveContentBlocks = (
132
+ html: string,
133
+ rawMarkdown: string,
134
+ page: SitePage,
135
+ sectionId: string,
136
+ inlineStyles: InlineStyles | undefined,
137
+ ) => {
138
+ const rawBlocks = extractNornaMarkdownBlocks(rawMarkdown);
139
+ const splitBlocks = rawBlocks.length > 0
140
+ ? splitNornaRenderedCodeBlocks(html, rawBlocks)
141
+ : splitNornaMarkdownBlockMarkers(html);
142
+
143
+ return splitBlocks.map((block): SectionContentBlock => {
144
+ if (block.type === 'html') {
145
+ return {
146
+ type: 'html',
147
+ html: prepareContentHtml(block.html, inlineStyles),
148
+ };
149
+ }
150
+
151
+ return {
152
+ type: block.type,
153
+ ...(block.type === 'card-list'
154
+ ? {
155
+ layout: block.layout,
156
+ flow: block.flow,
157
+ size: block.size,
158
+ width: block.width,
159
+ cards: block.cards.map((card: CardListItem) => ({
160
+ ...card,
161
+ ...(card.image ? { src: getImageSourceKey(page, sectionId, card.image) } : {}),
162
+ })),
163
+ }
164
+ : {
165
+ images: block.images.map((image: { image: string; alt?: string; caption?: string }) => ({
166
+ ...image,
167
+ src: getImageSourceKey(page, sectionId, image.image),
168
+ })),
169
+ }),
170
+ };
171
+ });
172
+ };
63
173
 
64
174
  export const getSectionsContent = (
65
175
  html: string,
66
- sections: SiteSection[],
176
+ rawMarkdown: string,
177
+ sectionMetadata: SiteSectionMetadataMap,
178
+ page: SitePage,
67
179
  inlineStyles?: InlineStyles,
68
180
  ) => {
69
181
  const matches = Array.from(html.matchAll(headingRegex));
70
- const contentById = new Map<string, { title: string; contentHtml: string }>();
71
- const sectionIds = new Set(sections.map((section) => section.id));
72
- const markdownSectionIds: string[] = [];
182
+ const rawSections = getRawMarkdownSections(rawMarkdown);
183
+ const metadataIds = new Set(Object.keys(sectionMetadata));
184
+ const sections: ResolvedSection[] = [];
185
+ const sectionIds = new Set<string>();
73
186
 
74
187
  for (let index = 0; index < matches.length; index += 1) {
75
188
  const match = matches[index];
@@ -84,47 +197,29 @@ export const getSectionsContent = (
84
197
  const title = getHeadingTitle(headingHtml);
85
198
 
86
199
  if (!explicitId) {
87
- console.warn(`Markdown section is missing an explicit heading id: "${title}". Write for example "## ${title} {#${id}}".`);
200
+ throw new Error(`Section heading "${title}" is missing an explicit id. Write it as: ## ${title} {#${id}}`);
88
201
  }
89
202
 
90
- if (contentById.has(id)) {
203
+ if (sectionIds.has(id)) {
91
204
  throw new Error(`Duplicate Markdown section heading id: ${id}`);
92
205
  }
93
206
 
94
- contentById.set(id, {
207
+ sectionIds.add(id);
208
+ sections.push({
209
+ ...(sectionMetadata[id] ?? {}),
210
+ id,
95
211
  title,
96
- contentHtml: prepareContentHtml(content, inlineStyles),
212
+ contentBlocks: resolveContentBlocks(content, rawSections.get(id) ?? '', page, id, inlineStyles),
97
213
  });
98
- markdownSectionIds.push(id);
99
214
  }
100
215
 
101
- for (const id of contentById.keys()) {
216
+ for (const id of metadataIds) {
102
217
  if (!sectionIds.has(id)) {
103
- console.warn(`Markdown section exists but is not used in frontmatter: ${id}`);
104
- }
105
- }
106
-
107
- const orderedMarkdownSectionIds = markdownSectionIds.filter((id) => sectionIds.has(id));
108
- const frontmatterSectionIds = sections.map((section) => section.id);
109
- const hasOrderMismatch = frontmatterSectionIds.some((id, index) => id !== orderedMarkdownSectionIds[index]);
110
-
111
- if (hasOrderMismatch) {
112
- console.warn('Markdown section order differs from frontmatter. Run npm run content:sync to sort Markdown according to frontmatter.');
113
- }
114
-
115
- return sections.map((section) => {
116
- const content = contentById.get(section.id);
117
-
118
- if (!content) {
119
218
  throw new Error(
120
- `Cannot find heading for "${section.id}". Each frontmatter section must have a matching level 2 Markdown heading, for example: ## Heading {#${section.id}}`,
219
+ `Section metadata "${id}" does not match any Markdown section. Add "## Heading {#${id}}" or remove sections.${id}.`,
121
220
  );
122
221
  }
222
+ }
123
223
 
124
- return {
125
- ...section,
126
- title: content.title,
127
- contentHtml: content.contentHtml,
128
- };
129
- });
224
+ return sections;
130
225
  };
@@ -1,4 +1,5 @@
1
1
  import type { CollectionEntry } from 'astro:content';
2
+ import { parseRouteDirectory } from '../../scripts/lib/route-model.mjs';
2
3
 
3
4
  type SiteEntry = CollectionEntry<'site'>;
4
5
 
@@ -10,9 +11,10 @@ export type SitePage = {
10
11
  label: string;
11
12
  order: number;
12
13
  };
14
+ pathSegment: string;
13
15
  pathname: string;
14
- routeFolder: string | null;
15
- slug: string;
16
+ routeDirectory: string | null;
17
+ routeId: string;
16
18
  title: string;
17
19
  };
18
20
 
@@ -25,21 +27,21 @@ const stripRouteIdPrefix = (id: string) => {
25
27
 
26
28
  export const isHomePageEntry = (entry: SiteEntry) => stripRouteIdPrefix(entry.id) === null;
27
29
 
28
- const getRouteFolder = (entry: SiteEntry) => isHomePageEntry(entry) ? null : stripRouteIdPrefix(entry.id);
30
+ const getRouteDirectory = (entry: SiteEntry) => isHomePageEntry(entry) ? null : stripRouteIdPrefix(entry.id);
29
31
 
30
- const getPageSlug = (entry: SiteEntry) => {
31
- if (isHomePageEntry(entry)) return '';
32
-
33
- const routeFolder = getRouteFolder(entry);
34
- return entry.data.slug ?? routeFolder ?? '';
32
+ const getRouteMetadata = (entry: SiteEntry) => {
33
+ const routeDirectory = getRouteDirectory(entry);
34
+ return routeDirectory ? parseRouteDirectory(routeDirectory, `route directory ${routeDirectory}`) : null;
35
35
  };
36
36
 
37
- const getPagePathname = (slug: string) => slug ? `/${slug}/` : '/';
37
+ const getPagePathname = (pathSegment: string) => pathSegment ? `/${pathSegment}/` : '/';
38
38
 
39
39
  export const getSitePage = (entry: SiteEntry): SitePage => {
40
40
  const isHome = isHomePageEntry(entry);
41
- const routeFolder = getRouteFolder(entry);
42
- const slug = getPageSlug(entry);
41
+ const routeMetadata = getRouteMetadata(entry);
42
+ const routeDirectory = routeMetadata?.routeDirectory ?? null;
43
+ const routeId = routeMetadata?.routeId ?? '';
44
+ const pathSegment = isHome ? '' : routeId;
43
45
  const navigation = entry.data.navigation ?? {};
44
46
 
45
47
  return {
@@ -48,11 +50,12 @@ export const getSitePage = (entry: SiteEntry): SitePage => {
48
50
  navigation: {
49
51
  include: navigation.include ?? true,
50
52
  label: navigation.label ?? entry.data.title,
51
- order: navigation.order ?? (isHome ? 0 : 100),
53
+ order: isHome ? 0 : routeMetadata?.routeOrder ?? 100,
52
54
  },
53
- pathname: getPagePathname(slug),
54
- routeFolder,
55
- slug,
55
+ pathSegment,
56
+ pathname: getPagePathname(pathSegment),
57
+ routeDirectory,
58
+ routeId,
56
59
  title: entry.data.title,
57
60
  };
58
61
  };
@@ -60,6 +63,8 @@ export const getSitePage = (entry: SiteEntry): SitePage => {
60
63
  export const getSitePages = (entries: SiteEntry[]) => {
61
64
  const pages = entries.map(getSitePage);
62
65
  const pathnames = new Map<string, SitePage>();
66
+ const routeIds = new Map<string, SitePage>();
67
+ const routeOrders = new Map<number, SitePage>();
63
68
 
64
69
  for (const page of pages) {
65
70
  const existing = pathnames.get(page.pathname);
@@ -68,6 +73,22 @@ export const getSitePages = (entries: SiteEntry[]) => {
68
73
  }
69
74
 
70
75
  pathnames.set(page.pathname, page);
76
+
77
+ if (page.isHome) continue;
78
+
79
+ const existingRouteId = routeIds.get(page.routeId);
80
+ if (existingRouteId) {
81
+ throw new Error(`Duplicate route id "${page.routeId}" from "${existingRouteId.entry.id}" and "${page.entry.id}".`);
82
+ }
83
+
84
+ routeIds.set(page.routeId, page);
85
+
86
+ const existingRouteOrder = routeOrders.get(page.navigation.order);
87
+ if (existingRouteOrder) {
88
+ throw new Error(`Duplicate route order "${String(page.navigation.order).padStart(3, '0')}" from "${existingRouteOrder.entry.id}" and "${page.entry.id}".`);
89
+ }
90
+
91
+ routeOrders.set(page.navigation.order, page);
71
92
  }
72
93
 
73
94
  return pages.sort((left, right) => (
@@ -10,7 +10,7 @@ export async function getStaticPaths() {
10
10
  return pages
11
11
  .filter((page) => !page.isHome)
12
12
  .map((page) => ({
13
- params: { slug: page.slug },
13
+ params: { slug: page.pathSegment },
14
14
  props: { entry: page.entry },
15
15
  }));
16
16
  }
@@ -23,8 +23,8 @@ if (!entry || isHomePageEntry(entry)) {
23
23
 
24
24
  const currentPage = getSitePage(entry);
25
25
 
26
- if (!currentPage.slug) {
27
- throw new Error(`Route "${entry.id}" needs a slug or route folder name.`);
26
+ if (!currentPage.pathSegment) {
27
+ throw new Error(`Route "${entry.id}" needs a route id.`);
28
28
  }
29
29
  ---
30
30
 
@@ -458,6 +458,182 @@ ul {
458
458
  margin-top: 0.35rem;
459
459
  }
460
460
 
461
+ .card-list {
462
+ display: grid;
463
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
464
+ gap: var(--card-list-gap, clamp(0.9rem, 2vw, 1.25rem));
465
+ width: min(100%, var(--card-list-width, var(--gallery-layout-width)));
466
+ margin: 0 auto var(--space-section-body-to-gallery);
467
+ text-align: left;
468
+ --card-list-width: var(--gallery-layout-width);
469
+ --card-list-gap: clamp(0.9rem, 2vw, 1.25rem);
470
+ --card-list-item-gap: 0.85rem;
471
+ --card-list-padding: clamp(0.9rem, 2vw, 1.15rem);
472
+ --card-list-media-width: 32%;
473
+ --card-list-media-min-width: 7rem;
474
+ --card-list-top-media-width: 20rem;
475
+ --card-list-title-scale: 1;
476
+ --card-list-text-scale: 1;
477
+ }
478
+
479
+ .card-list-width-text {
480
+ --card-list-width: var(--text-width);
481
+ }
482
+
483
+ .card-list-width-narrow {
484
+ --card-list-width: min(48rem, var(--gallery-layout-width));
485
+ }
486
+
487
+ .card-list-width-wide {
488
+ --card-list-width: var(--page-width);
489
+ }
490
+
491
+ .card-list-size-s {
492
+ --card-list-gap: 0.75rem;
493
+ --card-list-item-gap: 0.65rem;
494
+ --card-list-padding: 0.8rem;
495
+ --card-list-media-width: 26%;
496
+ --card-list-media-min-width: 5.5rem;
497
+ --card-list-top-media-width: 14rem;
498
+ --card-list-title-scale: 0.92;
499
+ --card-list-text-scale: 0.92;
500
+ }
501
+
502
+ .card-list-size-l {
503
+ --card-list-gap: clamp(1rem, 2.4vw, 1.5rem);
504
+ --card-list-item-gap: 1rem;
505
+ --card-list-padding: clamp(1.1rem, 2.4vw, 1.45rem);
506
+ --card-list-media-width: 36%;
507
+ --card-list-media-min-width: 8.5rem;
508
+ --card-list-top-media-width: 24rem;
509
+ --card-list-title-scale: 1.08;
510
+ --card-list-text-scale: 1.02;
511
+ }
512
+
513
+ .card-list-size-xl {
514
+ --card-list-gap: clamp(1.15rem, 2.8vw, 1.8rem);
515
+ --card-list-item-gap: 1.1rem;
516
+ --card-list-padding: clamp(1.25rem, 3vw, 1.75rem);
517
+ --card-list-media-width: 40%;
518
+ --card-list-media-min-width: 10rem;
519
+ --card-list-top-media-width: 28rem;
520
+ --card-list-title-scale: 1.18;
521
+ --card-list-text-scale: 1.06;
522
+ }
523
+
524
+ .card-list-flow-stack {
525
+ grid-template-columns: 1fr;
526
+ }
527
+
528
+ .card-list-item {
529
+ display: grid;
530
+ gap: var(--card-list-item-gap);
531
+ align-content: start;
532
+ margin: 0;
533
+ border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
534
+ border-radius: 8px;
535
+ padding: var(--card-list-padding);
536
+ background: color-mix(in srgb, var(--section-background-color, var(--color-page)) 92%, currentColor 8%);
537
+ color: inherit;
538
+ }
539
+
540
+ .card-list-layout-image-left .card-list-item-has-image,
541
+ .card-list-layout-image-right .card-list-item-has-image {
542
+ grid-template-columns: minmax(var(--card-list-media-min-width), var(--card-list-media-width)) minmax(0, 1fr);
543
+ align-items: center;
544
+ }
545
+
546
+ .card-list-layout-image-right .card-list-item-has-image {
547
+ grid-template-columns: minmax(0, 1fr) minmax(var(--card-list-media-min-width), var(--card-list-media-width));
548
+ }
549
+
550
+ .card-list-layout-image-left .card-list-item-linked,
551
+ .card-list-layout-image-right .card-list-item-linked {
552
+ grid-template-columns: 1fr;
553
+ }
554
+
555
+ .card-list-layout-image-right .card-list-image-frame {
556
+ order: 2;
557
+ }
558
+
559
+ .card-list-link {
560
+ display: grid;
561
+ gap: var(--card-list-item-gap);
562
+ width: 100%;
563
+ color: inherit;
564
+ text-decoration: none;
565
+ }
566
+
567
+ .card-list-layout-image-left .card-list-item-has-image .card-list-link,
568
+ .card-list-layout-image-right .card-list-item-has-image .card-list-link {
569
+ grid-template-columns: minmax(var(--card-list-media-min-width), var(--card-list-media-width)) minmax(0, 1fr);
570
+ align-items: center;
571
+ }
572
+
573
+ .card-list-layout-image-right .card-list-item-has-image .card-list-link {
574
+ grid-template-columns: minmax(0, 1fr) minmax(var(--card-list-media-min-width), var(--card-list-media-width));
575
+ }
576
+
577
+ .card-list-link:hover .card-list-title,
578
+ .card-list-link:focus-visible .card-list-title {
579
+ text-decoration: underline;
580
+ text-underline-offset: 0.18em;
581
+ }
582
+
583
+ .card-list-image-frame {
584
+ display: grid;
585
+ width: 100%;
586
+ aspect-ratio: 16 / 10;
587
+ place-items: center;
588
+ overflow: hidden;
589
+ }
590
+
591
+ .card-list-flow-stack.card-list-layout-image-top .card-list-image-frame {
592
+ width: min(100%, var(--card-list-top-media-width));
593
+ }
594
+
595
+ .card-list-image {
596
+ display: block;
597
+ width: 100%;
598
+ height: 100%;
599
+ object-fit: contain;
600
+ }
601
+
602
+ .card-list-content {
603
+ display: grid;
604
+ gap: 0.4rem;
605
+ }
606
+
607
+ .card-list-badge {
608
+ justify-self: start;
609
+ margin: 0 0 0.15rem;
610
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
611
+ border-radius: 999px;
612
+ padding: 0.18rem 0.48rem;
613
+ background: color-mix(in srgb, currentColor 9%, transparent);
614
+ color: inherit;
615
+ font-size: calc(var(--section-body-font-size-desktop, 1rem) * 0.72);
616
+ font-weight: 700;
617
+ letter-spacing: 0;
618
+ line-height: 1.2;
619
+ }
620
+
621
+ .card-list-title {
622
+ margin: 0;
623
+ color: inherit;
624
+ font-size: calc(var(--section-markdown-h3-font-size-desktop, 1.1rem) * var(--card-list-title-scale));
625
+ font-weight: 700;
626
+ letter-spacing: 0;
627
+ line-height: var(--section-markdown-h3-line-height, 1.35);
628
+ text-transform: none;
629
+ }
630
+
631
+ .card-list-content p:not(.card-list-badge) {
632
+ margin: 0;
633
+ font-size: calc(var(--section-body-font-size-desktop, 1rem) * var(--card-list-text-scale));
634
+ line-height: var(--section-body-line-height, 1.5);
635
+ }
636
+
461
637
  .gallery {
462
638
  display: grid;
463
639
  gap: var(--space-gallery-item);
@@ -858,6 +1034,17 @@ ul {
858
1034
  text-align: var(--section-markdown-h4-align-mobile, var(--section-markdown-h4-align-desktop, left));
859
1035
  }
860
1036
 
1037
+ .card-list-layout-image-left .card-list-item-has-image,
1038
+ .card-list-layout-image-right .card-list-item-has-image,
1039
+ .card-list-layout-image-left .card-list-item-has-image .card-list-link,
1040
+ .card-list-layout-image-right .card-list-item-has-image .card-list-link {
1041
+ grid-template-columns: 1fr;
1042
+ }
1043
+
1044
+ .card-list-layout-image-right .card-list-image-frame {
1045
+ order: 0;
1046
+ }
1047
+
861
1048
  .section-markdown ul,
862
1049
  .section-markdown ol {
863
1050
  display: block;
@@ -2,14 +2,10 @@
2
2
  title: Example Site
3
3
  description: Minimal norna starter site.
4
4
  sections:
5
- - id: intro
5
+ intro:
6
6
  presentation:
7
7
  typography:
8
8
  preset: statement
9
- - id: work
10
- - id: lorem1
11
- - id: lorem2
12
- - id: lorem3
13
9
 
14
10
  ---
15
11
 
@@ -19,7 +15,15 @@ routes defined, the navigation menu will be a sticky bar with anchor links for
19
15
  ease of use on phones etc. [Highlighted inline text]{.highlight} when a short
20
16
  phrase needs emphasis
21
17
  ## Work {#work}
22
- Add image rows in frontmatter and matching source images under images/work/.
18
+ Add Norna image blocks in Markdown and matching source images under
19
+ `images/work/`.
20
+
21
+ ````
22
+ ```norna-image-stack
23
+ - image: example.jpg
24
+ caption: Replace this with a real image and caption.
25
+ ```
26
+ ````
23
27
  ## What is Lorem Ipsum? {#lorem1}
24
28
  Lorem Ipsum is simply dummy text of the printing and typesetting
25
29
  industry. Lorem Ipsum has been the industry's standard dummy text ever
@@ -24,7 +24,7 @@ npm run dev -- --kill
24
24
  density, typography rhythm, colors, frame colors, and inline text styles.
25
25
  - `site/content.md`: homepage title, sections, placeholders, project summary,
26
26
  links, install command, example usage, benefits, next steps, and license.
27
- - `site/routes/guide/route-content.md`: short secondary page with realistic
27
+ - `site/routes/010-guide/route-content.md`: short secondary page with realistic
28
28
  project guide content.
29
29
  - `site/public/robots.txt`: static public file copied into the built site.
30
30
 
@@ -36,7 +36,7 @@ GitHub Pages workflow uses `npm ci`.
36
36
 
37
37
  1. Replace the project name, tagline, links, install command, example usage,
38
38
  benefits, use cases, and license in `site/content.md`.
39
- 2. Replace the guide examples in `site/routes/guide/route-content.md`, or
39
+ 2. Replace the guide examples in `site/routes/010-guide/route-content.md`, or
40
40
  delete the route if the homepage is enough.
41
41
  3. Edit `site/theme.md` for brand name, layout density, typography rhythm,
42
42
  colors, frame colors, and inline text styles.
@@ -3,16 +3,6 @@ title: Your project
3
3
  description: A compact project site starter for an open source developer tool.
4
4
  navigation:
5
5
  label: Home
6
- order: 0
7
- sections:
8
- - id: intro
9
- - id: links
10
- - id: install
11
- - id: example
12
- - id: why
13
- - id: good-fit
14
- - id: next
15
- - id: license
16
6
  ---
17
7
 
18
8
  ## `<Your project> - <short value proposition>` {#intro}
@@ -3,12 +3,6 @@ title: Guide
3
3
  description: Adapt this compact guide for normal project use.
4
4
  navigation:
5
5
  label: Guide
6
- order: 10
7
- sections:
8
- - id: quickstart
9
- - id: configuration
10
- - id: usage
11
- - id: topic
12
6
  ---
13
7
 
14
8
  ## Quickstart {#quickstart}