@avocadostudio-ai/shared 0.3.3 → 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/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 +35 -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 +342 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/links.d.ts +126 -5
- package/dist/links.js +256 -5
- package/package.json +2 -2
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which editable fields does a rendered preview actually offer?
|
|
3
|
+
*
|
|
4
|
+
* The overlay finds every job it does by walking `[data-editable-target]`, so a
|
|
5
|
+
* field the site never marked is a field with no inline editing, no hover pill
|
|
6
|
+
* and — for an image — no Change button. Nothing about that is visible: the
|
|
7
|
+
* property panel is built from this same manifest and never looks at the page,
|
|
8
|
+
* so an unmarked field still appears there, still edits, and still saves. The
|
|
9
|
+
* preview simply offers less than the panel does, quietly and forever.
|
|
10
|
+
*
|
|
11
|
+
* `missingEditableTargetsWarning` in preview-adapter catches the all-or-nothing
|
|
12
|
+
* case at runtime. This is the graded one, and it is the case that actually
|
|
13
|
+
* recurs: instrumentation is per-component work spread over a dozen files, so
|
|
14
|
+
* it gets done on one branch, not merged, and re-lost on the next. The symptom
|
|
15
|
+
* is always a single missing button, reported as a bug in the button.
|
|
16
|
+
*
|
|
17
|
+
* The manifest already knows every editable field of every block type, and the
|
|
18
|
+
* rendered page already carries the block type on each wrapper. Nothing further
|
|
19
|
+
* is needed to answer the question exactly — only somebody asking it, which is
|
|
20
|
+
* what this is for: an integrator asserts on it in their own test suite, and a
|
|
21
|
+
* branch that drops the markers goes red instead of going quiet.
|
|
22
|
+
*/
|
|
23
|
+
import { resolveManifestFieldMeta } from "./block-manifest.js";
|
|
24
|
+
/**
|
|
25
|
+
* The field kinds a marker is expected for: the ones something on the page
|
|
26
|
+
* *draws*.
|
|
27
|
+
*
|
|
28
|
+
* A renderer emits `data-editable-target` for an element, and only these three
|
|
29
|
+
* are elements. `enum`, `boolean`, `number`, `color` and `headingLevel` are
|
|
30
|
+
* settings — they change how something looks rather than being a thing on the
|
|
31
|
+
* page. `url`, `link` and `file` are attributes on an anchor whose *label* is
|
|
32
|
+
* the text field next to them, and `imageAlt` is an attribute on the image. A
|
|
33
|
+
* checker that demanded markers for those would report gaps that cannot be
|
|
34
|
+
* closed, which is the fastest way to get a checker ignored.
|
|
35
|
+
*/
|
|
36
|
+
const DRAWN_KINDS = new Set(["text", "richtext", "image"]);
|
|
37
|
+
/**
|
|
38
|
+
* Does this field need an element marked for it?
|
|
39
|
+
*
|
|
40
|
+
* `inlineEditable: false` on a text field is the site saying the string is not
|
|
41
|
+
* something anybody edits on the page — an anchor id, a slug fragment, a
|
|
42
|
+
* machine value that happens to be typed as text. Honouring it is what keeps
|
|
43
|
+
* this checker worth reading: PBA declares seven of those, and counting them as
|
|
44
|
+
* gaps would have made the first report 16% instead of 19% and every one of the
|
|
45
|
+
* extra findings unfixable.
|
|
46
|
+
*
|
|
47
|
+
* It is not consulted for images. There, "inline editable" means typing into
|
|
48
|
+
* it, which nobody does to a photo; the marker is what carries the Change
|
|
49
|
+
* button, and the panel offering one is the site saying the image is editable.
|
|
50
|
+
*/
|
|
51
|
+
function needsMarker(meta) {
|
|
52
|
+
if (!DRAWN_KINDS.has(meta.kind))
|
|
53
|
+
return false;
|
|
54
|
+
if (meta.kind === "image")
|
|
55
|
+
return true;
|
|
56
|
+
return meta.inlineEditable !== false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Elements that cannot contain anything.
|
|
60
|
+
*
|
|
61
|
+
* The overlay mounts its image Change button by *appending it into* the marked
|
|
62
|
+
* element, so a marker on an `<img>` is inert — the attribute is in the HTML,
|
|
63
|
+
* the editor finds the field, and no button can ever appear. It looks
|
|
64
|
+
* instrumented from every angle except the one that matters, and this package's
|
|
65
|
+
* own Card and Hero renderers both did it on their full-bleed variants.
|
|
66
|
+
*/
|
|
67
|
+
const VOID_ELEMENTS = new Set([
|
|
68
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
69
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
70
|
+
]);
|
|
71
|
+
/** One element: its tag name, and the raw attribute text, quotes respected. */
|
|
72
|
+
const TAG_RE = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
|
73
|
+
const BLOCK_ID_RE = /\bdata-block-id="([^"]*)"/;
|
|
74
|
+
const BLOCK_TYPE_RE = /\bdata-block-type="([^"]*)"/;
|
|
75
|
+
const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
|
|
76
|
+
/**
|
|
77
|
+
* Read the marked blocks out of a rendered page's HTML.
|
|
78
|
+
*
|
|
79
|
+
* A linear scan: each `data-editable-target` belongs to the most recently seen
|
|
80
|
+
* block. That is exact when block wrappers are siblings, which is the contract
|
|
81
|
+
* `getPreviewWrapperProps` describes — one wrapper per block, not nested. A
|
|
82
|
+
* site that nests blocks inside blocks will see inner fields attributed to the
|
|
83
|
+
* outer one; it would also confuse the overlay's own `closest()` walk, so it is
|
|
84
|
+
* out of contract on both ends rather than a limitation of this function.
|
|
85
|
+
*/
|
|
86
|
+
export function extractMarkedBlocks(html) {
|
|
87
|
+
const blocks = [];
|
|
88
|
+
let current = null;
|
|
89
|
+
for (const match of html.matchAll(TAG_RE)) {
|
|
90
|
+
const tag = match[1].toLowerCase();
|
|
91
|
+
const attrs = match[2] ?? "";
|
|
92
|
+
const blockId = BLOCK_ID_RE.exec(attrs)?.[1];
|
|
93
|
+
const blockType = BLOCK_TYPE_RE.exec(attrs)?.[1];
|
|
94
|
+
if (blockId !== undefined || blockType !== undefined) {
|
|
95
|
+
current = {
|
|
96
|
+
blockType: blockType ?? "",
|
|
97
|
+
...(blockId !== undefined ? { blockId } : {}),
|
|
98
|
+
paths: [],
|
|
99
|
+
};
|
|
100
|
+
blocks.push(current);
|
|
101
|
+
}
|
|
102
|
+
const target = TARGET_RE.exec(attrs)?.[1];
|
|
103
|
+
if (target !== undefined && current) {
|
|
104
|
+
current.paths.push(target);
|
|
105
|
+
if (VOID_ELEMENTS.has(tag))
|
|
106
|
+
(current.voidPaths ??= []).push(target);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return blocks;
|
|
110
|
+
}
|
|
111
|
+
/** Does this prop hold anything a renderer would draw? */
|
|
112
|
+
function hasContent(value) {
|
|
113
|
+
if (value === null || value === undefined)
|
|
114
|
+
return false;
|
|
115
|
+
if (typeof value === "string")
|
|
116
|
+
return value.trim() !== "";
|
|
117
|
+
if (Array.isArray(value))
|
|
118
|
+
return value.length > 0;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/** `cards[2].title` → `cards[].title`; anything else unchanged. */
|
|
122
|
+
function generalizeIndex(path) {
|
|
123
|
+
return path.replace(/\[\d+\]/g, "[]");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The item fields a list is expected to have markers for, given what is in it.
|
|
127
|
+
*
|
|
128
|
+
* Two refinements of "every field the manifest declares for this list", both
|
|
129
|
+
* for the same reason the top-level fields already consult `props`: an element
|
|
130
|
+
* that was never drawn cannot carry a marker, and reporting it is a finding
|
|
131
|
+
* nobody can act on.
|
|
132
|
+
*
|
|
133
|
+
* 1. A field no item has a value for is not expected. `imageUrl` is optional on
|
|
134
|
+
* every card block here, and a page whose cards are all text would otherwise
|
|
135
|
+
* report a missing image marker on every one of them.
|
|
136
|
+
*
|
|
137
|
+
* 2. When the list is polymorphic — `discriminator` plus `itemFieldsByType`,
|
|
138
|
+
* the shape a `oneOf` schema derives and a permissive one has to declare —
|
|
139
|
+
* each item is measured against *its own* branch. TwoColumn is the case: a
|
|
140
|
+
* `type: "image"` child has `src` and `alt` and no `label`, and asking it for
|
|
141
|
+
* a button label is asking the renderer to draw a field the item does not
|
|
142
|
+
* have.
|
|
143
|
+
*
|
|
144
|
+
* With no props in hand there is nothing to narrow by, so every declared field
|
|
145
|
+
* is expected — the behaviour before this existed.
|
|
146
|
+
*/
|
|
147
|
+
function expectedItemFields(listMeta, items) {
|
|
148
|
+
/*
|
|
149
|
+
* The discriminator is never content. It decides which shape the item is —
|
|
150
|
+
* `derivePolymorphicListField` deletes it from every branch it builds for
|
|
151
|
+
* exactly that reason — and a list whose metadata was declared rather than
|
|
152
|
+
* derived has nothing doing the same, so a `type` typed as a plain string
|
|
153
|
+
* arrives here looking like an ordinary text field nobody marked.
|
|
154
|
+
*/
|
|
155
|
+
const isContent = ([key, meta]) => key !== listMeta.discriminator && needsMarker(meta);
|
|
156
|
+
const declared = Object.entries(listMeta.itemFields ?? {}).filter(isContent);
|
|
157
|
+
if (!items)
|
|
158
|
+
return declared;
|
|
159
|
+
const expected = new Map();
|
|
160
|
+
for (const raw of items) {
|
|
161
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
162
|
+
continue;
|
|
163
|
+
const item = raw;
|
|
164
|
+
const branchKey = listMeta.discriminator ? String(item[listMeta.discriminator] ?? "") : "";
|
|
165
|
+
const fields = listMeta.itemFieldsByType?.[branchKey] ?? listMeta.itemFields ?? {};
|
|
166
|
+
for (const entry of Object.entries(fields)) {
|
|
167
|
+
if (!isContent(entry))
|
|
168
|
+
continue;
|
|
169
|
+
if (!hasContent(item[entry[0]]))
|
|
170
|
+
continue;
|
|
171
|
+
expected.set(entry[0], entry[1]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [...expected];
|
|
175
|
+
}
|
|
176
|
+
export function editableCoverage(manifest, blocks) {
|
|
177
|
+
const definitions = new Map(manifest.blocks.map((block) => [block.type, block]));
|
|
178
|
+
// Aggregate by type: a missing marker is a property of the component, not of
|
|
179
|
+
// the one instance that happened to render first.
|
|
180
|
+
const pathsByType = new Map();
|
|
181
|
+
const exampleIdByType = new Map();
|
|
182
|
+
const unknownBlockTypes = new Set();
|
|
183
|
+
/*
|
|
184
|
+
* Props that at least one instance of a type actually had. Only consulted
|
|
185
|
+
* when some instance supplied props at all — a caller that passes none gets
|
|
186
|
+
* the old behaviour, where every declared field is expected.
|
|
187
|
+
*/
|
|
188
|
+
const filledByType = new Map();
|
|
189
|
+
const sawPropsForType = new Set();
|
|
190
|
+
const voidByType = new Map();
|
|
191
|
+
/** Every list item seen for a type, by list key — what `expectedItemFields` narrows by. */
|
|
192
|
+
const listItemsByType = new Map();
|
|
193
|
+
for (const block of blocks) {
|
|
194
|
+
if (!block.blockType)
|
|
195
|
+
continue;
|
|
196
|
+
if (!definitions.has(block.blockType)) {
|
|
197
|
+
unknownBlockTypes.add(block.blockType);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
let paths = pathsByType.get(block.blockType);
|
|
201
|
+
if (!paths) {
|
|
202
|
+
paths = new Set();
|
|
203
|
+
pathsByType.set(block.blockType, paths);
|
|
204
|
+
if (block.blockId)
|
|
205
|
+
exampleIdByType.set(block.blockType, block.blockId);
|
|
206
|
+
}
|
|
207
|
+
for (const path of block.paths)
|
|
208
|
+
paths.add(generalizeIndex(path));
|
|
209
|
+
if (block.voidPaths?.length) {
|
|
210
|
+
let voids = voidByType.get(block.blockType);
|
|
211
|
+
if (!voids) {
|
|
212
|
+
voids = new Set();
|
|
213
|
+
voidByType.set(block.blockType, voids);
|
|
214
|
+
}
|
|
215
|
+
for (const path of block.voidPaths)
|
|
216
|
+
voids.add(generalizeIndex(path));
|
|
217
|
+
}
|
|
218
|
+
if (!block.props)
|
|
219
|
+
continue;
|
|
220
|
+
sawPropsForType.add(block.blockType);
|
|
221
|
+
let filled = filledByType.get(block.blockType);
|
|
222
|
+
if (!filled) {
|
|
223
|
+
filled = new Set();
|
|
224
|
+
filledByType.set(block.blockType, filled);
|
|
225
|
+
}
|
|
226
|
+
let lists = listItemsByType.get(block.blockType);
|
|
227
|
+
if (!lists) {
|
|
228
|
+
lists = new Map();
|
|
229
|
+
listItemsByType.set(block.blockType, lists);
|
|
230
|
+
}
|
|
231
|
+
for (const [key, value] of Object.entries(block.props)) {
|
|
232
|
+
if (hasContent(value))
|
|
233
|
+
filled.add(key);
|
|
234
|
+
if (Array.isArray(value)) {
|
|
235
|
+
const seen = lists.get(key);
|
|
236
|
+
if (seen)
|
|
237
|
+
seen.push(...value);
|
|
238
|
+
else
|
|
239
|
+
lists.set(key, [...value]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let expected = 0;
|
|
244
|
+
let marked = 0;
|
|
245
|
+
const gaps = [];
|
|
246
|
+
for (const [blockType, paths] of pathsByType) {
|
|
247
|
+
const definition = definitions.get(blockType);
|
|
248
|
+
const { fields, listFields } = resolveManifestFieldMeta(definition);
|
|
249
|
+
const missing = [];
|
|
250
|
+
const missingItemFields = [];
|
|
251
|
+
const unmarkedLists = [];
|
|
252
|
+
const voids = voidByType.get(blockType) ?? new Set();
|
|
253
|
+
const markedOnVoidElement = [];
|
|
254
|
+
const knowsContent = sawPropsForType.has(blockType);
|
|
255
|
+
const filled = filledByType.get(blockType) ?? new Set();
|
|
256
|
+
for (const [key, meta] of Object.entries(fields)) {
|
|
257
|
+
if (!needsMarker(meta))
|
|
258
|
+
continue;
|
|
259
|
+
// Nothing drew it, so nothing could have marked it.
|
|
260
|
+
if (knowsContent && !filled.has(key))
|
|
261
|
+
continue;
|
|
262
|
+
expected += 1;
|
|
263
|
+
if (!paths.has(key)) {
|
|
264
|
+
missing.push(key);
|
|
265
|
+
}
|
|
266
|
+
else if (meta.kind === "image" && voids.has(key)) {
|
|
267
|
+
// Marked, found by the editor, and unable to hold the button.
|
|
268
|
+
markedOnVoidElement.push(key);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
marked += 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [listKey, listMeta] of Object.entries(listFields)) {
|
|
275
|
+
const listPresent = [...paths].some((path) => path.startsWith(`${listKey}[]`));
|
|
276
|
+
const drawnItemFields = expectedItemFields(listMeta, knowsContent ? (listItemsByType.get(blockType)?.get(listKey) ?? []) : undefined);
|
|
277
|
+
if (drawnItemFields.length === 0)
|
|
278
|
+
continue;
|
|
279
|
+
// An empty list on every page is not an instrumentation question either,
|
|
280
|
+
// and with props in hand we can say so instead of guessing.
|
|
281
|
+
if (knowsContent && !filled.has(listKey))
|
|
282
|
+
continue;
|
|
283
|
+
if (!listPresent) {
|
|
284
|
+
// Could be an empty list on this page. Not counted against coverage.
|
|
285
|
+
unmarkedLists.push(listKey);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const [itemKey, itemMeta] of drawnItemFields) {
|
|
289
|
+
const path = `${listKey}[].${itemKey}`;
|
|
290
|
+
expected += 1;
|
|
291
|
+
if (!paths.has(path)) {
|
|
292
|
+
missingItemFields.push(path);
|
|
293
|
+
}
|
|
294
|
+
else if (itemMeta.kind === "image" && voids.has(path)) {
|
|
295
|
+
markedOnVoidElement.push(path);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
marked += 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (missing.length > 0 ||
|
|
303
|
+
missingItemFields.length > 0 ||
|
|
304
|
+
unmarkedLists.length > 0 ||
|
|
305
|
+
markedOnVoidElement.length > 0) {
|
|
306
|
+
const exampleBlockId = exampleIdByType.get(blockType);
|
|
307
|
+
gaps.push({
|
|
308
|
+
blockType,
|
|
309
|
+
...(exampleBlockId ? { exampleBlockId } : {}),
|
|
310
|
+
missing,
|
|
311
|
+
missingItemFields,
|
|
312
|
+
unmarkedLists,
|
|
313
|
+
markedOnVoidElement,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { expected, marked, gaps, unknownBlockTypes: [...unknownBlockTypes] };
|
|
318
|
+
}
|
|
319
|
+
/** A report a person can read in a terminal. */
|
|
320
|
+
export function formatEditableCoverage(report) {
|
|
321
|
+
const lines = [];
|
|
322
|
+
const pct = report.expected === 0 ? 100 : Math.round((report.marked / report.expected) * 100);
|
|
323
|
+
lines.push(`editable fields marked: ${report.marked}/${report.expected} (${pct}%)`);
|
|
324
|
+
for (const gap of report.gaps) {
|
|
325
|
+
const where = gap.exampleBlockId ? ` (e.g. ${gap.exampleBlockId})` : "";
|
|
326
|
+
lines.push(` ${gap.blockType}${where}`);
|
|
327
|
+
for (const key of gap.missing)
|
|
328
|
+
lines.push(` unmarked ${key}`);
|
|
329
|
+
for (const key of gap.missingItemFields)
|
|
330
|
+
lines.push(` unmarked ${key}`);
|
|
331
|
+
for (const key of gap.markedOnVoidElement) {
|
|
332
|
+
lines.push(` marked on a void element (no button can mount) ${key}`);
|
|
333
|
+
}
|
|
334
|
+
for (const key of gap.unmarkedLists) {
|
|
335
|
+
lines.push(` no marker for any item of ${key}[] — empty on this page, or not instrumented`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (report.unknownBlockTypes.length > 0) {
|
|
339
|
+
lines.push(` not in the manifest, skipped: ${report.unknownBlockTypes.join(", ")}`);
|
|
340
|
+
}
|
|
341
|
+
return lines.join("\n");
|
|
342
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,13 @@ export { EDITOR_PROTOCOL_VERSION } from "./protocol.ts";
|
|
|
2
2
|
export { getConfiguredDraftSecret, getSafeInternalRedirectPath, validateDraftSecret, type DraftSecretValidationResult } from "./draft-mode.ts";
|
|
3
3
|
export type { FieldDiffKind, FieldDiff, BlockDiffStatus, BlockDiff, PageDiffStatus, PageDiff, PublishDiff, SiteConfigFieldDiff, SiteConfigDiff, } from "./publish-diff.ts";
|
|
4
4
|
export { isImagePath, toAltPath, isAltPath, toImagePath, setPropAtPath } from "./editable-path.ts";
|
|
5
|
-
export {
|
|
5
|
+
export { extractMarkedBlocks, editableCoverage, formatEditableCoverage } from "./editable-coverage.ts";
|
|
6
|
+
export type { MarkedBlock, BlockCoverageGap, EditableCoverage } from "./editable-coverage.ts";
|
|
7
|
+
export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, rankFileTargets, suggestLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, isFilePath, knownFileExtensions, linksInRichText, type LinkKind, type ParsedLink, type ResolvedLink, type LinkPageOption, type LinkFileOption, type LinkSuggestions, } from "./links.ts";
|
|
6
8
|
export { parseInline, parseRichText, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel, clampMarkdownHeadings, unescapeMarkdownText, isRichTextDoc, fromMarkdown, toMarkdown, mergeRichTextDoc, NODE, MARK, type InlineToken, type RichTextBlock, type RichTextList, type RichTextListItem, type RichTextDoc, type RichTextNode, type RichTextMark } from "@avocadostudio-ai/richtext";
|
|
7
9
|
export { blockDefinitionSchema, blockManifestSchema, buildBlockManifest, jsonSchemaLikeSchema, validateByJsonSchemaLike, findManifestSchemaIssue, type ManifestSchemaIssue, validateManifestDefaultProps, deriveFieldMetaFromSchema, resolveManifestFieldMeta, isProseMirrorDocSchema, type BlockDefinition, type BlockManifest } from "./block-manifest.ts";
|
|
8
10
|
export { z } from "zod";
|
|
9
|
-
export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes, blockSchemas, allowedBlockTypes, declareBlockCatalogue, getBlockCatalogue, isInBlockCatalogue, catalogueBlockTypes, undeclaredBlockTypes, getPropDisplayName, defaultListItemForBlock, blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.ts";
|
|
11
|
+
export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes, blockSchemas, allowedBlockTypes, declareBlockCatalogue, getBlockCatalogue, isInBlockCatalogue, catalogueBlockTypes, undeclaredBlockTypes, getPropDisplayName, defaultListItemForBlock, blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.ts";
|
|
10
12
|
export { defaultPropsForType, declaredDefaultPropsForType, resolveHeadingTag, resolveItemHeadingTag, DEFAULT_HEADING_LEVELS, } from "./blocks/index.ts";
|
|
11
13
|
export { blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "./block-names.ts";
|
|
12
14
|
export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, type AddBlockOp, type MakeAddBlockOptions, type AddItemOp, type MakeAddItemOptions, } from "./ops/builders.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { EDITOR_PROTOCOL_VERSION } from "./protocol.js";
|
|
2
2
|
export { getConfiguredDraftSecret, getSafeInternalRedirectPath, validateDraftSecret } from "./draft-mode.js";
|
|
3
3
|
export { isImagePath, toAltPath, isAltPath, toImagePath, setPropAtPath } from "./editable-path.js";
|
|
4
|
-
export {
|
|
4
|
+
export { extractMarkedBlocks, editableCoverage, formatEditableCoverage } from "./editable-coverage.js";
|
|
5
|
+
export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, rankFileTargets, suggestLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, isFilePath, knownFileExtensions, linksInRichText, } from "./links.js";
|
|
5
6
|
/*
|
|
6
7
|
* The rich-text grammar lives in `@avocadostudio-ai/richtext`, which owns the
|
|
7
8
|
* parser and every CMS converter and has no dependencies of its own. It is
|
|
@@ -34,7 +35,7 @@ export {
|
|
|
34
35
|
// Constants & helpers
|
|
35
36
|
IMAGE_PLACEHOLDER, isImagePlaceholder,
|
|
36
37
|
// Registry functions
|
|
37
|
-
registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
|
|
38
|
+
registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
|
|
38
39
|
// Backwards-compatible exports
|
|
39
40
|
blockSchemas, allowedBlockTypes,
|
|
40
41
|
// The catalogue a site actually renders — see `declareBlockCatalogue`
|
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[];
|