@avocadostudio-ai/shared 0.3.2 → 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.
- package/dist/api-responses.d.ts +5 -0
- package/dist/api-responses.js +12 -0
- package/dist/block-manifest.d.ts +18 -10
- package/dist/block-manifest.js +114 -1
- package/dist/blocks/_helpers.d.ts +21 -0
- package/dist/blocks/_helpers.js +21 -0
- package/dist/blocks/_registry.d.ts +61 -2
- package/dist/blocks/_registry.js +88 -1
- package/dist/blocks/banner.js +3 -1
- package/dist/blocks/card-grid.js +3 -1
- package/dist/blocks/card.js +3 -1
- package/dist/blocks/carousel.js +3 -1
- package/dist/blocks/cta.js +7 -3
- package/dist/blocks/feature-grid.js +1 -1
- package/dist/blocks/hero.js +7 -3
- package/dist/blocks/site-header.js +3 -1
- package/dist/blocks/stats.js +1 -1
- package/dist/blocks/table.js +5 -2
- package/dist/blocks/testimonials.js +1 -1
- package/dist/blocks/two-column.js +49 -3
- package/dist/editable-coverage.d.ts +85 -0
- package/dist/editable-coverage.js +342 -0
- package/dist/editable-path.d.ts +11 -0
- package/dist/editable-path.js +17 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/links.d.ts +243 -0
- package/dist/links.js +509 -0
- package/package.json +2 -2
package/dist/links.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a link field's string actually means.
|
|
3
|
+
*
|
|
4
|
+
* A link is stored as a plain string — `/pricing`, `https://example.com`,
|
|
5
|
+
* `mailto:hi@example.com` — and three places need to agree on how to read it:
|
|
6
|
+
* the editor's link control (which icon, which label, which warning), the
|
|
7
|
+
* `seo.internal-link-dead` check (is this route real?), and the rename rewriter
|
|
8
|
+
* (is this string a route at all?). Before this module each answered the
|
|
9
|
+
* question with its own inline `startsWith("/")`, and they did not agree: the
|
|
10
|
+
* rename rewriter matched on prop *names* containing "href", so a link stored
|
|
11
|
+
* under `url` or inside Footer's `label|url` text went stale on every rename.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here validates in the blocking sense. A path this module cannot
|
|
14
|
+
* resolve is still a legitimate link — on a CMS-backed site Avocado sees only
|
|
15
|
+
* the pages it was handed, and a route can exist in Next, in the CMS, or behind
|
|
16
|
+
* a rewrite and be invisible from here. `resolveLink` reports what it knows; it
|
|
17
|
+
* never decides what the editor may type.
|
|
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
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A bare "/" is the default value every link field is born with
|
|
68
|
+
* (`defaultScalarForField`), so treating it as a real link to the homepage
|
|
69
|
+
* would make every freshly added card claim to link somewhere.
|
|
70
|
+
*/
|
|
71
|
+
function isEmptyValue(value) {
|
|
72
|
+
return value === "" || value === "/";
|
|
73
|
+
}
|
|
74
|
+
/** Classify a link string. Never throws; anything unrecognised parses as `page`-ish text. */
|
|
75
|
+
export function parseLink(value) {
|
|
76
|
+
const raw = typeof value === "string" ? value : "";
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
if (isEmptyValue(trimmed))
|
|
79
|
+
return { kind: "empty", raw };
|
|
80
|
+
if (/^mailto:/i.test(trimmed)) {
|
|
81
|
+
return { kind: "email", raw, target: trimmed.replace(/^mailto:/i, "") };
|
|
82
|
+
}
|
|
83
|
+
if (/^tel:/i.test(trimmed)) {
|
|
84
|
+
return { kind: "phone", raw, target: trimmed.replace(/^tel:/i, "") };
|
|
85
|
+
}
|
|
86
|
+
if (trimmed.startsWith("#")) {
|
|
87
|
+
return { kind: "anchor", raw, target: trimmed.slice(1) };
|
|
88
|
+
}
|
|
89
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
90
|
+
let target = trimmed;
|
|
91
|
+
try {
|
|
92
|
+
const u = new URL(trimmed);
|
|
93
|
+
target = u.host + (u.pathname === "/" ? "" : u.pathname);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* keep the raw string — a half-typed URL is still an external link */
|
|
97
|
+
}
|
|
98
|
+
return { kind: "external", raw, target };
|
|
99
|
+
}
|
|
100
|
+
/*
|
|
101
|
+
* A protocol-relative `//host/path` is external, not a route. Without this it
|
|
102
|
+
* would parse as a path and be reported dead against every site's slug list —
|
|
103
|
+
* and `draft-mode.ts` already rejects it for the same reason on the way in.
|
|
104
|
+
*/
|
|
105
|
+
if (trimmed.startsWith("//"))
|
|
106
|
+
return { kind: "external", raw, target: trimmed.slice(2) };
|
|
107
|
+
/*
|
|
108
|
+
* Anything else is a route. Not only strings starting with "/": an editor who
|
|
109
|
+
* types "pricing" means the pricing page, and `normalizeLinkPath` is what
|
|
110
|
+
* turns that into one. Treating it as a page is also what lets the picker
|
|
111
|
+
* offer matches while you type.
|
|
112
|
+
*/
|
|
113
|
+
const [pathPart = "", ...rest] = trimmed.split(/(?=[?#])/);
|
|
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 };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Give a route its leading slash and drop a trailing one.
|
|
127
|
+
*
|
|
128
|
+
* `pricing`, `/pricing`, `/pricing/` are the same page. The editor accepts all
|
|
129
|
+
* three and stores the middle one. The homepage keeps its lone slash.
|
|
130
|
+
*/
|
|
131
|
+
export function normalizeLinkPath(path) {
|
|
132
|
+
const trimmed = path.trim();
|
|
133
|
+
if (trimmed === "" || trimmed === "/")
|
|
134
|
+
return "/";
|
|
135
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
136
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Does this route exist among the site's known pages?
|
|
140
|
+
*
|
|
141
|
+
* Compares on the path alone — a link may legitimately carry a query or a
|
|
142
|
+
* fragment, and neither changes which page it points at — and accepts a
|
|
143
|
+
* trailing slash on either side. This is the matcher `seo.internal-link-dead`
|
|
144
|
+
* uses, extracted so the inline field warning and the checks panel cannot
|
|
145
|
+
* disagree about what "dead" means.
|
|
146
|
+
*/
|
|
147
|
+
export function isKnownRoute(path, knownSlugs) {
|
|
148
|
+
const known = knownSlugs instanceof Set ? knownSlugs : new Set(knownSlugs);
|
|
149
|
+
const target = path.split(/[?#]/)[0] ?? "";
|
|
150
|
+
const normalized = normalizeLinkPath(target);
|
|
151
|
+
return known.has(target) || known.has(normalized) || known.has(`${normalized}/`);
|
|
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
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Classify a link *and* look up its target. Pages may be keyed by `slug` or by
|
|
159
|
+
* `path` (they differ on locale-prefixed sites — see
|
|
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.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveLink(value, pages = [], files) {
|
|
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
|
+
}
|
|
173
|
+
if (parsed.kind !== "page")
|
|
174
|
+
return parsed;
|
|
175
|
+
const wanted = normalizeLinkPath(parsed.path ?? "");
|
|
176
|
+
const page = pages.find((p) => normalizeLinkPath(p.slug) === wanted || (p.path != null && normalizeLinkPath(p.path) === wanted));
|
|
177
|
+
if (page)
|
|
178
|
+
return { ...parsed, page };
|
|
179
|
+
return { ...parsed, missing: true };
|
|
180
|
+
}
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Suggesting a page
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
/** Words in a slug, a path or a title, lowercased and punctuation-free. */
|
|
185
|
+
function tokenize(value) {
|
|
186
|
+
return value.toLowerCase().split(/[^a-z0-9]+/i).filter((t) => t.length > 0);
|
|
187
|
+
}
|
|
188
|
+
/** Everything a page can be recognised by: its slug, its URL and its title. */
|
|
189
|
+
function pageTokens(page) {
|
|
190
|
+
return [...new Set([...tokenize(page.slug), ...tokenize(page.path ?? ""), ...tokenize(page.title ?? "")])];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* How much each word says about *which* page is meant.
|
|
194
|
+
*
|
|
195
|
+
* A word every page carries — a locale prefix, the company name — identifies
|
|
196
|
+
* nothing, and counting it equally is what makes a naive matcher propose
|
|
197
|
+
* `/de/impressum` for `/de/veranstaltungen`: the two share only the "de". This
|
|
198
|
+
* is inverse document frequency over the site's own pages, smoothed so a word
|
|
199
|
+
* on no page at all still has a finite weight.
|
|
200
|
+
*/
|
|
201
|
+
function tokenWeights(pages) {
|
|
202
|
+
const total = pages.length;
|
|
203
|
+
const seen = new Map();
|
|
204
|
+
for (const page of pages) {
|
|
205
|
+
for (const token of pageTokens(page))
|
|
206
|
+
seen.set(token, (seen.get(token) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
const weights = new Map();
|
|
209
|
+
for (const [token, count] of seen)
|
|
210
|
+
weights.set(token, Math.log(1 + total / (count + 1)));
|
|
211
|
+
return weights;
|
|
212
|
+
}
|
|
213
|
+
function weightOf(token, weights, total) {
|
|
214
|
+
if (!weights)
|
|
215
|
+
return 1;
|
|
216
|
+
return weights.get(token) ?? Math.log(1 + total);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* How well a page answers what was typed, from 0 (nothing in common) to 1.
|
|
220
|
+
*
|
|
221
|
+
* The balanced mean of precision and recall over shared words. Balanced because
|
|
222
|
+
* both halves are wrong alone: precision by itself ranks a sprawling page that
|
|
223
|
+
* happens to contain the word above the page actually named for it, and recall
|
|
224
|
+
* by itself ranks a one-word slug above every longer match.
|
|
225
|
+
*
|
|
226
|
+
* Pass the site's pages as `corpus` to weight words by how distinctive they are
|
|
227
|
+
* (see `tokenWeights`); without it every word counts the same.
|
|
228
|
+
*/
|
|
229
|
+
export function scoreLinkCandidate(query, page, corpus) {
|
|
230
|
+
const wanted = [...new Set(tokenize(query))];
|
|
231
|
+
if (wanted.length === 0)
|
|
232
|
+
return 0;
|
|
233
|
+
const have = pageTokens(page);
|
|
234
|
+
if (have.length === 0)
|
|
235
|
+
return 0;
|
|
236
|
+
const weights = corpus ? tokenWeights(corpus) : undefined;
|
|
237
|
+
const total = corpus?.length ?? 0;
|
|
238
|
+
const w = (token) => weightOf(token, weights, total);
|
|
239
|
+
const sum = (tokens) => tokens.reduce((acc, t) => acc + w(t), 0);
|
|
240
|
+
const shared = wanted.filter((t) => have.includes(t));
|
|
241
|
+
if (shared.length === 0)
|
|
242
|
+
return 0;
|
|
243
|
+
const overlap = sum(shared);
|
|
244
|
+
const precision = overlap / sum(wanted);
|
|
245
|
+
const recall = overlap / sum(have);
|
|
246
|
+
return (2 * precision * recall) / (precision + recall);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Below this a "did you mean" is noise, and a wrong suggestion is worse than
|
|
250
|
+
* none — the editor has to stop and rule it out.
|
|
251
|
+
*/
|
|
252
|
+
const SUGGESTION_MIN_SCORE = 0.45;
|
|
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
|
+
*/
|
|
264
|
+
export function rankLinkTargets(query, pages) {
|
|
265
|
+
return pages
|
|
266
|
+
.map((page) => ({ page, score: scoreLinkCandidate(query, page, pages) }))
|
|
267
|
+
.filter((entry) => entry.score >= SUGGESTION_MIN_SCORE)
|
|
268
|
+
.sort((a, b) => b.score - a.score)
|
|
269
|
+
.map((entry) => entry.page);
|
|
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
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The page a dead internal link probably meant.
|
|
346
|
+
*
|
|
347
|
+
* `/avocado-sustainability` on a site that has `/sustainability` is a typo, a
|
|
348
|
+
* stale link, or a slug someone renamed by hand — and the site already holds
|
|
349
|
+
* everything needed to say so. Returns undefined when nothing scores well
|
|
350
|
+
* enough, or when the link already resolves; the caller offers what comes back,
|
|
351
|
+
* and never applies it on the editor's behalf.
|
|
352
|
+
*/
|
|
353
|
+
export function suggestLinkTarget(value, pages) {
|
|
354
|
+
const resolved = resolveLink(value, pages);
|
|
355
|
+
if (resolved.kind !== "page" || !resolved.missing)
|
|
356
|
+
return undefined;
|
|
357
|
+
const best = pages
|
|
358
|
+
.map((page) => ({ page, score: scoreLinkCandidate(resolved.path ?? "", page, pages) }))
|
|
359
|
+
.sort((a, b) => b.score - a.score)[0];
|
|
360
|
+
return best && best.score >= SUGGESTION_MIN_SCORE ? best.page : undefined;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The absolute URL a viewer would copy out of the address bar, turned back into
|
|
364
|
+
* an internal route — when it belongs to this site.
|
|
365
|
+
*
|
|
366
|
+
* Pasting the address bar is how people actually get a URL, and an absolute one
|
|
367
|
+
* survives right up until the site moves domain. Returns undefined when the
|
|
368
|
+
* origin is someone else's, so the caller leaves the link alone.
|
|
369
|
+
*/
|
|
370
|
+
export function internalPathForUrl(value, siteOrigin) {
|
|
371
|
+
if (!siteOrigin)
|
|
372
|
+
return undefined;
|
|
373
|
+
let url;
|
|
374
|
+
let origin;
|
|
375
|
+
try {
|
|
376
|
+
url = new URL(value);
|
|
377
|
+
origin = new URL(siteOrigin);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (url.host !== origin.host)
|
|
383
|
+
return undefined;
|
|
384
|
+
return normalizeLinkPath(url.pathname) + url.search + url.hash;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The prop holding the "open in new tab" flag for a link prop, by convention.
|
|
388
|
+
*
|
|
389
|
+
* `ctaHref` → `ctaNewTab`, `secondaryCtaHref` → `secondaryCtaNewTab`,
|
|
390
|
+
* `href` → `newTab`. The same shape as the `image`/`imageAlt` pairing the
|
|
391
|
+
* property panel already resolves by name, so a block declares two props and
|
|
392
|
+
* the editor renders one control.
|
|
393
|
+
*/
|
|
394
|
+
export function newTabKeyFor(linkKey) {
|
|
395
|
+
const base = linkKey.replace(/href$/i, "");
|
|
396
|
+
return base ? `${base}NewTab` : "newTab";
|
|
397
|
+
}
|
|
398
|
+
/** Anchor attributes for a link, given its stored value and new-tab flag. */
|
|
399
|
+
export function linkAttrs(href, newTab) {
|
|
400
|
+
const value = typeof href === "string" && href.length > 0 ? href : "#";
|
|
401
|
+
/*
|
|
402
|
+
* `noopener` is not decoration. A `_blank` link without it hands the opened
|
|
403
|
+
* page a live `window.opener` handle on this one, which is a real
|
|
404
|
+
* cross-origin hazard and the reason the pair always ships together.
|
|
405
|
+
*/
|
|
406
|
+
if (newTab === true && parseLink(value).kind === "external") {
|
|
407
|
+
return { href: value, target: "_blank", rel: "noopener noreferrer" };
|
|
408
|
+
}
|
|
409
|
+
return { href: value };
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.4.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"tsx": "^4.21.0",
|