@janga/norna 0.7.24 → 0.7.26
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 +5 -4
- package/astro.config.mjs +27 -11
- package/bin/norna-cli.mjs +12 -0
- package/package.json +26 -5
- package/schemas/card-list.schema.json +121 -0
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +342 -33
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/image-carousel.schema.json +43 -0
- package/schemas/image-stack.schema.json +43 -0
- package/schemas/manifest.json +6 -1
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -262
- package/scripts/check-config.mjs +5 -1
- package/scripts/dev-local.mjs +130 -13
- package/scripts/generate-schemas.mjs +15 -3
- package/scripts/init-site.mjs +2 -0
- package/scripts/lib/category-destinations.mjs +48 -0
- package/scripts/lib/code-fence-metadata.mjs +25 -43
- package/scripts/lib/content-tabs.mjs +155 -0
- package/scripts/lib/details-headings.mjs +58 -0
- package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
- package/scripts/lib/edit-source-link.mjs +53 -0
- package/scripts/lib/editor-language-service.mjs +238 -116
- package/scripts/lib/locale-registry.mjs +597 -0
- package/scripts/lib/locales/az-Latn.mjs +78 -0
- package/scripts/lib/locales/be.mjs +78 -0
- package/scripts/lib/locales/bg.mjs +78 -0
- package/scripts/lib/locales/bs.mjs +78 -0
- package/scripts/lib/locales/ca.mjs +78 -0
- package/scripts/lib/locales/cnr.mjs +78 -0
- package/scripts/lib/locales/cs.mjs +78 -0
- package/scripts/lib/locales/da.mjs +78 -0
- package/scripts/lib/locales/de.mjs +78 -0
- package/scripts/lib/locales/el.mjs +78 -0
- package/scripts/lib/locales/en.mjs +78 -0
- package/scripts/lib/locales/es.mjs +78 -0
- package/scripts/lib/locales/et.mjs +78 -0
- package/scripts/lib/locales/fi.mjs +78 -0
- package/scripts/lib/locales/fil.mjs +78 -0
- package/scripts/lib/locales/fr.mjs +78 -0
- package/scripts/lib/locales/ga.mjs +78 -0
- package/scripts/lib/locales/ha.mjs +78 -0
- package/scripts/lib/locales/hr.mjs +78 -0
- package/scripts/lib/locales/hu.mjs +78 -0
- package/scripts/lib/locales/id.mjs +78 -0
- package/scripts/lib/locales/is.mjs +78 -0
- package/scripts/lib/locales/it.mjs +78 -0
- package/scripts/lib/locales/jv.mjs +78 -0
- package/scripts/lib/locales/lb.mjs +78 -0
- package/scripts/lib/locales/lt.mjs +78 -0
- package/scripts/lib/locales/lv.mjs +78 -0
- package/scripts/lib/locales/mk.mjs +78 -0
- package/scripts/lib/locales/mt.mjs +78 -0
- package/scripts/lib/locales/nb.mjs +78 -0
- package/scripts/lib/locales/nl.mjs +78 -0
- package/scripts/lib/locales/nn.mjs +78 -0
- package/scripts/lib/locales/pcm.mjs +78 -0
- package/scripts/lib/locales/pl.mjs +78 -0
- package/scripts/lib/locales/pt.mjs +78 -0
- package/scripts/lib/locales/ro.mjs +78 -0
- package/scripts/lib/locales/ru.mjs +78 -0
- package/scripts/lib/locales/sk.mjs +78 -0
- package/scripts/lib/locales/sl.mjs +78 -0
- package/scripts/lib/locales/sq.mjs +78 -0
- package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
- package/scripts/lib/locales/sr-Latn.mjs +78 -0
- package/scripts/lib/locales/sv.mjs +78 -0
- package/scripts/lib/locales/sw.mjs +78 -0
- package/scripts/lib/locales/tr.mjs +78 -0
- package/scripts/lib/locales/uk.mjs +78 -0
- package/scripts/lib/locales/vi.mjs +78 -0
- package/scripts/lib/markdown-links.mjs +4 -25
- package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
- package/scripts/lib/markdown-notes.mjs +165 -0
- package/scripts/lib/navigation-model.mjs +1 -1
- package/scripts/lib/norna-markdown-blocks.mjs +123 -447
- package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
- package/scripts/lib/page-aliases.mjs +12 -1
- package/scripts/lib/page-markdown.mjs +63 -16
- package/scripts/lib/page-move-plan.mjs +22 -5
- package/scripts/lib/pagefind-translations.mjs +620 -0
- package/scripts/lib/presentation-contract.mjs +6 -1
- package/scripts/lib/presentation.mjs +29 -30
- package/scripts/lib/project-config.mjs +22 -163
- package/scripts/lib/schema-definitions.mjs +13 -12
- package/scripts/lib/schema-editor-metadata.mjs +42 -46
- package/scripts/lib/schema-value-definitions.mjs +7 -8
- package/scripts/lib/semantic-callouts.mjs +4 -2
- package/scripts/lib/site-content.mjs +4 -2
- package/scripts/lib/site-link-graph.mjs +16 -6
- package/scripts/lib/site-navigation-tree.mjs +0 -11
- package/scripts/lib/site-paths.mjs +14 -2
- package/scripts/lib/sitemap.mjs +5 -3
- package/scripts/lib/structured-blocks.mjs +108 -0
- package/scripts/lib/table-render-plugin.mjs +76 -0
- package/scripts/lib/table-row-headers.mjs +218 -0
- package/scripts/lib/theme-presets.mjs +3 -10
- package/scripts/lib/yaml-config.mjs +2 -6
- package/scripts/migrate-check.mjs +19 -0
- package/scripts/sync-content-sections.mjs +39 -10
- package/scripts/sync-site-public.mjs +7 -1
- package/src/components/CardList.astro +1 -0
- package/src/components/CodeBlockCopyScript.astro +49 -6
- package/src/components/ContentBlocks.astro +39 -0
- package/src/components/ContentTabsScript.astro +60 -0
- package/src/components/DisplaySettings.astro +17 -19
- package/src/components/ImageCarousel.astro +4 -1
- package/src/components/ImageStack.astro +34 -9
- package/src/components/ImageStackEnhancement.astro +331 -0
- package/src/components/NavigationPageTree.astro +1 -1
- package/src/components/NoteNavigationScript.astro +4 -0
- package/src/components/PageAliasRedirect.astro +5 -4
- package/src/components/PageContentsNavigation.astro +2 -3
- package/src/components/SearchNavigationScript.astro +4 -0
- package/src/components/SearchPage.astro +43 -2
- package/src/components/SectionNavigationScript.astro +86 -16
- package/src/components/SiteBreadcrumbs.astro +1 -1
- package/src/components/SiteNavigation.astro +98 -65
- package/src/components/SitePage.astro +73 -33
- package/src/components/SiteSection.astro +5 -81
- package/src/components/SiteTreeNavigation.astro +5 -2
- package/src/components/TableOverflowScript.astro +579 -0
- package/src/components/TopPageMenu.astro +47 -0
- package/src/components/TreeNavigationScript.astro +86 -23
- package/src/layouts/BaseLayout.astro +34 -12
- package/src/lib/generatedImages.ts +18 -0
- package/src/lib/navigationFollowing.ts +142 -0
- package/src/lib/noteNavigation.ts +50 -0
- package/src/lib/searchNavigation.ts +107 -0
- package/src/lib/sectionContent.ts +23 -116
- package/src/lib/siteNavigation.ts +0 -5
- package/src/lib/sitePages.ts +18 -4
- package/src/lib/table-sort-tooltip.ts +109 -0
- package/src/pages/[...slug].astro +16 -6
- package/src/styles/content.css +563 -26
- package/src/styles/foundations.css +9 -0
- package/src/styles/media.css +192 -3
- package/src/styles/navigation.css +214 -60
- package/src/styles/page-layout.css +55 -16
- package/src/styles/responsive.css +62 -20
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { markdownToHtml } from 'satteri';
|
|
2
|
+
import { groupRenderedTabs } from '../../scripts/lib/content-tabs.mjs';
|
|
2
3
|
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
3
4
|
import {
|
|
4
5
|
formatHeadingIdentifierIssue,
|
|
5
6
|
} from '../../scripts/lib/heading-ids.mjs';
|
|
7
|
+
import { splitNornaRenderedBlocks } from '../../scripts/lib/norna-markdown-blocks.mjs';
|
|
6
8
|
import { applyBasePathToHtml } from './basePath';
|
|
7
9
|
import type { SitePage } from './sitePages';
|
|
8
10
|
|
|
@@ -46,15 +48,9 @@ type PageListItem = {
|
|
|
46
48
|
pathname: string;
|
|
47
49
|
description?: string;
|
|
48
50
|
};
|
|
49
|
-
type
|
|
50
|
-
markdown: string;
|
|
51
|
-
number: number;
|
|
52
|
-
id: string;
|
|
53
|
-
referenceId: string;
|
|
54
|
-
html?: string;
|
|
55
|
-
};
|
|
56
|
-
type SectionContentBlock =
|
|
51
|
+
export type SectionContentBlock =
|
|
57
52
|
| { type: 'html'; html: string }
|
|
53
|
+
| { type: 'tabs'; index: number; panels: Array<{ label: string; contentBlocks: SectionContentBlock[] }> }
|
|
58
54
|
| { type: 'image-stack'; images: ManagedImage[] }
|
|
59
55
|
| { type: 'image-carousel'; images: ManagedImage[] }
|
|
60
56
|
| { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] }
|
|
@@ -77,6 +73,11 @@ export type HeadingNavigation = SectionNavigation & {
|
|
|
77
73
|
parentId: string | null;
|
|
78
74
|
};
|
|
79
75
|
|
|
76
|
+
export const flattenContentBlocks = (blocks: SectionContentBlock[]): SectionContentBlock[] =>
|
|
77
|
+
blocks.flatMap((block) => block.type === 'tabs'
|
|
78
|
+
? [block, ...block.panels.flatMap((panel) => flattenContentBlocks(panel.contentBlocks))]
|
|
79
|
+
: [block]);
|
|
80
|
+
|
|
80
81
|
const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
|
|
81
82
|
const imageProvenanceCommentRegex = /<!--\s*norna-image-provenance:[\s\S]*?-->/gi;
|
|
82
83
|
|
|
@@ -88,43 +89,6 @@ const prepareContentHtml = (html: string) =>
|
|
|
88
89
|
stripImageProvenanceComments(html),
|
|
89
90
|
);
|
|
90
91
|
|
|
91
|
-
const noteDeclarationParagraphRegex = /<p>\s*\{note:\s*[\s\S]*?\}\s*<\/p>/gi;
|
|
92
|
-
const htmlCodeRegionRegex = /<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>/gi;
|
|
93
|
-
|
|
94
|
-
const replaceHtmlOutsideCode = (html: string, transform: (value: string) => string) => {
|
|
95
|
-
let result = '';
|
|
96
|
-
let cursor = 0;
|
|
97
|
-
|
|
98
|
-
for (const match of html.matchAll(htmlCodeRegionRegex)) {
|
|
99
|
-
const start = match.index ?? 0;
|
|
100
|
-
result += transform(html.slice(cursor, start));
|
|
101
|
-
result += match[0];
|
|
102
|
-
cursor = start + match[0].length;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return result + transform(html.slice(cursor));
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const applyInlineNoteMarkup = (html: string, notes: InlineNote[]) => {
|
|
109
|
-
let referenceIndex = 0;
|
|
110
|
-
const withReferences = replaceHtmlOutsideCode(html, (value) => value.replace(/\{note-ref\}/g, () => {
|
|
111
|
-
const note = notes[referenceIndex];
|
|
112
|
-
if (!note) return '{note-ref}';
|
|
113
|
-
|
|
114
|
-
referenceIndex += 1;
|
|
115
|
-
return [
|
|
116
|
-
'\u2060',
|
|
117
|
-
`<sup class="section-note-ref"><a id="${note.referenceId}" href="#${note.id}" aria-label="Note ${note.number}" aria-describedby="${note.id}">${note.number}</a></sup>`,
|
|
118
|
-
`<span class="section-note section-note-margin" id="${note.id}" aria-label="Note ${note.number}" role="note">`,
|
|
119
|
-
`<a class="section-note-number" href="#${note.referenceId}" aria-label="Note ${note.number}">${note.number}</a>`,
|
|
120
|
-
`<span class="section-note-content">${note.html ?? ''}</span>`,
|
|
121
|
-
'</span>',
|
|
122
|
-
].join('');
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
return withReferences.replace(noteDeclarationParagraphRegex, '');
|
|
126
|
-
};
|
|
127
|
-
|
|
128
92
|
const applyH3HeadingIds = (
|
|
129
93
|
html: string,
|
|
130
94
|
headings: Array<{ id: string | null }>,
|
|
@@ -149,27 +113,6 @@ const applyH3HeadingIds = (
|
|
|
149
113
|
|
|
150
114
|
const getImageSourceKey = (page: SitePage, image: string) => `pages/${page.pageDirectory}/images/${image}`;
|
|
151
115
|
|
|
152
|
-
const renderInlineNoteMarkdown = async (markdown: string) => {
|
|
153
|
-
const result = await markdownToHtml(markdown, {
|
|
154
|
-
features: {
|
|
155
|
-
gfm: true,
|
|
156
|
-
smartPunctuation: true,
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
if (/<img\b/i.test(result.html)) {
|
|
161
|
-
throw new Error('Inline notes cannot contain images. Use a Norna image block with a caption instead.');
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const inlineHtml = result.html.trim();
|
|
165
|
-
const paragraph = inlineHtml.match(/^<p>([\s\S]*)<\/p>$/i);
|
|
166
|
-
if (!paragraph) {
|
|
167
|
-
throw new Error('Inline notes support inline Markdown only. Move headings, lists, and separate paragraphs into the page content.');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return paragraph[1] ?? '';
|
|
171
|
-
};
|
|
172
|
-
|
|
173
116
|
const renderHeadingTitleHtml = async (headingSource: string) => {
|
|
174
117
|
const headingMarkdown = headingSource
|
|
175
118
|
.replace(/^#{1,6}[ \t]+/, '')
|
|
@@ -202,45 +145,15 @@ const splitRenderedRegions = (html: string, regionCount: number) => {
|
|
|
202
145
|
});
|
|
203
146
|
};
|
|
204
147
|
|
|
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
148
|
const resolveContentBlocks = async (
|
|
232
149
|
html: string,
|
|
233
150
|
blocks: ParsedNornaBlock[],
|
|
234
151
|
page: SitePage,
|
|
235
152
|
childPages: SitePage[],
|
|
236
|
-
inlineNotes: InlineNote[] = [],
|
|
237
153
|
) => {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
})));
|
|
242
|
-
const renderedHtml = applyInlineNoteMarkup(html, renderedNotes);
|
|
243
|
-
const splitBlocks = splitNornaBlockMarkers(renderedHtml, blocks);
|
|
154
|
+
const splitBlocks = splitNornaRenderedBlocks(html, blocks) as Array<
|
|
155
|
+
{ type: 'html'; html: string } | ParsedNornaBlock
|
|
156
|
+
>;
|
|
244
157
|
const resolvedBlocks: SectionContentBlock[] = [];
|
|
245
158
|
|
|
246
159
|
for (const block of splitBlocks) {
|
|
@@ -296,6 +209,10 @@ export const getSectionsContent = async (
|
|
|
296
209
|
childPages: SitePage[] = [],
|
|
297
210
|
) => {
|
|
298
211
|
const pageDocument = page.markdownDocument;
|
|
212
|
+
const noteError = pageDocument.noteDiagnostics.find((issue) => issue.severity === 'error');
|
|
213
|
+
if (noteError) throw new Error(noteError.message);
|
|
214
|
+
const tabError = pageDocument.diagnostics.find((issue) => issue.code === 'invalid-content-tabs');
|
|
215
|
+
if (tabError) throw new Error(tabError.message);
|
|
299
216
|
const sourceLabel = page.contentLabel;
|
|
300
217
|
const headingIdentifierIssues = pageDocument.headingIssues;
|
|
301
218
|
if (headingIdentifierIssues.length > 0) {
|
|
@@ -303,7 +220,6 @@ export const getSectionsContent = async (
|
|
|
303
220
|
}
|
|
304
221
|
const sections: ResolvedSection[] = [];
|
|
305
222
|
const sectionIds = new Set<string>();
|
|
306
|
-
let nextNoteNumber = 1;
|
|
307
223
|
const pageHeadingCount = pageDocument.pageHeadings.length;
|
|
308
224
|
|
|
309
225
|
if (pageHeadingCount !== 1 || pageDocument.regions[0]?.kind !== 'page-intro') {
|
|
@@ -333,29 +249,20 @@ export const getSectionsContent = async (
|
|
|
333
249
|
if (bodySection.noteErrors.length > 0) {
|
|
334
250
|
throw new Error(bodySection.noteErrors[0].message);
|
|
335
251
|
}
|
|
336
|
-
|
|
337
|
-
|
|
252
|
+
const calloutErrors = bodySection.calloutErrors.filter((error) => error.severity !== 'warning');
|
|
253
|
+
if (calloutErrors.length > 0) {
|
|
254
|
+
throw new Error(calloutErrors[0].message);
|
|
338
255
|
}
|
|
339
256
|
|
|
340
|
-
const inlineNotes = bodySection.notes.map((note) => {
|
|
341
|
-
const number = nextNoteNumber;
|
|
342
|
-
nextNoteNumber += 1;
|
|
343
|
-
const pageKey = page.pageId || 'home';
|
|
344
|
-
const noteKey = `${pageKey}-${id ?? 'page-title'}-${number}`;
|
|
345
|
-
return {
|
|
346
|
-
...note,
|
|
347
|
-
number,
|
|
348
|
-
id: `note-${noteKey}`,
|
|
349
|
-
referenceId: `note-ref-${noteKey}`,
|
|
350
|
-
};
|
|
351
|
-
});
|
|
352
|
-
|
|
353
257
|
sections.push({
|
|
354
258
|
id,
|
|
355
259
|
title,
|
|
356
260
|
titleHtml: await renderHeadingTitleHtml(bodySection.heading.source),
|
|
357
261
|
headingLevel,
|
|
358
|
-
contentBlocks:
|
|
262
|
+
contentBlocks: groupRenderedTabs(
|
|
263
|
+
await resolveContentBlocks(content, bodySection.blocks, page, childPages),
|
|
264
|
+
bodySection.tabGroups,
|
|
265
|
+
),
|
|
359
266
|
});
|
|
360
267
|
}
|
|
361
268
|
|
|
@@ -3,7 +3,6 @@ import type { SiteNode, SitePage } from './sitePages';
|
|
|
3
3
|
import {
|
|
4
4
|
flattenSiteNavigationTree as flattenSiteNavigationTreeShared,
|
|
5
5
|
getDirectChildPages as getDirectChildPagesShared,
|
|
6
|
-
getFirstPageInNavigationNode as getFirstPageInNavigationNodeShared,
|
|
7
6
|
getListedSiteNavigationTree as getListedSiteNavigationTreeShared,
|
|
8
7
|
getSequentialPageNavigation as getSequentialPageNavigationShared,
|
|
9
8
|
} from '../../scripts/lib/site-navigation-tree.mjs';
|
|
@@ -22,10 +21,6 @@ export const getListedSiteNavigationTree = (entries: SiteNavigationEntry[]) => (
|
|
|
22
21
|
getListedSiteNavigationTreeShared(entries) as SiteNavigationNode[]
|
|
23
22
|
);
|
|
24
23
|
|
|
25
|
-
export const getFirstPageInNavigationNode = (node: SiteNavigationNode): SitePage | null => (
|
|
26
|
-
getFirstPageInNavigationNodeShared(node) as SitePage | null
|
|
27
|
-
);
|
|
28
|
-
|
|
29
24
|
export const flattenSiteNavigationTree = (nodes: SiteNavigationNode[]): SiteNavigationNode[] => (
|
|
30
25
|
flattenSiteNavigationTreeShared(nodes) as SiteNavigationNode[]
|
|
31
26
|
);
|
package/src/lib/sitePages.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { decodePageDirectoryPath, parsePageDirectoryPath } from '../../scripts/l
|
|
|
4
4
|
import { parsePageMarkdown } from '../../scripts/lib/page-markdown.mjs';
|
|
5
5
|
import { readSiteFile } from '../../scripts/lib/site-content.mjs';
|
|
6
6
|
import { homePageDirectory } from '../../scripts/lib/site-conventions.mjs';
|
|
7
|
+
import { assertCategoryDestinationModel, createCategoryDestinationModel } from '../../scripts/lib/category-destinations.mjs';
|
|
7
8
|
import { getSiteStructure } from '../../scripts/lib/site-structure.mjs';
|
|
8
9
|
import { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
|
|
9
10
|
|
|
@@ -20,6 +21,7 @@ type SiteNodeBase = {
|
|
|
20
21
|
depth: number;
|
|
21
22
|
pageDirectories: string[];
|
|
22
23
|
pathSegment: string;
|
|
24
|
+
pathname: string;
|
|
23
25
|
pageDirectory: string;
|
|
24
26
|
pageId: string;
|
|
25
27
|
pageIds: string[];
|
|
@@ -36,10 +38,12 @@ export type SitePage = SiteNodeBase & {
|
|
|
36
38
|
markdown: string;
|
|
37
39
|
markdownDocument: Awaited<ReturnType<typeof parsePageMarkdown>>;
|
|
38
40
|
contentLabel: string;
|
|
41
|
+
contentPath: string;
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
export type SiteCategory = SiteNodeBase & {
|
|
42
45
|
kind: 'category';
|
|
46
|
+
categorySourceLabel: string;
|
|
43
47
|
};
|
|
44
48
|
|
|
45
49
|
export type SiteNode = SitePage | SiteCategory;
|
|
@@ -86,20 +90,26 @@ const compareNumberPaths = (left: number[], right: number[]) => {
|
|
|
86
90
|
const readPageMarkdownDocument = async (entry: SiteEntry) => {
|
|
87
91
|
const pageDirectory = getPageDirectory(entry);
|
|
88
92
|
const contentLabel = `${sitePagesLabel}/${pageDirectory}/content.md`;
|
|
89
|
-
const
|
|
90
|
-
const
|
|
93
|
+
const contentPath = path.join(sitePagesDir, pageDirectory, 'content.md');
|
|
94
|
+
const { body, frontmatter } = await readSiteFile(contentPath, contentLabel);
|
|
95
|
+
const markdownDocument = await parsePageMarkdown(body, {
|
|
96
|
+
label: contentLabel,
|
|
97
|
+
lineOffset: frontmatter.split('\n').length - 1,
|
|
98
|
+
});
|
|
99
|
+
const detailsError = markdownDocument.diagnostics.find((issue) => issue.code === 'heading-inside-details');
|
|
100
|
+
if (detailsError) throw new Error(`${detailsError.message} ${detailsError.fix}`);
|
|
91
101
|
|
|
92
102
|
if (markdownDocument.pageHeadings.length !== 1 || markdownDocument.regions[0]?.kind !== 'page-intro') {
|
|
93
103
|
throw new Error(`Page entry "${entry.id}" must contain exactly one Markdown H1 page title.`);
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
return { body, contentLabel, markdownDocument };
|
|
106
|
+
return { body, contentLabel, contentPath, markdownDocument };
|
|
97
107
|
};
|
|
98
108
|
|
|
99
109
|
const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
|
|
100
110
|
const isHome = isHomePageEntry(entry);
|
|
101
111
|
const pageMetadata = getPageMetadata(entry);
|
|
102
|
-
const { body, contentLabel, markdownDocument } = await readPageMarkdownDocument(entry);
|
|
112
|
+
const { body, contentLabel, contentPath, markdownDocument } = await readPageMarkdownDocument(entry);
|
|
103
113
|
const pageDirectory = pageMetadata.pageDirectory;
|
|
104
114
|
const pageId = pageMetadata.pageId;
|
|
105
115
|
const pagePath = pageMetadata.pagePath;
|
|
@@ -126,6 +136,7 @@ const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
|
|
|
126
136
|
pagePath,
|
|
127
137
|
parentPagePath: isHome ? null : pageMetadata.parentPagePath,
|
|
128
138
|
contentLabel,
|
|
139
|
+
contentPath,
|
|
129
140
|
markdown: body,
|
|
130
141
|
markdownDocument,
|
|
131
142
|
title: markdownDocument.pageTitle.title,
|
|
@@ -142,6 +153,8 @@ const createSiteCategory = (category: Awaited<ReturnType<typeof getSiteStructure
|
|
|
142
153
|
order: category.pageOrder,
|
|
143
154
|
},
|
|
144
155
|
pathSegment: category.pagePath,
|
|
156
|
+
pathname: getPagePathname(category.pagePath),
|
|
157
|
+
categorySourceLabel: category.categorySourceLabel,
|
|
145
158
|
pageDirectory: category.pageDirectory,
|
|
146
159
|
pageDirectories: category.pageDirectories,
|
|
147
160
|
pageId: category.pageId,
|
|
@@ -223,6 +236,7 @@ export const getSiteModel = async (entries: SiteEntry[]) => {
|
|
|
223
236
|
}
|
|
224
237
|
|
|
225
238
|
return {
|
|
239
|
+
categoryDestinations: assertCategoryDestinationModel(createCategoryDestinationModel(nodes)),
|
|
226
240
|
categories: nodes.filter((node): node is SiteCategory => node.kind === 'category'),
|
|
227
241
|
nodes,
|
|
228
242
|
pages,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const createTableSortTooltip = () => {
|
|
2
|
+
const tooltip = document.createElement('div');
|
|
3
|
+
tooltip.id = 'norna-table-sort-tooltip';
|
|
4
|
+
tooltip.className = 'norna-table-sort-tooltip';
|
|
5
|
+
tooltip.setAttribute('role', 'tooltip');
|
|
6
|
+
tooltip.hidden = true;
|
|
7
|
+
document.body.append(tooltip);
|
|
8
|
+
let owner: HTMLButtonElement | null = null;
|
|
9
|
+
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
|
10
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
|
|
12
|
+
const setDescription = (button: HTMLButtonElement, enabled: boolean) => {
|
|
13
|
+
const ids = new Set((button.getAttribute('aria-describedby') ?? '').split(/\s+/u).filter(Boolean));
|
|
14
|
+
if (enabled) ids.add(tooltip.id);
|
|
15
|
+
else ids.delete(tooltip.id);
|
|
16
|
+
if (ids.size) button.setAttribute('aria-describedby', [...ids].join(' '));
|
|
17
|
+
else button.removeAttribute('aria-describedby');
|
|
18
|
+
};
|
|
19
|
+
const hide = () => {
|
|
20
|
+
clearTimeout(openTimer);
|
|
21
|
+
clearTimeout(closeTimer);
|
|
22
|
+
if (owner) setDescription(owner, false);
|
|
23
|
+
owner = null;
|
|
24
|
+
tooltip.hidden = true;
|
|
25
|
+
};
|
|
26
|
+
const position = () => {
|
|
27
|
+
if (!owner || tooltip.hidden) return;
|
|
28
|
+
const bounds = owner.getBoundingClientRect();
|
|
29
|
+
const tip = tooltip.getBoundingClientRect();
|
|
30
|
+
const width = document.documentElement.clientWidth;
|
|
31
|
+
const height = window.innerHeight;
|
|
32
|
+
const left = Math.max(8, Math.min(width - tip.width - 8, bounds.left + (bounds.width - tip.width) / 2));
|
|
33
|
+
const below = bounds.bottom + 4;
|
|
34
|
+
const top = below + tip.height <= height - 8 ? below : Math.max(8, bounds.top - tip.height - 4);
|
|
35
|
+
tooltip.style.left = `${left}px`;
|
|
36
|
+
tooltip.style.top = `${top}px`;
|
|
37
|
+
};
|
|
38
|
+
const refresh = (button: HTMLButtonElement) => {
|
|
39
|
+
if (button !== owner || tooltip.hidden) return;
|
|
40
|
+
tooltip.textContent = button.dataset.tableSortAction ?? '';
|
|
41
|
+
position();
|
|
42
|
+
};
|
|
43
|
+
const show = (button: HTMLButtonElement, delay: number) => {
|
|
44
|
+
if (button === owner && !tooltip.hidden) {
|
|
45
|
+
clearTimeout(closeTimer);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
hide();
|
|
49
|
+
owner = button;
|
|
50
|
+
const open = () => {
|
|
51
|
+
if (!button.isConnected || button.closest('[inert]') || button.getAttribute('aria-hidden') === 'true') {
|
|
52
|
+
hide();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
tooltip.hidden = false;
|
|
56
|
+
setDescription(button, true);
|
|
57
|
+
refresh(button);
|
|
58
|
+
};
|
|
59
|
+
if (delay === 0) open();
|
|
60
|
+
else openTimer = setTimeout(open, delay);
|
|
61
|
+
};
|
|
62
|
+
const scheduleHide = () => {
|
|
63
|
+
clearTimeout(openTimer);
|
|
64
|
+
clearTimeout(closeTimer);
|
|
65
|
+
// Allow the pointer to cross the small gap into the tooltip.
|
|
66
|
+
closeTimer = setTimeout(() => {
|
|
67
|
+
if (!tooltip.matches(':hover') && !owner?.matches(':hover, :focus-visible')) hide();
|
|
68
|
+
}, 120);
|
|
69
|
+
};
|
|
70
|
+
tooltip.addEventListener('pointerenter', () => clearTimeout(closeTimer));
|
|
71
|
+
tooltip.addEventListener('pointerleave', scheduleHide);
|
|
72
|
+
document.addEventListener('keydown', (event) => {
|
|
73
|
+
if (event.key === 'Escape' && owner) {
|
|
74
|
+
hide();
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
document.addEventListener('scroll', () => {
|
|
79
|
+
// Keyboard focus may scroll a clipped heading into view after opening its tip.
|
|
80
|
+
if (owner?.matches(':focus-visible')) {
|
|
81
|
+
const bounds = owner.getBoundingClientRect();
|
|
82
|
+
if (bounds.bottom > 0 && bounds.top < window.innerHeight) {
|
|
83
|
+
position();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
hide();
|
|
88
|
+
}, { capture: true, passive: true });
|
|
89
|
+
window.addEventListener('resize', hide, { passive: true });
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
refresh,
|
|
93
|
+
attach(button: HTMLButtonElement) {
|
|
94
|
+
setDescription(button, false);
|
|
95
|
+
button.addEventListener('pointerenter', (event) => {
|
|
96
|
+
if (event.pointerType !== 'touch') show(button, 500);
|
|
97
|
+
});
|
|
98
|
+
button.addEventListener('pointerleave', () => {
|
|
99
|
+
if (button === owner) scheduleHide();
|
|
100
|
+
});
|
|
101
|
+
button.addEventListener('focus', () => {
|
|
102
|
+
if (button.matches(':focus-visible')) show(button, 0);
|
|
103
|
+
});
|
|
104
|
+
button.addEventListener('blur', () => {
|
|
105
|
+
if (button === owner) scheduleHide();
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
@@ -45,18 +45,28 @@ export async function getStaticPaths() {
|
|
|
45
45
|
? [{ params: { slug: 'search' }, props: { searchPage: true } }]
|
|
46
46
|
: [];
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
const categoryPaths = siteModel.categoryDestinations.destinations.map((destination) => ({
|
|
49
|
+
params: { slug: destination.category.pathSegment },
|
|
50
|
+
props: destination.kind === 'redirect' ? {
|
|
51
|
+
alias: {
|
|
52
|
+
targetPathname: destination.target.pathname,
|
|
53
|
+
targetTitle: destination.target.title,
|
|
54
|
+
categoryTitle: destination.category.title,
|
|
55
|
+
},
|
|
56
|
+
} : { categoryPath: destination.pathname },
|
|
57
|
+
}));
|
|
58
|
+
return [...pagePaths, ...aliasPaths, ...searchPath, ...categoryPaths];
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
const { alias, entry, searchPage } = Astro.props;
|
|
61
|
+
const { alias, entry, searchPage, categoryPath } = Astro.props;
|
|
52
62
|
|
|
53
|
-
if (!searchPage && !alias && (!entry || isHomePageEntry(entry))) {
|
|
63
|
+
if (!searchPage && !alias && !categoryPath && (!entry || isHomePageEntry(entry))) {
|
|
54
64
|
throw new Error('Page entry is missing.');
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
const currentPage = entry ? await getSitePage(entry) : null;
|
|
58
68
|
|
|
59
|
-
if (!searchPage && !alias && !currentPage?.pathSegment) {
|
|
69
|
+
if (!searchPage && !alias && !categoryPath && !currentPage?.pathSegment) {
|
|
60
70
|
throw new Error(`Page "${entry.id}" needs a page id.`);
|
|
61
71
|
}
|
|
62
72
|
---
|
|
@@ -64,5 +74,5 @@ if (!searchPage && !alias && !currentPage?.pathSegment) {
|
|
|
64
74
|
{searchPage
|
|
65
75
|
? <SearchPage />
|
|
66
76
|
: alias
|
|
67
|
-
? <PageAliasRedirect targetPathname={alias.targetPathname} targetTitle={alias.targetTitle} />
|
|
68
|
-
: <SitePage entry={entry} />}
|
|
77
|
+
? <PageAliasRedirect targetPathname={alias.targetPathname} targetTitle={alias.targetTitle} categoryTitle={alias.categoryTitle} />
|
|
78
|
+
: <SitePage entry={entry} categoryPath={categoryPath} />}
|