@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.
- package/README.md +2 -2
- package/astro.config.mjs +2 -0
- package/package.json +14 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +56 -21
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +241 -241
- package/scripts/check-config.mjs +5 -1
- package/scripts/dev-local.mjs +106 -11
- package/scripts/init-site.mjs +2 -0
- package/scripts/lib/code-fence-metadata.mjs +11 -6
- package/scripts/lib/edit-source-link.mjs +53 -0
- package/scripts/lib/navigation-model.mjs +1 -1
- package/scripts/lib/norna-markdown-blocks.mjs +91 -0
- package/scripts/lib/project-config.mjs +42 -12
- package/scripts/lib/schema-definitions.mjs +10 -2
- package/scripts/lib/schema-editor-metadata.mjs +20 -3
- package/scripts/lib/schema-value-definitions.mjs +3 -0
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/table-render-plugin.mjs +33 -0
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +2 -1
- package/src/components/ImageCarousel.astro +4 -1
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/SectionNavigationScript.astro +45 -7
- package/src/components/SiteNavigation.astro +73 -49
- package/src/components/SitePage.astro +38 -23
- package/src/components/SiteTreeNavigation.astro +3 -0
- package/src/components/TableOverflowScript.astro +251 -0
- package/src/components/TreeNavigationScript.astro +78 -22
- package/src/layouts/BaseLayout.astro +31 -5
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/sectionContent.ts +4 -27
- package/src/lib/sitePages.ts +6 -3
- package/src/styles/content.css +298 -23
- package/src/styles/media.css +187 -3
- package/src/styles/navigation.css +168 -3
- package/src/styles/page-layout.css +48 -16
- 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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
||
|
|
69
|
+
|| state.openPaths.includes(pagePath);
|
|
54
70
|
});
|
|
55
71
|
sectionBranches.forEach((branch) => {
|
|
56
72
|
const pagePath = branch.dataset.pagePath ?? '';
|
|
57
|
-
const storedOpen =
|
|
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
|
|
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
|
-
|
|
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
|
|
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} - ${
|
|
167
|
-
`:root[data-reading-width="${readingWidth}"] .site-page-layout-tree
|
|
168
|
-
|
|
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 =
|
|
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) {
|
package/src/lib/sitePages.ts
CHANGED
|
@@ -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
|
|
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,
|