@janga/norna 0.7.24 → 0.7.25

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 (43) hide show
  1. package/README.md +2 -2
  2. package/astro.config.mjs +2 -0
  3. package/package.json +14 -2
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +56 -21
  6. package/schemas/content-frontmatter.schema.json +7 -7
  7. package/schemas/page-theme.schema.json +26 -26
  8. package/schemas/sitewide-content.schema.json +18 -18
  9. package/schemas/theme.schema.json +241 -241
  10. package/scripts/check-config.mjs +5 -1
  11. package/scripts/dev-local.mjs +106 -11
  12. package/scripts/init-site.mjs +2 -0
  13. package/scripts/lib/code-fence-metadata.mjs +11 -6
  14. package/scripts/lib/edit-source-link.mjs +53 -0
  15. package/scripts/lib/navigation-model.mjs +1 -1
  16. package/scripts/lib/norna-markdown-blocks.mjs +91 -0
  17. package/scripts/lib/project-config.mjs +42 -12
  18. package/scripts/lib/schema-definitions.mjs +10 -2
  19. package/scripts/lib/schema-editor-metadata.mjs +20 -3
  20. package/scripts/lib/schema-value-definitions.mjs +3 -0
  21. package/scripts/lib/site-paths.mjs +14 -2
  22. package/scripts/lib/table-render-plugin.mjs +33 -0
  23. package/src/components/CardList.astro +1 -0
  24. package/src/components/CodeBlockCopyScript.astro +2 -1
  25. package/src/components/ImageCarousel.astro +4 -1
  26. package/src/components/ImageStack.astro +34 -9
  27. package/src/components/ImageStackEnhancement.astro +331 -0
  28. package/src/components/PageContentsNavigation.astro +2 -3
  29. package/src/components/SectionNavigationScript.astro +45 -7
  30. package/src/components/SiteNavigation.astro +73 -49
  31. package/src/components/SitePage.astro +38 -23
  32. package/src/components/SiteTreeNavigation.astro +3 -0
  33. package/src/components/TableOverflowScript.astro +251 -0
  34. package/src/components/TreeNavigationScript.astro +78 -22
  35. package/src/layouts/BaseLayout.astro +31 -5
  36. package/src/lib/generatedImages.ts +18 -0
  37. package/src/lib/sectionContent.ts +4 -27
  38. package/src/lib/sitePages.ts +6 -3
  39. package/src/styles/content.css +298 -23
  40. package/src/styles/media.css +187 -3
  41. package/src/styles/navigation.css +168 -3
  42. package/src/styles/page-layout.css +48 -16
  43. package/src/styles/responsive.css +67 -20
@@ -4,6 +4,7 @@
4
4
  sectionOpenByPath: Record<string, boolean>;
5
5
  scrollTop: number;
6
6
  };
