@glw907/cairn-cms 0.62.2 → 0.68.0
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/CHANGELOG.md +134 -0
- package/dist/auth/types.d.ts +7 -0
- package/dist/components/ComponentInsertDialog.svelte +17 -6
- package/dist/components/ConceptList.svelte +25 -4
- package/dist/components/cairn-admin.css +175 -2
- package/dist/content/field-rules.d.ts +15 -0
- package/dist/content/field-rules.js +39 -0
- package/dist/content/fields.d.ts +121 -0
- package/dist/content/fields.js +30 -0
- package/dist/content/fieldset.d.ts +86 -0
- package/dist/content/fieldset.js +233 -0
- package/dist/content/schema.js +16 -20
- package/dist/delivery/public-routes.d.ts +8 -0
- package/dist/delivery/public-routes.js +10 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/manifest.d.ts +11 -0
- package/dist/media/manifest.js +13 -0
- package/dist/render/highlight.d.ts +9 -0
- package/dist/render/highlight.js +206 -0
- package/dist/render/pipeline.js +12 -1
- package/dist/render/registry.d.ts +10 -2
- package/dist/render/registry.js +21 -1
- package/dist/render/rehype-dispatch.d.ts +2 -6
- package/dist/render/rehype-dispatch.js +2 -6
- package/dist/render/sanitize-schema.d.ts +10 -0
- package/dist/render/sanitize-schema.js +29 -0
- package/dist/sveltekit/guard.js +10 -0
- package/package.json +13 -2
- package/src/lib/auth/types.ts +7 -0
- package/src/lib/components/ComponentInsertDialog.svelte +17 -6
- package/src/lib/components/ConceptList.svelte +41 -4
- package/src/lib/content/field-rules.ts +40 -0
- package/src/lib/content/fields.ts +127 -0
- package/src/lib/content/fieldset.ts +307 -0
- package/src/lib/content/schema.ts +9 -13
- package/src/lib/delivery/public-routes.ts +19 -1
- package/src/lib/index.ts +7 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/index.ts +1 -0
- package/src/lib/media/manifest.ts +14 -0
- package/src/lib/render/highlight.ts +259 -0
- package/src/lib/render/pipeline.ts +12 -1
- package/src/lib/render/registry.ts +30 -3
- package/src/lib/render/rehype-dispatch.ts +2 -6
- package/src/lib/render/sanitize-schema.ts +31 -0
- package/src/lib/sveltekit/guard.ts +15 -0
package/dist/media/manifest.js
CHANGED
|
@@ -13,6 +13,19 @@ export function parseMediaManifest(json) {
|
|
|
13
13
|
return {};
|
|
14
14
|
return json;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Read the committed media manifest from an `import.meta.glob` eager result, degrading a missing
|
|
18
|
+
* file to an empty manifest. A static import of an absent `media.json` fails the Vite build before
|
|
19
|
+
* any runtime degrade can run, so a fresh site with no manifest cannot build. A glob result is the
|
|
20
|
+
* build-safe read: `import.meta.glob` returns `{}` when nothing matches rather than throwing, and
|
|
21
|
+
* this helper extracts the single matched value and parses it, so a missing file reads a clean `{}`.
|
|
22
|
+
* @param globResult - The eager glob result for the committed manifest, an empty object when the
|
|
23
|
+
* file is absent. The consumer passes
|
|
24
|
+
* `import.meta.glob('<path-to-media.json>', { eager: true, import: 'default' })`.
|
|
25
|
+
*/
|
|
26
|
+
export function readCommittedManifest(globResult) {
|
|
27
|
+
return parseMediaManifest(Object.values(globResult)[0]);
|
|
28
|
+
}
|
|
16
29
|
/**
|
|
17
30
|
* Validate one posted value as a MediaEntry, returning it narrowed or undefined. The trust boundary
|
|
18
31
|
* for an optimistic record the client re-posts: the upload action server-owned each field at
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Root } from 'hast';
|
|
2
|
+
/**
|
|
3
|
+
* The Shiki rehype plugin. Highlights every fenced-code block into a `<pre class="shiki">` whose
|
|
4
|
+
* tokens carry the cairn-tok-* classes (no inline style), then leaves the rest of the tree
|
|
5
|
+
* untouched. It runs as an async transformer because Shiki tokenizes asynchronously. Because the
|
|
6
|
+
* output is class-only it needs no special placement; it is safe anywhere after `remarkRehype`.
|
|
7
|
+
* @returns A unified transformer that mutates the hast tree in place.
|
|
8
|
+
*/
|
|
9
|
+
export declare function rehypeCairnHighlight(): (tree: Root) => Promise<void>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { toString } from 'hast-util-to-string';
|
|
2
|
+
// The curated language set Shiki preloads. Each id pulls its aliases too (loading `bash` also
|
|
3
|
+
// registers `sh`, `shell`, `zsh`; loading `js` registers `javascript`, `mjs`, `cjs`). A fence whose
|
|
4
|
+
// language is absent or outside this set falls back to plaintext rather than throwing.
|
|
5
|
+
const LANGS = [
|
|
6
|
+
'js',
|
|
7
|
+
'ts',
|
|
8
|
+
'jsx',
|
|
9
|
+
'tsx',
|
|
10
|
+
'svelte',
|
|
11
|
+
'html',
|
|
12
|
+
'css',
|
|
13
|
+
'json',
|
|
14
|
+
'bash',
|
|
15
|
+
'markdown',
|
|
16
|
+
'python',
|
|
17
|
+
'yaml',
|
|
18
|
+
'sql',
|
|
19
|
+
];
|
|
20
|
+
// The plaintext language id. Shiki special-cases it (and `plaintext`/`txt`/`ansi`) as always
|
|
21
|
+
// available, so it is the safe fallback for an unknown or absent fence language: it escapes the
|
|
22
|
+
// code text into the same <pre class="shiki"> wrapper with no token coloring.
|
|
23
|
+
const PLAINTEXT = 'text';
|
|
24
|
+
// Sentinel foreground colors, one per ramp slot. Shiki has no class-emitting mode, so the theme
|
|
25
|
+
// assigns each scope group a unique sentinel hex, then the transformer below maps the resolved
|
|
26
|
+
// sentinel back to its cairn-tok-* class and deletes the style. The sentinels never reach the
|
|
27
|
+
// output. They are arbitrary distinct values; only their uniqueness and exact round-trip matter.
|
|
28
|
+
const SENTINEL_INK = '#000000';
|
|
29
|
+
const SENTINEL_TO_CLASS = {
|
|
30
|
+
'#000010': 'cairn-tok-comment',
|
|
31
|
+
'#000020': 'cairn-tok-keyword',
|
|
32
|
+
'#000030': 'cairn-tok-string',
|
|
33
|
+
'#000040': 'cairn-tok-function',
|
|
34
|
+
'#000050': 'cairn-tok-number',
|
|
35
|
+
'#000060': 'cairn-tok-punct',
|
|
36
|
+
};
|
|
37
|
+
// The Shiki theme that drives the class mapping. Every scope group resolves to its sentinel color;
|
|
38
|
+
// the transformer turns the sentinel into a class. The background and default foreground are
|
|
39
|
+
// sentinels too, stripped from the <pre> by the transformer so the site theme owns the surround.
|
|
40
|
+
const CAIRN_CODE_THEME = {
|
|
41
|
+
name: 'cairn-roles',
|
|
42
|
+
type: 'light',
|
|
43
|
+
fg: SENTINEL_INK,
|
|
44
|
+
bg: '#ffffff',
|
|
45
|
+
settings: [
|
|
46
|
+
{ settings: { foreground: SENTINEL_INK, background: '#ffffff' } },
|
|
47
|
+
{
|
|
48
|
+
scope: ['comment', 'punctuation.definition.comment', 'string.comment'],
|
|
49
|
+
settings: { foreground: '#000010' },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
scope: [
|
|
53
|
+
'keyword',
|
|
54
|
+
'keyword.control',
|
|
55
|
+
'storage',
|
|
56
|
+
'storage.type',
|
|
57
|
+
'storage.modifier',
|
|
58
|
+
'variable.language',
|
|
59
|
+
'keyword.operator.new',
|
|
60
|
+
'keyword.operator.expression',
|
|
61
|
+
],
|
|
62
|
+
settings: { foreground: '#000020' },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
scope: ['string', 'string.quoted', 'string.template', 'constant.other.symbol'],
|
|
66
|
+
settings: { foreground: '#000030' },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
scope: [
|
|
70
|
+
'entity.name.function',
|
|
71
|
+
'support.function',
|
|
72
|
+
'meta.function-call',
|
|
73
|
+
'entity.name.tag',
|
|
74
|
+
'support.type.property-name',
|
|
75
|
+
],
|
|
76
|
+
settings: { foreground: '#000040' },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
scope: [
|
|
80
|
+
'constant.numeric',
|
|
81
|
+
'constant.language',
|
|
82
|
+
'constant.character',
|
|
83
|
+
'constant.other',
|
|
84
|
+
'keyword.other.unit',
|
|
85
|
+
],
|
|
86
|
+
settings: { foreground: '#000050' },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
scope: ['punctuation', 'meta.brace', 'keyword.operator', 'meta.delimiter'],
|
|
90
|
+
settings: { foreground: '#000060' },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
// Read the `color:#rrggbb` hex out of a token span's inline style, lowercased for the sentinel map.
|
|
95
|
+
function styleColor(style) {
|
|
96
|
+
if (typeof style !== 'string')
|
|
97
|
+
return undefined;
|
|
98
|
+
const match = /color:(#[0-9a-fA-F]{6})/.exec(style);
|
|
99
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
100
|
+
}
|
|
101
|
+
// Append a class to a hast element's class list, building the string form Shiki uses.
|
|
102
|
+
function addClass(node, className) {
|
|
103
|
+
const existing = node.properties?.class;
|
|
104
|
+
const prefix = typeof existing === 'string' && existing.length > 0 ? `${existing} ` : '';
|
|
105
|
+
(node.properties ??= {}).class = `${prefix}${className}`;
|
|
106
|
+
}
|
|
107
|
+
// The transformer that converts Shiki's sentinel-colored inline styles into the cairn-tok-* classes
|
|
108
|
+
// and strips every style attribute, so the output is class-only. The <pre> loses its
|
|
109
|
+
// background/foreground style (the site theme owns the surround); each token <span> trades its
|
|
110
|
+
// sentinel color for the matching class, and a default-foreground span is left unclassed.
|
|
111
|
+
const cairnTokenClasses = {
|
|
112
|
+
name: 'cairn-token-classes',
|
|
113
|
+
pre(node) {
|
|
114
|
+
if (node.properties)
|
|
115
|
+
delete node.properties.style;
|
|
116
|
+
},
|
|
117
|
+
code(node) {
|
|
118
|
+
if (node.properties)
|
|
119
|
+
delete node.properties.style;
|
|
120
|
+
},
|
|
121
|
+
span(node) {
|
|
122
|
+
const hex = styleColor(node.properties?.style);
|
|
123
|
+
if (node.properties)
|
|
124
|
+
delete node.properties.style;
|
|
125
|
+
const className = hex ? SENTINEL_TO_CLASS[hex] : undefined;
|
|
126
|
+
if (className)
|
|
127
|
+
addClass(node, className);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
// The cached highlighter. Created once on first use and reused for every render. The dynamic import
|
|
131
|
+
// keeps Shiki off the static client graph; the promise is cached so the WASM grammar load and the
|
|
132
|
+
// theme registration happen at most once per process.
|
|
133
|
+
let highlighterPromise;
|
|
134
|
+
function getHighlighter() {
|
|
135
|
+
if (!highlighterPromise) {
|
|
136
|
+
highlighterPromise = import('shiki').then((shiki) => shiki.createHighlighter({ themes: [CAIRN_CODE_THEME], langs: [...LANGS] }));
|
|
137
|
+
}
|
|
138
|
+
return highlighterPromise;
|
|
139
|
+
}
|
|
140
|
+
// Read the fenced language off a <code> element's class list. Markdown emits the language as a
|
|
141
|
+
// `language-<id>` class on the inner <code>. An empty or missing language returns undefined, which
|
|
142
|
+
// the caller maps to plaintext.
|
|
143
|
+
function codeLanguage(code) {
|
|
144
|
+
const className = code.properties?.className;
|
|
145
|
+
const classes = Array.isArray(className) ? className.map(String) : [];
|
|
146
|
+
const langClass = classes.find((c) => c.startsWith('language-'));
|
|
147
|
+
const lang = langClass?.slice('language-'.length);
|
|
148
|
+
return lang && lang.length > 0 ? lang : undefined;
|
|
149
|
+
}
|
|
150
|
+
// A fenced-code <pre> is a <pre> whose single element child is a <code>. Inline code (a bare <code>
|
|
151
|
+
// with no <pre> parent) and any other <pre> are left untouched.
|
|
152
|
+
function fencedCode(pre) {
|
|
153
|
+
const child = pre.children.find((c) => c.type === 'element');
|
|
154
|
+
return child?.tagName === 'code' ? child : undefined;
|
|
155
|
+
}
|
|
156
|
+
// Descend the tree and collect every fenced-code <pre>. The walk is synchronous and gathers the
|
|
157
|
+
// targets up front, because the highlight itself is async (unist-util-visit cannot await).
|
|
158
|
+
function collectFencedCode(node, jobs) {
|
|
159
|
+
for (const child of node.children) {
|
|
160
|
+
if (child.type !== 'element')
|
|
161
|
+
continue;
|
|
162
|
+
if (child.tagName === 'pre') {
|
|
163
|
+
const code = fencedCode(child);
|
|
164
|
+
if (code) {
|
|
165
|
+
jobs.push({ pre: child, code, lang: codeLanguage(code) ?? PLAINTEXT });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
collectFencedCode(child, jobs);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The Shiki rehype plugin. Highlights every fenced-code block into a `<pre class="shiki">` whose
|
|
174
|
+
* tokens carry the cairn-tok-* classes (no inline style), then leaves the rest of the tree
|
|
175
|
+
* untouched. It runs as an async transformer because Shiki tokenizes asynchronously. Because the
|
|
176
|
+
* output is class-only it needs no special placement; it is safe anywhere after `remarkRehype`.
|
|
177
|
+
* @returns A unified transformer that mutates the hast tree in place.
|
|
178
|
+
*/
|
|
179
|
+
export function rehypeCairnHighlight() {
|
|
180
|
+
return async (tree) => {
|
|
181
|
+
const jobs = [];
|
|
182
|
+
collectFencedCode(tree, jobs);
|
|
183
|
+
if (jobs.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
const highlighter = await getHighlighter();
|
|
186
|
+
const loaded = new Set(highlighter.getLoadedLanguages());
|
|
187
|
+
for (const job of jobs) {
|
|
188
|
+
// Fall back to plaintext for any language outside the preloaded set so Shiki never throws on
|
|
189
|
+
// an unknown fence. Plaintext still produces the <pre class="shiki"> wrapper with escaped text.
|
|
190
|
+
const lang = loaded.has(job.lang) ? job.lang : PLAINTEXT;
|
|
191
|
+
const result = highlighter.codeToHast(toString(job.code), {
|
|
192
|
+
lang,
|
|
193
|
+
theme: 'cairn-roles',
|
|
194
|
+
transformers: [cairnTokenClasses],
|
|
195
|
+
});
|
|
196
|
+
const pre = result.children.find((c) => c.type === 'element' && c.tagName === 'pre');
|
|
197
|
+
if (!pre)
|
|
198
|
+
continue;
|
|
199
|
+
// Rewrite the original <pre> into Shiki's <pre> in place: adopt its tag, properties, and
|
|
200
|
+
// children. Mutating the existing node avoids reindexing the parent's children array.
|
|
201
|
+
job.pre.tagName = pre.tagName;
|
|
202
|
+
job.pre.properties = pre.properties;
|
|
203
|
+
job.pre.children = pre.children;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
package/dist/render/pipeline.js
CHANGED
|
@@ -8,7 +8,8 @@ import rehypeSlug from 'rehype-slug';
|
|
|
8
8
|
import rehypeStringify from 'rehype-stringify';
|
|
9
9
|
import rehypeSanitize from 'rehype-sanitize';
|
|
10
10
|
import { VFile } from 'vfile';
|
|
11
|
-
import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard } from './sanitize-schema.js';
|
|
11
|
+
import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard, rehypeTaskListA11y } from './sanitize-schema.js';
|
|
12
|
+
import { rehypeCairnHighlight } from './highlight.js';
|
|
12
13
|
import { remarkDirectiveStamp } from './remark-directives.js';
|
|
13
14
|
import { remarkFigure } from './remark-figure.js';
|
|
14
15
|
import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
|
|
@@ -40,6 +41,16 @@ export function createRenderer(registry = defineRegistry({ components: [] }), op
|
|
|
40
41
|
...floor,
|
|
41
42
|
[rehypeDispatch, registry, options.stagger],
|
|
42
43
|
rehypeSlug,
|
|
44
|
+
// Name each GFM task-list checkbox from its item text. It runs after the sanitize floor (which
|
|
45
|
+
// does not allow aria-label) so the added attribute survives, and is content-not-sink, so it is
|
|
46
|
+
// not gated by unsafeDisableSanitize.
|
|
47
|
+
rehypeTaskListA11y,
|
|
48
|
+
// Build-time syntax highlighting. It emits class-only output (the cairn-tok-* ramp, no inline
|
|
49
|
+
// style), so it is class-driven like the rest of the pipeline and needs no special placement
|
|
50
|
+
// relative to the sanitize floor or the sink guard: the token classes survive the floor because
|
|
51
|
+
// `className` is already allowed on `*`. It runs unconditionally (a code fence is content, not a
|
|
52
|
+
// sink) and ships no client highlighter (Shiki is build-only behind a dynamic import).
|
|
53
|
+
rehypeCairnHighlight,
|
|
43
54
|
];
|
|
44
55
|
if (rel !== false)
|
|
45
56
|
rehypePlugins.push([rehypeAnchorRel, rel]);
|
|
@@ -78,7 +78,12 @@ export interface ComponentDef {
|
|
|
78
78
|
* result, so a build fn stays free of any motion concern.
|
|
79
79
|
*/
|
|
80
80
|
build: (ctx: ComponentContext) => Element;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. Maps a free-string role to a
|
|
83
|
+
* glyph key in the site IconSet; choose a logically representative glyph and prefer glyphs
|
|
84
|
+
* distinct across roles so the picker stays scannable. Overrides the engine
|
|
85
|
+
* {@link DEFAULT_ICON_BY_ROLE} fallback for the roles it names.
|
|
86
|
+
*/
|
|
82
87
|
defaultIconByRole?: Record<string, string>;
|
|
83
88
|
/** One line on when to reach for this component; feeds the picker and the reference file. */
|
|
84
89
|
use?: string;
|
|
@@ -86,7 +91,10 @@ export interface ComponentDef {
|
|
|
86
91
|
attributes?: AttributeField[];
|
|
87
92
|
/** The named content regions this component accepts. */
|
|
88
93
|
slots?: SlotDef[];
|
|
89
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* A glyph key from the site IconSet, shown beside the label in the picker. Choose a logically
|
|
96
|
+
* representative glyph and prefer glyphs distinct across components so the picker stays scannable.
|
|
97
|
+
*/
|
|
90
98
|
icon?: string;
|
|
91
99
|
/** A category heading for the picker. Components order by declaration within a group. */
|
|
92
100
|
group?: string;
|
package/dist/render/registry.js
CHANGED
|
@@ -14,6 +14,19 @@ export function dataAttrProp(key) {
|
|
|
14
14
|
function findIconField(def) {
|
|
15
15
|
return def.attributes?.find((field) => field.type === 'icon');
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The engine's role-to-glyph-key fallback for the conventional admonition roles, which a site's
|
|
19
|
+
* IconSet may satisfy. A component's own {@link ComponentDef.defaultIconByRole} overrides it.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_ICON_BY_ROLE = {
|
|
22
|
+
note: 'info',
|
|
23
|
+
tip: 'lightbulb',
|
|
24
|
+
important: 'star',
|
|
25
|
+
warning: 'warning',
|
|
26
|
+
caution: 'alert-triangle',
|
|
27
|
+
info: 'info',
|
|
28
|
+
danger: 'flame',
|
|
29
|
+
};
|
|
17
30
|
/**
|
|
18
31
|
* Build a registry from a site's component definitions. The single source the render
|
|
19
32
|
* pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
|
|
@@ -32,7 +45,14 @@ export function defineRegistry({ components }) {
|
|
|
32
45
|
defs: components,
|
|
33
46
|
names: components.map((c) => c.name),
|
|
34
47
|
get: (name) => byName.get(name),
|
|
35
|
-
defaultIcon: (name, role) =>
|
|
48
|
+
defaultIcon: (name, role) => {
|
|
49
|
+
if (!role)
|
|
50
|
+
return undefined;
|
|
51
|
+
const def = byName.get(name);
|
|
52
|
+
if (!def || !findIconField(def))
|
|
53
|
+
return undefined;
|
|
54
|
+
return def.defaultIconByRole?.[role] ?? DEFAULT_ICON_BY_ROLE[role];
|
|
55
|
+
},
|
|
36
56
|
iconField: (name) => {
|
|
37
57
|
const def = byName.get(name);
|
|
38
58
|
return def ? findIconField(def) : undefined;
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type { Root, Element, ElementContent } from 'hast';
|
|
2
2
|
import { type ComponentContext, type ComponentRegistry } from './registry.js';
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*/
|
|
3
|
+
/** Narrow a hast node to an Element, false for a text node, a comment, or undefined. */
|
|
6
4
|
export declare function isElement(node: ElementContent | undefined): node is Element;
|
|
7
5
|
/**
|
|
8
6
|
* Read a declared string attribute off the component context, returning undefined for a boolean or
|
|
9
7
|
* absent value. Replaces the `typeof ctx.attributes[key] === 'string'` narrowing a build repeats.
|
|
10
8
|
*/
|
|
11
9
|
export declare function strAttr(ctx: ComponentContext, key: string): string | undefined;
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
10
|
+
/** Read a hast element property as a string, or undefined when it is absent or non-string. */
|
|
15
11
|
export declare function strProp(node: Element, name: string): string | undefined;
|
|
16
12
|
/** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
|
|
17
13
|
export declare function iconSpan(glyphEl: Element, role?: string): Element;
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { h } from 'hastscript';
|
|
2
2
|
import { dataAttrProp } from './registry.js';
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*/
|
|
3
|
+
/** Narrow a hast node to an Element, false for a text node, a comment, or undefined. */
|
|
6
4
|
export function isElement(node) {
|
|
7
5
|
return !!node && node.type === 'element';
|
|
8
6
|
}
|
|
@@ -17,9 +15,7 @@ export function strAttr(ctx, key) {
|
|
|
17
15
|
// hast Properties values are PropertyValue (string | number | boolean | array | null).
|
|
18
16
|
// Directive markers (dataPrimitive/dataRole/dataAttr<Key>) are always stamped as strings;
|
|
19
17
|
// this reads them back with that guarantee instead of casting at each call site.
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*/
|
|
18
|
+
/** Read a hast element property as a string, or undefined when it is absent or non-string. */
|
|
23
19
|
export function strProp(node, name) {
|
|
24
20
|
const value = node.properties?.[name];
|
|
25
21
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -20,6 +20,16 @@ export declare function buildSanitizeSchema(registry: ComponentRegistry, extend?
|
|
|
20
20
|
* `anchorRel` option (default `noopener noreferrer`); a site can override it or disable it entirely.
|
|
21
21
|
*/
|
|
22
22
|
export declare function rehypeAnchorRel(rel: string): (tree: Root) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Give every GFM task-list checkbox an accessible name from its item text. remark-gfm emits a real
|
|
25
|
+
* `<input type="checkbox" disabled>` with no label, which axe's `label` rule flags as a critical
|
|
26
|
+
* violation even though the control is read-only; the visible label is the surrounding `<li>` text,
|
|
27
|
+
* not associated programmatically. This sets `aria-label` on each task-list checkbox to its item's
|
|
28
|
+
* text so the name travels with the control, keeping the engine's real disabled input (the bar's
|
|
29
|
+
* non-color cue) while clearing the violation on every site. It must run after the sanitize floor,
|
|
30
|
+
* which does not allow `aria-label`, so the attribute is added once the floor has run.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rehypeTaskListA11y(): (tree: Root) => void;
|
|
23
33
|
/**
|
|
24
34
|
* Post-dispatch safety floor over the fully-built tree. The pre-dispatch rehype-sanitize floor
|
|
25
35
|
* cleans author content, but a component build() runs after it and can route a raw author
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultSchema } from 'hast-util-sanitize';
|
|
2
2
|
import { visit } from 'unist-util-visit';
|
|
3
|
+
import { toString } from 'hast-util-to-string';
|
|
3
4
|
import { dataAttrProp } from './registry.js';
|
|
4
5
|
// The fixed directive markers the stamp writes and the dispatch reads. They are inert data
|
|
5
6
|
// attributes, never a script vector, and must survive the floor so the dispatch still runs.
|
|
@@ -58,6 +59,34 @@ export function rehypeAnchorRel(rel) {
|
|
|
58
59
|
});
|
|
59
60
|
};
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Give every GFM task-list checkbox an accessible name from its item text. remark-gfm emits a real
|
|
64
|
+
* `<input type="checkbox" disabled>` with no label, which axe's `label` rule flags as a critical
|
|
65
|
+
* violation even though the control is read-only; the visible label is the surrounding `<li>` text,
|
|
66
|
+
* not associated programmatically. This sets `aria-label` on each task-list checkbox to its item's
|
|
67
|
+
* text so the name travels with the control, keeping the engine's real disabled input (the bar's
|
|
68
|
+
* non-color cue) while clearing the violation on every site. It must run after the sanitize floor,
|
|
69
|
+
* which does not allow `aria-label`, so the attribute is added once the floor has run.
|
|
70
|
+
*/
|
|
71
|
+
export function rehypeTaskListA11y() {
|
|
72
|
+
return (tree) => {
|
|
73
|
+
visit(tree, 'element', (node) => {
|
|
74
|
+
const className = node.properties?.className;
|
|
75
|
+
const isTaskItem = node.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item');
|
|
76
|
+
if (!isTaskItem)
|
|
77
|
+
return;
|
|
78
|
+
const checkbox = node.children.find((child) => child.type === 'element' &&
|
|
79
|
+
child.tagName === 'input' &&
|
|
80
|
+
child.properties?.type === 'checkbox');
|
|
81
|
+
if (!checkbox)
|
|
82
|
+
return;
|
|
83
|
+
const label = toString(node).trim();
|
|
84
|
+
// Only when there is text to name it; an empty item leaves the box unnamed rather than blank.
|
|
85
|
+
if (label)
|
|
86
|
+
(checkbox.properties ??= {})['ariaLabel'] = label;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
}
|
|
61
90
|
// URL-bearing hast properties the post-dispatch guard scheme-checks. hast camelCases attribute
|
|
62
91
|
// names through property-information (srcset -> srcSet, xlink:href -> xLinkHref with a capital L,
|
|
63
92
|
// formaction -> formAction). data is the <object data> URL attribute; data-* attributes camelCase
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -32,6 +32,16 @@ function isLocalHost(hostname) {
|
|
|
32
32
|
export function createAuthGuard() {
|
|
33
33
|
return async function handle({ event, resolve }) {
|
|
34
34
|
const { pathname } = event.url;
|
|
35
|
+
// Fail closed if the dev-backend flag is set in a deployed runtime. Read both env sources: a
|
|
36
|
+
// Cloudflare Worker var lands on platform.env, an adapter-node OS var on process.env. A correct
|
|
37
|
+
// production build already eliminated the dev backend (the consumer gates it on the build-foldable
|
|
38
|
+
// `dev`), so a set flag signals a polluted environment; refuse loudly.
|
|
39
|
+
const platformFlag = event.platform?.env?.CAIRN_DEV_BACKEND;
|
|
40
|
+
const processFlag = typeof process !== 'undefined' ? process.env?.CAIRN_DEV_BACKEND : undefined;
|
|
41
|
+
if (platformFlag === '1' || platformFlag === true || processFlag === '1') {
|
|
42
|
+
log.error('guard.rejected', { reason: 'dev_backend_in_prod', path: pathname });
|
|
43
|
+
return new Response('cairn: the dev backend flag is set in a deployed environment. Unset CAIRN_DEV_BACKEND.', { status: 503 });
|
|
44
|
+
}
|
|
35
45
|
// Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
|
|
36
46
|
// they set checkOrigin: false to hand cairn the admin CSRF authority.
|
|
37
47
|
if (!isAdminPath(pathname)) {
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"packages/*"
|
|
8
|
+
],
|
|
6
9
|
"sideEffects": [
|
|
7
10
|
"**/*.svelte",
|
|
8
11
|
"**/*.css"
|
|
@@ -34,15 +37,20 @@
|
|
|
34
37
|
"check:docs": "node scripts/docs-links.mjs",
|
|
35
38
|
"check:version": "node scripts/check-version.mjs",
|
|
36
39
|
"check:prose": "node scripts/check-admin-prose.mjs",
|
|
40
|
+
"check:public-tokens": "node scripts/check-public-tokens.mjs",
|
|
41
|
+
"test:reskin": "node scripts/reskin-fixture.mjs",
|
|
37
42
|
"lint": "eslint src/lib",
|
|
38
43
|
"check:comments": "bash scripts/check-comments.sh",
|
|
44
|
+
"check:dev-package": "node scripts/check-dev-package.mjs",
|
|
39
45
|
"prepare": "npm run package",
|
|
40
46
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
41
47
|
"test": "vitest run",
|
|
42
48
|
"test:watch": "vitest",
|
|
43
49
|
"test:unit": "vitest run --project unit",
|
|
44
50
|
"test:integration": "vitest run --project integration",
|
|
45
|
-
"test:component": "vitest run --project component"
|
|
51
|
+
"test:component": "vitest run --project component",
|
|
52
|
+
"emit-template": "node scripts/emit-template.mjs",
|
|
53
|
+
"test:emit": "node --test scripts/emit-template.test.mjs"
|
|
46
54
|
},
|
|
47
55
|
"exports": {
|
|
48
56
|
".": {
|
|
@@ -131,6 +139,7 @@
|
|
|
131
139
|
"codemirror": "^6.0.2",
|
|
132
140
|
"gray-matter": "^4",
|
|
133
141
|
"hast-util-sanitize": "^5.0.2",
|
|
142
|
+
"hast-util-to-string": "^3.0.1",
|
|
134
143
|
"hastscript": "^9.0.1",
|
|
135
144
|
"heic-to": "^1.5.2",
|
|
136
145
|
"mdast-util-directive": "^3.1.0",
|
|
@@ -143,6 +152,7 @@
|
|
|
143
152
|
"remark-parse": "^11.0.0",
|
|
144
153
|
"remark-rehype": "^11.1.2",
|
|
145
154
|
"remark-stringify": "^11.0.0",
|
|
155
|
+
"shiki": "^4.3.0",
|
|
146
156
|
"spellchecker-wasm": "^0.3.3",
|
|
147
157
|
"unified": "^11.0.5",
|
|
148
158
|
"unist-util-visit": "^5.1.0",
|
|
@@ -159,6 +169,7 @@
|
|
|
159
169
|
"@types/node": "^22.19.19",
|
|
160
170
|
"@vitest/browser": "^4.1.7",
|
|
161
171
|
"@vitest/browser-playwright": "^4.1.7",
|
|
172
|
+
"culori": "^4.0.2",
|
|
162
173
|
"daisyui": "^5.5.23",
|
|
163
174
|
"eslint": "^9.39.4",
|
|
164
175
|
"eslint-plugin-jsdoc": "^63.0.7",
|
package/src/lib/auth/types.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface AuthEnv {
|
|
|
14
14
|
AUTH_DB?: D1Database;
|
|
15
15
|
/** Canonical origin for confirmation links, never read from a request header (spec 7.1, risk H3). */
|
|
16
16
|
PUBLIC_ORIGIN?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Dev-backend tripwire flag. The dev backend sets this in local development; if it is ever set in
|
|
19
|
+
* a deployed runtime the guard refuses (the build-foldable `dev` gate should have eliminated the
|
|
20
|
+
* dev backend, so a set flag signals a polluted environment). A string from a Worker var or a
|
|
21
|
+
* boolean.
|
|
22
|
+
*/
|
|
23
|
+
CAIRN_DEV_BACKEND?: string | boolean;
|
|
17
24
|
/** Cloudflare Email Sending binding. */
|
|
18
25
|
EMAIL?: {
|
|
19
26
|
send(message: {
|
|
@@ -317,11 +317,16 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
317
317
|
|
|
318
318
|
{#if defs.length > 0}
|
|
319
319
|
<dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={onClose} oncancel={onCancel}>
|
|
320
|
-
|
|
320
|
+
<!-- The box caps at 85vh and is a flex column so its header holds while only the body scrolls,
|
|
321
|
+
per the design system's dialog-sizing recipe. The cap rides Tailwind utilities (the utilities
|
|
322
|
+
layer) so it beats DaisyUI's `.modal-box` max-height: 100vh; a components-layer rule loses the
|
|
323
|
+
cascade. overflow-hidden keeps the box from being a second scroll container. Matches TidyReview. -->
|
|
324
|
+
<div class="modal-box flex max-h-[85vh] flex-col overflow-hidden {twoPane ? 'max-w-3xl' : ''}">
|
|
321
325
|
<!-- The shared header: at the configure step it carries the Back control and the
|
|
322
326
|
"Insert > group" eyebrow breadcrumb above the component label; while browsing it is the
|
|
323
|
-
plain "Insert a component" title.
|
|
324
|
-
|
|
327
|
+
plain "Insert a component" title. It holds (flex-none) while the body scrolls, per the
|
|
328
|
+
design system's dialog-sizing recipe. -->
|
|
329
|
+
<div class="mb-3 flex flex-none items-center gap-3">
|
|
325
330
|
{#if picked && !editing}
|
|
326
331
|
<button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Back to components" onclick={back}>
|
|
327
332
|
<svg class="h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M165.7 202.3a8 8 0 0 1-11.4 11.4l-80-80a8 8 0 0 1 0-11.4l80-80a8 8 0 0 1 11.4 11.4L91.3 128Z" /></svg>
|
|
@@ -339,6 +344,9 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
339
344
|
</div>
|
|
340
345
|
|
|
341
346
|
{#if picked}
|
|
347
|
+
<!-- The configure body is the box's scroll container (flex-1, min-h-0): the shared header
|
|
348
|
+
above holds while the form scrolls within the 85vh cap. -->
|
|
349
|
+
<div class="-mr-1 flex min-h-0 flex-1 flex-col overflow-y-auto pr-1">
|
|
342
350
|
{#key picked}
|
|
343
351
|
{#if twoPane}
|
|
344
352
|
<!-- Two panes: the form on the left, the live preview on the right. Below the breakpoint
|
|
@@ -395,9 +403,10 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
395
403
|
{@render configureForm(picked)}
|
|
396
404
|
{/if}
|
|
397
405
|
{/key}
|
|
406
|
+
</div>
|
|
398
407
|
{:else}
|
|
399
408
|
{#if showSearch}
|
|
400
|
-
<div class="mb-3 flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
|
|
409
|
+
<div class="mb-3 flex flex-none items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
|
|
401
410
|
<svg class="ec-glyph h-4 w-4 text-[var(--color-muted)]" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M229.7 218.3 179.6 168.2A92.2 92.2 0 1 0 168.2 179.6l50.1 50.1a8 8 0 0 0 11.4-11.4ZM40 112a72 72 0 1 1 72 72 72.1 72.1 0 0 1-72-72Z" /></svg>
|
|
402
411
|
<input
|
|
403
412
|
type="search"
|
|
@@ -421,8 +430,10 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
421
430
|
<button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={() => (query = '')}>Clear search</button>
|
|
422
431
|
</div>
|
|
423
432
|
{:else}
|
|
424
|
-
<!-- One scroll region holds every group, so the arrow keys roam the whole catalog.
|
|
425
|
-
|
|
433
|
+
<!-- One scroll region holds every group, so the arrow keys roam the whole catalog. It
|
|
434
|
+
is the box's scroll container (flex-1, min-h-0): the header above holds while the
|
|
435
|
+
list scrolls within the 85vh cap. -->
|
|
436
|
+
<div data-cairn-pk-list class="-mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
|
426
437
|
{#each groups as group (group.heading)}
|
|
427
438
|
<div class="mt-3 first:mt-0">
|
|
428
439
|
{#if group.heading}
|
|
@@ -200,6 +200,38 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
200
200
|
? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
|
|
201
201
|
: '',
|
|
202
202
|
);
|
|
203
|
+
|
|
204
|
+
// The one lifecycle error to announce (the visible alerts below keep their own styling). A blocked
|
|
205
|
+
// delete leads, then a form error, then a load error, since the refusal is the most recent and most
|
|
206
|
+
// actionable outcome of the last submit. The refusal announcement carries the blocker count, so a
|
|
207
|
+
// screen reader hears the magnitude (matching the visible banner) before navigating to the list.
|
|
208
|
+
const lifecycleError = $derived(
|
|
209
|
+
deleteRefused
|
|
210
|
+
? `This ${data.label.toLowerCase()} could not be deleted. ${deleteRefused.inboundLinks.length} ${deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it.`
|
|
211
|
+
: (data.formError ?? data.error ?? ''),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// The polite live region's text re-announces only when it changes, so a repeated identical error
|
|
215
|
+
// (a second submit failing the same way) would go silent. An invisible nonce flips on every fresh
|
|
216
|
+
// error so the region text always mutates and the screen reader speaks again (the MediaPicker
|
|
217
|
+
// discipline). The nonce is a zero-width space, never voiced, so the heard sentence is unchanged.
|
|
218
|
+
let announceNonce = $state(0);
|
|
219
|
+
function nonce(): string {
|
|
220
|
+
return announceNonce % 2 === 0 ? '' : '';
|
|
221
|
+
}
|
|
222
|
+
// Each submit hands a fresh `form` (or `data` on a load) object, so the nonce bumps once per submit,
|
|
223
|
+
// keying the re-announce to the submit rather than to a string change the live region would swallow.
|
|
224
|
+
// The guard reads a plain non-reactive `lastSubmit`, so the bump fires only when the submit identity
|
|
225
|
+
// changes, never on the re-render the bump itself causes; that is what keeps the effect from looping.
|
|
226
|
+
let lastSubmit: unknown;
|
|
227
|
+
$effect(() => {
|
|
228
|
+
const submit = form ?? data;
|
|
229
|
+
if (submit !== lastSubmit) {
|
|
230
|
+
lastSubmit = submit;
|
|
231
|
+
if (lifecycleError) announceNonce++;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
const liveError = $derived(lifecycleError ? `${lifecycleError}${nonce()}` : '');
|
|
203
235
|
</script>
|
|
204
236
|
|
|
205
237
|
<!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
|
|
@@ -225,20 +257,25 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
225
257
|
{#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
|
|
226
258
|
below keeps its styling without a role and the message is announced once. -->
|
|
227
259
|
<div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
|
|
260
|
+
<!-- One persistent polite region announces the lifecycle errors, re-announcing a repeat through the
|
|
261
|
+
nonce. The visible alerts below keep their styling and drop the live `role` (a fresh-inserted
|
|
262
|
+
role element announces inconsistently and clobbers a repeat), so the message is announced once. -->
|
|
263
|
+
<div class="sr-only" aria-live="polite">{liveError}</div>
|
|
228
264
|
{#if publishedAllMessage}
|
|
229
265
|
<div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
|
|
230
266
|
{/if}
|
|
231
267
|
{#if data.formError}
|
|
232
|
-
<div
|
|
268
|
+
<div class="alert alert-error mb-4 text-sm">{data.formError}</div>
|
|
233
269
|
{/if}
|
|
234
270
|
{#if data.error}
|
|
235
|
-
<div
|
|
271
|
+
<div class="alert alert-warning mb-4 text-sm">{data.error}</div>
|
|
236
272
|
{/if}
|
|
237
273
|
|
|
238
274
|
{#if deleteRefused}
|
|
239
275
|
<!-- A `?/delete` was refused: name the blockers up front, matching the editor's refusal banner,
|
|
240
|
-
so the author sees why without re-opening a dialog.
|
|
241
|
-
|
|
276
|
+
so the author sees why without re-opening a dialog. The polite region above announces it, so
|
|
277
|
+
the box itself carries no role or label (a bare div with an aria-label gets no accessible name). -->
|
|
278
|
+
<div class="alert alert-error mb-4 flex-col items-start text-sm">
|
|
242
279
|
<p class="font-medium">This {data.label.toLowerCase()} could not be deleted.</p>
|
|
243
280
|
<p>{deleteRefused.inboundLinks.length} {deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it. Remove or repoint the {deleteRefused.inboundLinks.length === 1 ? 'link' : 'links'} listed below, then delete again.</p>
|
|
244
281
|
<ul class="mt-1 w-full">
|