@bettercms-ai/richtext 0.16.0 → 0.17.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/package.json +1 -1
- package/src/href.ts +39 -0
- package/src/html.ts +50 -42
- package/src/ingest.ts +22 -28
- package/src/read.ts +21 -0
- package/src/schema.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
|
package/src/href.ts
CHANGED
|
@@ -25,3 +25,42 @@ export function safeHref(raw: string): string | null {
|
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A MEDIA url: `safeHref`, plus a plain relative path.
|
|
31
|
+
*
|
|
32
|
+
* An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
|
|
33
|
+
* a path with no leading slash — so every one of those images would render as nothing. Widened
|
|
34
|
+
* only for `<img>`, `<video>` and a poster: they load a picture, while an `<iframe>` frames a
|
|
35
|
+
* whole page and keeps the strict check. A colon or whitespace is refused, so no scheme
|
|
36
|
+
* (`javascript:`) can ride this path, and a PROTOCOL-RELATIVE `//host/clip.mp4` is refused with
|
|
37
|
+
* it — it is off-origin, not relative, and `safeHref` rejects the same shape for the same reason.
|
|
38
|
+
*/
|
|
39
|
+
export const safeMediaSrc = (raw: string): string | null =>
|
|
40
|
+
safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
|
|
41
|
+
|
|
42
|
+
/** What a new tab always carries on render, so it is never the author's own `rel`. */
|
|
43
|
+
const NEW_TAB_REL = ["noopener", "noreferrer"];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A link's `rel` is author data (V2's link form writes `nofollow`, `sponsored`, `ugc`; the
|
|
47
|
+
* importer keeps `prev`/`next`): plain link-type tokens only, lowercased, once each. V2's
|
|
48
|
+
* `follow` is its "no rel" and is no token.
|
|
49
|
+
*/
|
|
50
|
+
const relTokens = (raw: unknown): string[] =>
|
|
51
|
+
typeof raw === "string"
|
|
52
|
+
? [...new Set(raw.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t) && t !== "follow"))]
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
/** The `rel` to store from a link's markup: a new tab's noopener/noreferrer are the renderer's, not kept. */
|
|
56
|
+
export function readLinkRel(raw: unknown, blank: boolean): string | undefined {
|
|
57
|
+
const tokens = relTokens(raw).filter((t) => !blank || !NEW_TAB_REL.includes(t));
|
|
58
|
+
return tokens.length ? tokens.join(" ") : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `rel` both renderers write: the stored tokens, and a new tab always adds noopener + noreferrer. */
|
|
62
|
+
export function writeLinkRel(raw: unknown, blank: boolean): string | undefined {
|
|
63
|
+
const tokens = new Set(relTokens(raw));
|
|
64
|
+
if (blank) for (const t of NEW_TAB_REL) tokens.add(t);
|
|
65
|
+
return tokens.size ? [...tokens].join(" ") : undefined;
|
|
66
|
+
}
|
package/src/html.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
BUILTIN_CALLOUT_KEY,
|
|
4
4
|
BUILTIN_EMBED_KEY,
|
|
5
5
|
BUILTIN_IMAGE_KEY,
|
|
6
|
+
BUILTIN_TABLE_KEY,
|
|
6
7
|
BUILTIN_TOGGLE_KEY,
|
|
7
8
|
PT_TYPES,
|
|
8
9
|
pullQuoteHref,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
import { resolveVideoSource } from "./video";
|
|
13
14
|
import { builtInTableToTable, designOf, footnoteNumbers, footnotesOf, highlightedColumns, isTypedTable, jsonLdOf, sortableColumns, tableColumns, tableGroupLayout, tableSections, typedCellView } from "./table";
|
|
14
15
|
import { portableTextToPlainText } from "./plain";
|
|
15
|
-
import { safeHref } from "./href";
|
|
16
|
+
import { safeHref, safeMediaSrc, writeLinkRel } from "./href";
|
|
16
17
|
import { isStyledTag, styledAttrEntries } from "./styled";
|
|
17
18
|
import { richTextImageFrame, richTextImageLayout } from "./image-layout";
|
|
18
19
|
import { keyTakeawaysView } from "./key-takeaways";
|
|
@@ -253,19 +254,8 @@ const STYLE_TAGS: Record<string, ClassTag> = {
|
|
|
253
254
|
|
|
254
255
|
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
255
256
|
|
|
256
|
-
/**
|
|
257
|
-
|
|
258
|
-
*
|
|
259
|
-
* An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
|
|
260
|
-
* a path with no leading slash — so every one of those images would render as nothing, which is
|
|
261
|
-
* the loss this whole pass exists to stop. Widened only for `<img>`, `<video>` and a poster:
|
|
262
|
-
* they load a picture, while an `<iframe>` frames a whole page and keeps the strict check.
|
|
263
|
-
* A colon or whitespace is refused, so no scheme (`javascript:`) can ride this path, and a
|
|
264
|
-
* PROTOCOL-RELATIVE `//host/clip.mp4` is refused with it — it is off-origin, not relative, and
|
|
265
|
-
* `safeHref` rejects the same shape for the same reason (href.ts:20).
|
|
266
|
-
*/
|
|
267
|
-
const mediaSrc = (raw: string): string | null =>
|
|
268
|
-
safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
|
|
257
|
+
/** A media url (`<img>`, `<video>`, a poster): href.ts `safeMediaSrc`, shared with ui's renderer. */
|
|
258
|
+
const mediaSrc = safeMediaSrc;
|
|
269
259
|
|
|
270
260
|
const fieldsOf = (value: unknown): Record<string, unknown> => {
|
|
271
261
|
const f = isRecord(value) ? value.fields : undefined;
|
|
@@ -664,6 +654,35 @@ const checkItemHtml = (classes: HtmlClasses, value: { checked?: unknown }, child
|
|
|
664
654
|
return `<li${cls(classes, "li")}${address} data-checked="${checked}"><input disabled type="checkbox"${checked ? " checked" : ""}>${children ?? ""}</li>`;
|
|
665
655
|
};
|
|
666
656
|
|
|
657
|
+
/**
|
|
658
|
+
* The built-in blocks drawn from their OWN fields, by `schemaKey`: null falls through to the
|
|
659
|
+
* caller's `renderBlock`, then the marker. A map, so ui's twin can be checked key for key
|
|
660
|
+
* (portable-text-coverage.test.tsx): a built-in drawn here and not there is content one site loses.
|
|
661
|
+
*
|
|
662
|
+
* 🔴 AN IMAGE AND AN EMBED ARE READABLE, and rendering them as markers was silent loss: an
|
|
663
|
+
* imported article's illustrations and its YouTube embed disappeared from every delivered
|
|
664
|
+
* rendition while the stored value still held them. Their payload is a URL — there is nothing to
|
|
665
|
+
* resolve. A legacy columns block reads as columns only in the shape it was written in.
|
|
666
|
+
*
|
|
667
|
+
* A pre-P1c TABLE resolves against nothing either: its rows are text, held right here, so a marker
|
|
668
|
+
* would DELETE visible content. Migrating it rather than rendering the matrix directly is what
|
|
669
|
+
* keeps ONE table renderer: a value that came through the reader and one that did not produce the
|
|
670
|
+
* same page, which is the property four surfaces used to get wrong independently.
|
|
671
|
+
*/
|
|
672
|
+
const SELF_RENDERED_BUILTINS = {
|
|
673
|
+
[BUILTIN_IMAGE_KEY]: (value: unknown, classes: HtmlClasses) => imageHtml(value, classes),
|
|
674
|
+
[BUILTIN_EMBED_KEY]: (value: unknown, classes: HtmlClasses) => embedHtml(value, classes),
|
|
675
|
+
[BUILTIN_CALLOUT_KEY]: (value: unknown) => calloutHtml(value),
|
|
676
|
+
[BUILTIN_TOGGLE_KEY]: (value: unknown) => toggleHtml(value),
|
|
677
|
+
[BUILTIN_COLUMNS_KEY]: (value: unknown, _classes: HtmlClasses, components: Parameters<typeof columnsHtml>[1]) =>
|
|
678
|
+
columnsView(value) ? columnsHtml(value, components) : null,
|
|
679
|
+
[BUILTIN_TABLE_KEY]: (value: unknown) => {
|
|
680
|
+
const legacy = builtInTableToTable(value);
|
|
681
|
+
return legacy ? tableHtml(legacy) : null;
|
|
682
|
+
},
|
|
683
|
+
} satisfies Record<string, (value: unknown, classes: HtmlClasses, components: Parameters<typeof columnsHtml>[1]) => string | null>;
|
|
684
|
+
type SelfRenderedBuiltin = keyof typeof SELF_RENDERED_BUILTINS;
|
|
685
|
+
|
|
667
686
|
/**
|
|
668
687
|
* `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
|
|
669
688
|
* item that has none, and an invented key is an address that resolves to nothing.
|
|
@@ -725,24 +744,10 @@ const buildComponents = (
|
|
|
725
744
|
*/
|
|
726
745
|
[PT_TYPES.block]: ({ value }) => {
|
|
727
746
|
const v = value as { schemaKey?: unknown };
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (
|
|
732
|
-
// A legacy columns block in the shape it was written in reads as columns; any other stays a marker.
|
|
733
|
-
if (v.schemaKey === BUILTIN_COLUMNS_KEY && columnsView(value)) return columnsHtml(value, () => buildComponents(classes, undefined, renderBlock, embeds, resolved));
|
|
734
|
-
/**
|
|
735
|
-
* A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
|
|
736
|
-
* nothing. Its rows are text, held right here, so a marker would DELETE visible content.
|
|
737
|
-
* Every other built-in block (an image, a video, a component) carries a reference this
|
|
738
|
-
* serialiser cannot resolve, so those stay markers.
|
|
739
|
-
*
|
|
740
|
-
* Migrating it here rather than rendering the matrix directly is what keeps ONE table
|
|
741
|
-
* renderer: a value that came through the reader and one that did not produce the same
|
|
742
|
-
* page, which is the property four surfaces used to get wrong independently.
|
|
743
|
-
*/
|
|
744
|
-
const legacy = builtInTableToTable(value);
|
|
745
|
-
if (legacy) return tableHtml(legacy);
|
|
747
|
+
const own = typeof v.schemaKey === "string" && Object.hasOwn(SELF_RENDERED_BUILTINS, v.schemaKey)
|
|
748
|
+
? SELF_RENDERED_BUILTINS[v.schemaKey as SelfRenderedBuiltin](value, classes, () => buildComponents(classes, undefined, renderBlock, embeds, resolved))
|
|
749
|
+
: null;
|
|
750
|
+
if (own !== null) return own;
|
|
746
751
|
const rendered = renderBlock?.(value as Record<string, unknown>);
|
|
747
752
|
if (rendered !== undefined) return rendered;
|
|
748
753
|
const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
|
|
@@ -768,17 +773,10 @@ const buildComponents = (
|
|
|
768
773
|
if (!href) return children;
|
|
769
774
|
// RT2: a button is a link the author marked as one; V2's `data-button` is the site's style hook.
|
|
770
775
|
const button = v.button === true ? ' data-button="true"' : "";
|
|
771
|
-
// `rel` is author data too
|
|
772
|
-
// plain link-type tokens pass; a new tab always adds noopener + noreferrer.
|
|
773
|
-
const rel = new Set(
|
|
774
|
-
typeof v.rel === "string" ? v.rel.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t)) : [],
|
|
775
|
-
);
|
|
776
|
+
// `rel` is author data too: only plain link-type tokens pass; a new tab always adds noopener + noreferrer.
|
|
776
777
|
const target = v.target === "_blank" ? ' target="_blank"' : "";
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
rel.add("noreferrer");
|
|
780
|
-
}
|
|
781
|
-
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
778
|
+
const rel = writeLinkRel(v.rel, !!target);
|
|
779
|
+
const relAttr = rel ? ` rel="${esc(rel)}"` : "";
|
|
782
780
|
// The link's own class (kept on ingest) wins over the caller's default: it IS the site.
|
|
783
781
|
const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
|
|
784
782
|
return `<a${own || cls(classes, "a")}${button} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
@@ -817,6 +815,16 @@ const buildComponents = (
|
|
|
817
815
|
/** The undressed components, built once: the overwhelming majority of calls pass no classes. */
|
|
818
816
|
const defaultComponents = buildComponents();
|
|
819
817
|
|
|
818
|
+
/**
|
|
819
|
+
* What this serialiser draws: its node types, its marks, and the built-in blocks it draws from
|
|
820
|
+
* their own fields. ui's React renderer must draw every one of them (portable-text-coverage.test.tsx).
|
|
821
|
+
*/
|
|
822
|
+
export const HTML_RENDERS = {
|
|
823
|
+
types: Object.keys(defaultComponents.types ?? {}),
|
|
824
|
+
marks: Object.keys(defaultComponents.marks ?? {}),
|
|
825
|
+
builtins: Object.keys(SELF_RENDERED_BUILTINS),
|
|
826
|
+
} as const;
|
|
827
|
+
|
|
820
828
|
const isListItem = (node: unknown): boolean =>
|
|
821
829
|
isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
|
|
822
830
|
|
package/src/ingest.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type PortableTextTableRow,
|
|
15
15
|
} from "./types";
|
|
16
16
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
17
|
-
import { safeHref } from "./href";
|
|
17
|
+
import { readLinkRel, safeHref } from "./href";
|
|
18
18
|
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
19
19
|
import { EMBED_TITLE_MAX, isEmbedAspect, nearestEmbedAspect, resolveEmbed } from "./embed";
|
|
20
20
|
import { cellTypeOf, columnOf, designOf, isColumnType, isValueType, jsonLdOf, PT_TABLE_LIMITS, typedCellText } from "./table";
|
|
@@ -755,45 +755,39 @@ function buttonLinkRule(key: KeyGenerator, schema: PtSchema | undefined): Deseri
|
|
|
755
755
|
const flag = el.getAttribute("data-button");
|
|
756
756
|
const href = el.getAttribute("href")?.trim();
|
|
757
757
|
if (flag === null || flag === "false" || !href) return undefined;
|
|
758
|
-
const
|
|
759
|
-
const rel = el.getAttribute("rel")?.trim();
|
|
760
|
-
const markDef = {
|
|
761
|
-
_type: PT_TYPES.link,
|
|
762
|
-
_key: key(),
|
|
763
|
-
href,
|
|
764
|
-
button: true,
|
|
765
|
-
...(className ? { className } : {}),
|
|
766
|
-
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
767
|
-
...(rel ? { rel } : {}),
|
|
768
|
-
};
|
|
758
|
+
const markDef = { _type: PT_TYPES.link, _key: key(), href, button: true, ...linkExtras(el) };
|
|
769
759
|
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
770
760
|
},
|
|
771
761
|
};
|
|
772
762
|
}
|
|
773
763
|
|
|
774
764
|
/**
|
|
775
|
-
* `<a
|
|
765
|
+
* What an `<a>` stores beyond its href: the site's class, a new tab (only `_blank`) and its
|
|
766
|
+
* `rel` (author tokens; the noopener/noreferrer a new tab adds on render are not kept).
|
|
767
|
+
*/
|
|
768
|
+
function linkExtras(el: Element): { className?: string; target?: "_blank"; rel?: string } {
|
|
769
|
+
const className = el.getAttribute("class")?.trim();
|
|
770
|
+
const blank = el.getAttribute("target")?.trim().toLowerCase() === "_blank";
|
|
771
|
+
const rel = readLinkRel(el.getAttribute("rel"), blank);
|
|
772
|
+
return { ...(className ? { className } : {}), ...(blank ? { target: "_blank" } : {}), ...(rel ? { rel } : {}) };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* `<a class>`, `<a target="_blank">`, `<a rel>` → a `link` annotation that keeps them.
|
|
776
777
|
*
|
|
777
778
|
* The built-in link rule records `href` and nothing else, so a styled link (`<a class="underline
|
|
778
|
-
* text-brand">`) came back bare and the design died on ingest
|
|
779
|
-
*
|
|
779
|
+
* text-brand">`) came back bare and the design died on ingest, and a link's new tab and its
|
|
780
|
+
* `nofollow` (V2's link form writes both) were lost the same way. Only a link carrying one of
|
|
781
|
+
* them takes this rule; a bare link still goes to the built-in one. `href` is checked by `normalise`.
|
|
780
782
|
*/
|
|
781
|
-
function
|
|
783
|
+
function linkRule(key: KeyGenerator): DeserializerRule {
|
|
782
784
|
return {
|
|
783
785
|
deserialize(el: Node, next) {
|
|
784
786
|
if (!isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
|
|
785
|
-
const className = el.getAttribute("class")?.trim();
|
|
786
787
|
const href = el.getAttribute("href")?.trim();
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const markDef = {
|
|
790
|
-
_type: PT_TYPES.link,
|
|
791
|
-
_key: key(),
|
|
792
|
-
href,
|
|
793
|
-
className,
|
|
794
|
-
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
795
|
-
...(rel ? { rel } : {}),
|
|
796
|
-
};
|
|
788
|
+
const extras = linkExtras(el);
|
|
789
|
+
if (!href || !Object.keys(extras).length) return undefined;
|
|
790
|
+
const markDef = { _type: PT_TYPES.link, _key: key(), href, ...extras };
|
|
797
791
|
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
798
792
|
},
|
|
799
793
|
};
|
|
@@ -1253,7 +1247,7 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
1253
1247
|
ownDecoratorRule(opts.schema),
|
|
1254
1248
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
1255
1249
|
buttonLinkRule(key, opts.schema),
|
|
1256
|
-
...(allowsLinks(opts.schema) ? [
|
|
1250
|
+
...(allowsLinks(opts.schema) ? [linkRule(key)] : []),
|
|
1257
1251
|
],
|
|
1258
1252
|
keyGenerator: key,
|
|
1259
1253
|
});
|
package/src/read.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { migrateTable } from "./table";
|
|
|
4
4
|
import { detachedComponentCopy, type DetachedComponentCopy } from "./component-copy";
|
|
5
5
|
import { randomKey, type KeyGenerator } from "./key";
|
|
6
6
|
import type { EmbedCard, ResolvedAuthor, ResolvedPost, RichTextEmbeds, RichTextResolved } from "@bettercms-ai/types";
|
|
7
|
+
import type { ClassTag, HtmlClasses } from "./html";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* THE reader. One function, every stored shape, and it never throws.
|
|
@@ -335,3 +336,23 @@ export function readStructuredResolved(props: unknown): RichTextResolved | undef
|
|
|
335
336
|
}
|
|
336
337
|
return { posts, authors, related };
|
|
337
338
|
}
|
|
339
|
+
|
|
340
|
+
/** Every tag a caller may class (html.ts `ClassTag`); a Record so a tag added there must be added here. */
|
|
341
|
+
const CLASS_TAGS = Object.keys({
|
|
342
|
+
p: 1, h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1, a: 1, img: 1, figure: 1, blockquote: 1,
|
|
343
|
+
} satisfies Record<ClassTag, 1>) as ClassTag[];
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The site's own class per tag for a DELIVERED value (`value.classes`, the field's
|
|
347
|
+
* `config.htmlClasses`, which the delivery puts beside the value); undefined when there are none.
|
|
348
|
+
* Checked, not trusted: only the tags a renderer dresses, with non-empty string classes.
|
|
349
|
+
*/
|
|
350
|
+
export function readStructuredClasses(props: unknown): HtmlClasses | undefined {
|
|
351
|
+
if (!isRecord(props)) return undefined;
|
|
352
|
+
const candidate = props.content ?? props.value;
|
|
353
|
+
const raw = isRecord(candidate) ? candidate.classes : undefined;
|
|
354
|
+
if (!isRecord(raw)) return undefined;
|
|
355
|
+
const out: HtmlClasses = {};
|
|
356
|
+
for (const tag of CLASS_TAGS) if (typeof raw[tag] === "string" && raw[tag]) out[tag] = raw[tag] as string;
|
|
357
|
+
return Object.keys(out).length ? out : undefined;
|
|
358
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -111,6 +111,9 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
111
111
|
fields: [
|
|
112
112
|
{ name: "href", type: "string" },
|
|
113
113
|
{ name: "target", type: "string" },
|
|
114
|
+
// The author's link-type tokens (V2's link form: nofollow, sponsored, ugc); a new tab's
|
|
115
|
+
// noopener/noreferrer are the renderer's and are not stored (href.ts `readLinkRel`).
|
|
116
|
+
{ name: "rel", type: "string" },
|
|
114
117
|
// The site's own class on the `<a>`, kept so a styled link round-trips.
|
|
115
118
|
{ name: "className", type: "string" },
|
|
116
119
|
// RT2: V2's button is a link the author marked as one (`<a data-button="true">`), edited as
|