7
+ type SharedNavigationState = Pick<NavigationState, 'openPaths' | 'sectionOpenByPath'>;
7
8
  type NavigationSnapshot = {
8
9
  branchOpen: boolean[];
9
10
  sectionOpen: boolean[];
@@ -12,7 +13,9 @@
12
13
 
13
14
  const setupNavigationState = (container: HTMLElement, scope: 'desktop' | 'mobile') => {
14
15
  const root = container.dataset.navigationRoot ?? '/';
16
+ const sharedRoot = container.dataset.navigationStateRoot ?? root;
15
17
  const stateKey = `norna:tree-navigation:${scope}:${root}`;
18
+ const sharedStateKey = `norna:tree-navigation:shared:${sharedRoot}`;
16
19
  const branches = Array.from(container.querySelectorAll<HTMLDetailsElement>(
17
20
  '.navigation-page-disclosure[data-page-path]',
18
21
  ));
@@ -34,31 +37,69 @@
34
37
  };
35
38
  const scrollContainer = getScrollContainer();
36
39
  let filterSnapshot: NavigationSnapshot | null = null;
37
-
38
- try {
39
- const parsedState = JSON.parse(sessionStorage.getItem(stateKey) ?? '{}');
40
- const storedState: NavigationState = {
41
- openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
42
- sectionOpenByPath: parsedState.sectionOpenByPath
43
- && typeof parsedState.sectionOpenByPath === 'object'
44
- ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
45
- .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
46
- : {},
47
- scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
48
- };
49
-
40
+ let lastSharedState = '';
41
+ const parseState = (value: string | null): NavigationState => {
42
+ try {
43
+ const parsedState = JSON.parse(value ?? '{}');
44
+ return {
45
+ openPaths: Array.isArray(parsedState.openPaths) ? parsedState.openPaths : [],
46
+ sectionOpenByPath: parsedState.sectionOpenByPath
47
+ && typeof parsedState.sectionOpenByPath === 'object'
48
+ ? Object.fromEntries(Object.entries(parsedState.sectionOpenByPath)
49
+ .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
50
+ : {},
51
+ scrollTop: Number.isFinite(Number(parsedState.scrollTop)) ? Number(parsedState.scrollTop) : 0,
52
+ };
53
+ } catch {
54
+ return { openPaths: [], sectionOpenByPath: {}, scrollTop: 0 };
55
+ }
56
+ };
57
+ const getDisclosureState = (): SharedNavigationState => ({
58
+ openPaths: branches.filter((branch) => branch.open)
59
+ .map((branch) => branch.dataset.pagePath ?? ''),
60
+ sectionOpenByPath: Object.fromEntries(sectionBranches.map((branch) => [
61
+ branch.dataset.pagePath ?? '',
62
+ branch.open,
63
+ ])),
64
+ });
65
+ const applyDisclosureState = (state: SharedNavigationState) => {
50
66
  branches.forEach((branch) => {
51
67
  const pagePath = branch.dataset.pagePath ?? '';
52
68
  branch.open = branch.dataset.currentBranch === 'true'
53
- || storedState.openPaths.includes(pagePath);
69
+ || state.openPaths.includes(pagePath);
54
70
  });
55
71
  sectionBranches.forEach((branch) => {
56
72
  const pagePath = branch.dataset.pagePath ?? '';
57
- const storedOpen = storedState.sectionOpenByPath[pagePath];
73
+ const storedOpen = state.sectionOpenByPath[pagePath];
58
74
  branch.open = typeof storedOpen === 'boolean'
59
75
  ? storedOpen
60
76
  : branch.dataset.currentPage === 'true';
61
77
  });
78
+ };
79
+ const mergeSharedState = (state: SharedNavigationState): SharedNavigationState => {
80
+ const existingState = parseState(sessionStorage.getItem(sharedStateKey));
81
+ const knownBranchPaths = new Set(branches.map((branch) => branch.dataset.pagePath ?? ''));
82
+ return {
83
+ openPaths: [
84
+ ...existingState.openPaths.filter((pagePath) => !knownBranchPaths.has(pagePath)),
85
+ ...state.openPaths,
86
+ ],
87
+ sectionOpenByPath: {
88
+ ...existingState.sectionOpenByPath,
89
+ ...state.sectionOpenByPath,
90
+ },
91
+ };
92
+ };
93
+
94
+ try {
95
+ const storedState = parseState(sessionStorage.getItem(stateKey));
96
+ const storedSharedState = sessionStorage.getItem(sharedStateKey);
97
+ applyDisclosureState(storedSharedState === null
98
+ ? storedState
99
+ : parseState(storedSharedState));
100
+ const initialSharedState = mergeSharedState(getDisclosureState());
101
+ lastSharedState = JSON.stringify(initialSharedState);
102
+ sessionStorage.setItem(sharedStateKey, lastSharedState);
62
103
 
63
104
  if (storedState.scrollTop > 0) {
64
105
  requestAnimationFrame(() => {
@@ -73,21 +114,36 @@
73
114
  if (filterSnapshot) return;
74
115
 
75
116
  try {
76
- const openBranches = branches.filter((branch) => branch.open);
77
- const sectionOpenByPath = Object.fromEntries(sectionBranches.map((branch) => [
78
- branch.dataset.pagePath ?? '',
79
- branch.open,
80
- ]));
117
+ const disclosureState = getDisclosureState();
81
118
  const state: NavigationState = {
82
- openPaths: openBranches.map((branch) => branch.dataset.pagePath ?? ''),
83
- sectionOpenByPath,
119
+ ...disclosureState,
84
120
  scrollTop: scrollContainer.scrollTop,
85
121
  };
86
122
  sessionStorage.setItem(stateKey, JSON.stringify(state));
123
+ const sharedState = mergeSharedState(disclosureState);
124
+ const serializedSharedState = JSON.stringify(sharedState);
125
+ sessionStorage.setItem(sharedStateKey, serializedSharedState);
126
+ if (serializedSharedState !== lastSharedState) {
127
+ lastSharedState = serializedSharedState;
128
+ window.dispatchEvent(new CustomEvent('norna:tree-navigation-state', {
129
+ detail: { root: sharedRoot, state: sharedState },
130
+ }));
131
+ }
87
132
  } catch {
88
133
  // Navigation state persistence is an optional enhancement.
89
134
  }
90
135
  };
136
+ window.addEventListener('norna:tree-navigation-state', (event) => {
137
+ const detail = (event as CustomEvent<{
138
+ root?: string;
139
+ state?: SharedNavigationState;
140
+ }>).detail;
141
+ if (detail?.root !== sharedRoot || !detail.state) return;
142
+ const serializedSharedState = JSON.stringify(detail.state);
143
+ if (serializedSharedState === lastSharedState) return;
144
+ lastSharedState = serializedSharedState;
145
+ applyDisclosureState(detail.state);
146
+ });
91
147
  const controls = container.querySelector<HTMLElement>('[data-tree-controls]');
92
148
  const filterInput = controls?.querySelector<HTMLInputElement>('[data-tree-filter]') ?? null;
93
149
  const expandButton = controls?.querySelector<HTMLButtonElement>('[data-tree-expand-all]') ?? null;
@@ -145,7 +145,7 @@ const marginNoteReadingWidths = [
145
145
  ['standard', 'min(72ch, 680px)'],
146
146
  ['wide', `min(80ch, ${visualTheme.images.width})`],
147
147
  ] as const;
148
- const vacantContentsTrackWidth = `11rem + ${visualTheme.layout.localNavigationGap}`;
148
+ const marginNoteOuterReserve = '0.5rem';
149
149
  const marginNoteDeclarations = [
150
150
  'float: right;',
151
151
  'clear: right;',
@@ -157,15 +157,41 @@ const marginNoteLayoutCss = [
157
157
  ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
158
158
  [
159
159
  `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
160
- `:root[data-reading-width="${readingWidth}"] .section-note {`,
160
+ `:root[data-reading-width="${readingWidth}"] .site-page-layout:not(.site-page-layout-tree) .section-note {`,
161
161
  marginNoteDeclarations,
162
162
  '}',
163
163
  '}',
164
164
  ].join('\n'),
165
165
  [
166
- `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} - ${vacantContentsTrackWidth})) {`,
167
- `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note,`,
168
- `:root[data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note {`,
166
+ `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
167
+ `:root[data-reader-preferences-ready="true"][data-reading-width="${readingWidth}"][data-focus-reading="on"] .site-page-layout-tree .section-note {`,
168
+ marginNoteDeclarations,
169
+ '}',
170
+ '}',
171
+ ].join('\n'),
172
+ ]),
173
+ '}',
174
+ '@media (min-width: 1101px) and (max-width: 1280px) {',
175
+ ...marginNoteReadingWidths.map(([readingWidth, textWidth]) => [
176
+ `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve})) {`,
177
+ `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note {`,
178
+ marginNoteDeclarations,
179
+ '}',
180
+ '}',
181
+ ].join('\n')),
182
+ '}',
183
+ '@media (min-width: 1281px) {',
184
+ ...marginNoteReadingWidths.flatMap(([readingWidth, textWidth]) => [
185
+ [
186
+ `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap})) {`,
187
+ `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree.site-page-layout-contents .section-note {`,
188
+ marginNoteDeclarations,
189
+ '}',
190
+ '}',
191
+ ].join('\n'),
192
+ [
193
+ `@container (min-width: calc(${textWidth} + ${visualTheme.layout.noteWidth} + ${visualTheme.layout.noteGap} + ${marginNoteOuterReserve} - 11rem - ${visualTheme.layout.localNavigationGap})) {`,
194
+ `:root[data-reading-width="${readingWidth}"] .site-page-layout-tree:not(.site-page-layout-contents) .section-note {`,
169
195
  marginNoteDeclarations,
170
196
  '}',
171
197
  '}',
@@ -45,6 +45,24 @@ export const getGeneratedImage = (src: string) => readGeneratedImages()[src];
45
45
 
46
46
  const displaySrc = (src: string) => withBasePath(projectConfig.site.basePath, src);
47
47
 
48
+ export const getImageInspectionAttributes = (src: string) => {
49
+ const image = getGeneratedImage(src);
50
+ const largestVariant = image?.variants
51
+ ? [...image.variants].sort((left, right) => left.width - right.width).at(-1)
52
+ : undefined;
53
+ const publishedSource = image?.kind === 'static'
54
+ ? image.src
55
+ : largestVariant?.src;
56
+
57
+ return {
58
+ href: displaySrc(publishedSource ?? src),
59
+ scalable: image?.kind === 'static' || /\.svg(?:[?#]|$)/iu.test(publishedSource ?? src),
60
+ ...(Number.isFinite(image?.width) && Number.isFinite(image?.height)
61
+ ? { width: image?.width, height: image?.height }
62
+ : {}),
63
+ };
64
+ };
65
+
48
66
  const getDisplayVariants = (variants: NonNullable<GeneratedImage['variants']>) => {
49
67
  const sortedVariants = [...variants].sort((a, b) => a.width - b.width);
50
68
  const displayVariants = sortedVariants.filter((variant) => variant.width <= maxDisplayImageWidth);
@@ -3,6 +3,7 @@ import projectConfig from '../../scripts/lib/project-config.mjs';
3
3
  import {
4
4
  formatHeadingIdentifierIssue,
5
5
  } from '../../scripts/lib/heading-ids.mjs';
6
+ import { splitNornaRenderedBlocks } from '../../scripts/lib/norna-markdown-blocks.mjs';
6
7
  import { applyBasePathToHtml } from './basePath';
7
8
  import type { SitePage } from './sitePages';
8
9
 
@@ -202,32 +203,6 @@ const splitRenderedRegions = (html: string, regionCount: number) => {
202
203
  });
203
204
  };
204
205
 
205
- const splitNornaBlockMarkers = (html: string, blocks: ParsedNornaBlock[]) => {
206
- const result: Array<{ type: 'html'; html: string } | ParsedNornaBlock> = [];
207
- const markerRegex = /<norna-block\s+data-index="(\d+)"\s*><\/norna-block>/g;
208
- const seen = new Set<number>();
209
- let cursor = 0;
210
-
211
- for (const match of html.matchAll(markerRegex)) {
212
- const start = match.index ?? 0;
213
- if (start > cursor) result.push({ type: 'html', html: html.slice(cursor, start) });
214
-
215
- const index = Number.parseInt(match[1] ?? '', 10);
216
- const block = blocks[index];
217
- if (!block) throw new Error(`Rendered Norna block ${index + 1} has no matching parsed block.`);
218
- seen.add(index);
219
- result.push(block);
220
- cursor = start + match[0].length;
221
- }
222
-
223
- if (cursor < html.length) result.push({ type: 'html', html: html.slice(cursor) });
224
- if (seen.size !== blocks.length) {
225
- throw new Error(`Rendered Markdown contains ${seen.size} Norna block markers, but ${blocks.length} blocks were parsed.`);
226
- }
227
-
228
- return result.filter((block) => block.type !== 'html' || block.html.trim());
229
- };
230
-
231
206
  const resolveContentBlocks = async (
232
207
  html: string,
233
208
  blocks: ParsedNornaBlock[],
@@ -240,7 +215,9 @@ const resolveContentBlocks = async (
240
215
  html: await renderInlineNoteMarkdown(note.markdown),
241
216
  })));
242
217
  const renderedHtml = applyInlineNoteMarkup(html, renderedNotes);
243
- const splitBlocks = splitNornaBlockMarkers(renderedHtml, blocks);
218
+ const splitBlocks = splitNornaRenderedBlocks(renderedHtml, blocks) as Array<
219
+ { type: 'html'; html: string } | ParsedNornaBlock
220
+ >;
244
221
  const resolvedBlocks: SectionContentBlock[] = [];
245
222
 
246
223
  for (const block of splitBlocks) {
@@ -36,6 +36,7 @@ export type SitePage = SiteNodeBase & {
36
36
  markdown: string;
37
37
  markdownDocument: Awaited<ReturnType<typeof parsePageMarkdown>>;
38
38
  contentLabel: string;
39
+ contentPath: string;
39
40
  };
40
41
 
41
42
  export type SiteCategory = SiteNodeBase & {
@@ -86,20 +87,21 @@ const compareNumberPaths = (left: number[], right: number[]) => {
86
87
  const readPageMarkdownDocument = async (entry: SiteEntry) => {
87
88
  const pageDirectory = getPageDirectory(entry);
88
89
  const contentLabel = `${sitePagesLabel}/${pageDirectory}/content.md`;
89
- const { body } = await readSiteFile(path.join(sitePagesDir, pageDirectory, 'content.md'), contentLabel);
90
+ const contentPath = path.join(sitePagesDir, pageDirectory, 'content.md');
91
+ const { body } = await readSiteFile(contentPath, contentLabel);
90
92
  const markdownDocument = await parsePageMarkdown(body, { label: contentLabel });
91
93
 
92
94
  if (markdownDocument.pageHeadings.length !== 1 || markdownDocument.regions[0]?.kind !== 'page-intro') {
93
95
  throw new Error(`Page entry "${entry.id}" must contain exactly one Markdown H1 page title.`);
94
96
  }
95
97
 
96
- return { body, contentLabel, markdownDocument };
98
+ return { body, contentLabel, contentPath, markdownDocument };
97
99
  };
98
100
 
99
101
  const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
100
102
  const isHome = isHomePageEntry(entry);
101
103
  const pageMetadata = getPageMetadata(entry);
102
- const { body, contentLabel, markdownDocument } = await readPageMarkdownDocument(entry);
104
+ const { body, contentLabel, contentPath, markdownDocument } = await readPageMarkdownDocument(entry);
103
105
  const pageDirectory = pageMetadata.pageDirectory;
104
106
  const pageId = pageMetadata.pageId;
105
107
  const pagePath = pageMetadata.pagePath;
@@ -126,6 +128,7 @@ const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
126
128
  pagePath,
127
129
  parentPagePath: isHome ? null : pageMetadata.parentPagePath,
128
130
  contentLabel,
131
+ contentPath,
129
132
  markdown: body,
130
133
  markdownDocument,
131
134
  title: markdownDocument.pageTitle.title,