@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
package/scripts/check-config.mjs
CHANGED
|
@@ -76,7 +76,11 @@ try {
|
|
|
76
76
|
console.log('Config check passed.');
|
|
77
77
|
console.log(`Site URL: ${projectConfig.site.url}`);
|
|
78
78
|
console.log(`Base path: ${projectConfig.site.basePath}`);
|
|
79
|
-
|
|
79
|
+
const editLinkDestinations = [
|
|
80
|
+
projectConfig.editLink?.localEditor ? `local ${projectConfig.editLink.localEditor}` : null,
|
|
81
|
+
projectConfig.editLink?.baseUrl ? `remote ${projectConfig.editLink.baseUrl}` : null,
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
console.log(`Edit links: ${editLinkDestinations.join(', ') || '(disabled)'}`);
|
|
80
84
|
console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
|
|
81
85
|
console.log(`Page width: ${projectConfig.layout.pageWidth}`);
|
|
82
86
|
console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
|
package/scripts/dev-local.mjs
CHANGED
|
@@ -5,7 +5,12 @@ import { networkInterfaces } from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { getAstroArgs, runAstroInherit } from './lib/astro-command.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
astroCacheDir,
|
|
10
|
+
engineRoot,
|
|
11
|
+
siteProjectRoot,
|
|
12
|
+
siteStateDir,
|
|
13
|
+
} from './lib/site-paths.mjs';
|
|
9
14
|
|
|
10
15
|
const execFileAsync = promisify(execFile);
|
|
11
16
|
|
|
@@ -27,8 +32,12 @@ const probeUrls = [
|
|
|
27
32
|
`http://localhost:${port}${projectConfig.site.basePath}`,
|
|
28
33
|
];
|
|
29
34
|
const skipOpen = process.env.NORNA_NO_OPEN === '1';
|
|
30
|
-
const
|
|
35
|
+
const stateDirectory = path.join(siteStateDir, 'dev');
|
|
36
|
+
const statePath = path.join(stateDirectory, 'state.json');
|
|
37
|
+
const legacyStatePath = path.join(astroCacheDir, 'dev-local.json');
|
|
38
|
+
const astroStatePath = path.join(astroCacheDir, 'dev.json');
|
|
31
39
|
const logPath = path.join(astroCacheDir, 'dev.log');
|
|
40
|
+
const preparationLogPath = path.join(stateDirectory, 'preparation.log');
|
|
32
41
|
const args = process.argv.slice(2);
|
|
33
42
|
const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
|
|
34
43
|
const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
|
|
@@ -51,9 +60,9 @@ const generateImages = async () => execFileAsync(process.execPath, [path.join(en
|
|
|
51
60
|
maxBuffer: 1024 * 1024 * 10,
|
|
52
61
|
});
|
|
53
62
|
|
|
54
|
-
const
|
|
63
|
+
const readJsonFile = async (filePath) => {
|
|
55
64
|
try {
|
|
56
|
-
return JSON.parse(await readFile(
|
|
65
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
57
66
|
} catch (error) {
|
|
58
67
|
if (error.code === 'ENOENT') {
|
|
59
68
|
return null;
|
|
@@ -63,15 +72,34 @@ const readState = async () => {
|
|
|
63
72
|
}
|
|
64
73
|
};
|
|
65
74
|
|
|
75
|
+
const readState = async () => (
|
|
76
|
+
await readJsonFile(statePath)
|
|
77
|
+
?? await readJsonFile(legacyStatePath)
|
|
78
|
+
);
|
|
79
|
+
|
|
66
80
|
const getLanUrls = () => Object.values(networkInterfaces())
|
|
67
81
|
.flat()
|
|
68
82
|
.filter((network) => network?.family === 'IPv4' && !network.internal)
|
|
69
83
|
.map((network) => `http://${network.address}:${port}${projectConfig.site.basePath}`);
|
|
70
84
|
|
|
71
85
|
const writeState = async (host) => {
|
|
72
|
-
|
|
86
|
+
const astroState = await readJsonFile(astroStatePath);
|
|
87
|
+
let pid = astroState?.port === port && isProcessAlive(astroState.pid)
|
|
88
|
+
? astroState.pid
|
|
89
|
+
: null;
|
|
90
|
+
if (!Number.isInteger(pid)) {
|
|
91
|
+
const listeningPids = await getPortPids({ required: false });
|
|
92
|
+
pid = listeningPids?.length === 1 ? listeningPids[0] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!Number.isInteger(pid)) {
|
|
96
|
+
throw new Error(`The dev server started at ${localUrl}, but Norna could not record its process. Stop the process on port ${port} before starting it again.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await mkdir(stateDirectory, { recursive: true });
|
|
73
100
|
await writeFile(statePath, `${JSON.stringify({
|
|
74
101
|
port,
|
|
102
|
+
pid,
|
|
75
103
|
host,
|
|
76
104
|
mode: host === '0.0.0.0' ? 'lan' : 'local',
|
|
77
105
|
url: localUrl,
|
|
@@ -81,6 +109,8 @@ const writeState = async (host) => {
|
|
|
81
109
|
|
|
82
110
|
const removeState = async () => {
|
|
83
111
|
await rm(statePath, { force: true });
|
|
112
|
+
await rm(legacyStatePath, { force: true });
|
|
113
|
+
await rm(stateDirectory, { recursive: true, force: true });
|
|
84
114
|
};
|
|
85
115
|
|
|
86
116
|
const readDevLog = async () => {
|
|
@@ -161,6 +191,16 @@ const sleep = (milliseconds) => new Promise((resolve) => {
|
|
|
161
191
|
setTimeout(resolve, milliseconds);
|
|
162
192
|
});
|
|
163
193
|
|
|
194
|
+
const isProcessAlive = (pid) => {
|
|
195
|
+
if (!Number.isInteger(pid)) return false;
|
|
196
|
+
try {
|
|
197
|
+
process.kill(pid, 0);
|
|
198
|
+
return true;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return error.code === 'EPERM';
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
164
204
|
const isServerReachable = async () => {
|
|
165
205
|
for (const probeUrl of probeUrls) {
|
|
166
206
|
try {
|
|
@@ -213,7 +253,7 @@ const parseWindowsPortPids = (stdout) => [...new Set(
|
|
|
213
253
|
.filter(Number.isInteger),
|
|
214
254
|
)];
|
|
215
255
|
|
|
216
|
-
const getPortPids = async () => {
|
|
256
|
+
const getPortPids = async ({ required = true } = {}) => {
|
|
217
257
|
try {
|
|
218
258
|
if (process.platform === 'win32') {
|
|
219
259
|
const { stdout } = await execFileAsync('powershell.exe', [
|
|
@@ -234,6 +274,7 @@ const getPortPids = async () => {
|
|
|
234
274
|
} catch (error) {
|
|
235
275
|
if (error.code === 1) return [];
|
|
236
276
|
if (error.code === 'ENOENT') {
|
|
277
|
+
if (!required) return null;
|
|
237
278
|
const commandName = process.platform === 'win32' ? 'PowerShell' : 'lsof';
|
|
238
279
|
throw new Error(`Cannot use --kill because ${commandName} is not available. Stop the process on port ${port} manually, then rerun the command.`);
|
|
239
280
|
}
|
|
@@ -273,6 +314,31 @@ const terminatePortProcesses = async (host) => {
|
|
|
273
314
|
}
|
|
274
315
|
};
|
|
275
316
|
|
|
317
|
+
const terminateTrackedProcess = async (state, host) => {
|
|
318
|
+
if (state?.port !== port || !Number.isInteger(state.pid)) return false;
|
|
319
|
+
if (!isProcessAlive(state.pid)) return false;
|
|
320
|
+
|
|
321
|
+
const listeningPids = await getPortPids({ required: false });
|
|
322
|
+
if (listeningPids && !listeningPids.includes(state.pid)) return false;
|
|
323
|
+
if (!listeningPids && !(await isServerReachable())) return false;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
process.kill(state.pid, 'SIGTERM');
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error.code !== 'ESRCH') throw error;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (await waitForPortToBeFree(host)) return true;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
process.kill(state.pid, 'SIGKILL');
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error.code !== 'ESRCH') throw error;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return waitForPortToBeFree(host);
|
|
340
|
+
};
|
|
341
|
+
|
|
276
342
|
const openBrowser = async () => {
|
|
277
343
|
if (skipOpen) {
|
|
278
344
|
console.log(`Browser open skipped. Open ${localUrl}`);
|
|
@@ -294,10 +360,27 @@ const openBrowser = async () => {
|
|
|
294
360
|
|
|
295
361
|
const stopServer = async ({ quiet = false } = {}) => {
|
|
296
362
|
const state = await readState();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
363
|
+
let astroError;
|
|
364
|
+
let astroOutput = '';
|
|
365
|
+
try {
|
|
366
|
+
const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
|
|
367
|
+
astroOutput = `${stdout}\n${stderr}`;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
astroError = error;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let stopped = astroOutput.includes('Stopped dev server');
|
|
373
|
+
if (stopped) {
|
|
374
|
+
await waitForPortToBeFree(state?.host ?? localHost);
|
|
375
|
+
} else {
|
|
376
|
+
stopped = await terminateTrackedProcess(state, state?.host ?? localHost);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (stopped || !(await isServerReachable())) {
|
|
380
|
+
await removeState();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (astroError && !stopped) throw astroError;
|
|
301
384
|
|
|
302
385
|
if (!quiet) {
|
|
303
386
|
console.log(stopped
|
|
@@ -317,8 +400,29 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
|
|
|
317
400
|
await terminatePortProcesses(host);
|
|
318
401
|
}
|
|
319
402
|
|
|
320
|
-
|
|
321
|
-
|
|
403
|
+
try {
|
|
404
|
+
await syncSitePublic();
|
|
405
|
+
await generateImages();
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const output = [error.stdout, error.stderr].filter(Boolean).join('\n') || String(error);
|
|
408
|
+
const excerpt = getLogExcerpt(output)
|
|
409
|
+
.replace(/\n\s+at [\s\S]*$/u, '')
|
|
410
|
+
.replace(/\nNode\.js v[^\n]*$/u, '')
|
|
411
|
+
.trim();
|
|
412
|
+
let logHint;
|
|
413
|
+
try {
|
|
414
|
+
await mkdir(stateDirectory, { recursive: true });
|
|
415
|
+
await writeFile(preparationLogPath, output);
|
|
416
|
+
logHint = `Full log: ${preparationLogPath}`;
|
|
417
|
+
} catch (logError) {
|
|
418
|
+
logHint = `Could not save the preparation log: ${logError.message}`;
|
|
419
|
+
}
|
|
420
|
+
throw new Error([
|
|
421
|
+
`Could not prepare the site for the dev server at ${localUrl}.`,
|
|
422
|
+
excerpt,
|
|
423
|
+
logHint,
|
|
424
|
+
].join('\n\n'), { cause: error });
|
|
425
|
+
}
|
|
322
426
|
const previousLog = await readDevLog();
|
|
323
427
|
try {
|
|
324
428
|
await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
|
|
@@ -370,7 +474,20 @@ const showStatus = async () => {
|
|
|
370
474
|
}
|
|
371
475
|
|
|
372
476
|
if (reachable) {
|
|
373
|
-
|
|
477
|
+
const listeningPids = await getPortPids({ required: false });
|
|
478
|
+
const stateOwnsPort = state?.port === port
|
|
479
|
+
&& Number.isInteger(state.pid)
|
|
480
|
+
&& (listeningPids ? listeningPids.includes(state.pid) : isProcessAlive(state.pid));
|
|
481
|
+
if (stateOwnsPort) {
|
|
482
|
+
console.log(`dev:${state.mode ?? 'local'} is running at ${localUrl}. Norna retained its process record after Astro cleared its cache.`);
|
|
483
|
+
if (state.host === '0.0.0.0') {
|
|
484
|
+
const lanUrls = getLanUrls();
|
|
485
|
+
if (lanUrls.length > 0) console.log(`On this local network: ${lanUrls.join(', ')}`);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
console.log(`A server is responding at ${localUrl}, but neither Astro nor Norna tracks it for this site.`);
|
|
374
491
|
return;
|
|
375
492
|
}
|
|
376
493
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
themeVisualSchema,
|
|
12
12
|
} from './lib/schema-definitions.mjs';
|
|
13
13
|
import { applySchemaEditorMetadata } from './lib/schema-editor-metadata.mjs';
|
|
14
|
+
import { getNornaBlockSchema } from './lib/norna-markdown-blocks.mjs';
|
|
14
15
|
|
|
15
16
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
17
|
const schemaDirectory = path.join(root, 'schemas');
|
|
@@ -32,9 +33,20 @@ for (const [filename, title, schema] of definitions) {
|
|
|
32
33
|
schemaFiles.set(filename, `${JSON.stringify(jsonSchema, null, 2)}\n`);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
const blockTypes = ['image-stack', 'image-carousel', 'card-list'];
|
|
37
|
+
for (const type of blockTypes) {
|
|
38
|
+
const filename = `${type}.schema.json`;
|
|
39
|
+
schemaFiles.set(filename, `${JSON.stringify({
|
|
40
|
+
...getNornaBlockSchema(type),
|
|
41
|
+
$id: `https://janga.github.io/norna/schemas/${filename}`,
|
|
42
|
+
title: `Norna ${type}`,
|
|
43
|
+
}, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
schemaFiles.set('manifest.json', `${JSON.stringify({
|
|
36
|
-
editorApiVersion:
|
|
47
|
+
editorApiVersion: 2,
|
|
37
48
|
schemaVersion: 3,
|
|
49
|
+
blockSchemas: Object.fromEntries(blockTypes.map((type) => [type, `${type}.schema.json`])),
|
|
38
50
|
files: {
|
|
39
51
|
category: 'category.schema.json',
|
|
40
52
|
config: 'config.schema.json',
|
|
@@ -64,12 +76,12 @@ if (checkOnly) {
|
|
|
64
76
|
throw new Error(`Generated Norna schemas are stale: ${stale.join(', ')}. Run "npm run schemas:generate".`);
|
|
65
77
|
}
|
|
66
78
|
|
|
67
|
-
console.log(`Norna schemas are up to date (${definitions.length} schemas).`);
|
|
79
|
+
console.log(`Norna schemas are up to date (${definitions.length + blockTypes.length} schemas).`);
|
|
68
80
|
} else {
|
|
69
81
|
await mkdir(schemaDirectory, { recursive: true });
|
|
70
82
|
for (const [filename, source] of schemaFiles) {
|
|
71
83
|
await writeFile(path.join(schemaDirectory, filename), source);
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
console.log(`Generated ${definitions.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
|
|
86
|
+
console.log(`Generated ${definitions.length + blockTypes.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
|
|
75
87
|
}
|
package/scripts/init-site.mjs
CHANGED
|
@@ -18,6 +18,7 @@ dist/
|
|
|
18
18
|
# generated site state
|
|
19
19
|
**/.norna/public/
|
|
20
20
|
**/.norna/.astro/
|
|
21
|
+
**/.norna/dev/
|
|
21
22
|
|
|
22
23
|
# legacy Astro cache location
|
|
23
24
|
.astro/
|
|
@@ -40,6 +41,7 @@ pnpm-debug.log*
|
|
|
40
41
|
`;
|
|
41
42
|
const siteGitignore = `.norna/public/
|
|
42
43
|
.norna/.astro/
|
|
44
|
+
.norna/dev/
|
|
43
45
|
`;
|
|
44
46
|
|
|
45
47
|
const usage = `
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getSiteNavigationTree } from './site-navigation-tree.mjs';
|
|
2
|
+
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
3
|
+
|
|
4
|
+
// Accept the same normalized nodes as navigation; excluded ancestors exclude
|
|
5
|
+
// their whole subtree, including category destinations.
|
|
6
|
+
export const createCategoryDestinationModel = (nodes) => {
|
|
7
|
+
const destinations = [];
|
|
8
|
+
const diagnostics = [];
|
|
9
|
+
const visit = (branch) => {
|
|
10
|
+
const { node } = branch;
|
|
11
|
+
if (node.navigation?.listed === false) return false;
|
|
12
|
+
const children = branch.children.filter(({ node: child }) => child.navigation?.listed !== false);
|
|
13
|
+
const reachable = children.map(visit).some(Boolean);
|
|
14
|
+
if (node.kind !== 'category') return true;
|
|
15
|
+
if (!reachable) {
|
|
16
|
+
const source = node.categorySourceLabel ?? node.nodeLabel ?? node.pageDirectory;
|
|
17
|
+
diagnostics.push({
|
|
18
|
+
code: 'category-without-listed-content',
|
|
19
|
+
severity: 'error',
|
|
20
|
+
message: `${source} has no listed reachable content page.`,
|
|
21
|
+
fix: 'Add a listed page under this category, or remove the category.',
|
|
22
|
+
});
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const first = children[0].node;
|
|
26
|
+
destinations.push({
|
|
27
|
+
category: node,
|
|
28
|
+
pathname: getSiteNodePathname(node),
|
|
29
|
+
kind: first.kind === 'page' ? 'redirect' : 'listing',
|
|
30
|
+
target: first.kind === 'page' ? first : null,
|
|
31
|
+
children: children.map(({ node: child }) => child),
|
|
32
|
+
});
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
getSiteNavigationTree(nodes.map((node) => ({ node }))).forEach(visit);
|
|
36
|
+
return {
|
|
37
|
+
destinations,
|
|
38
|
+
byPathname: new Map(destinations.map((destination) => [destination.pathname, destination])),
|
|
39
|
+
diagnostics,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const assertCategoryDestinationModel = (model) => {
|
|
44
|
+
if (model.diagnostics.length) {
|
|
45
|
+
throw new Error(model.diagnostics.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
|
|
46
|
+
}
|
|
47
|
+
return model;
|
|
48
|
+
};
|
|
@@ -9,40 +9,17 @@ const invalidMetadata = (message, fix) => ({
|
|
|
9
9
|
},
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (character === '"') {
|
|
19
|
-
return {
|
|
20
|
-
rest: source.slice(index + 1),
|
|
21
|
-
title,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (character === '\\') {
|
|
26
|
-
const escaped = source[index + 1];
|
|
27
|
-
if (escaped !== '"' && escaped !== '\\') {
|
|
28
|
-
return invalidMetadata(
|
|
29
|
-
`Code title contains unsupported escape "\\${escaped ?? ''}".`,
|
|
30
|
-
'Only escape a double quote (\\") or backslash (\\\\) inside the title.',
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
title += escaped;
|
|
34
|
-
index += 2;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
title += character;
|
|
39
|
-
index += 1;
|
|
12
|
+
export const parseQuotedContentString = (source) => {
|
|
13
|
+
const quoted = source.match(/^"(?:[^"\\]|\\[\s\S])*"/)?.[0];
|
|
14
|
+
if (!quoted) return { error: 'Use a JSON double-quoted string with a closing double quote.' };
|
|
15
|
+
let value;
|
|
16
|
+
try { value = JSON.parse(quoted); } catch {
|
|
17
|
+
return { error: 'Use valid JSON escapes inside the double-quoted string.' };
|
|
40
18
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
19
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
|
|
20
|
+
return { error: 'Quoted labels and titles cannot contain control characters, including line breaks and tabs.' };
|
|
21
|
+
}
|
|
22
|
+
return { value, rest: source.slice(quoted.length) };
|
|
46
23
|
};
|
|
47
24
|
|
|
48
25
|
const parseHighlightedLines = (selector, lineCount) => {
|
|
@@ -94,15 +71,15 @@ export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
|
|
|
94
71
|
let rest = source;
|
|
95
72
|
let title = null;
|
|
96
73
|
if (rest.startsWith(titlePrefix)) {
|
|
97
|
-
const titleResult =
|
|
98
|
-
if (titleResult.error) return titleResult;
|
|
99
|
-
if (!titleResult.
|
|
74
|
+
const titleResult = parseQuotedContentString(rest.slice('title='.length));
|
|
75
|
+
if (titleResult.error) return invalidMetadata(`Invalid code title. ${titleResult.error}`, 'Write a single-line title, for example title="src/config.js".');
|
|
76
|
+
if (!titleResult.value.trim()) {
|
|
100
77
|
return invalidMetadata(
|
|
101
78
|
'Code title cannot be empty.',
|
|
102
79
|
'Remove title="" or provide a short filename or label.',
|
|
103
80
|
);
|
|
104
81
|
}
|
|
105
|
-
title = titleResult.
|
|
82
|
+
title = titleResult.value;
|
|
106
83
|
rest = titleResult.rest;
|
|
107
84
|
if (rest && !rest.startsWith(' ')) {
|
|
108
85
|
return invalidMetadata(
|
|
@@ -210,12 +187,17 @@ export const nornaCodeFenceTransformer = {
|
|
|
210
187
|
tagName: 'figure',
|
|
211
188
|
properties: { className: ['norna-code-example'] },
|
|
212
189
|
children: [
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
190
|
+
{
|
|
191
|
+
type: 'element',
|
|
192
|
+
tagName: 'figcaption',
|
|
193
|
+
properties: { className: ['norna-code-title'] },
|
|
194
|
+
children: [{
|
|
195
|
+
type: 'element',
|
|
196
|
+
tagName: 'span',
|
|
197
|
+
properties: { className: ['norna-code-title-text'] },
|
|
198
|
+
children: [{ type: 'text', value: metadata.title }],
|
|
199
|
+
}],
|
|
200
|
+
},
|
|
219
201
|
node,
|
|
220
202
|
],
|
|
221
203
|
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { markdownToMdast } from 'satteri';
|
|
2
|
+
import { parseQuotedContentString } from './code-fence-metadata.mjs';
|
|
3
|
+
|
|
4
|
+
// Only delimiters belong to this scanner. Markdown parsing and source positions
|
|
5
|
+
// stay with Satteri, including code examples that happen to contain delimiters.
|
|
6
|
+
export const parseContentTabs = (source, { label = 'Markdown', lineOffset = 0 } = {}) => {
|
|
7
|
+
if (!/^\s*(?:(?:>\s*|[-+*]\s+))?:{3,}\s*tabs?\b/m.test(source)) return { groups: [], diagnostics: [], replacements: [], maskedSource: source };
|
|
8
|
+
const tree = markdownToMdast(source, { features: { gfm: true, frontmatter: true } });
|
|
9
|
+
const nodes = [];
|
|
10
|
+
const visit = (node, parent) => {
|
|
11
|
+
nodes.push({ node, parent });
|
|
12
|
+
for (const child of node.children ?? []) visit(child, node);
|
|
13
|
+
};
|
|
14
|
+
visit(tree, null);
|
|
15
|
+
const ignored = nodes.filter(({ node }) => ['code', 'inlineCode', 'html', 'yaml', 'toml'].includes(node.type));
|
|
16
|
+
const groups = [];
|
|
17
|
+
const diagnostics = [];
|
|
18
|
+
const replacements = [];
|
|
19
|
+
let group = null;
|
|
20
|
+
let panel = null;
|
|
21
|
+
const outerContainers = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
let section = 'page introduction';
|
|
24
|
+
const error = (message, at = offset) => diagnostics.push({
|
|
25
|
+
code: 'invalid-content-tabs', severity: 'error', offset: at,
|
|
26
|
+
line: lineOffset + source.slice(0, at).split('\n').length,
|
|
27
|
+
message: `${label} line ${lineOffset + source.slice(0, at).split('\n').length}, section "${groups.find((item) => at >= item.start && at < (item.end ?? source.length))?.section ?? section}": ${message}`,
|
|
28
|
+
});
|
|
29
|
+
for (const [index, line] of source.split('\n').entries()) {
|
|
30
|
+
const start = offset;
|
|
31
|
+
offset += line.length + 1;
|
|
32
|
+
const contentStart = start + line.search(/\S|$/);
|
|
33
|
+
if (ignored.some(({ node }) => contentStart >= node.position.start.offset && contentStart < node.position.end.offset)) continue;
|
|
34
|
+
if (!group && /^#{1,3}\s/.test(line)) section = line.replace(/^#+\s*/, '').replace(/\s*\{#.*\}$/, '');
|
|
35
|
+
const match = line.match(/^(\s*)(:{3,})\s*(.*?)\s*$/);
|
|
36
|
+
if (!match) {
|
|
37
|
+
if (group && !panel && line.trim()) error('Put content inside a labelled tab, not directly inside tabs.', start);
|
|
38
|
+
if (/^\s*(?:>\s*|[-+*]\s+)(?::+)\s*tabs?\b/.test(line)) error('Place tabs directly in the section, outside lists and blockquotes.', start);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const [, indent, fence, declaration] = match;
|
|
42
|
+
const name = declaration.split(/\s/)[0];
|
|
43
|
+
if (!group && name !== 'tabs' && name !== 'tab') {
|
|
44
|
+
if (declaration) outerContainers.push(fence.length);
|
|
45
|
+
else if (outerContainers.length) outerContainers.pop();
|
|
46
|
+
else if (groups.length) error('This closing marker has no open tab group.', start);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const replace = (kind, data = {}) => replacements.push({ start, end: start + line.length, kind, ...data });
|
|
50
|
+
if (indent || (!group && nodes.some(({ node, parent }) => node.type === 'paragraph' && parent?.type !== 'root'
|
|
51
|
+
&& start >= node.position.start.offset && start < node.position.end.offset))) {
|
|
52
|
+
error('Place tabs directly in the section, outside other blocks.', start);
|
|
53
|
+
}
|
|
54
|
+
if (name === 'tabs') {
|
|
55
|
+
if (outerContainers.length) error('Place tabs directly in the section, outside other containers.', start);
|
|
56
|
+
if (group) { error('Tab groups cannot be nested.', start); continue; }
|
|
57
|
+
if (fence !== '::::' || declaration !== 'tabs') error('Open a tab group with ":::: tabs" on its own line.', start);
|
|
58
|
+
group = { index: groups.length, start, section, line: lineOffset + index + 1, panels: [] };
|
|
59
|
+
groups.push(group);
|
|
60
|
+
replace('group-start', { index: group.index });
|
|
61
|
+
} else if (name === 'tab') {
|
|
62
|
+
if (!group) { error('A tab must be inside a ":::: tabs" block.', start); continue; }
|
|
63
|
+
if (panel) error('Close the preceding tab with ":::" before starting another tab.', start);
|
|
64
|
+
const decoded = parseQuotedContentString(declaration.slice(3).trim());
|
|
65
|
+
let title = decoded.value ?? '';
|
|
66
|
+
if (fence !== ':::' || decoded.error || decoded.rest || !title.trim()) {
|
|
67
|
+
error(`Write a nonempty quoted label, for example ::: tab "Windows".${decoded.error ? ` ${decoded.error}` : ''}`, start);
|
|
68
|
+
title = '';
|
|
69
|
+
}
|
|
70
|
+
if (group.panels.some((item) => item.label.normalize('NFC') === title.trim().normalize('NFC'))) error(`Duplicate tab label "${title}". Use unique labels within this group.`, start);
|
|
71
|
+
panel = { label: title.trim(), start: offset, declarationStart: start, index: group.panels.length };
|
|
72
|
+
group.panels.push(panel);
|
|
73
|
+
replace('panel-start', { index: panel.index });
|
|
74
|
+
} else if (!declaration) {
|
|
75
|
+
if (fence === '::::') {
|
|
76
|
+
if (panel) error('Close the tab with ":::" before closing the group.', start);
|
|
77
|
+
if (group.panels.length < 2) error('A tab group needs at least two alternatives.', group.start);
|
|
78
|
+
group.end = offset;
|
|
79
|
+
replace('group-end');
|
|
80
|
+
group = panel = null;
|
|
81
|
+
} else if (fence !== ':::') {
|
|
82
|
+
error('Close tabs with ":::" and the group with "::::".', start);
|
|
83
|
+
} else if (panel) {
|
|
84
|
+
panel.end = start;
|
|
85
|
+
if (!source.slice(panel.start, start).replace(/<!--[\s\S]*?-->/g, '').trim()) error('A tab cannot be empty. Explain when the step does not apply.', panel.declarationStart);
|
|
86
|
+
replace('panel-end');
|
|
87
|
+
panel = null;
|
|
88
|
+
} else error('This closing marker has no open tab.', start);
|
|
89
|
+
} else error(`Unknown or misplaced tab marker "${declaration}". Write callouts as GitHub-style alerts, for example > [!TIP] followed by quoted body text.`, start);
|
|
90
|
+
}
|
|
91
|
+
if (group) error('The tab group is unclosed. Close each tab with ":::" and the group with "::::".', group.start);
|
|
92
|
+
let maskedSource = source;
|
|
93
|
+
for (const replacement of [...replacements].reverse()) maskedSource = maskedSource.slice(0, replacement.start)
|
|
94
|
+
+ ' '.repeat(replacement.end - replacement.start) + maskedSource.slice(replacement.end);
|
|
95
|
+
nodes.length = 0;
|
|
96
|
+
visit(markdownToMdast(maskedSource, { features: { gfm: true, frontmatter: true } }), null);
|
|
97
|
+
for (const item of groups) {
|
|
98
|
+
for (const tab of item.panels) {
|
|
99
|
+
const end = tab.end ?? item.end ?? source.length;
|
|
100
|
+
for (const { node } of nodes) {
|
|
101
|
+
if (!node.position || node.position.start.offset < tab.start || node.position.start.offset >= end) continue;
|
|
102
|
+
if (node.type === 'heading' || ['yaml', 'toml'].includes(node.type)
|
|
103
|
+
|| (node.type === 'html' && /<(?:h[1-6]|nav)\b/i.test(node.value))) error('Tabs cannot contain headings, frontmatter, or navigation definitions. Keep the shared headings outside the group.', node.position.start.offset);
|
|
104
|
+
}
|
|
105
|
+
if (/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/.test(source.slice(tab.start, end).trimStart())) error('Frontmatter belongs at the start of content.md, not inside a tab.', tab.declarationStart);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { groups, diagnostics, replacements, maskedSource };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const prepareContentTabs = (source, options = {}) => {
|
|
112
|
+
const parsed = parseContentTabs(source, options);
|
|
113
|
+
if (parsed.diagnostics.length) throw new Error(parsed.diagnostics[0].message);
|
|
114
|
+
let result = source;
|
|
115
|
+
for (const marker of [...parsed.replacements].reverse()) {
|
|
116
|
+
const html = `<div data-norna-tabs-marker="${marker.kind}"${marker.index === undefined ? '' : ` data-index="${marker.index}"`}></div>`;
|
|
117
|
+
result = result.slice(0, marker.start) + '\n' + html + '\n' + result.slice(marker.end);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const groupRenderedTabs = (blocks, groups) => {
|
|
123
|
+
const result = [];
|
|
124
|
+
let group = null;
|
|
125
|
+
let panel = null;
|
|
126
|
+
const append = (block) => (panel ? panel.contentBlocks : result).push(block);
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
if (block.type !== 'html') { append(block); continue; }
|
|
129
|
+
const pattern = /<div data-norna-tabs-marker="([a-z-]+)"(?: data-index="(\d+)")?><\/div>/g;
|
|
130
|
+
let cursor = 0;
|
|
131
|
+
for (const match of block.html.matchAll(pattern)) {
|
|
132
|
+
const before = block.html.slice(cursor, match.index);
|
|
133
|
+
if (before.trim()) append({ type: 'html', html: before });
|
|
134
|
+
const kind = match[1];
|
|
135
|
+
if (kind === 'group-start') {
|
|
136
|
+
const source = groups.find((item) => item.index === Number(match[2]));
|
|
137
|
+
if (!source) throw new Error('Rendered tab group has no matching source.');
|
|
138
|
+
group = { type: 'tabs', index: source.index, panels: [], source };
|
|
139
|
+
} else if (kind === 'panel-start') {
|
|
140
|
+
panel = { label: group.source.panels[Number(match[2])].label, contentBlocks: [] };
|
|
141
|
+
group.panels.push(panel);
|
|
142
|
+
} else if (kind === 'panel-end') panel = null;
|
|
143
|
+
else if (kind === 'group-end') {
|
|
144
|
+
const { source, ...resolved } = group;
|
|
145
|
+
result.push(resolved);
|
|
146
|
+
group = null;
|
|
147
|
+
}
|
|
148
|
+
cursor = match.index + match[0].length;
|
|
149
|
+
}
|
|
150
|
+
const after = block.html.slice(cursor);
|
|
151
|
+
if (after.trim()) append({ type: 'html', html: after });
|
|
152
|
+
}
|
|
153
|
+
if (group || panel) throw new Error('Rendered tab group is incomplete.');
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parseFragment } from 'parse5';
|
|
2
|
+
|
|
3
|
+
export const getDetailsHeadingDiagnostics = (tree, { source, label, lineOffset = 0 }) => {
|
|
4
|
+
if (!/<details[\s/>]/i.test(source)) return [];
|
|
5
|
+
const lines = source.split('\n');
|
|
6
|
+
const lineStarts = [];
|
|
7
|
+
let offset = 0;
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
lineStarts.push(offset);
|
|
10
|
+
offset += line.length + 1;
|
|
11
|
+
}
|
|
12
|
+
const html = source.replace(/[^\n]/g, ' ').split('');
|
|
13
|
+
const headings = [];
|
|
14
|
+
const visitMarkdown = (node) => {
|
|
15
|
+
if (node.type === 'heading') headings.push(node.position.start.offset);
|
|
16
|
+
if (node.type === 'html') {
|
|
17
|
+
// Keep only parsed HTML, preserving source offsets and stripping list/quote
|
|
18
|
+
// prefixes. Literal HTML in Markdown code or escapes never reaches parse5.
|
|
19
|
+
for (const [index, value] of node.value.split('\n').entries()) {
|
|
20
|
+
const lineIndex = node.position.start.line - 1 + index;
|
|
21
|
+
const column = index === 0 ? node.position.start.column - 1 : lines[lineIndex].indexOf(value);
|
|
22
|
+
const start = lineStarts[lineIndex] + column;
|
|
23
|
+
for (let i = 0; i < value.length; i += 1) html[start + i] = value[i];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const child of node.children ?? []) visitMarkdown(child);
|
|
27
|
+
};
|
|
28
|
+
visitMarkdown(tree);
|
|
29
|
+
const fragment = parseFragment(html.join(''), { sourceCodeLocationInfo: true });
|
|
30
|
+
const disclosures = [];
|
|
31
|
+
const visitHtml = (node) => {
|
|
32
|
+
const location = node.sourceCodeLocation;
|
|
33
|
+
if (location && node.tagName === 'details') {
|
|
34
|
+
disclosures.push({
|
|
35
|
+
start: location.startTag.endOffset,
|
|
36
|
+
end: location.endTag?.startOffset ?? location.endOffset,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (location && /^h[1-6]$/.test(node.tagName)) headings.push(location.startOffset);
|
|
40
|
+
for (const child of node.childNodes ?? []) visitHtml(child);
|
|
41
|
+
if (node.content) visitHtml(node.content);
|
|
42
|
+
};
|
|
43
|
+
visitHtml(fragment);
|
|
44
|
+
const fix = 'Move the heading outside <details>, or use bold text inside it.';
|
|
45
|
+
return [...new Set(headings)].sort((a, b) => a - b)
|
|
46
|
+
.filter((start) => disclosures.some((details) => start >= details.start && start < details.end))
|
|
47
|
+
.map((start) => {
|
|
48
|
+
const line = lineOffset + source.slice(0, start).split('\n').length;
|
|
49
|
+
return {
|
|
50
|
+
code: 'heading-inside-details',
|
|
51
|
+
severity: 'error',
|
|
52
|
+
offset: start,
|
|
53
|
+
line,
|
|
54
|
+
message: `${label} line ${line}: Headings H1-H6 are not allowed inside <details>, including <summary>.`,
|
|
55
|
+
fix,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
};
|