@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
|
@@ -62,6 +62,7 @@ const formatDiagnostic = ({ code, fix, label, line, message, offset }) => ({
|
|
|
62
62
|
line,
|
|
63
63
|
message: `${label} line ${line}: ${message}`,
|
|
64
64
|
offset,
|
|
65
|
+
severity: 'error',
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
|
|
@@ -101,7 +102,8 @@ export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
|
|
|
101
102
|
issue = {
|
|
102
103
|
code: 'unknown-semantic-callout-type',
|
|
103
104
|
message: `Unknown semantic callout type "${marker.rawType}".`,
|
|
104
|
-
fix: `Use one of: ${semanticCalloutTypes.join(', ')}.`,
|
|
105
|
+
fix: `Use one of: ${semanticCalloutTypes.join(', ')}. The block will remain a neutral blockquote until then.`,
|
|
106
|
+
severity: 'warning',
|
|
105
107
|
};
|
|
106
108
|
} else if (marker.title) {
|
|
107
109
|
issue = {
|
|
@@ -117,7 +119,7 @@ export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
|
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
if (issue) diagnostics.push(formatDiagnostic({ ...issue, label, line, offset }));
|
|
122
|
+
if (issue) diagnostics.push({ ...formatDiagnostic({ ...issue, label, line, offset }), severity: issue.severity ?? 'error' });
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
for (const child of node.children ?? []) visit(child, nextBlockquoteDepth);
|
|
@@ -67,7 +67,6 @@ const knownNestedFrontmatterKeys = new Set([
|
|
|
67
67
|
'palette',
|
|
68
68
|
'preset',
|
|
69
69
|
'profile',
|
|
70
|
-
'readerControls',
|
|
71
70
|
'readingWidth',
|
|
72
71
|
'rhythm',
|
|
73
72
|
'sectionGap',
|
|
@@ -171,7 +170,10 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
171
170
|
fix = fileKind === 'page theme'
|
|
172
171
|
? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
|
|
173
172
|
: 'Replace "colorMode:" with "appearance:".';
|
|
174
|
-
} else if (fileKind === '
|
|
173
|
+
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'readerControls') {
|
|
174
|
+
message = `Frontmatter line ${lineNumber}: Theme setting "readerControls" was removed. Appearance and reading width are always available; Focus reading follows tree navigation.`;
|
|
175
|
+
fix = 'Remove "readerControls:". Use "appearance.default" for the initial Appearance; reading width is always available and tree navigation provides Focus reading.';
|
|
176
|
+
} else if (fileKind === 'page theme' && ['preset', 'appearance', 'corners', 'palette', 'typography'].includes(key)) {
|
|
175
177
|
message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
|
|
176
178
|
fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
|
|
177
179
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './site-paths.mjs';
|
|
18
18
|
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
19
19
|
import { getSiteStructure } from './site-structure.mjs';
|
|
20
|
+
import { createCategoryDestinationModel } from './category-destinations.mjs';
|
|
20
21
|
|
|
21
22
|
const internalUrlOrigin = 'https://norna.invalid';
|
|
22
23
|
const externalSchemePattern = /^[a-z][a-z0-9+.-]*:/i;
|
|
@@ -161,6 +162,13 @@ export const createSiteLinkGraph = ({
|
|
|
161
162
|
return createPageRecord(pageDocument);
|
|
162
163
|
});
|
|
163
164
|
const pagesByPathname = new Map(pages.map((page) => [page.pathname, page]));
|
|
165
|
+
const pagesByDirectory = new Map(pages.map((page) => [page.contentFile.pageDirectory, page]));
|
|
166
|
+
const categoryModel = createCategoryDestinationModel(siteStructure.nodes.map((node) => ({
|
|
167
|
+
...node,
|
|
168
|
+
navigation: node.kind === 'page'
|
|
169
|
+
? pagesByDirectory.get(node.pageDirectory).navigation
|
|
170
|
+
: { listed: true },
|
|
171
|
+
})));
|
|
164
172
|
const categoriesByPathname = new Map(siteStructure.categories.map((category) => [
|
|
165
173
|
getSiteNodePathname(category),
|
|
166
174
|
{ ...category, pathname: getSiteNodePathname(category) },
|
|
@@ -185,7 +193,7 @@ export const createSiteLinkGraph = ({
|
|
|
185
193
|
|
|
186
194
|
const references = [];
|
|
187
195
|
const referencesByTarget = new Map();
|
|
188
|
-
const diagnostics = [...aliasModel.diagnostics];
|
|
196
|
+
const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics];
|
|
189
197
|
|
|
190
198
|
for (const page of pages) {
|
|
191
199
|
for (const sourceReference of page.document.links) {
|
|
@@ -278,13 +286,14 @@ export const createSiteLinkGraph = ({
|
|
|
278
286
|
const targetCategory = target.pageLookupPathname
|
|
279
287
|
? categoriesByPathname.get(target.pageLookupPathname)
|
|
280
288
|
: null;
|
|
281
|
-
if (targetCategory) {
|
|
289
|
+
if (targetCategory && categoryModel.byPathname.has(targetCategory.pathname)) {
|
|
282
290
|
reference.resolution = { category: targetCategory, kind: 'category', pathname: targetCategory.pathname };
|
|
283
|
-
|
|
291
|
+
const destination = categoryModel.byPathname.get(targetCategory.pathname);
|
|
292
|
+
if (target.fragment && !(destination.kind === 'listing' && target.fragment === 'page-title')) diagnostics.push(createIssue(
|
|
284
293
|
reference,
|
|
285
|
-
'
|
|
286
|
-
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to
|
|
287
|
-
'Link to
|
|
294
|
+
'missing-internal-anchor',
|
|
295
|
+
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to an unavailable category anchor.`,
|
|
296
|
+
'Link to the category without a fragment, or use the content page URL for a heading link.',
|
|
288
297
|
));
|
|
289
298
|
continue;
|
|
290
299
|
}
|
|
@@ -318,6 +327,7 @@ export const createSiteLinkGraph = ({
|
|
|
318
327
|
}
|
|
319
328
|
|
|
320
329
|
return {
|
|
330
|
+
categoryModel,
|
|
321
331
|
aliasModel,
|
|
322
332
|
aliases: aliasModel.aliases,
|
|
323
333
|
aliasesByPathname: aliasModel.aliasesByPathname,
|
|
@@ -37,17 +37,6 @@ export const getListedSiteNavigationTree = (entries) => {
|
|
|
37
37
|
return filterListed(getSiteNavigationTree(entries));
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
export const getFirstPageInNavigationNode = (node) => {
|
|
41
|
-
if (node.node.kind === 'page') return node.node;
|
|
42
|
-
|
|
43
|
-
for (const child of node.children) {
|
|
44
|
-
const page = getFirstPageInNavigationNode(child);
|
|
45
|
-
if (page) return page;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return null;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
40
|
export const flattenSiteNavigationTree = (nodes) => nodes.flatMap((node) => [
|
|
52
41
|
node,
|
|
53
42
|
...flattenSiteNavigationTree(node.children),
|
|
@@ -5,6 +5,7 @@ import { homePageDirectory } from './site-conventions.mjs';
|
|
|
5
5
|
|
|
6
6
|
const siteDirectoryEnvName = 'NORNA_SITE_DIR';
|
|
7
7
|
const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
|
|
8
|
+
const stateDirectoryEnvName = 'NORNA_INTERNAL_STATE_DIR';
|
|
8
9
|
const defaultSiteDirectory = 'site';
|
|
9
10
|
export { homePageDirectory };
|
|
10
11
|
|
|
@@ -16,12 +17,19 @@ export const siteDirectoryEnv = siteDirectoryEnvName;
|
|
|
16
17
|
|
|
17
18
|
const normalizeSiteDirectory = (value) => String(value ?? '').trim();
|
|
18
19
|
const normalizeInvocationRoot = (value) => String(value ?? '').trim();
|
|
20
|
+
const normalizeStateDirectory = (value) => String(value ?? '').trim();
|
|
19
21
|
|
|
20
22
|
const configuredInvocationRoot = normalizeInvocationRoot(process.env[invocationRootEnvName]);
|
|
21
23
|
export const invocationRoot = configuredInvocationRoot
|
|
22
24
|
? path.resolve(configuredInvocationRoot)
|
|
23
25
|
: process.cwd();
|
|
24
26
|
|
|
27
|
+
const hasStateDirectoryEnv = Object.hasOwn(process.env, stateDirectoryEnvName);
|
|
28
|
+
const configuredStateDirectory = normalizeStateDirectory(process.env[stateDirectoryEnvName]);
|
|
29
|
+
if (hasStateDirectoryEnv && !configuredStateDirectory) {
|
|
30
|
+
throw new Error(`${stateDirectoryEnvName} must not be empty.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
const hasSiteFilesInDirectory = (siteDir) => {
|
|
26
34
|
return (
|
|
27
35
|
existsSync(path.join(siteDir, 'config.yaml'))
|
|
@@ -126,9 +134,13 @@ export const siteHomePageDir = path.join(sitePagesDir, homePageDirectory);
|
|
|
126
134
|
export const siteContentPath = path.join(siteHomePageDir, 'content.md');
|
|
127
135
|
export const siteImagesDir = path.join(siteHomePageDir, 'images');
|
|
128
136
|
export const sitePublicDir = path.join(siteDir, 'public');
|
|
129
|
-
export const siteStateDir =
|
|
137
|
+
export const siteStateDir = configuredStateDirectory
|
|
138
|
+
? path.resolve(configuredStateDirectory)
|
|
139
|
+
: path.join(siteDir, '.norna');
|
|
130
140
|
export const astroPublicDir = path.join(siteStateDir, 'public');
|
|
131
|
-
export const astroDistDir =
|
|
141
|
+
export const astroDistDir = configuredStateDirectory
|
|
142
|
+
? path.join(siteStateDir, 'dist')
|
|
143
|
+
: path.join(siteProjectRoot, 'dist');
|
|
132
144
|
export const astroRootDir = siteStateDir;
|
|
133
145
|
export const astroCacheDir = path.join(astroRootDir, '.astro');
|
|
134
146
|
export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
|
package/scripts/lib/sitemap.mjs
CHANGED
|
@@ -11,9 +11,11 @@ const escapeXml = (value) => String(value)
|
|
|
11
11
|
|
|
12
12
|
const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
13
13
|
|
|
14
|
-
export const getSitemapUrls = ({ siteStructure, siteUrl }) => (
|
|
15
|
-
|
|
16
|
-
.map((page) => getSiteNodePathname(page))
|
|
14
|
+
export const getSitemapUrls = ({ siteStructure, siteUrl, categoryDestinations = [] }) => (
|
|
15
|
+
[
|
|
16
|
+
...siteStructure.contentFiles.map((page) => getSiteNodePathname(page)),
|
|
17
|
+
...categoryDestinations.filter(({ kind }) => kind === 'listing').map(({ pathname }) => pathname),
|
|
18
|
+
]
|
|
17
19
|
.sort(compareStrings)
|
|
18
20
|
.map((pathname) => getAbsolutePageUrl(siteUrl, pathname))
|
|
19
21
|
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from 'yaml';
|
|
2
|
+
|
|
3
|
+
const stringSchema = (name, definition) => ({
|
|
4
|
+
type: 'string',
|
|
5
|
+
description: definition.description,
|
|
6
|
+
...(definition.default !== undefined ? { default: definition.default } : {}),
|
|
7
|
+
...(definition.values ? { enum: Object.keys(definition.values) } : {}),
|
|
8
|
+
...(name === 'alt' ? {} : { minLength: 1 }),
|
|
9
|
+
pattern: name === 'image'
|
|
10
|
+
? '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])'
|
|
11
|
+
: name === 'caption' || name === 'text'
|
|
12
|
+
? '^(?=[\\s\\S]*\\S)[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]*$(?![\\s\\S])'
|
|
13
|
+
: name === 'alt'
|
|
14
|
+
? '^(?:|(?=.*\\S)[^\\x00-\\x1F\\x7F-\\x9F\\u2028\\u2029]+)$(?![\\s\\S])'
|
|
15
|
+
: '^(?=.*\\S)[^\\x00-\\x1F\\x7F-\\x9F\\u2028\\u2029]+$(?![\\s\\S])',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const createStructuredBlockSchema = (definition) => {
|
|
19
|
+
if (!definition?.item) return null;
|
|
20
|
+
const fields = { [definition.item.start.key]: definition.item.start, ...definition.item.fields };
|
|
21
|
+
const item = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
required: [definition.item.start.key],
|
|
25
|
+
properties: Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, stringSchema(key, field)])),
|
|
26
|
+
...(definition.item.start.key === 'title' ? {
|
|
27
|
+
anyOf: ['text', 'image', 'link'].map((key) => ({ required: [key] })),
|
|
28
|
+
} : {}),
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
32
|
+
description: definition.description,
|
|
33
|
+
type: 'object',
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
required: ['items'],
|
|
36
|
+
properties: {
|
|
37
|
+
...Object.fromEntries(Object.entries(definition.options ?? {}).map(([key, field]) => [key, stringSchema(key, field)])),
|
|
38
|
+
items: { type: 'array', minItems: definition.minItems ?? 1, items: item },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Validate the AST directly so errors and later link edits retain exact source ranges.
|
|
44
|
+
export const parseStructuredBlock = (source, schema, options = {}) => {
|
|
45
|
+
const lines = new LineCounter();
|
|
46
|
+
const document = parseDocument(source, { schema: 'core', version: '1.2', uniqueKeys: true, lineCounter: lines });
|
|
47
|
+
const lineAt = (node) => (options.line ?? 1) + lines.linePos(node?.range?.[0] ?? 0).line - 1;
|
|
48
|
+
const fail = (message, node) => {
|
|
49
|
+
const line = lineAt(node);
|
|
50
|
+
throw Object.assign(new Error(`${options.label ? `${options.label} ` : ''}line ${line}: ${options.type}: ${message}`), { line });
|
|
51
|
+
};
|
|
52
|
+
if (document.errors.length) {
|
|
53
|
+
const error = document.errors[0];
|
|
54
|
+
fail(`Invalid YAML: ${error.message.split('\n')[0]}`, { range: error.pos });
|
|
55
|
+
}
|
|
56
|
+
if (document.directives.yaml.version !== '1.2') fail('Use YAML 1.2 Core; other YAML versions are not supported.', document.contents);
|
|
57
|
+
const validate = (node, rule, path, depth = 0) => {
|
|
58
|
+
if (depth > 16) fail('YAML nesting is too deep.', node);
|
|
59
|
+
if (node?.anchor || node?.tag || isAlias(node)) fail('YAML anchors, aliases, and explicit tags are not allowed.', node);
|
|
60
|
+
if (rule.type === 'object') {
|
|
61
|
+
if (!isMap(node)) fail(`${path} must be a YAML mapping${path === 'Block' ? ' with an items list' : ''}.`, node);
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const pair of node.items) {
|
|
64
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== 'string') fail(`${path} field names must be strings.`, pair.key);
|
|
65
|
+
if (pair.key.tag || pair.key.anchor) fail('YAML anchors and explicit tags are not allowed.', pair.key);
|
|
66
|
+
const key = pair.key.value;
|
|
67
|
+
if (!Object.hasOwn(rule.properties, key)) fail(`Unknown ${path} field "${key}".`, pair.key);
|
|
68
|
+
result[key] = validate(pair.value, rule.properties[key], `${path}.${key}`, depth + 1);
|
|
69
|
+
}
|
|
70
|
+
for (const key of rule.required ?? []) {
|
|
71
|
+
if (!Object.hasOwn(result, key)) fail(`${path} is missing "${key}".`, node);
|
|
72
|
+
}
|
|
73
|
+
if (rule.anyOf && !rule.anyOf.some((alternative) => alternative.required.every((key) => Object.hasOwn(result, key)))) {
|
|
74
|
+
fail(`${path} must specify at least one of text, image, or link.`, node);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
if (rule.type === 'array') {
|
|
79
|
+
if (!isSeq(node)) fail(`${path} must be a YAML list.`, node);
|
|
80
|
+
if (node.items.length < rule.minItems) fail(`${path} must contain at least ${rule.minItems} item.`, node);
|
|
81
|
+
return node.items.map((item, index) => validate(item, rule.items, `${path}[${index + 1}]`, depth + 1));
|
|
82
|
+
}
|
|
83
|
+
if (!isScalar(node) || typeof node.value !== 'string') fail(`${path} must be a string; quote values that YAML would interpret as numbers, booleans, or null.`, node);
|
|
84
|
+
if (rule.enum && !rule.enum.includes(node.value)) fail(`Invalid ${path} "${node.value}". Use one of: ${rule.enum.join(', ')}.`, node);
|
|
85
|
+
if (!new RegExp(rule.pattern).test(node.value)) {
|
|
86
|
+
fail(path.endsWith('.image')
|
|
87
|
+
? 'Image reference must be a filename ending in jpg, jpeg, png, or svg.'
|
|
88
|
+
: `${path} must ${path.endsWith('.alt') ? 'be empty or contain single-line text' : 'contain non-empty text'}${/\.(caption|text)$/.test(path) ? '' : ' without line breaks or control characters'}.`, node);
|
|
89
|
+
}
|
|
90
|
+
return node.value;
|
|
91
|
+
};
|
|
92
|
+
const data = validate(document.contents, schema, 'Block');
|
|
93
|
+
const items = document.get('items', true).items;
|
|
94
|
+
data.items = data.items.map((item, index) => {
|
|
95
|
+
const node = items[index];
|
|
96
|
+
const link = node.get('link', true);
|
|
97
|
+
return {
|
|
98
|
+
...item,
|
|
99
|
+
line: lineAt(node),
|
|
100
|
+
...(link ? {
|
|
101
|
+
linkLine: lineAt(link),
|
|
102
|
+
// A block scalar owns its trailing newline; leave that separator in place when editing the value.
|
|
103
|
+
linkRange: { start: link.range[0], end: source.slice(0, link.range[1]).trimEnd().length },
|
|
104
|
+
} : {}),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return data;
|
|
108
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineHastPlugin } from 'satteri';
|
|
2
|
+
|
|
3
|
+
const transformTableSection = (section, { columnHeaders = false, rowHeaders = false } = {}) => ({
|
|
4
|
+
...section,
|
|
5
|
+
children: (section.children ?? []).map((child) => {
|
|
6
|
+
if (child.type !== 'element' || child.tagName !== 'tr') return child;
|
|
7
|
+
let columnIndex = 0;
|
|
8
|
+
return {
|
|
9
|
+
...child,
|
|
10
|
+
children: (child.children ?? []).map((cell) => {
|
|
11
|
+
if (cell.type !== 'element' || (cell.tagName !== 'th' && cell.tagName !== 'td')) return cell;
|
|
12
|
+
const currentColumn = columnIndex;
|
|
13
|
+
columnIndex += 1;
|
|
14
|
+
if (columnHeaders) {
|
|
15
|
+
return {
|
|
16
|
+
...cell,
|
|
17
|
+
properties: { ...(cell.properties ?? {}), scope: 'col' },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (rowHeaders && currentColumn === 0) {
|
|
21
|
+
return {
|
|
22
|
+
...cell,
|
|
23
|
+
tagName: 'th',
|
|
24
|
+
properties: { ...(cell.properties ?? {}), scope: 'row' },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return cell;
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const transformRowHeaderTable = (node) => ({
|
|
34
|
+
...node,
|
|
35
|
+
children: (node.children ?? []).map((child) => {
|
|
36
|
+
if (child.type !== 'element') return child;
|
|
37
|
+
if (child.tagName === 'thead') return transformTableSection(child, { columnHeaders: true });
|
|
38
|
+
if (child.tagName === 'tbody') return transformTableSection(child, { rowHeaders: true });
|
|
39
|
+
return child;
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const nornaTableRenderPlugin = defineHastPlugin({
|
|
44
|
+
name: 'norna-table-render',
|
|
45
|
+
element: {
|
|
46
|
+
filter: ['table'],
|
|
47
|
+
visit(node, context) {
|
|
48
|
+
const hasRowHeaders = node.properties?.['data-row-headers'] === 'true';
|
|
49
|
+
const table = hasRowHeaders ? transformRowHeaderTable(node) : node;
|
|
50
|
+
context.replaceNode(node, {
|
|
51
|
+
type: 'element',
|
|
52
|
+
tagName: 'div',
|
|
53
|
+
properties: {
|
|
54
|
+
className: ['norna-table-frame', 'content-block-note-lane-boundary'],
|
|
55
|
+
'data-table-frame': '',
|
|
56
|
+
...(hasRowHeaders ? { 'data-table-row-headers': 'true' } : {}),
|
|
57
|
+
},
|
|
58
|
+
children: [{
|
|
59
|
+
type: 'element',
|
|
60
|
+
tagName: 'div',
|
|
61
|
+
properties: {
|
|
62
|
+
className: ['norna-table-scroll'],
|
|
63
|
+
'data-table-scroll': '',
|
|
64
|
+
tabIndex: 0,
|
|
65
|
+
},
|
|
66
|
+
children: [{
|
|
67
|
+
type: 'element',
|
|
68
|
+
tagName: table.tagName,
|
|
69
|
+
properties: { ...(table.properties ?? {}) },
|
|
70
|
+
children: [...(table.children ?? [])],
|
|
71
|
+
}],
|
|
72
|
+
}],
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export const tableRowHeaderMarker = '{row-header}';
|
|
2
|
+
|
|
3
|
+
const trailingMarkerPattern = /\s*\{row-header\}\s*$/u;
|
|
4
|
+
|
|
5
|
+
const getNodeText = (node) => {
|
|
6
|
+
if (!node || typeof node !== 'object') return '';
|
|
7
|
+
if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? '';
|
|
8
|
+
if (node.type === 'image') return node.alt ?? '';
|
|
9
|
+
if (!Array.isArray(node.children)) return '';
|
|
10
|
+
return node.children.map(getNodeText).join('');
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const getUnescapedMarkerIndexes = (source) => {
|
|
14
|
+
const indexes = [];
|
|
15
|
+
let searchFrom = 0;
|
|
16
|
+
while (searchFrom < source.length) {
|
|
17
|
+
const index = source.indexOf(tableRowHeaderMarker, searchFrom);
|
|
18
|
+
if (index < 0) break;
|
|
19
|
+
let backslashes = 0;
|
|
20
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === '\\'; cursor -= 1) {
|
|
21
|
+
backslashes += 1;
|
|
22
|
+
}
|
|
23
|
+
if (backslashes % 2 === 0) indexes.push(index);
|
|
24
|
+
searchFrom = index + tableRowHeaderMarker.length;
|
|
25
|
+
}
|
|
26
|
+
return indexes;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getTextNodeMarkerCount = (node, source) => {
|
|
30
|
+
if (node.type !== 'text' || !node.value?.includes(tableRowHeaderMarker)) return 0;
|
|
31
|
+
const start = node.position?.start.offset;
|
|
32
|
+
const end = node.position?.end.offset;
|
|
33
|
+
const raw = Number.isInteger(start) && Number.isInteger(end)
|
|
34
|
+
? source.slice(start, end)
|
|
35
|
+
: node.value;
|
|
36
|
+
return getUnescapedMarkerIndexes(raw).length;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const collectCellMarkers = (node, source, markers, location) => {
|
|
40
|
+
if (!node || typeof node !== 'object') return;
|
|
41
|
+
const count = getTextNodeMarkerCount(node, source);
|
|
42
|
+
for (let index = 0; index < count; index += 1) {
|
|
43
|
+
markers.push({ ...location, node });
|
|
44
|
+
}
|
|
45
|
+
for (const child of node.children ?? []) {
|
|
46
|
+
collectCellMarkers(child, source, markers, location);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const normalizeLabel = (value) => value.trim().replace(/\s+/gu, ' ').toLowerCase();
|
|
51
|
+
|
|
52
|
+
const issue = (code, node, message, fix, otherNode = null) => ({
|
|
53
|
+
code,
|
|
54
|
+
fix,
|
|
55
|
+
message,
|
|
56
|
+
node,
|
|
57
|
+
otherNode,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const inspectTableRowHeaders = (table, source = '') => {
|
|
61
|
+
if (table?.type !== 'table') return { declared: false, issues: [], valid: false };
|
|
62
|
+
|
|
63
|
+
const rows = table.children ?? [];
|
|
64
|
+
const markers = [];
|
|
65
|
+
for (const [rowIndex, row] of rows.entries()) {
|
|
66
|
+
for (const [columnIndex, cell] of (row.children ?? []).entries()) {
|
|
67
|
+
collectCellMarkers(cell, source, markers, { cell, columnIndex, rowIndex });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (markers.length === 0) return { declared: false, issues: [], valid: false };
|
|
72
|
+
|
|
73
|
+
const issues = [];
|
|
74
|
+
const declaration = markers[0];
|
|
75
|
+
for (const repeated of markers.slice(1)) {
|
|
76
|
+
issues.push(issue(
|
|
77
|
+
'repeated-table-row-header-marker',
|
|
78
|
+
repeated.cell,
|
|
79
|
+
`A table may contain ${tableRowHeaderMarker} only once.`,
|
|
80
|
+
`Keep ${tableRowHeaderMarker} only in the first column heading.`,
|
|
81
|
+
declaration.cell,
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const firstHeadingCell = rows[0]?.children?.[0];
|
|
86
|
+
if (declaration.rowIndex !== 0 || declaration.columnIndex !== 0) {
|
|
87
|
+
issues.push(issue(
|
|
88
|
+
'invalid-table-row-header-column',
|
|
89
|
+
declaration.cell,
|
|
90
|
+
`${tableRowHeaderMarker} must appear in the first column heading.`,
|
|
91
|
+
`Move ${tableRowHeaderMarker} to the end of the first heading cell.`,
|
|
92
|
+
));
|
|
93
|
+
} else if (!getNodeText(firstHeadingCell).trim().endsWith(tableRowHeaderMarker)) {
|
|
94
|
+
issues.push(issue(
|
|
95
|
+
'invalid-table-row-header-marker-position',
|
|
96
|
+
firstHeadingCell,
|
|
97
|
+
`${tableRowHeaderMarker} must be the final content in the first column heading.`,
|
|
98
|
+
`Move ${tableRowHeaderMarker} to the end of the first heading cell.`,
|
|
99
|
+
));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const headingLabel = getNodeText(firstHeadingCell).replace(trailingMarkerPattern, '').trim();
|
|
103
|
+
if (!headingLabel) {
|
|
104
|
+
issues.push(issue(
|
|
105
|
+
'empty-table-row-header-heading',
|
|
106
|
+
firstHeadingCell ?? table,
|
|
107
|
+
'The row-header column needs a visible column heading.',
|
|
108
|
+
`Write a heading before ${tableRowHeaderMarker}, for example "Feature ${tableRowHeaderMarker}".`,
|
|
109
|
+
));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (rows.length < 2) {
|
|
113
|
+
issues.push(issue(
|
|
114
|
+
'empty-table-row-header-body',
|
|
115
|
+
table,
|
|
116
|
+
'A table with a row-header declaration needs at least one body row.',
|
|
117
|
+
'Add a body row or remove the row-header declaration.',
|
|
118
|
+
));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const labels = new Map();
|
|
122
|
+
for (const row of rows.slice(1)) {
|
|
123
|
+
const cell = row.children?.[0];
|
|
124
|
+
if (!cell) {
|
|
125
|
+
issues.push(issue(
|
|
126
|
+
'missing-table-row-header',
|
|
127
|
+
row,
|
|
128
|
+
'This table row has no cell in the row-header column.',
|
|
129
|
+
'Add a unique row label in the first column.',
|
|
130
|
+
));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const label = getNodeText(cell).trim().replace(/\s+/gu, ' ');
|
|
135
|
+
if (!label) {
|
|
136
|
+
issues.push(issue(
|
|
137
|
+
'empty-table-row-header',
|
|
138
|
+
cell,
|
|
139
|
+
'This table row has an empty row header.',
|
|
140
|
+
'Add a unique row label in the first column.',
|
|
141
|
+
));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const normalizedLabel = normalizeLabel(label);
|
|
146
|
+
const existing = labels.get(normalizedLabel);
|
|
147
|
+
if (existing) {
|
|
148
|
+
issues.push(issue(
|
|
149
|
+
'duplicate-table-row-header',
|
|
150
|
+
cell,
|
|
151
|
+
`Row header "${label}" is repeated.`,
|
|
152
|
+
'Give every row a unique label so its header association is unambiguous.',
|
|
153
|
+
existing,
|
|
154
|
+
));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
labels.set(normalizedLabel, cell);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
declared: true,
|
|
162
|
+
firstHeadingCell,
|
|
163
|
+
issues,
|
|
164
|
+
markerNode: declaration.node,
|
|
165
|
+
valid: issues.length === 0,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const formatDiagnostic = (entry, options) => {
|
|
170
|
+
const lineOffset = options.lineOffset ?? 0;
|
|
171
|
+
const line = lineOffset + (entry.node?.position?.start.line ?? 1);
|
|
172
|
+
const otherLine = entry.otherNode
|
|
173
|
+
? lineOffset + (entry.otherNode.position?.start.line ?? 1)
|
|
174
|
+
: null;
|
|
175
|
+
const related = otherLine && otherLine !== line ? ` The first occurrence is on line ${otherLine}.` : '';
|
|
176
|
+
const label = options.label ?? 'Markdown';
|
|
177
|
+
return {
|
|
178
|
+
code: entry.code,
|
|
179
|
+
fix: entry.fix,
|
|
180
|
+
line,
|
|
181
|
+
message: `${label} line ${line}: ${entry.message}${related}`,
|
|
182
|
+
offset: entry.node?.position?.start.offset ?? 0,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const getTableRowHeaderDiagnostics = (tree, options = {}) => {
|
|
187
|
+
const diagnostics = [];
|
|
188
|
+
const source = options.source ?? '';
|
|
189
|
+
const visit = (node) => {
|
|
190
|
+
if (!node || typeof node !== 'object') return;
|
|
191
|
+
if (node.type === 'table') {
|
|
192
|
+
const inspection = inspectTableRowHeaders(node, source);
|
|
193
|
+
diagnostics.push(...inspection.issues.map((entry) => formatDiagnostic(entry, options)));
|
|
194
|
+
}
|
|
195
|
+
for (const child of node.children ?? []) visit(child);
|
|
196
|
+
};
|
|
197
|
+
visit(tree);
|
|
198
|
+
return diagnostics;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const applyTableRowHeaderDeclaration = (table, context) => {
|
|
202
|
+
const inspection = inspectTableRowHeaders(table, context.source);
|
|
203
|
+
if (!inspection.valid || !inspection.markerNode) return false;
|
|
204
|
+
|
|
205
|
+
context.setProperty(
|
|
206
|
+
inspection.markerNode,
|
|
207
|
+
'value',
|
|
208
|
+
inspection.markerNode.value.replace(trailingMarkerPattern, ''),
|
|
209
|
+
);
|
|
210
|
+
context.setProperty(table, 'data', {
|
|
211
|
+
...(table.data ?? {}),
|
|
212
|
+
hProperties: {
|
|
213
|
+
...(table.data?.hProperties ?? {}),
|
|
214
|
+
'data-row-headers': 'true',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
@@ -50,25 +50,21 @@ export const themePresetDefinitions = Object.freeze({
|
|
|
50
50
|
title: 'Portfolio',
|
|
51
51
|
description: 'For portfolios and image-led sites, with restrained typography and generous space for images.',
|
|
52
52
|
recipe: themePresetRecipes.portfolio,
|
|
53
|
-
readerControls: Object.freeze({ appearance: true }),
|
|
54
53
|
}),
|
|
55
54
|
documentation: Object.freeze({
|
|
56
55
|
title: 'Documentation',
|
|
57
56
|
description: 'For guides and reference material, with reading-focused typography and compact spacing.',
|
|
58
57
|
recipe: themePresetRecipes.documentation,
|
|
59
|
-
readerControls: Object.freeze({ appearance: true, focusReading: true }),
|
|
60
58
|
}),
|
|
61
59
|
project: Object.freeze({
|
|
62
60
|
title: 'Project',
|
|
63
61
|
description: 'For project and product sites that balance explanation, code, cards, and images.',
|
|
64
62
|
recipe: themePresetRecipes.project,
|
|
65
|
-
readerControls: Object.freeze({ appearance: true, focusReading: true }),
|
|
66
63
|
}),
|
|
67
64
|
statement: Object.freeze({
|
|
68
65
|
title: 'Statement',
|
|
69
66
|
description: 'For short, expressive sites, with larger typography, airy spacing, and stronger section emphasis.',
|
|
70
67
|
recipe: themePresetRecipes.statement,
|
|
71
|
-
readerControls: Object.freeze({ appearance: true }),
|
|
72
68
|
}),
|
|
73
69
|
});
|
|
74
70
|
|
|
@@ -82,7 +78,6 @@ export const themePresets = Object.freeze(Object.fromEntries(
|
|
|
82
78
|
themePresetDefinitions[name].recipe,
|
|
83
79
|
`${name} theme preset`,
|
|
84
80
|
),
|
|
85
|
-
readerControls: themePresetDefinitions[name].readerControls,
|
|
86
81
|
}),
|
|
87
82
|
]),
|
|
88
83
|
));
|
|
@@ -179,7 +174,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
|
|
|
179
174
|
export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
|
|
180
175
|
const preset = getThemePreset(presetName, sourceLabel);
|
|
181
176
|
const metadata = getThemePresetMetadata(presetName);
|
|
182
|
-
const { appearance,
|
|
177
|
+
const { appearance, corners, layout, images, blocks, typography, palette, sections } = preset;
|
|
183
178
|
|
|
184
179
|
return [
|
|
185
180
|
`# Original values for Norna's "${presetName}" theme preset.`,
|
|
@@ -193,10 +188,8 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
|
|
|
193
188
|
'appearance:',
|
|
194
189
|
` default: ${appearance.default}`,
|
|
195
190
|
'',
|
|
196
|
-
'#
|
|
197
|
-
'
|
|
198
|
-
` appearance: ${readerControls.appearance === true}`,
|
|
199
|
-
` focusReading: ${readerControls.focusReading === true}`,
|
|
191
|
+
'# Appearance and reading width are always available in the Display panel.',
|
|
192
|
+
'# Focus reading is available automatically when navigation resolves to tree.',
|
|
200
193
|
'',
|
|
201
194
|
'# Alternatives: square, rounded.',
|
|
202
195
|
`corners: ${corners}`,
|
|
@@ -36,12 +36,8 @@ const getLegacyThemeHint = (issue, data) => {
|
|
|
36
36
|
return 'Theme setting "colorMode" was replaced by "appearance".';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
if (location === 'readerControls'
|
|
40
|
-
return '
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (location === 'readerControls' && issue.keys?.includes('readingWidth')) {
|
|
44
|
-
return 'Reader control "readingWidth" was removed because reading width is now always available. Remove this field.';
|
|
39
|
+
if (location === 'readerControls') {
|
|
40
|
+
return 'Theme setting "readerControls" was removed. Appearance and reading width are always available; Focus reading follows tree navigation. Remove this block.';
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
return undefined;
|