@avocadostudio-ai/shared 0.3.3 → 0.5.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/dist/block-manifest.d.ts +9 -10
- package/dist/block-manifest.js +105 -1
- package/dist/blocks/_helpers.d.ts +12 -0
- package/dist/blocks/_helpers.js +12 -0
- package/dist/blocks/_registry.d.ts +56 -1
- package/dist/blocks/_registry.js +39 -0
- package/dist/blocks/feature-grid.js +1 -1
- package/dist/blocks/stats.js +1 -1
- package/dist/blocks/testimonials.js +1 -1
- package/dist/blocks/two-column.js +46 -2
- package/dist/editable-coverage.d.ts +85 -0
- package/dist/editable-coverage.js +368 -0
- package/dist/editor-block-meta.d.ts +70 -0
- package/dist/editor-block-meta.js +131 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -2
- package/dist/links.d.ts +126 -5
- package/dist/links.js +256 -5
- package/dist/panel-coverage.d.ts +90 -0
- package/dist/panel-coverage.js +0 -0
- package/package.json +2 -2
package/dist/links.d.ts
CHANGED
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
* never decides what the editor may type.
|
|
18
18
|
*/
|
|
19
19
|
/** How a link string addresses its target. */
|
|
20
|
-
export type LinkKind = "empty" | "page" | "external" | "email" | "phone" | "anchor";
|
|
20
|
+
export type LinkKind = "empty" | "page" | "file" | "external" | "email" | "phone" | "anchor";
|
|
21
|
+
/** Does this route-shaped path name a document rather than a page? */
|
|
22
|
+
export declare function isFilePath(path: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The file extensions this module recognises, for a caller that has to describe
|
|
25
|
+
* them — an upload control's `accept`, a picker's filter, a docs page.
|
|
26
|
+
*/
|
|
27
|
+
export declare function knownFileExtensions(): string[];
|
|
21
28
|
/** A link string, classified. */
|
|
22
29
|
export type ParsedLink = {
|
|
23
30
|
kind: LinkKind;
|
|
@@ -59,19 +66,49 @@ export declare function normalizeLinkPath(path: string): string;
|
|
|
59
66
|
* disagree about what "dead" means.
|
|
60
67
|
*/
|
|
61
68
|
export declare function isKnownRoute(path: string, knownSlugs: Iterable<string>): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* A document the site can link to, as the editor needs to know it.
|
|
71
|
+
*
|
|
72
|
+
* `path` is what goes in the link. Everything else is for showing it to a
|
|
73
|
+
* person, and every field but `path` is optional because a site that answers
|
|
74
|
+
* this question from a directory listing has only the path.
|
|
75
|
+
*/
|
|
76
|
+
export type LinkFileOption = {
|
|
77
|
+
/** The URL to link to — `/downloads/menu-de.pdf`. */
|
|
78
|
+
path: string;
|
|
79
|
+
/** What to call it in a picker. Defaults to the filename. */
|
|
80
|
+
name?: string;
|
|
81
|
+
/** MIME type, when the store knows it. */
|
|
82
|
+
contentType?: string;
|
|
83
|
+
/** Bytes, when the store knows it — a picker shows it, nothing decides on it. */
|
|
84
|
+
size?: number;
|
|
85
|
+
};
|
|
62
86
|
/** A parsed link plus what the site knows about its target. */
|
|
63
87
|
export type ResolvedLink = ParsedLink & {
|
|
64
88
|
/** The matching page, when `kind` is `page` and the route is known. */
|
|
65
89
|
page?: LinkPageOption;
|
|
66
|
-
/**
|
|
90
|
+
/** The matching document, when `kind` is `file` and the asset list has it. */
|
|
91
|
+
file?: LinkFileOption;
|
|
92
|
+
/**
|
|
93
|
+
* True for a link whose target matches nothing the site knows about.
|
|
94
|
+
*
|
|
95
|
+
* For a `page` this is decided from the slug list, which is always present.
|
|
96
|
+
* For a `file` it is decided from the asset list, which is *not* — a site
|
|
97
|
+
* that cannot enumerate its documents passes none, and then `missing` stays
|
|
98
|
+
* undefined rather than becoming `true`. "We did not check" and "it is not
|
|
99
|
+
* there" have to stay distinguishable, or every site without an asset store
|
|
100
|
+
* would report all of its own documents as broken.
|
|
101
|
+
*/
|
|
67
102
|
missing?: boolean;
|
|
68
103
|
};
|
|
69
104
|
/**
|
|
70
|
-
* Classify a link *and* look up its
|
|
105
|
+
* Classify a link *and* look up its target. Pages may be keyed by `slug` or by
|
|
71
106
|
* `path` (they differ on locale-prefixed sites — see
|
|
72
107
|
* `docs/ideas/page-identity-punch-list.md`), so both are matched.
|
|
108
|
+
*
|
|
109
|
+
* `files` is optional and its absence is meaningful — see `missing` above.
|
|
73
110
|
*/
|
|
74
|
-
export declare function resolveLink(value: unknown, pages?: readonly LinkPageOption[]): ResolvedLink;
|
|
111
|
+
export declare function resolveLink(value: unknown, pages?: readonly LinkPageOption[], files?: readonly LinkFileOption[]): ResolvedLink;
|
|
75
112
|
/**
|
|
76
113
|
* How well a page answers what was typed, from 0 (nothing in common) to 1.
|
|
77
114
|
*
|
|
@@ -84,8 +121,72 @@ export declare function resolveLink(value: unknown, pages?: readonly LinkPageOpt
|
|
|
84
121
|
* (see `tokenWeights`); without it every word counts the same.
|
|
85
122
|
*/
|
|
86
123
|
export declare function scoreLinkCandidate(query: string, page: LinkPageOption, corpus?: readonly LinkPageOption[]): number;
|
|
87
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Known pages ranked by how well they answer `query`, best first.
|
|
126
|
+
*
|
|
127
|
+
* Held to the same floor as `suggestLinkTarget`, which it did not used to be:
|
|
128
|
+
* anything sharing a single token came back, so on a tri-lingual site every
|
|
129
|
+
* `/fr/*` page answered every French-flavoured query. Typing a document path
|
|
130
|
+
* into the link picker listed `/fr/`, `/fr/faq/` and `/fr/evenements/` — six
|
|
131
|
+
* rows of pages, each one click away from replacing a working PDF link with a
|
|
132
|
+
* link to the FAQ. A near-miss still ranks well above the floor; what the floor
|
|
133
|
+
* removes is the coincidence.
|
|
134
|
+
*/
|
|
88
135
|
export declare function rankLinkTargets(query: string, pages: readonly LinkPageOption[]): LinkPageOption[];
|
|
136
|
+
/**
|
|
137
|
+
* Known documents ranked by how well they answer `query`, best first.
|
|
138
|
+
*
|
|
139
|
+
* The same scorer as pages, and the corpus weighting is what makes it work
|
|
140
|
+
* here: every document on a site shares its directory and its extension, so
|
|
141
|
+
* `downloads` and `pdf` identify nothing and are weighted to nearly nothing,
|
|
142
|
+
* while the part of the filename someone got wrong is what decides the order.
|
|
143
|
+
*
|
|
144
|
+
* This is the half a substring filter cannot do. `AadventureArenaBerm` is a
|
|
145
|
+
* real filename on a real site, typo included; a person typing it from memory
|
|
146
|
+
* gets one character wrong and a substring match returns nothing at all —
|
|
147
|
+
* which reads exactly like "this site has no such document".
|
|
148
|
+
*/
|
|
149
|
+
export declare function rankFileTargets(query: string, files: readonly LinkFileOption[]): LinkFileOption[];
|
|
150
|
+
/** What a link picker should offer for what has been typed so far. */
|
|
151
|
+
export type LinkSuggestions = {
|
|
152
|
+
pages: LinkPageOption[];
|
|
153
|
+
files: LinkFileOption[];
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* The pages and documents to offer for a partly-typed link.
|
|
157
|
+
*
|
|
158
|
+
* Two surfaces ask this question — the property panel's link field and the
|
|
159
|
+
* prose editor's link popover — and they answered it with their own inline
|
|
160
|
+
* copies of "substring, else rank". The copies disagreed, and both made the
|
|
161
|
+
* same mistake: they ranked *pages* for a query the parser had already
|
|
162
|
+
* classified as a document. A screenshot of the result is why this function
|
|
163
|
+
* exists — `/downloads/…-Gruppen-FR.pdf` typed in, six pages offered, not one
|
|
164
|
+
* of the site's fifteen PDFs among them.
|
|
165
|
+
*
|
|
166
|
+
* So the kind decides which list is offered at all:
|
|
167
|
+
*
|
|
168
|
+
* - a document path offers documents, never pages;
|
|
169
|
+
* - a route offers pages, and any document whose path literally contains what
|
|
170
|
+
* was typed (`menu` should still find the menu PDFs);
|
|
171
|
+
* - `mailto:`, `tel:`, `#anchor` and `https://` offer neither — there is
|
|
172
|
+
* nothing on this site they could mean, and the caller's empty state can say
|
|
173
|
+
* so instead.
|
|
174
|
+
*
|
|
175
|
+
* Within a kind, a literal substring is what a person typing into a box
|
|
176
|
+
* expects, and ranking is the fallback for when nothing matches literally.
|
|
177
|
+
*/
|
|
178
|
+
export declare function suggestLinkTargets(query: string, options?: {
|
|
179
|
+
pages?: readonly LinkPageOption[];
|
|
180
|
+
files?: readonly LinkFileOption[];
|
|
181
|
+
limit?: number;
|
|
182
|
+
/**
|
|
183
|
+
* With an empty box, list the site's first documents alongside its pages.
|
|
184
|
+
* The prose picker wants that — it is how an editor discovers the site has
|
|
185
|
+
* documents at all. The link *field* does not: a document shelf under every
|
|
186
|
+
* link field would bury the pages, which is what a link usually wants.
|
|
187
|
+
*/
|
|
188
|
+
browseFiles?: boolean;
|
|
189
|
+
}): LinkSuggestions;
|
|
89
190
|
/**
|
|
90
191
|
* The page a dead internal link probably meant.
|
|
91
192
|
*
|
|
@@ -120,3 +221,23 @@ export declare function linkAttrs(href: unknown, newTab?: unknown): {
|
|
|
120
221
|
target?: string;
|
|
121
222
|
rel?: string;
|
|
122
223
|
};
|
|
224
|
+
/**
|
|
225
|
+
* Every href inside a richtext value.
|
|
226
|
+
*
|
|
227
|
+
* A link is not only a `link`-kind prop. Most of the links on a real page are
|
|
228
|
+
* written *into* prose — `[Menükarte](/downloads/menu-de.pdf)` — and every
|
|
229
|
+
* link-aware surface we have was walking declared fields only. So the four
|
|
230
|
+
* menu-PDF links on a live site's Bistro section were not checked, not
|
|
231
|
+
* rewritten on rename, and not reported; the one linking to a filename with a
|
|
232
|
+
* typo in it had been wrong since August with nothing able to notice.
|
|
233
|
+
*
|
|
234
|
+
* Three shapes, because a richtext value is three things depending on where it
|
|
235
|
+
* came from: markdown (a site that projects its CMS prose to markdown), a
|
|
236
|
+
* ProseMirror document (the editor's own format), and raw HTML (a block with a
|
|
237
|
+
* loose schema). Walking all three costs one function and means a caller never
|
|
238
|
+
* has to know which it was handed.
|
|
239
|
+
*
|
|
240
|
+
* Returns hrefs in document order, duplicates included — a caller that reports
|
|
241
|
+
* findings wants one per occurrence, and a caller that wants a set can make one.
|
|
242
|
+
*/
|
|
243
|
+
export declare function linksInRichText(value: unknown): string[];
|
package/dist/links.js
CHANGED
|
@@ -16,6 +16,53 @@
|
|
|
16
16
|
* a rewrite and be invisible from here. `resolveLink` reports what it knows; it
|
|
17
17
|
* never decides what the editor may type.
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* Extensions that mean "this path is a document, not a route".
|
|
21
|
+
*
|
|
22
|
+
* A site's menu PDF lives at `/downloads/menu-de.pdf`, which has every
|
|
23
|
+
* syntactic property of an internal route and is not one. Classified as a page
|
|
24
|
+
* it resolves against the slug list, matches nothing, and comes back
|
|
25
|
+
* `missing: true` — so the editor's link field shows a broken-page warning on
|
|
26
|
+
* a link that works, and `seo.internal-link-dead` would report every document
|
|
27
|
+
* on the site. The distinction cannot come from the shape of the string; the
|
|
28
|
+
* extension is the only signal there is.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately documents and archives only. Images are absent because an image
|
|
31
|
+
* belongs to the `image` kind and its own picker, and media extensions are
|
|
32
|
+
* absent because a `.mp4` in a link is nearly always an embed URL. A path with
|
|
33
|
+
* no extension, or one not listed here, stays a page — the failure that costs
|
|
34
|
+
* something is calling a route a file, not the reverse.
|
|
35
|
+
*/
|
|
36
|
+
const FILE_EXTENSIONS = new Set([
|
|
37
|
+
"pdf",
|
|
38
|
+
"doc", "docx", "rtf", "odt",
|
|
39
|
+
"xls", "xlsx", "csv", "ods",
|
|
40
|
+
"ppt", "pptx", "odp",
|
|
41
|
+
"zip", "gz", "tar",
|
|
42
|
+
"txt", "ics", "vcf", "epub"
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* The extension of a path, lowercased, or "" — from the last segment only, so
|
|
46
|
+
* a dot in a directory name (`/v1.2/guide`) is not read as one.
|
|
47
|
+
*/
|
|
48
|
+
function extensionOf(path) {
|
|
49
|
+
const segment = path.split("/").pop() ?? "";
|
|
50
|
+
const dot = segment.lastIndexOf(".");
|
|
51
|
+
if (dot <= 0 || dot === segment.length - 1)
|
|
52
|
+
return "";
|
|
53
|
+
return segment.slice(dot + 1).toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
/** Does this route-shaped path name a document rather than a page? */
|
|
56
|
+
export function isFilePath(path) {
|
|
57
|
+
return FILE_EXTENSIONS.has(extensionOf(path));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The file extensions this module recognises, for a caller that has to describe
|
|
61
|
+
* them — an upload control's `accept`, a picker's filter, a docs page.
|
|
62
|
+
*/
|
|
63
|
+
export function knownFileExtensions() {
|
|
64
|
+
return [...FILE_EXTENSIONS];
|
|
65
|
+
}
|
|
19
66
|
/**
|
|
20
67
|
* A bare "/" is the default value every link field is born with
|
|
21
68
|
* (`defaultScalarForField`), so treating it as a real link to the homepage
|
|
@@ -64,7 +111,16 @@ export function parseLink(value) {
|
|
|
64
111
|
* offer matches while you type.
|
|
65
112
|
*/
|
|
66
113
|
const [pathPart = "", ...rest] = trimmed.split(/(?=[?#])/);
|
|
67
|
-
|
|
114
|
+
const suffix = rest.join("") || undefined;
|
|
115
|
+
/*
|
|
116
|
+
* A document is not a route. Decided here rather than at each call site so
|
|
117
|
+
* the editor's warning, the dead-link check and the rename rewriter cannot
|
|
118
|
+
* disagree about it — the reason this module exists at all.
|
|
119
|
+
*/
|
|
120
|
+
if (isFilePath(pathPart)) {
|
|
121
|
+
return { kind: "file", raw, path: pathPart, suffix, target: pathPart };
|
|
122
|
+
}
|
|
123
|
+
return { kind: "page", raw, path: pathPart, suffix };
|
|
68
124
|
}
|
|
69
125
|
/**
|
|
70
126
|
* Give a route its leading slash and drop a trailing one.
|
|
@@ -94,13 +150,26 @@ export function isKnownRoute(path, knownSlugs) {
|
|
|
94
150
|
const normalized = normalizeLinkPath(target);
|
|
95
151
|
return known.has(target) || known.has(normalized) || known.has(`${normalized}/`);
|
|
96
152
|
}
|
|
153
|
+
/** Compare two link paths the way a reader would: slashes and case-in-host aside. */
|
|
154
|
+
function samePath(a, b) {
|
|
155
|
+
return normalizeLinkPath(a) === normalizeLinkPath(b);
|
|
156
|
+
}
|
|
97
157
|
/**
|
|
98
|
-
* Classify a link *and* look up its
|
|
158
|
+
* Classify a link *and* look up its target. Pages may be keyed by `slug` or by
|
|
99
159
|
* `path` (they differ on locale-prefixed sites — see
|
|
100
160
|
* `docs/ideas/page-identity-punch-list.md`), so both are matched.
|
|
161
|
+
*
|
|
162
|
+
* `files` is optional and its absence is meaningful — see `missing` above.
|
|
101
163
|
*/
|
|
102
|
-
export function resolveLink(value, pages = []) {
|
|
164
|
+
export function resolveLink(value, pages = [], files) {
|
|
103
165
|
const parsed = parseLink(value);
|
|
166
|
+
if (parsed.kind === "file") {
|
|
167
|
+
if (!files)
|
|
168
|
+
return parsed;
|
|
169
|
+
const wanted = parsed.path ?? "";
|
|
170
|
+
const file = files.find((f) => samePath(f.path, wanted));
|
|
171
|
+
return file ? { ...parsed, file } : { ...parsed, missing: true };
|
|
172
|
+
}
|
|
104
173
|
if (parsed.kind !== "page")
|
|
105
174
|
return parsed;
|
|
106
175
|
const wanted = normalizeLinkPath(parsed.path ?? "");
|
|
@@ -181,14 +250,97 @@ export function scoreLinkCandidate(query, page, corpus) {
|
|
|
181
250
|
* none — the editor has to stop and rule it out.
|
|
182
251
|
*/
|
|
183
252
|
const SUGGESTION_MIN_SCORE = 0.45;
|
|
184
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Known pages ranked by how well they answer `query`, best first.
|
|
255
|
+
*
|
|
256
|
+
* Held to the same floor as `suggestLinkTarget`, which it did not used to be:
|
|
257
|
+
* anything sharing a single token came back, so on a tri-lingual site every
|
|
258
|
+
* `/fr/*` page answered every French-flavoured query. Typing a document path
|
|
259
|
+
* into the link picker listed `/fr/`, `/fr/faq/` and `/fr/evenements/` — six
|
|
260
|
+
* rows of pages, each one click away from replacing a working PDF link with a
|
|
261
|
+
* link to the FAQ. A near-miss still ranks well above the floor; what the floor
|
|
262
|
+
* removes is the coincidence.
|
|
263
|
+
*/
|
|
185
264
|
export function rankLinkTargets(query, pages) {
|
|
186
265
|
return pages
|
|
187
266
|
.map((page) => ({ page, score: scoreLinkCandidate(query, page, pages) }))
|
|
188
|
-
.filter((entry) => entry.score
|
|
267
|
+
.filter((entry) => entry.score >= SUGGESTION_MIN_SCORE)
|
|
189
268
|
.sort((a, b) => b.score - a.score)
|
|
190
269
|
.map((entry) => entry.page);
|
|
191
270
|
}
|
|
271
|
+
/** A document, in the shape the page scorer reads. */
|
|
272
|
+
function fileAsPage(file) {
|
|
273
|
+
return { slug: file.path, ...(file.name ? { title: file.name } : {}) };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Known documents ranked by how well they answer `query`, best first.
|
|
277
|
+
*
|
|
278
|
+
* The same scorer as pages, and the corpus weighting is what makes it work
|
|
279
|
+
* here: every document on a site shares its directory and its extension, so
|
|
280
|
+
* `downloads` and `pdf` identify nothing and are weighted to nearly nothing,
|
|
281
|
+
* while the part of the filename someone got wrong is what decides the order.
|
|
282
|
+
*
|
|
283
|
+
* This is the half a substring filter cannot do. `AadventureArenaBerm` is a
|
|
284
|
+
* real filename on a real site, typo included; a person typing it from memory
|
|
285
|
+
* gets one character wrong and a substring match returns nothing at all —
|
|
286
|
+
* which reads exactly like "this site has no such document".
|
|
287
|
+
*/
|
|
288
|
+
export function rankFileTargets(query, files) {
|
|
289
|
+
const corpus = files.map(fileAsPage);
|
|
290
|
+
return files
|
|
291
|
+
.map((file) => ({ file, score: scoreLinkCandidate(query, fileAsPage(file), corpus) }))
|
|
292
|
+
.filter((entry) => entry.score >= SUGGESTION_MIN_SCORE)
|
|
293
|
+
.sort((a, b) => b.score - a.score)
|
|
294
|
+
.map((entry) => entry.file);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The pages and documents to offer for a partly-typed link.
|
|
298
|
+
*
|
|
299
|
+
* Two surfaces ask this question — the property panel's link field and the
|
|
300
|
+
* prose editor's link popover — and they answered it with their own inline
|
|
301
|
+
* copies of "substring, else rank". The copies disagreed, and both made the
|
|
302
|
+
* same mistake: they ranked *pages* for a query the parser had already
|
|
303
|
+
* classified as a document. A screenshot of the result is why this function
|
|
304
|
+
* exists — `/downloads/…-Gruppen-FR.pdf` typed in, six pages offered, not one
|
|
305
|
+
* of the site's fifteen PDFs among them.
|
|
306
|
+
*
|
|
307
|
+
* So the kind decides which list is offered at all:
|
|
308
|
+
*
|
|
309
|
+
* - a document path offers documents, never pages;
|
|
310
|
+
* - a route offers pages, and any document whose path literally contains what
|
|
311
|
+
* was typed (`menu` should still find the menu PDFs);
|
|
312
|
+
* - `mailto:`, `tel:`, `#anchor` and `https://` offer neither — there is
|
|
313
|
+
* nothing on this site they could mean, and the caller's empty state can say
|
|
314
|
+
* so instead.
|
|
315
|
+
*
|
|
316
|
+
* Within a kind, a literal substring is what a person typing into a box
|
|
317
|
+
* expects, and ranking is the fallback for when nothing matches literally.
|
|
318
|
+
*/
|
|
319
|
+
export function suggestLinkTargets(query, options = {}) {
|
|
320
|
+
const { pages = [], files, limit = 6, browseFiles = false } = options;
|
|
321
|
+
const trimmed = query.trim();
|
|
322
|
+
const kind = parseLink(trimmed).kind;
|
|
323
|
+
if (kind === "empty") {
|
|
324
|
+
return {
|
|
325
|
+
pages: pages.slice(0, limit),
|
|
326
|
+
files: browseFiles ? (files ?? []).slice(0, limit) : []
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (kind !== "page" && kind !== "file")
|
|
330
|
+
return { pages: [], files: [] };
|
|
331
|
+
const needle = trimmed.toLowerCase();
|
|
332
|
+
const matchesFile = (file) => file.path.toLowerCase().includes(needle) || (file.name ?? "").toLowerCase().includes(needle);
|
|
333
|
+
const known = files ?? [];
|
|
334
|
+
const literalFiles = known.filter(matchesFile);
|
|
335
|
+
const fileMatches = literalFiles.length > 0 ? literalFiles : kind === "file" ? rankFileTargets(trimmed, known) : [];
|
|
336
|
+
if (kind === "file")
|
|
337
|
+
return { pages: [], files: fileMatches.slice(0, limit) };
|
|
338
|
+
const literalPages = pages.filter((page) => page.slug.toLowerCase().includes(needle) ||
|
|
339
|
+
(page.path ?? "").toLowerCase().includes(needle) ||
|
|
340
|
+
(page.title ?? "").toLowerCase().includes(needle));
|
|
341
|
+
const pageMatches = literalPages.length > 0 ? literalPages : rankLinkTargets(trimmed, pages);
|
|
342
|
+
return { pages: pageMatches.slice(0, limit), files: fileMatches.slice(0, limit) };
|
|
343
|
+
}
|
|
192
344
|
/**
|
|
193
345
|
* The page a dead internal link probably meant.
|
|
194
346
|
*
|
|
@@ -256,3 +408,102 @@ export function linkAttrs(href, newTab) {
|
|
|
256
408
|
}
|
|
257
409
|
return { href: value };
|
|
258
410
|
}
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Links written inside prose
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
/**
|
|
415
|
+
* Every href inside a richtext value.
|
|
416
|
+
*
|
|
417
|
+
* A link is not only a `link`-kind prop. Most of the links on a real page are
|
|
418
|
+
* written *into* prose — `[Menükarte](/downloads/menu-de.pdf)` — and every
|
|
419
|
+
* link-aware surface we have was walking declared fields only. So the four
|
|
420
|
+
* menu-PDF links on a live site's Bistro section were not checked, not
|
|
421
|
+
* rewritten on rename, and not reported; the one linking to a filename with a
|
|
422
|
+
* typo in it had been wrong since August with nothing able to notice.
|
|
423
|
+
*
|
|
424
|
+
* Three shapes, because a richtext value is three things depending on where it
|
|
425
|
+
* came from: markdown (a site that projects its CMS prose to markdown), a
|
|
426
|
+
* ProseMirror document (the editor's own format), and raw HTML (a block with a
|
|
427
|
+
* loose schema). Walking all three costs one function and means a caller never
|
|
428
|
+
* has to know which it was handed.
|
|
429
|
+
*
|
|
430
|
+
* Returns hrefs in document order, duplicates included — a caller that reports
|
|
431
|
+
* findings wants one per occurrence, and a caller that wants a set can make one.
|
|
432
|
+
*/
|
|
433
|
+
export function linksInRichText(value) {
|
|
434
|
+
const out = [];
|
|
435
|
+
collectLinks(value, out);
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Markdown inline links. Images (``) are matched only so the leading
|
|
440
|
+
* `!` can be seen and the match discarded — an image src is not a link and must
|
|
441
|
+
* not be route-checked.
|
|
442
|
+
*
|
|
443
|
+
* Two forms for the destination, because markdown has two: `<...>`, which
|
|
444
|
+
* exists precisely so a path may contain spaces, and the bare form, which may
|
|
445
|
+
* not. Written as separate branches rather than one optional bracket, since a
|
|
446
|
+
* pattern that forbids spaces inside the brackets silently drops exactly the
|
|
447
|
+
* links the brackets were there for.
|
|
448
|
+
*/
|
|
449
|
+
const MARKDOWN_LINK_RE = /(!?)\[(?:[^\]\\]|\\.)*\]\(\s*(?:<([^>]*)>|([^\s)]+))(?:\s+["'(][^)]*)?\s*\)/g;
|
|
450
|
+
const HTML_HREF_RE = /<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s">]+))/gi;
|
|
451
|
+
function collectLinks(value, out) {
|
|
452
|
+
if (typeof value === "string") {
|
|
453
|
+
for (const m of value.matchAll(MARKDOWN_LINK_RE)) {
|
|
454
|
+
// `!` marks an image, whose src is not a link and must not be route-checked.
|
|
455
|
+
if (m[1] === "!")
|
|
456
|
+
continue;
|
|
457
|
+
const href = m[2] ?? m[3];
|
|
458
|
+
if (href)
|
|
459
|
+
out.push(href);
|
|
460
|
+
}
|
|
461
|
+
for (const m of value.matchAll(HTML_HREF_RE)) {
|
|
462
|
+
const href = m[1] ?? m[2] ?? m[3];
|
|
463
|
+
if (href)
|
|
464
|
+
out.push(href);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (Array.isArray(value)) {
|
|
469
|
+
for (const entry of value)
|
|
470
|
+
collectLinks(entry, out);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (typeof value !== "object" || value === null)
|
|
474
|
+
return;
|
|
475
|
+
const node = value;
|
|
476
|
+
/*
|
|
477
|
+
* A ProseMirror link is a mark on a text node, and Portable Text keeps the
|
|
478
|
+
* same idea in `markDefs`. Both put the target under `href`, so reading that
|
|
479
|
+
* covers the editor's own documents and a CMS's alike.
|
|
480
|
+
*/
|
|
481
|
+
const marks = node.marks;
|
|
482
|
+
if (Array.isArray(marks)) {
|
|
483
|
+
for (const mark of marks) {
|
|
484
|
+
if (typeof mark !== "object" || mark === null)
|
|
485
|
+
continue;
|
|
486
|
+
const attrs = mark.attrs;
|
|
487
|
+
const href = isRecordish(attrs) ? attrs.href : undefined;
|
|
488
|
+
if (typeof href === "string" && href)
|
|
489
|
+
out.push(href);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const markDefs = node.markDefs;
|
|
493
|
+
if (Array.isArray(markDefs)) {
|
|
494
|
+
for (const def of markDefs) {
|
|
495
|
+
const href = isRecordish(def) ? def.href : undefined;
|
|
496
|
+
if (typeof href === "string" && href)
|
|
497
|
+
out.push(href);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (typeof node.href === "string" && node.href && node.type !== "image")
|
|
501
|
+
out.push(node.href);
|
|
502
|
+
for (const key of ["content", "children", "blocks"]) {
|
|
503
|
+
if (Array.isArray(node[key]))
|
|
504
|
+
collectLinks(node[key], out);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function isRecordish(value) {
|
|
508
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
509
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can a human actually edit this site in the property panel?
|
|
3
|
+
*
|
|
4
|
+
* `editableCoverage` answers the same question for the *preview*: which fields
|
|
5
|
+
* carry a marker the overlay can find. This is the panel's half, and it was the
|
|
6
|
+
* half with no check at all — which is why a real integration shipped a panel
|
|
7
|
+
* whose list rows read `Item 4`, `Item 5`, whose labels came from somebody
|
|
8
|
+
* else's block, and whose polymorphic branches were never narrowed. Every one of
|
|
9
|
+
* those was visible in the data the whole time. Nobody was asking.
|
|
10
|
+
*
|
|
11
|
+
* The check needs no browser, no screenshot and no model. It has the manifest
|
|
12
|
+
* (what the panel will render) and the site's own pages (what the rows really
|
|
13
|
+
* contain), and every finding below is a disagreement between the two.
|
|
14
|
+
*
|
|
15
|
+
* It resolves metadata through `resolveEditorBlockMeta` and rows through
|
|
16
|
+
* `resolveListItemFields` — the same functions the panel itself uses — so its
|
|
17
|
+
* findings are the panel's behaviour rather than a model of it. A checker that
|
|
18
|
+
* approximates the panel reports gaps the panel does not have and misses the
|
|
19
|
+
* ones it does, and gets switched off within a week.
|
|
20
|
+
*
|
|
21
|
+
* Pass `builtinTypes` (the registry the *editor* will run with) to get the
|
|
22
|
+
* collision findings. Without it, collisions are simply not reported — an
|
|
23
|
+
* absent input is never evidence.
|
|
24
|
+
*/
|
|
25
|
+
import { type EditorBlockDefinition } from "./editor-block-meta.ts";
|
|
26
|
+
import type { BlockMeta, FieldMeta } from "./blocks/_registry.ts";
|
|
27
|
+
export type PanelFindingCode =
|
|
28
|
+
/** A list row the panel labels `Item N` — present in the content, unidentifiable in the panel. */
|
|
29
|
+
"unlabelled_row"
|
|
30
|
+
/** A row whose discriminant value has no branch, so it is edited against the union of all branches. */
|
|
31
|
+
| "unmatched_branch"
|
|
32
|
+
/** A list declares a discriminator and no branch map, or vice versa. */
|
|
33
|
+
| "incomplete_polymorphism"
|
|
34
|
+
/** A prop the content holds that no field or list describes — uneditable, and invisible. */
|
|
35
|
+
| "orphan_prop"
|
|
36
|
+
/** A field declared for rows that no row ever has. Panel noise. */
|
|
37
|
+
| "phantom_field"
|
|
38
|
+
/** The type name exists in the editor's own registry with a different shape. */
|
|
39
|
+
| "colliding_type"
|
|
40
|
+
/** An image row labelled by its filename while a populated alt field sits beside it. */
|
|
41
|
+
| "filename_row_label";
|
|
42
|
+
export type PanelFinding = {
|
|
43
|
+
code: PanelFindingCode;
|
|
44
|
+
blockType: string;
|
|
45
|
+
/** Where the problem is, in the same path grammar operations use. */
|
|
46
|
+
path?: string;
|
|
47
|
+
/** One page slug that exhibits it, so the report points somewhere. */
|
|
48
|
+
exampleSlug?: string;
|
|
49
|
+
/** How many occurrences across everything examined. */
|
|
50
|
+
count: number;
|
|
51
|
+
detail: string;
|
|
52
|
+
};
|
|
53
|
+
export type PanelCoverage = {
|
|
54
|
+
/** List rows examined across every page. */
|
|
55
|
+
rowsExamined: number;
|
|
56
|
+
/** Rows the panel can label from their own content. */
|
|
57
|
+
rowsLabelled: number;
|
|
58
|
+
findings: PanelFinding[];
|
|
59
|
+
/** Block types on a page that the manifest does not describe. Not our business, but worth saying. */
|
|
60
|
+
unknownBlockTypes: string[];
|
|
61
|
+
};
|
|
62
|
+
type PageLike = {
|
|
63
|
+
slug?: string;
|
|
64
|
+
blocks?: Array<{
|
|
65
|
+
type?: string;
|
|
66
|
+
props?: Record<string, unknown>;
|
|
67
|
+
} | null | undefined>;
|
|
68
|
+
};
|
|
69
|
+
type ManifestLike = {
|
|
70
|
+
blocks: EditorBlockDefinition[];
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The label the panel puts on a collapsed list row.
|
|
74
|
+
*
|
|
75
|
+
* Mirrors `PropertyPanel`'s own derivation exactly, including its fallbacks: the
|
|
76
|
+
* first text-ish field with a value, else the filename of the first image, else
|
|
77
|
+
* `Item N`. Kept here so the two cannot drift — the panel imports this.
|
|
78
|
+
*/
|
|
79
|
+
export declare function deriveRowLabel(fields: Record<string, FieldMeta>, item: Record<string, unknown>, index: number, options?: {
|
|
80
|
+
discriminator?: string;
|
|
81
|
+
}): {
|
|
82
|
+
label: string;
|
|
83
|
+
source: "text" | "filename" | "fallback";
|
|
84
|
+
};
|
|
85
|
+
export declare function panelCoverage(manifest: ManifestLike, pages: PageLike[], options?: {
|
|
86
|
+
builtinTypes?: Record<string, BlockMeta>;
|
|
87
|
+
}): PanelCoverage;
|
|
88
|
+
/** A human-readable report, in the shape `formatEditableCoverage` uses. */
|
|
89
|
+
export declare function formatPanelCoverage(report: PanelCoverage): string;
|
|
90
|
+
export {};
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"zod": "^4.3.6",
|
|
22
|
-
"@avocadostudio-ai/richtext": "^0.
|
|
22
|
+
"@avocadostudio-ai/richtext": "^0.5.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"tsx": "^4.21.0",
|