wiki-formant 0.2.1 → 0.4.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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A readable label for an anchor whose text is empty, derived from its href.
3
+ * Without one, crawlers and screen readers meet a link with nothing in it.
4
+ */
5
+ export declare function fallbackAnchorText(href: string): string;
6
+ export interface NormaliseLinkOptions {
7
+ /**
8
+ * This wiki's own origin. An absolute link back to it is really internal, so
9
+ * it is folded to a relative path and navigates in place instead of opening a
10
+ * new tab. Matched case-insensitively, with or without a `www.` prefix.
11
+ * e.g. `'radix.wiki'` or `'caper.network'`.
12
+ */
13
+ selfHost?: string;
14
+ /**
15
+ * Shared across one document so a `#ref-n` citation can be given a
16
+ * `id="cite-n"` target exactly once — the reference list renders a `^`
17
+ * back-link to it. Pass the same Set for every block of a page.
18
+ */
19
+ citedRefs?: Set<number>;
20
+ /**
21
+ * Give an empty anchor a synthesised label. On by default; pass `false` to
22
+ * keep an empty anchor empty.
23
+ */
24
+ fallbackText?: boolean;
25
+ }
26
+ /**
27
+ * Tag every anchor in `html` as internal or external. Attribute-order agnostic;
28
+ * nested `<a>` is not handled, being invalid HTML anyway.
29
+ */
30
+ export declare function normaliseLinks(html: string, options?: NormaliseLinkOptions): string;
31
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../src/links.ts"],"names":[],"mappings":"AA2BA;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWvD;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAyCD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,GAAG,MAAM,CAMvF"}
package/dist/links.js ADDED
@@ -0,0 +1,81 @@
1
+ // links.ts — internal vs. external link normalisation for wiki content.
2
+ //
3
+ // The editor stores whatever href an author types. At render time a wiki has to
4
+ // draw the line between *internal* links (relative paths, in-page anchors, and
5
+ // absolute links back to its own host) and *external* ones: externals open in a
6
+ // new tab with a safe rel, internals navigate in place, and CSS keys off the
7
+ // same distinction to tint one and badge the other.
8
+ //
9
+ // Both wikis had written this out — caper's `normaliseLinks`, radix-wiki's
10
+ // anchor branch in `processHtml` — down to identical `getAttr` and `removeAttr`
11
+ // helpers and the same `#ref-n` -> `id="cite-n"` citation wiring. caper's file
12
+ // said "ported from radix-wiki" at the top, which is the drift admitting itself.
13
+ //
14
+ // What they drifted on: only radix-wiki synthesised a label for an anchor with
15
+ // no visible text. That is an accessibility fix, not a preference, so it is the
16
+ // default here and caper gains it by adopting this module.
17
+ /** Read one double-quoted attribute out of a tag's attribute string. */
18
+ const getAttr = (attrs, name) => attrs.match(new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? null;
19
+ /** Drop every occurrence of one attribute from a tag's attribute string. */
20
+ const removeAttr = (attrs, name) => attrs.replace(new RegExp(`\\s${name}\\s*=\\s*"[^"]*"`, 'gi'), '');
21
+ const stripTags = (s) => s.replace(/<[^>]*>/g, '').trim();
22
+ /**
23
+ * A readable label for an anchor whose text is empty, derived from its href.
24
+ * Without one, crawlers and screen readers meet a link with nothing in it.
25
+ */
26
+ export function fallbackAnchorText(href) {
27
+ if (href.startsWith('#'))
28
+ return href.slice(1).replace(/-/g, ' ') || 'section';
29
+ if (href.startsWith('/')) {
30
+ const last = href.split('/').filter(Boolean).pop();
31
+ return last ? last.replace(/-/g, ' ') : 'page';
32
+ }
33
+ try {
34
+ return new URL(href).hostname.replace(/^www\./, '') || href;
35
+ }
36
+ catch {
37
+ return href;
38
+ }
39
+ }
40
+ const ANCHOR = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
41
+ function selfHostPattern(host) {
42
+ const escaped = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+ return new RegExp(`^https?://(?:www\\.)?${escaped}(/[^\\s"]*)?$`, 'i');
44
+ }
45
+ function normaliseAnchor(attrs, inner, opts, self) {
46
+ // Injected heading permalink anchors carry their glyph via CSS. Leave them
47
+ // alone — in particular the empty-text synthesis below must not fill them,
48
+ // or the label leaks into the heading's textContent and out into a TOC.
49
+ if (/\bheading-anchor\b/.test(attrs))
50
+ return `<a${attrs}>${inner}</a>`;
51
+ const rawHref = getAttr(attrs, 'href');
52
+ if (rawHref == null)
53
+ return `<a${attrs}>${inner}</a>`;
54
+ const href = self ? rawHref.replace(self, (_m, path) => path || '/') : rawHref;
55
+ const isInternal = href.startsWith('/') || href.startsWith('#');
56
+ // Drop any author-supplied target/rel; both are re-derived from the link kind.
57
+ let rest = removeAttr(removeAttr(removeAttr(attrs, 'href'), 'rel'), 'target');
58
+ const refMatch = opts.citedRefs ? href.match(/^#ref-(\d+)$/) : null;
59
+ if (refMatch && !/\bid\s*=/.test(rest)) {
60
+ const n = Number(refMatch[1]);
61
+ if (!opts.citedRefs.has(n)) {
62
+ opts.citedRefs.add(n);
63
+ rest = ` id="cite-${n}"${rest}`;
64
+ }
65
+ }
66
+ const safeInner = opts.fallbackText === false || stripTags(inner) ? inner : fallbackAnchorText(href);
67
+ return isInternal
68
+ ? `<a href="${href}"${rest}>${safeInner}</a>`
69
+ : `<a href="${href}"${rest} target="_blank" rel="noopener">${safeInner}</a>`;
70
+ }
71
+ /**
72
+ * Tag every anchor in `html` as internal or external. Attribute-order agnostic;
73
+ * nested `<a>` is not handled, being invalid HTML anyway.
74
+ */
75
+ export function normaliseLinks(html, options = {}) {
76
+ if (!html.trim())
77
+ return html;
78
+ const self = options.selfHost ? selfHostPattern(options.selfHost) : null;
79
+ return html.replace(ANCHOR, (_m, attrs, inner) => normaliseAnchor(attrs, inner, options, self));
80
+ }
81
+ //# sourceMappingURL=links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.js","sourceRoot":"","sources":["../src/links.ts"],"names":[],"mappings":"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"}
package/dist/maps.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export interface MapCoords {
2
+ lat: number;
3
+ lon: number;
4
+ zoom?: number | undefined;
5
+ }
6
+ /** A plain embed URL for a coordinate pair. */
7
+ export declare function mapsEmbedUrl(lat: number, lon: number, zoom?: number): string;
8
+ /**
9
+ * Dig a coordinate pair out of a maps URL. Four shapes in descending
10
+ * specificity: the `@lat,lon,zoom` path segment, a `/search/lat,lon`, the
11
+ * `!3d…!4d…` data blob, and finally an `ll`/`sll` query parameter.
12
+ */
13
+ export declare function extractCoordsFromUrl(url: string): MapCoords | null;
14
+ /**
15
+ * An embeddable URL for `url`, or null when it is not a map link this
16
+ * understands. Already-embeddable URLs pass through untouched.
17
+ */
18
+ export declare function toMapEmbedUrl(url: string): string | null;
19
+ /** True for the shortener forms that only a redirect can resolve. */
20
+ export declare const isShortMapUrl: (url: string) => boolean;
21
+ //# sourceMappingURL=maps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"maps.d.ts","sourceRoot":"","sources":["../src/maps.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED,+CAA+C;AAC/C,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,SAAK,GAAG,MAAM,CAExE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAoBlE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA6BxD;AAED,qEAAqE;AACrE,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,KAAG,OACE,CAAC"}
package/dist/maps.js ADDED
@@ -0,0 +1,77 @@
1
+ // maps.ts — turn a map URL a human pasted into one an <iframe> will accept.
2
+ //
3
+ // Both wikis carried this byte-for-byte apart from one `export` keyword. It is
4
+ // pure string work over Google and Apple Maps URL shapes, with no framework or
5
+ // database in it, which is why neither copy had a reason to diverge.
6
+ /** A plain embed URL for a coordinate pair. */
7
+ export function mapsEmbedUrl(lat, lon, zoom = 15) {
8
+ return `https://maps.google.com/maps?q=${lat},${lon}&z=${zoom}&output=embed`;
9
+ }
10
+ /**
11
+ * Dig a coordinate pair out of a maps URL. Four shapes in descending
12
+ * specificity: the `@lat,lon,zoom` path segment, a `/search/lat,lon`, the
13
+ * `!3d…!4d…` data blob, and finally an `ll`/`sll` query parameter.
14
+ */
15
+ export function extractCoordsFromUrl(url) {
16
+ const coords = url.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*),?(\d+\.?\d*)?z?/);
17
+ if (coords)
18
+ return { lat: +coords[1], lon: +coords[2], zoom: coords[3] ? +coords[3] : undefined };
19
+ const search = url.match(/\/search\/(-?\d+\.?\d*),[\s+]*(-?\d+\.?\d*)/);
20
+ if (search)
21
+ return { lat: +search[1], lon: +search[2] };
22
+ const data = url.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
23
+ if (data)
24
+ return { lat: +data[1], lon: +data[2] };
25
+ try {
26
+ const ll = new URL(url).searchParams.get('ll') ?? new URL(url).searchParams.get('sll');
27
+ if (ll) {
28
+ const [lat, lon] = ll.split(',').map(Number);
29
+ if (lat !== undefined && lon !== undefined && !isNaN(lat) && !isNaN(lon))
30
+ return { lat, lon };
31
+ }
32
+ }
33
+ catch {
34
+ /* not a valid URL */
35
+ }
36
+ return null;
37
+ }
38
+ /**
39
+ * An embeddable URL for `url`, or null when it is not a map link this
40
+ * understands. Already-embeddable URLs pass through untouched.
41
+ */
42
+ export function toMapEmbedUrl(url) {
43
+ if (/google\.[a-z.]+\/maps\/embed/.test(url))
44
+ return url;
45
+ if (/embed\.apple\.com\/maps/.test(url))
46
+ return url;
47
+ const c = extractCoordsFromUrl(url);
48
+ if (c)
49
+ return mapsEmbedUrl(c.lat, c.lon, c.zoom);
50
+ if (/google\.[a-z.]+\/maps/.test(url)) {
51
+ const place = url.match(/\/place\/([^/@]+)/);
52
+ if (place) {
53
+ const q = encodeURIComponent(decodeURIComponent(place[1]).replace(/\+/g, ' '));
54
+ return `https://maps.google.com/maps?q=${q}&output=embed`;
55
+ }
56
+ }
57
+ if (/maps\.apple\.com/.test(url)) {
58
+ try {
59
+ const u = new URL(url);
60
+ const ll = u.searchParams.get('ll') ?? u.searchParams.get('sll');
61
+ const q = u.searchParams.get('q') ?? u.searchParams.get('address');
62
+ const params = new URLSearchParams();
63
+ if (ll)
64
+ params.set('ll', ll);
65
+ if (q)
66
+ params.set('q', q);
67
+ return `https://embed.apple.com/maps?${params.toString()}`;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /** True for the shortener forms that only a redirect can resolve. */
76
+ export const isShortMapUrl = (url) => /maps\.app\.goo\.gl|goo\.gl\/maps/.test(url);
77
+ //# sourceMappingURL=maps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"maps.js","sourceRoot":"","sources":["../src/maps.ts"],"names":[],"mappings":"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"}
package/dist/text.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { CodeTab, ReferenceItem } from './blocks.js';
2
+ /**
3
+ * HTML to readable text. Links keep their href in parentheses so a model can
4
+ * still follow a citation; block-level tags become newlines; list items get a
5
+ * bullet. Entities are decoded last, after the tags are gone, so a `&lt;` in
6
+ * prose cannot become a tag the strip has already run past.
7
+ */
8
+ export declare function stripHtml(html: string): string;
9
+ /** The six maintenance-banner variants every wiki here renders. */
10
+ export type BannerVariant = 'stub' | 'unsourced' | 'outdated' | 'promotional' | 'cleanup' | 'coi';
11
+ /**
12
+ * Display labels for the maintenance banners. Canonical: the prose extractor,
13
+ * the markdown twin and the MDX export all render the same six strings.
14
+ */
15
+ export declare const BANNER_LABELS: Record<BannerVariant, string>;
16
+ /** A maintenance notice, inline: `[Notice: Needs citations] …`. */
17
+ export declare function bannerToText(label: string, text?: string | null): string;
18
+ /** Each tab under its label, tags stripped — highlighted markup is noise here. */
19
+ export declare function codeTabsToText(tabs: readonly CodeTab[]): string;
20
+ /** A numbered reference list, or `''` when there are none. */
21
+ export declare function referencesToText(items: readonly ReferenceItem[]): string;
22
+ /**
23
+ * Every `text` value at any depth of a block tree, in document order.
24
+ *
25
+ * Deliberately NOT the typed extractor above: that walks a switch and formats
26
+ * for reading (labels, bullets, reference numbering), so it can surface text a
27
+ * search index never matched and miss text it did. A snippet claiming to show
28
+ * why a row matched has to read the same bytes the match was made against.
29
+ */
30
+ export declare function collectText(node: unknown, out?: string[]): string[];
31
+ /**
32
+ * The passage that matched `query`, not the opening of the page.
33
+ *
34
+ * Wiki pages open with an infobox far more often than not — 243 of 269 on caper
35
+ * when this was measured — so an opening-line snippet hands nine search rows in
36
+ * ten a flattened metadata table, whatever the query was. Falls back to the
37
+ * opening when the term appears only in the title, which is a real case rather
38
+ * than a failure: title-only hits are how the top tiers match.
39
+ */
40
+ export declare function matchSnippet(blocks: unknown, query: string, opening: () => string, maxLen?: number): string;
41
+ //# sourceMappingURL=text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE1D;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAW9C;AAED,mEAAmE;AACnE,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,UAAU,GAAG,aAAa,GAAG,SAAS,GAAG,KAAK,CAAC;AAElG;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAOvD,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAExE;AAED,kFAAkF;AAClF,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED,8DAA8D;AAC9D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,CAIxE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CAYvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,MAAM,EAAE,MAAM,SAAM,GAAG,MAAM,CAwBxG"}
package/dist/text.js ADDED
@@ -0,0 +1,112 @@
1
+ // text.ts — a block tree as plain prose, for LLM and MCP exports.
2
+ //
3
+ // The markdown twin in `blocks.ts` is for a reader; this is for a model reading
4
+ // the page as evidence. Both wikis had the same `stripHtml`, the same six
5
+ // banner labels and the same four leaf bodies, character for character.
6
+ //
7
+ // As in `blocks.ts`, the dispatch stays with the caller — a `switch` over its
8
+ // own block union, where a new type is a compile error until it is handled —
9
+ // and only the bodies live here.
10
+ import { decodeEntities } from './entities.js';
11
+ /**
12
+ * HTML to readable text. Links keep their href in parentheses so a model can
13
+ * still follow a citation; block-level tags become newlines; list items get a
14
+ * bullet. Entities are decoded last, after the tags are gone, so a `&lt;` in
15
+ * prose cannot become a tag the strip has already run past.
16
+ */
17
+ export function stripHtml(html) {
18
+ return decodeEntities(html
19
+ .replace(/<a[^>]+href="([^"]*)"[^>]*>(.*?)<\/a>/gi, ' $2 ($1) ')
20
+ .replace(/<br\s*\/?>/gi, '\n')
21
+ .replace(/<\/(?:p|h[1-6]|li|tr|th|td|div)>/gi, '\n')
22
+ .replace(/<(?:li)>/gi, '- ')
23
+ .replace(/<[^>]+>/g, ''))
24
+ .replace(/\n{3,}/g, '\n\n')
25
+ .trim();
26
+ }
27
+ /**
28
+ * Display labels for the maintenance banners. Canonical: the prose extractor,
29
+ * the markdown twin and the MDX export all render the same six strings.
30
+ */
31
+ export const BANNER_LABELS = {
32
+ stub: 'Stub',
33
+ unsourced: 'Needs citations',
34
+ outdated: 'May be outdated',
35
+ promotional: 'Written like an advertisement',
36
+ cleanup: 'Needs cleanup',
37
+ coi: 'Conflict of interest',
38
+ };
39
+ /** A maintenance notice, inline: `[Notice: Needs citations] …`. */
40
+ export function bannerToText(label, text) {
41
+ return `[Notice: ${label}]${text ? ' ' + stripHtml(text) : ''}`;
42
+ }
43
+ /** Each tab under its label, tags stripped — highlighted markup is noise here. */
44
+ export function codeTabsToText(tabs) {
45
+ return tabs.map(t => `[${t.label}]\n${t.code}`).join('\n');
46
+ }
47
+ /** A numbered reference list, or `''` when there are none. */
48
+ export function referencesToText(items) {
49
+ if (!items.length)
50
+ return '';
51
+ const lines = items.map((it, i) => `${i + 1}. ${stripHtml(it.text)}${it.url ? ` (${it.url})` : ''}`);
52
+ return `References:\n${lines.join('\n')}`;
53
+ }
54
+ /**
55
+ * Every `text` value at any depth of a block tree, in document order.
56
+ *
57
+ * Deliberately NOT the typed extractor above: that walks a switch and formats
58
+ * for reading (labels, bullets, reference numbering), so it can surface text a
59
+ * search index never matched and miss text it did. A snippet claiming to show
60
+ * why a row matched has to read the same bytes the match was made against.
61
+ */
62
+ export function collectText(node, out = []) {
63
+ if (Array.isArray(node)) {
64
+ for (const item of node)
65
+ collectText(item, out);
66
+ return out;
67
+ }
68
+ if (node && typeof node === 'object') {
69
+ for (const [key, value] of Object.entries(node)) {
70
+ if (key === 'text' && typeof value === 'string')
71
+ out.push(value);
72
+ else
73
+ collectText(value, out);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * The passage that matched `query`, not the opening of the page.
80
+ *
81
+ * Wiki pages open with an infobox far more often than not — 243 of 269 on caper
82
+ * when this was measured — so an opening-line snippet hands nine search rows in
83
+ * ten a flattened metadata table, whatever the query was. Falls back to the
84
+ * opening when the term appears only in the title, which is a real case rather
85
+ * than a failure: title-only hits are how the top tiers match.
86
+ */
87
+ export function matchSnippet(blocks, query, opening, maxLen = 200) {
88
+ const term = query.trim();
89
+ if (!term)
90
+ return opening();
91
+ // Collapse tags and non-breaking spaces the way SQL does, so a phrase broken
92
+ // by markup is still one searchable string, then decode what is left: editors
93
+ // store typographic punctuation named, and without this a snippet reads back
94
+ // "docs &middot; Related".
95
+ const text = decodeEntities(collectText(blocks).join(' ').replace(/<[^>]*>|&nbsp;/g, ' '))
96
+ .replace(/\s+/g, ' ') // JS \s covers U+00A0, which SQL has to translate by hand
97
+ .trim();
98
+ const at = text.toLowerCase().indexOf(term.toLowerCase());
99
+ if (at === -1)
100
+ return opening();
101
+ // Keep about a line of lead-in, cut to a word boundary so it does not open
102
+ // mid-word.
103
+ let start = Math.max(0, at - 60);
104
+ if (start > 0) {
105
+ const boundary = text.indexOf(' ', start);
106
+ if (boundary > -1 && boundary < at)
107
+ start = boundary + 1;
108
+ }
109
+ const end = Math.min(text.length, start + maxLen);
110
+ return `${start > 0 ? '…' : ''}${text.slice(start, end).trimEnd()}${end < text.length ? '…' : ''}`;
111
+ }
112
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.js","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,gFAAgF;AAChF,0EAA0E;AAC1E,wEAAwE;AACxE,EAAE;AACF,8EAA8E;AAC9E,6EAA6E;AAC7E,iCAAiC;AAEjC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY;IACpC,OAAO,cAAc,CACnB,IAAI;SACD,OAAO,CAAC,yCAAyC,EAAE,WAAW,CAAC;SAC/D,OAAO,CAAC,cAAc,EAAE,IAAI,CAAC;SAC7B,OAAO,CAAC,oCAAoC,EAAE,IAAI,CAAC;SACnD,OAAO,CAAC,YAAY,EAAE,IAAI,CAAC;SAC3B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAC3B;SACE,OAAO,CAAC,SAAS,EAAE,MAAM,CAAC;SAC1B,IAAI,EAAE,CAAC;AACZ,CAAC;AAKD;;;GAGG;AACH,MAAM,CAAC,MAAM,aAAa,GAAkC;IAC1D,IAAI,EAAE,MAAM;IACZ,SAAS,EAAE,iBAAiB;IAC5B,QAAQ,EAAE,iBAAiB;IAC3B,WAAW,EAAE,+BAA+B;IAC5C,OAAO,EAAE,eAAe;IACxB,GAAG,EAAE,sBAAsB;CAC5B,CAAC;AAEF,mEAAmE;AACnE,MAAM,UAAU,YAAY,CAAC,KAAa,EAAE,IAAoB;IAC9D,OAAO,YAAY,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;AAClE,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,cAAc,CAAC,IAAwB;IACrD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC7D,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,gBAAgB,CAAC,KAA+B;IAC9D,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IAC7B,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACrG,OAAO,gBAAgB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AAC5C,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CAAC,IAAa,EAAE,MAAgB,EAAE;IAC3D,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,KAAK,MAAM,IAAI,IAAI,IAAI;YAAE,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAChD,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QACrC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAChD,IAAI,GAAG,KAAK,MAAM,IAAI,OAAO,KAAK,KAAK,QAAQ;gBAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;;gBAC5D,WAAW,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAC/B,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,MAAe,EAAE,KAAa,EAAE,OAAqB,EAAE,MAAM,GAAG,GAAG;IAC9F,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC1B,IAAI,CAAC,IAAI;QAAE,OAAO,OAAO,EAAE,CAAC;IAE5B,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2BAA2B;IAC3B,MAAM,IAAI,GAAG,cAAc,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,CAAC;SACvF,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,0DAA0D;SAC/E,IAAI,EAAE,CAAC;IAEV,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;IAC1D,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,OAAO,EAAE,CAAC;IAEhC,2EAA2E;IAC3E,YAAY;IACZ,IAAI,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;IACjC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACd,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAC1C,IAAI,QAAQ,GAAG,CAAC,CAAC,IAAI,QAAQ,GAAG,EAAE;YAAE,KAAK,GAAG,QAAQ,GAAG,CAAC,CAAC;IAC3D,CAAC;IACD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC;IAClD,OAAO,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,EAAE,GAAG,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;AACrG,CAAC"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * An href acceptable to persist: http, https, mailto, or a relative path or
3
+ * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
4
+ * protocol-relative `//host` — comes back null.
5
+ *
6
+ * React 19 already neutralises `javascript:` in an href at render time, so this
7
+ * is defence in depth rather than the only thing between an editor and a
8
+ * reader. Its value is at the write path: a URL that can never render safely is
9
+ * better rejected than stored.
10
+ */
11
+ export declare function safeLinkHref(raw: string | null | undefined): string | null;
12
+ /** An author-supplied URL is fine iff it is empty (unset) or resolves safely. */
13
+ export declare const okUrl: (u: unknown) => boolean;
14
+ /** `[{ id, text, url? }]` — the shape a references block stores. */
15
+ export declare function validateReferenceItems(items: unknown, urlCheck?: (u: unknown) => boolean): boolean;
16
+ /** `[{ id, heading, links: [{ label, href }] }]` — a link-grid block's groups. */
17
+ export declare function validateLinkGroups(groups: unknown, urlCheck?: (u: unknown) => boolean): boolean;
18
+ export interface BlockValidatorOptions {
19
+ /** True for a type this wiki knows at all. */
20
+ isKnownType: (type: string) => boolean;
21
+ /** True for a type that may nest inside a container. */
22
+ isAtomicType: (type: string) => boolean;
23
+ /** This wiki's switch over its leaf types. Runs only after id/type pass. */
24
+ validateAtomic: (block: Record<string, unknown>) => boolean;
25
+ }
26
+ /**
27
+ * The block-tree walk, with one wiki's leaf switch plugged into it. The caller
28
+ * keeps its own type parameter, so this stays free of any repo's types while
29
+ * the call site still gets a real type guard.
30
+ */
31
+ export declare function createBlockValidator(opts: BlockValidatorOptions): {
32
+ /** One leaf block; container types are rejected. */
33
+ validateAtomicBlock: (block: unknown) => boolean;
34
+ /** One block of any kind, containers included. */
35
+ validateBlock: (block: unknown) => boolean;
36
+ /** A whole page's content array. */
37
+ validateBlocks: (content: unknown) => boolean;
38
+ };
39
+ /**
40
+ * A copy of a block with a fresh id at every level, so a duplicated container
41
+ * does not share child ids with its original.
42
+ */
43
+ export declare function duplicateBlockIds<B extends {
44
+ type: string;
45
+ id: string;
46
+ }>(block: B, newId: () => string): B;
47
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAWA;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAa1E;AAED,iFAAiF;AACjF,eAAO,MAAM,KAAK,GAAI,GAAG,OAAO,KAAG,OAC8B,CAAC;AAKlE,oEAAoE;AACpE,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,GAAE,CAAC,CAAC,EAAE,OAAO,KAAK,OAAe,GAAG,OAAO,CAWzG;AAED,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,GAAE,CAAC,CAAC,EAAE,OAAO,KAAK,OAAe,GAAG,OAAO,CAYtG;AAED,MAAM,WAAW,qBAAqB;IACpC,8CAA8C;IAC9C,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACvC,wDAAwD;IACxD,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACxC,4EAA4E;IAC5E,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC;CAC7D;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,qBAAqB;IAkC5D,oDAAoD;iCA/B/B,OAAO,KAAG,OAAO;IAiCtC,kDAAkD;2BA1BhC,OAAO,KAAG,OAAO;IA4BnC,oCAAoC;8BACV,OAAO,KAAG,OAAO;EAE9C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,MAAM,GAAG,CAAC,CAgB1G"}
@@ -0,0 +1,124 @@
1
+ // validation.ts — the block-tree checks that are the same wiki to wiki.
2
+ //
3
+ // Both wikis validate a block tree before it reaches the database, and both had
4
+ // written the same walk: an id-and-type gate, a container branch for
5
+ // columns/infobox, and a switch over leaf types. Only the switch is a project's
6
+ // own — its type set is. So the walk and the two fiddly nested validators live
7
+ // here, and the switch is passed in.
8
+ //
9
+ // As elsewhere in this package, the dispatch stays with the caller so a new
10
+ // block type is a compile error there until it is handled.
11
+ /**
12
+ * An href acceptable to persist: http, https, mailto, or a relative path or
13
+ * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
14
+ * protocol-relative `//host` — comes back null.
15
+ *
16
+ * React 19 already neutralises `javascript:` in an href at render time, so this
17
+ * is defence in depth rather than the only thing between an editor and a
18
+ * reader. Its value is at the write path: a URL that can never render safely is
19
+ * better rejected than stored.
20
+ */
21
+ export function safeLinkHref(raw) {
22
+ if (!raw)
23
+ return null;
24
+ // Strip C0 controls (including tab, newline and CR) and DEL before reading
25
+ // the scheme, so a newline cannot smuggle one past the match below.
26
+ const href = raw.replace(/[\u0000-\u0020\u007f]/g, '').trim();
27
+ if (!href)
28
+ return null;
29
+ if (href.startsWith('//'))
30
+ return null; // protocol-relative -> external host
31
+ const scheme = href.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
32
+ if (scheme) {
33
+ const s = scheme[1].toLowerCase();
34
+ if (s !== 'http' && s !== 'https' && s !== 'mailto')
35
+ return null;
36
+ }
37
+ return href;
38
+ }
39
+ /** An author-supplied URL is fine iff it is empty (unset) or resolves safely. */
40
+ export const okUrl = (u) => typeof u === 'string' && (u === '' || safeLinkHref(u) !== null);
41
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
42
+ /** `[{ id, text, url? }]` — the shape a references block stores. */
43
+ export function validateReferenceItems(items, urlCheck = okUrl) {
44
+ return (Array.isArray(items) &&
45
+ items.every(it => isRecord(it) &&
46
+ typeof it.id === 'string' &&
47
+ typeof it.text === 'string' &&
48
+ (it.url === undefined || urlCheck(it.url))));
49
+ }
50
+ /** `[{ id, heading, links: [{ label, href }] }]` — a link-grid block's groups. */
51
+ export function validateLinkGroups(groups, urlCheck = okUrl) {
52
+ return (Array.isArray(groups) &&
53
+ groups.every(g => isRecord(g) &&
54
+ typeof g.id === 'string' &&
55
+ typeof g.heading === 'string' &&
56
+ Array.isArray(g.links) &&
57
+ g.links.every(l => isRecord(l) && typeof l.label === 'string' && urlCheck(l.href))));
58
+ }
59
+ /**
60
+ * The block-tree walk, with one wiki's leaf switch plugged into it. The caller
61
+ * keeps its own type parameter, so this stays free of any repo's types while
62
+ * the call site still gets a real type guard.
63
+ */
64
+ export function createBlockValidator(opts) {
65
+ const { isKnownType, isAtomicType, validateAtomic } = opts;
66
+ const atomic = (block) => {
67
+ if (!isRecord(block))
68
+ return false;
69
+ if (typeof block.id !== 'string')
70
+ return false;
71
+ if (typeof block.type !== 'string' || !isKnownType(block.type) || !isAtomicType(block.type))
72
+ return false;
73
+ return validateAtomic(block);
74
+ };
75
+ const one = (block) => {
76
+ if (!isRecord(block))
77
+ return false;
78
+ if (typeof block.id !== 'string')
79
+ return false;
80
+ if (typeof block.type !== 'string' || !isKnownType(block.type))
81
+ return false;
82
+ if (block.type === 'columns') {
83
+ return (Array.isArray(block.columns) &&
84
+ block.columns.every(col => isRecord(col) &&
85
+ typeof col.id === 'string' &&
86
+ Array.isArray(col.blocks) &&
87
+ col.blocks.every(atomic)));
88
+ }
89
+ if (block.type === 'infobox') {
90
+ return Array.isArray(block.blocks) && block.blocks.every(atomic);
91
+ }
92
+ return atomic(block);
93
+ };
94
+ return {
95
+ /** One leaf block; container types are rejected. */
96
+ validateAtomicBlock: atomic,
97
+ /** One block of any kind, containers included. */
98
+ validateBlock: one,
99
+ /** A whole page's content array. */
100
+ validateBlocks: (content) => Array.isArray(content) && content.every(one),
101
+ };
102
+ }
103
+ /**
104
+ * A copy of a block with a fresh id at every level, so a duplicated container
105
+ * does not share child ids with its original.
106
+ */
107
+ export function duplicateBlockIds(block, newId) {
108
+ const b = block;
109
+ if (block.type === 'columns' && Array.isArray(b.columns)) {
110
+ return {
111
+ ...block,
112
+ id: newId(),
113
+ columns: b.columns.map(col => {
114
+ const c = col;
115
+ return { ...c, id: newId(), blocks: (c.blocks ?? []).map(x => ({ ...x, id: newId() })) };
116
+ }),
117
+ };
118
+ }
119
+ if (block.type === 'infobox' && Array.isArray(b.blocks)) {
120
+ return { ...block, id: newId(), blocks: b.blocks.map(x => ({ ...x, id: newId() })) };
121
+ }
122
+ return { ...block, id: newId() };
123
+ }
124
+ //# sourceMappingURL=validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,EAAE;AACF,gFAAgF;AAChF,qEAAqE;AACrE,gFAAgF;AAChF,+EAA+E;AAC/E,qCAAqC;AACrC,EAAE;AACF,4EAA4E;AAC5E,2DAA2D;AAE3D;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,GAA8B;IACzD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,2EAA2E;IAC3E,oEAAoE;IACpE,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,wBAAwB,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,qCAAqC;IAC7E,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACzD,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,CAAC;QACnC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,KAAK,OAAO,IAAI,CAAC,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;IACnE,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,iFAAiF;AACjF,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,CAAU,EAAW,EAAE,CAC3C,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,KAAK,EAAE,IAAI,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC;AAElE,MAAM,QAAQ,GAAG,CAAC,CAAU,EAAgC,EAAE,CAC5D,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAEpD,oEAAoE;AACpE,MAAM,UAAU,sBAAsB,CAAC,KAAc,EAAE,WAAoC,KAAK;IAC9F,OAAO,CACL,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QACpB,KAAK,CAAC,KAAK,CACT,EAAE,CAAC,EAAE,CACH,QAAQ,CAAC,EAAE,CAAC;YACZ,OAAO,EAAE,CAAC,EAAE,KAAK,QAAQ;YACzB,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ;YAC3B,CAAC,EAAE,CAAC,GAAG,KAAK,SAAS,IAAI,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAC7C,CACF,CAAC;AACJ,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,kBAAkB,CAAC,MAAe,EAAE,WAAoC,KAAK;IAC3F,OAAO,CACL,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QACrB,MAAM,CAAC,KAAK,CACV,CAAC,CAAC,EAAE,CACF,QAAQ,CAAC,CAAC,CAAC;YACX,OAAO,CAAC,CAAC,EAAE,KAAK,QAAQ;YACxB,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ;YAC7B,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC;YACtB,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,IAAI,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CACrF,CACF,CAAC;AACJ,CAAC;AAWD;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAA2B;IAC9D,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,GAAG,IAAI,CAAC;IAE3D,MAAM,MAAM,GAAG,CAAC,KAAc,EAAW,EAAE;QACzC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACnC,IAAI,OAAO,KAAK,CAAC,EAAE,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QAC/C,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QAC1G,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC,CAAC;IAEF,MAAM,GAAG,GAAG,CAAC,KAAc,EAAW,EAAE;QACtC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACnC,IAAI,OAAO,KAAK,CAAC,EAAE,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QAC/C,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QAE7E,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,OAAO,CACL,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC;gBAC5B,KAAK,CAAC,OAAO,CAAC,KAAK,CACjB,GAAG,CAAC,EAAE,CACJ,QAAQ,CAAC,GAAG,CAAC;oBACb,OAAO,GAAG,CAAC,EAAE,KAAK,QAAQ;oBAC1B,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC;oBACzB,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAC3B,CACF,CAAC;QACJ,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACnE,CAAC;QACD,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;IACvB,CAAC,CAAC;IAEF,OAAO;QACL,oDAAoD;QACpD,mBAAmB,EAAE,MAAM;QAC3B,kDAAkD;QAClD,aAAa,EAAE,GAAG;QAClB,oCAAoC;QACpC,cAAc,EAAE,CAAC,OAAgB,EAAW,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC;KAC5F,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAyC,KAAQ,EAAE,KAAmB;IACrG,MAAM,CAAC,GAAG,KAA2C,CAAC;IACtD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;QACzD,OAAO;YACL,GAAG,KAAK;YACR,EAAE,EAAE,KAAK,EAAE;YACX,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE;gBAC3B,MAAM,CAAC,GAAG,GAA8B,CAAC;gBACzC,OAAO,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,CAAE,CAAC,CAAC,MAAoB,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAI,CAAY,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;YACtH,CAAC,CAAC;SACa,CAAC;IACpB,CAAC;IACD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;QACxD,OAAO,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAI,CAAY,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAkB,CAAC;IACnH,CAAC;IACD,OAAO,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC;AACnC,CAAC"}