@everystack/admin 0.4.1 → 0.4.3

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.
@@ -25,12 +25,16 @@ export interface RowActionButtonProps {
25
25
  /** Value sent as `{ id }` to an rpc action (record[primaryKey]). */
26
26
  recordId: string | number;
27
27
  adapter: DataAdapter;
28
- /** Re-fetch the current view after the action completes. */
28
+ /** Re-fetch the current view after the action completes or fails. */
29
29
  refetch: () => Promise<void> | void;
30
30
  /** Navigate to an admin path, when the route layer provides it. */
31
31
  navigate?: (path: string) => void;
32
32
  }
33
33
 
34
+ function messageOf(err: unknown): string {
35
+ return err instanceof Error ? err.message : String(err);
36
+ }
37
+
34
38
  export function RowActionButton({
35
39
  action,
36
40
  record,
@@ -56,15 +60,26 @@ export function RowActionButton({
56
60
  setBusy(true);
57
61
  setError(null);
58
62
  try {
59
- if (action.onPress) {
60
- await action.onPress({ record, adapter, refetch, navigate });
61
- } else if (action.rpc) {
62
- if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
63
- await adapter.rpc(action.rpc, { id: recordId });
63
+ let failed = false;
64
+ try {
65
+ if (action.onPress) {
66
+ await action.onPress({ record, adapter, refetch, navigate });
67
+ } else if (action.rpc) {
68
+ if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
69
+ await adapter.rpc(action.rpc, { id: recordId });
70
+ }
71
+ } catch (err) {
72
+ failed = true;
73
+ setError(messageOf(err));
74
+ }
75
+ // Refetch on failure too: a delete of a row another session already
76
+ // removed fails, and the list must still drop the stale row.
77
+ try {
78
+ await refetch();
79
+ } catch (err) {
80
+ // A refetch fault never replaces the action's own error.
81
+ if (!failed) setError(messageOf(err));
64
82
  }
65
- await refetch();
66
- } catch (err) {
67
- setError(err instanceof Error ? err.message : String(err));
68
83
  } finally {
69
84
  setBusy(false);
70
85
  setConfirming(false);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * TagsDisplay — read-only pills for a `type: 'tags'` value on the show view.
3
+ * Labels come from the field's groups; a value no group lists shows as itself.
4
+ */
5
+
6
+ import React from 'react';
7
+ import { View, Text } from 'react-native';
8
+ import { Badge } from '@everystack/ui';
9
+ import { tagLabel, tagValues } from '../utils/tags';
10
+ import type { FieldDefinition } from '../types';
11
+
12
+ export function TagsDisplay({ field, value }: { field: FieldDefinition; value: unknown }): React.ReactElement {
13
+ const tags = tagValues(value);
14
+ if (tags.length === 0) return <Text className="text-sm text-gray-900">{'—'}</Text>;
15
+ return (
16
+ <View testID={`tags-${field.name}`} className="flex-row flex-wrap gap-2">
17
+ {tags.map((tag, i) => (
18
+ <Badge key={`${i}-${tag}`} label={tagLabel(field, tag)} />
19
+ ))}
20
+ </View>
21
+ );
22
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * TagsFieldInput
3
+ *
4
+ * Create/edit input for `type: 'tags'` (a text[] column). Each group from
5
+ * `field.tagGroups` is a row of pills. A press toggles that one value and
6
+ * keeps every other stored value in place. Stored values that no group lists
7
+ * show in an "Other" row as selected pills, so they stay visible and a save
8
+ * keeps them; pressing one removes it.
9
+ */
10
+
11
+ import React from 'react';
12
+ import { View, Text, Pressable } from 'react-native';
13
+ import { FormField, Badge } from '@everystack/ui';
14
+ import { tagValues, toggleTag, unlistedTags } from '../utils/tags';
15
+ import type { FieldDefinition } from '../types';
16
+
17
+ export interface TagsFieldInputProps {
18
+ field: FieldDefinition;
19
+ value: unknown;
20
+ error?: string;
21
+ onChange: (value: unknown) => void;
22
+ }
23
+
24
+ interface PillOption {
25
+ value: string;
26
+ label: string;
27
+ }
28
+
29
+ export function TagsFieldInput({ field, value, error, onChange }: TagsFieldInputProps): React.ReactElement {
30
+ const label = field.label ?? field.name;
31
+ const selected = tagValues(value);
32
+ const toggle = (tag: string): void => onChange(toggleTag(selected, tag));
33
+ const other = unlistedTags(field, selected);
34
+
35
+ return (
36
+ <FormField label={label} error={error} required={field.validation?.required}>
37
+ {(field.tagGroups ?? []).map((group, i) => (
38
+ <PillRow
39
+ key={i}
40
+ testID={`tags-${field.name}-group-${i}`}
41
+ fieldName={field.name}
42
+ heading={group.label}
43
+ options={group.options.map(o => ({ value: o.value, label: o.label ?? o.value }))}
44
+ selected={selected}
45
+ onToggle={toggle}
46
+ />
47
+ ))}
48
+ {other.length > 0 && (
49
+ <PillRow
50
+ testID={`tags-${field.name}-other`}
51
+ fieldName={field.name}
52
+ heading="Other"
53
+ options={other.map(v => ({ value: v, label: v }))}
54
+ selected={selected}
55
+ onToggle={toggle}
56
+ />
57
+ )}
58
+ </FormField>
59
+ );
60
+ }
61
+
62
+ interface PillRowProps {
63
+ testID: string;
64
+ fieldName: string;
65
+ heading?: string;
66
+ options: PillOption[];
67
+ selected: readonly string[];
68
+ onToggle: (tag: string) => void;
69
+ }
70
+
71
+ function PillRow({ testID, fieldName, heading, options, selected, onToggle }: PillRowProps): React.ReactElement {
72
+ return (
73
+ <View testID={testID} className="mb-2">
74
+ {heading ? <Text className="text-xs font-medium text-gray-500 mb-1">{heading}</Text> : null}
75
+ <View className="flex-row flex-wrap gap-2">
76
+ {options.map(option => {
77
+ const isSelected = selected.includes(option.value);
78
+ return (
79
+ <Pressable
80
+ key={option.value}
81
+ testID={`tag-${fieldName}-${option.value}`}
82
+ accessibilityRole="button"
83
+ accessibilityLabel={option.label}
84
+ accessibilityState={{ selected: isSelected }}
85
+ hitSlop={6}
86
+ onPress={() => onToggle(option.value)}
87
+ >
88
+ <Badge label={option.label} variant={isSelected ? 'info' : 'default'} />
89
+ </Pressable>
90
+ );
91
+ })}
92
+ </View>
93
+ </View>
94
+ );
95
+ }
@@ -0,0 +1,63 @@
1
+ import { Node, type Extensions } from '@tiptap/react';
2
+ import type { DOMOutputSpec, TagParseRule } from '@tiptap/pm/model';
3
+ import StarterKit from '@tiptap/starter-kit';
4
+ import { Code } from '@tiptap/extension-code';
5
+ import { CodeBlock } from '@tiptap/extension-code-block';
6
+ import { Image } from '@tiptap/extension-image';
7
+ import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
8
+ import { Subscript } from '@tiptap/extension-subscript';
9
+ import { Superscript } from '@tiptap/extension-superscript';
10
+
11
+ export const Figure = Node.create({
12
+ name: 'figure',
13
+ group: 'block',
14
+ content: 'image+ figcaption?',
15
+ isolating: true,
16
+ draggable: true,
17
+ parseHTML(): TagParseRule[] {
18
+ return [{
19
+ tag: 'figure',
20
+ // An unparseable image must not make ProseMirror invent a required image.
21
+ getAttrs: (element: HTMLElement): null | false => element.querySelector('img[src]') ? null : false,
22
+ }];
23
+ },
24
+ renderHTML(): DOMOutputSpec {
25
+ return ['figure', 0];
26
+ },
27
+ });
28
+
29
+ export const Figcaption = Node.create({
30
+ name: 'figcaption',
31
+ // Images are inline; the figure's content rule cannot mix inline and block nodes.
32
+ inline: true,
33
+ content: 'inline*',
34
+ defining: true,
35
+ parseHTML(): TagParseRule[] {
36
+ return [{ tag: 'figcaption', context: 'figure/' }];
37
+ },
38
+ renderHTML(): DOMOutputSpec {
39
+ return ['figcaption', 0];
40
+ },
41
+ });
42
+
43
+ export function richHtmlExtensions(): Extensions {
44
+ return [
45
+ StarterKit.configure({
46
+ code: false,
47
+ codeBlock: false,
48
+ link: { openOnClick: false, HTMLAttributes: { target: null, rel: null } },
49
+ }),
50
+ // Preserve links without changing code's treatment of the other stock marks.
51
+ Code.extend({ excludes: 'bold italic underline strike subscript superscript code' }),
52
+ CodeBlock.extend({ marks: 'link' }),
53
+ Image.configure({ inline: true, allowBase64: true }),
54
+ Figure,
55
+ Figcaption,
56
+ Table,
57
+ TableRow,
58
+ TableCell,
59
+ TableHeader,
60
+ Subscript,
61
+ Superscript,
62
+ ];
63
+ }
@@ -2,23 +2,29 @@
2
2
  * TipTap editor adapter — a reference EditorRegistry plugin (HTML).
3
3
  *
4
4
  * Opt-in and web-only. The core admin never imports this; tiptap is an
5
- * optional peer dependency. To use:
5
+ * optional peer dependency. Tiptap 3 is required. To use:
6
6
  *
7
- * pnpm add @tiptap/react @tiptap/starter-kit @tiptap/pm
7
+ * pnpm add @tiptap/react@^3 @tiptap/starter-kit@^3 @tiptap/pm@^3
8
+ * @tiptap/extension-image@^3 @tiptap/extension-table@^3
9
+ * @tiptap/extension-subscript@^3 @tiptap/extension-superscript@^3
10
+ * @tiptap/extension-code@^3 @tiptap/extension-code-block@^3
8
11
  * import { TiptapEditor } from '@everystack/admin/editors/tiptap';
9
12
  * <AdminShell editors={{ tiptap: TiptapEditor }} ... />
10
13
  * // field: { name: 'body', type: 'string', editor: 'tiptap', format: 'html' }
11
14
  *
12
- * Reads and writes an HTML string via the EditorProps contract. The toolbar
15
+ * Reads and writes an HTML string via the EditorProps contract. The schema
16
+ * keeps inline images, figures with captions, tables, links, underline,
17
+ * subscript and superscript. The toolbar
13
18
  * uses React Native primitives; the editable surface is tiptap's EditorContent
14
19
  * (a DOM contenteditable, which is why this is web-only). See
15
20
  * docs/plans/admin-editors.md.
16
21
  */
17
22
 
18
- import React from 'react';
19
- import { View, Text, Pressable } from 'react-native';
23
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
24
+ import { View, Text, Pressable, TextInput } from 'react-native';
20
25
  import { useEditor, EditorContent } from '@tiptap/react';
21
- import StarterKit from '@tiptap/starter-kit';
26
+ import { richHtmlExtensions } from './rich-html-extensions';
27
+ import { unsupportedMarkup } from './unsupported-markup';
22
28
  import type { EditorProps } from '../types';
23
29
 
24
30
  interface ToolbarButtonProps {
@@ -40,29 +46,124 @@ function ToolbarButton({ label, active, onPress }: ToolbarButtonProps): React.Re
40
46
  );
41
47
  }
42
48
 
43
- export function TiptapEditor({ value, onChange, error }: EditorProps): React.ReactElement {
49
+ // The registry calls adapters as plain functions, so hooks belong in the element.
50
+ export function TiptapEditor(props: EditorProps): React.ReactElement {
51
+ return <RichHtmlEditor {...props} />;
52
+ }
53
+
54
+ function RichHtmlEditor({ value, onChange, error }: EditorProps): React.ReactElement {
55
+ const incoming = typeof value === 'string' ? value : '';
56
+ const [html, setHtml] = useState(incoming);
57
+ const [mode, setMode] = useState<'visual' | 'html'>(() =>
58
+ unsupportedMarkup(incoming).length > 0 ? 'html' : 'visual');
59
+ const original = useRef(incoming);
60
+ const dirty = useRef(false);
61
+ const currentMode = useRef(mode);
62
+ const loadingContent = useRef(false);
63
+ const extensions = useMemo(richHtmlExtensions, []);
64
+ const unsupported = useMemo(() => unsupportedMarkup(html), [html]);
44
65
  const editor = useEditor({
45
- extensions: [StarterKit],
46
- content: typeof value === 'string' ? value : '',
66
+ extensions,
67
+ content: mode === 'visual' ? html : '',
47
68
  // Render on the client only — the admin shell SSRs, and a contenteditable
48
69
  // can't hydrate from server output.
49
70
  immediatelyRender: false,
50
- onUpdate: ({ editor }) => onChange(editor.getHTML()),
71
+ onUpdate: ({ editor, transaction, appendedTransactions }) => {
72
+ // Loading/normalizing is not a user edit. setContent below also suppresses
73
+ // update events; the initialization guard covers plugin updates on load.
74
+ if (loadingContent.current || !editor.isInitialized || currentMode.current !== 'visual'
75
+ || ![transaction, ...appendedTransactions].some(tr => tr.docChanged)) return;
76
+ dirty.current = true;
77
+ const next = editor.getHTML();
78
+ setHtml(next);
79
+ onChange(next);
80
+ },
51
81
  });
52
82
 
83
+ useEffect(() => {
84
+ if (dirty.current || incoming === original.current) return;
85
+ original.current = incoming;
86
+ const nextMode = unsupportedMarkup(incoming).length > 0 ? 'html' : 'visual';
87
+ currentMode.current = nextMode;
88
+ setHtml(incoming);
89
+ setMode(nextMode);
90
+ if (editor) {
91
+ loadingContent.current = true;
92
+ try {
93
+ editor.commands.setContent(nextMode === 'visual' ? incoming : '', { emitUpdate: false });
94
+ } finally {
95
+ loadingContent.current = false;
96
+ }
97
+ }
98
+ }, [incoming, editor]);
99
+
100
+ const showVisual = () => {
101
+ if (mode === 'visual' || unsupported.length > 0 || !editor) return;
102
+ loadingContent.current = true;
103
+ try {
104
+ editor.commands.setContent(html, { emitUpdate: false });
105
+ } finally {
106
+ loadingContent.current = false;
107
+ }
108
+ currentMode.current = 'visual';
109
+ setMode('visual');
110
+ };
111
+ const showHtml = () => {
112
+ if (mode === 'html') return;
113
+ // Keep stored bytes until an edit, even if Tiptap normalized its own copy.
114
+ setHtml(dirty.current && editor ? editor.getHTML() : original.current);
115
+ currentMode.current = 'html';
116
+ setMode('html');
117
+ };
118
+
53
119
  return (
54
120
  <View>
55
- <View className="flex-row flex-wrap gap-1 mb-2">
56
- <ToolbarButton label="B" active={editor?.isActive('bold')} onPress={() => editor?.chain().focus().toggleBold().run()} />
57
- <ToolbarButton label="I" active={editor?.isActive('italic')} onPress={() => editor?.chain().focus().toggleItalic().run()} />
58
- <ToolbarButton label="H2" active={editor?.isActive('heading', { level: 2 })} onPress={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
59
- <ToolbarButton label="List" active={editor?.isActive('bulletList')} onPress={() => editor?.chain().focus().toggleBulletList().run()} />
60
- <ToolbarButton label="1." active={editor?.isActive('orderedList')} onPress={() => editor?.chain().focus().toggleOrderedList().run()} />
61
- <ToolbarButton label="Quote" active={editor?.isActive('blockquote')} onPress={() => editor?.chain().focus().toggleBlockquote().run()} />
62
- </View>
63
- <View className="border border-gray-200 rounded-lg px-3 py-2">
64
- <EditorContent editor={editor} />
121
+ <View className="flex-row gap-1 mb-2">
122
+ <Pressable onPress={showVisual} disabled={unsupported.length > 0}
123
+ accessibilityRole="button" accessibilityState={{ disabled: unsupported.length > 0, selected: mode === 'visual' }}
124
+ className={unsupported.length > 0 ? 'px-2 py-1 rounded opacity-50' : 'px-2 py-1 rounded border border-gray-200'}>
125
+ <Text className="text-sm text-gray-700">Visual</Text>
126
+ </Pressable>
127
+ <Pressable onPress={showHtml} accessibilityRole="button" accessibilityState={{ selected: mode === 'html' }}
128
+ className="px-2 py-1 rounded border border-gray-200">
129
+ <Text className="text-sm text-gray-700">HTML</Text>
130
+ </Pressable>
65
131
  </View>
132
+ {unsupported.length > 0 ? (
133
+ <Text className="text-sm text-gray-700 mb-2">
134
+ {`This content has ${unsupported.map(({ name, count }) => `${count} ${name}`).join(' and ')} that the visual editor would delete. Edit the HTML, or remove them to use the visual editor.`}
135
+ </Text>
136
+ ) : null}
137
+ {mode === 'html' ? (
138
+ <TextInput
139
+ accessibilityLabel="HTML source"
140
+ multiline
141
+ value={html}
142
+ onChangeText={next => {
143
+ dirty.current = true;
144
+ setHtml(next);
145
+ onChange(next);
146
+ }}
147
+ autoCapitalize="none"
148
+ autoCorrect={false}
149
+ style={{ fontFamily: 'monospace', minHeight: 200, textAlignVertical: 'top' }}
150
+ className="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900"
151
+ />
152
+ ) : (
153
+ <>
154
+ <View className="flex-row flex-wrap gap-1 mb-2">
155
+ <ToolbarButton label="B" active={editor?.isActive('bold')} onPress={() => editor?.chain().focus().toggleBold().run()} />
156
+ <ToolbarButton label="I" active={editor?.isActive('italic')} onPress={() => editor?.chain().focus().toggleItalic().run()} />
157
+ <ToolbarButton label="H2" active={editor?.isActive('heading', { level: 2 })} onPress={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
158
+ <ToolbarButton label="List" active={editor?.isActive('bulletList')} onPress={() => editor?.chain().focus().toggleBulletList().run()} />
159
+ <ToolbarButton label="1." active={editor?.isActive('orderedList')} onPress={() => editor?.chain().focus().toggleOrderedList().run()} />
160
+ <ToolbarButton label="Quote" active={editor?.isActive('blockquote')} onPress={() => editor?.chain().focus().toggleBlockquote().run()} />
161
+ </View>
162
+ <View className="border border-gray-200 rounded-lg px-3 py-2">
163
+ <EditorContent editor={editor} />
164
+ </View>
165
+ </>
166
+ )}
66
167
  {error ? <Text className="text-xs text-red-600 mt-1">{error}</Text> : null}
67
168
  </View>
68
169
  );
@@ -0,0 +1,150 @@
1
+ const UNSUPPORTED_ELEMENTS = new Set([
2
+ 'iframe', 'video', 'audio', 'picture', 'source', 'svg', 'math', 'form', 'input',
3
+ 'button', 'select', 'textarea', 'noscript', 'embed', 'object',
4
+ ]);
5
+
6
+ // These bodies may be text, depending on the parser's tree and scripting mode.
7
+ const TEXT_ELEMENTS = new Set([
8
+ 'iframe', 'script', 'style', 'textarea', 'title', 'xmp', 'noembed', 'noframes', 'noscript',
9
+ ]);
10
+
11
+ interface Tag {
12
+ name: string;
13
+ closing: boolean;
14
+ hasSource: boolean;
15
+ end: number;
16
+ }
17
+
18
+ function isSpace(char: string): boolean {
19
+ return char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '\f';
20
+ }
21
+
22
+ function readTag(html: string, start: number, limit: number): Tag | null {
23
+ const opening = /<(\/?)([a-z][^\t\n\f\r />]*)/iy;
24
+ opening.lastIndex = start;
25
+ const match = opening.exec(html);
26
+ if (!match || opening.lastIndex > limit) return null;
27
+
28
+ let cursor = opening.lastIndex;
29
+ let sourceSeen = false;
30
+ let hasSource = false;
31
+ while (cursor < limit) {
32
+ if (html[cursor] === '>') {
33
+ const name = match[2].toLowerCase();
34
+ return { name: name === 'image' ? 'img' : name, closing: match[1] === '/', hasSource, end: cursor + 1 };
35
+ }
36
+ if (isSpace(html[cursor]) || html[cursor] === '/') {
37
+ cursor++;
38
+ continue;
39
+ }
40
+
41
+ const attrStart = cursor;
42
+ // Always consume a character, including malformed attribute names beginning with '='.
43
+ cursor++;
44
+ while (cursor < limit && !isSpace(html[cursor]) && !'/=>'.includes(html[cursor])) cursor++;
45
+ const attribute = html.slice(attrStart, cursor).toLowerCase();
46
+ while (cursor < limit && isSpace(html[cursor])) cursor++;
47
+ let value = '';
48
+ if (html[cursor] === '=') {
49
+ cursor++;
50
+ while (cursor < limit && isSpace(html[cursor])) cursor++;
51
+ const quote = html[cursor];
52
+ if (quote === '"' || quote === "'") {
53
+ const valueStart = ++cursor;
54
+ cursor = html.indexOf(quote, cursor);
55
+ if (cursor === -1 || cursor >= limit) return null;
56
+ value = html.slice(valueStart, cursor++);
57
+ } else {
58
+ const valueStart = cursor;
59
+ while (cursor < limit && !isSpace(html[cursor]) && html[cursor] !== '>') cursor++;
60
+ value = html.slice(valueStart, cursor);
61
+ }
62
+ }
63
+ // Duplicate HTML attributes keep their first value.
64
+ if (attribute === 'src' && !sourceSeen) {
65
+ sourceSeen = true;
66
+ hasSource = value.length > 0;
67
+ }
68
+ }
69
+ return null; // An unfinished tag at EOF creates no element.
70
+ }
71
+
72
+ /** Content-bearing elements outside the stock editor schema, in first-occurrence order.
73
+ * This scans HTML tokens without a DOM; it is not an HTML sanitizer.
74
+ */
75
+ export function unsupportedMarkup(html: string): Array<{ kind: 'element'; name: string; count: number }> {
76
+ if (typeof html !== 'string' || !html) return [];
77
+ const found = new Map<string, { kind: 'element'; name: string; count: number }>();
78
+ const ranges = [{ start: 0, end: html.length }];
79
+ // Foreign content, select/frameset parsing and noscript can change text-element rules.
80
+ // Once these occur, scan text bodies conservatively rather than guessing the tree.
81
+ let uncertainText = false;
82
+ while (ranges.length) {
83
+ const range = ranges.pop()!;
84
+ let cursor = range.start;
85
+ let limit = range.end;
86
+ while ((cursor = html.indexOf('<', cursor)) !== -1 && cursor < limit) {
87
+ if (html.startsWith('<!--', cursor)) {
88
+ cursor += 4;
89
+ if (html[cursor] === '>') cursor++;
90
+ else if (html.startsWith('->', cursor)) cursor += 2;
91
+ else {
92
+ const ending = /--!?>/g;
93
+ ending.lastIndex = cursor;
94
+ if (!ending.exec(html) || ending.lastIndex > limit) break;
95
+ cursor = ending.lastIndex;
96
+ }
97
+ continue;
98
+ }
99
+ // Declarations (including CDATA outside foreign content) and processing instructions
100
+ // consume through the first '>'. Doing so in foreign content can only over-report.
101
+ if (html.startsWith('<!', cursor) || html.startsWith('<?', cursor)) {
102
+ const end = html.indexOf('>', cursor + 2);
103
+ if (end === -1 || end >= limit) break;
104
+ cursor = end + 1;
105
+ continue;
106
+ }
107
+
108
+ const tag = readTag(html, cursor, limit);
109
+ if (!tag) {
110
+ // A real tag prefix consumes to EOF if its attributes or closing bracket never finish.
111
+ if (/^<\/?[a-z]/i.test(html.slice(cursor, cursor + 3))) break;
112
+ cursor++;
113
+ continue;
114
+ }
115
+ cursor = tag.end;
116
+ if (tag.closing) continue;
117
+
118
+ const name = UNSUPPORTED_ELEMENTS.has(tag.name) ? tag.name
119
+ : tag.name === 'img' && !tag.hasSource ? 'img[no-src]' : null;
120
+ if (name) {
121
+ const entry = found.get(name);
122
+ if (entry) entry.count++;
123
+ else found.set(name, { kind: 'element', name, count: 1 });
124
+ }
125
+
126
+ if (['svg', 'math', 'select', 'frameset', 'noscript'].includes(tag.name)) uncertainText = true;
127
+ if (tag.name === 'plaintext' && !uncertainText) break;
128
+ if (TEXT_ELEMENTS.has(tag.name)) {
129
+ const closing = new RegExp(`</${tag.name}(?=[\\t\\n\\f\\r />])`, 'ig');
130
+ closing.lastIndex = cursor;
131
+ const end = closing.exec(html);
132
+ // Escaped scripts can contain apparent closing tags that do not end the script.
133
+ // Scan possible suffixes conservatively instead of trusting the first one.
134
+ if (tag.name === 'script') {
135
+ const escaped = html.indexOf('<!--', cursor);
136
+ if (escaped !== -1 && escaped < (end?.index ?? limit)) uncertainText = true;
137
+ }
138
+ if (!end || end.index >= limit) {
139
+ if (!uncertainText) break;
140
+ } else if (uncertainText) {
141
+ // A quote or comment in a possible text body must not swallow its closing tag
142
+ // and the real elements after it. Scan the body and the suffix separately.
143
+ ranges.push({ start: end.index, end: limit });
144
+ limit = end.index;
145
+ } else cursor = end.index;
146
+ }
147
+ }
148
+ }
149
+ return Array.from(found.values());
150
+ }