slidemind 0.1.8 → 0.1.9
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/CLAUDE.md +7 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +377 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +66 -15
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +72 -10
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Pure OOXML helpers over a .pptx entry map (zip path → Uint8Array), shared by
|
|
2
|
+
// read-local.mjs and slide-swap.mjs. No fflate import here — callers hand in an
|
|
3
|
+
// already-unzipped `files` map (see zip.mjs). These are deliberately
|
|
4
|
+
// regex-over-XML, not a DOM: the shapes we touch (sldIdLst, rels, placeholders,
|
|
5
|
+
// text runs) are simple, and @xmldom/xmldom is forbidden by the packaging guard.
|
|
6
|
+
|
|
7
|
+
import { readText } from "./zip.mjs";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Presentation order — the authoritative slide sequence
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// The slide play order as PowerPoint stores it: the `<p:sldId>` entries in
|
|
14
|
+
// `<p:sldIdLst>` (document order), each `r:id` mapped through
|
|
15
|
+
// presentation.xml.rels to its slide part. This is NOT filename order — after a
|
|
16
|
+
// reorder in PowerPoint, slide3.xml can be the first slide. Returns normalized
|
|
17
|
+
// `ppt/slides/slideN.xml` paths, in play order; non-slide targets are dropped.
|
|
18
|
+
export function slideOrderPaths(files) {
|
|
19
|
+
const presXml = readText(files, "ppt/presentation.xml");
|
|
20
|
+
if (!presXml) return [];
|
|
21
|
+
|
|
22
|
+
// Isolate the sldIdLst so a stray r:id elsewhere in the part can't leak in.
|
|
23
|
+
const listMatch = /<p:sldIdLst[\s\S]*?<\/p:sldIdLst>/.exec(presXml);
|
|
24
|
+
if (!listMatch) return [];
|
|
25
|
+
|
|
26
|
+
// r:id in document order (the on-screen slide sequence).
|
|
27
|
+
const rIds = [];
|
|
28
|
+
const idRe = /<p:sldId\b[^>]*\br:id\s*=\s*"([^"]+)"/g;
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = idRe.exec(listMatch[0])) !== null) rIds.push(m[1]);
|
|
31
|
+
|
|
32
|
+
const rels = relsById(files, "ppt/presentation.xml");
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const rId of rIds) {
|
|
35
|
+
const rel = rels[rId];
|
|
36
|
+
if (!rel) continue;
|
|
37
|
+
const resolved = resolvePartPath("ppt", rel.target);
|
|
38
|
+
if (/^ppt\/slides\/slide\d+\.xml$/.test(resolved)) out.push(resolved);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The lowest-numbered slide part, `ppt/slides/slideN.xml` with the minimum N, or
|
|
44
|
+
// null if the package has none. This mirrors the add-in render-time splicer
|
|
45
|
+
// byte-for-byte: it picks the lowest-numbered slide part and copies only that
|
|
46
|
+
// slide's dependency tree. Keep this the SINGLE definition of "lowest" so the
|
|
47
|
+
// fold in slide-swap.mjs lands the picked slide exactly where the splicer looks.
|
|
48
|
+
export function firstSlidePath(files) {
|
|
49
|
+
let best = null;
|
|
50
|
+
let bestN = Infinity;
|
|
51
|
+
for (const key of Object.keys(files)) {
|
|
52
|
+
const m = /^ppt\/slides\/slide(\d+)\.xml$/.exec(key);
|
|
53
|
+
if (!m) continue;
|
|
54
|
+
const n = parseInt(m[1], 10);
|
|
55
|
+
if (n < bestN) {
|
|
56
|
+
bestN = n;
|
|
57
|
+
best = key;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return best;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Relationships
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
// Parse the `.rels` for any part into `[{ id, type, target, targetMode }]`;
|
|
68
|
+
// empty array when the part has no rels. The rels part lives beside the part in
|
|
69
|
+
// a `_rels/` sibling dir, named `<base>.rels` (e.g. ppt/slides/slide1.xml →
|
|
70
|
+
// ppt/slides/_rels/slide1.xml.rels).
|
|
71
|
+
export function relsFor(files, partPath) {
|
|
72
|
+
const relsPath = relsPathFor(partPath);
|
|
73
|
+
const xml = readText(files, relsPath);
|
|
74
|
+
if (!xml) return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
const re = /<Relationship\b[^>]*\/?>/g;
|
|
77
|
+
let m;
|
|
78
|
+
while ((m = re.exec(xml)) !== null) {
|
|
79
|
+
const tag = m[0];
|
|
80
|
+
const id = attr(tag, "Id");
|
|
81
|
+
const type = attr(tag, "Type");
|
|
82
|
+
const target = attr(tag, "Target");
|
|
83
|
+
const targetMode = attr(tag, "TargetMode");
|
|
84
|
+
if (id === null && type === null && target === null) continue;
|
|
85
|
+
out.push({ id, type, target, targetMode });
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// relsFor keyed by relationship Id — the r:id → Target lookup slideOrderPaths
|
|
91
|
+
// needs. Internal helper; callers outside this module use relsFor.
|
|
92
|
+
function relsById(files, partPath) {
|
|
93
|
+
const map = {};
|
|
94
|
+
for (const rel of relsFor(files, partPath)) {
|
|
95
|
+
if (rel.id) map[rel.id] = rel;
|
|
96
|
+
}
|
|
97
|
+
return map;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The `_rels/<base>.rels` sibling path for a part. Exported shape is implicit
|
|
101
|
+
// via relsFor/slide-swap; kept internal since callers reason in part paths.
|
|
102
|
+
export function relsPathFor(partPath) {
|
|
103
|
+
const slash = partPath.lastIndexOf("/");
|
|
104
|
+
const dir = slash === -1 ? "" : partPath.slice(0, slash);
|
|
105
|
+
const base = slash === -1 ? partPath : partPath.slice(slash + 1);
|
|
106
|
+
return `${dir ? dir + "/" : ""}_rels/${base}.rels`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Path + entity primitives
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
// Resolve a rels Target to a normalized zip path (no leading slash). Targets are
|
|
114
|
+
// either relative to the part's own dir (`../slideLayouts/slideLayout1.xml`,
|
|
115
|
+
// `slides/slide3.xml`) or package-absolute (`/ppt/slides/slide3.xml`). `fromDir`
|
|
116
|
+
// is the directory the relative target is anchored at (e.g. "ppt" for
|
|
117
|
+
// presentation.xml.rels, "ppt/slides" for a slide's rels).
|
|
118
|
+
export function resolvePartPath(fromDir, target) {
|
|
119
|
+
if (target == null) return "";
|
|
120
|
+
let t = String(target).trim();
|
|
121
|
+
// Package-absolute: drop the leading slash, ignore fromDir.
|
|
122
|
+
if (t.startsWith("/")) return normalizeSegments(t.slice(1).split("/"));
|
|
123
|
+
const base = fromDir ? fromDir.split("/").filter(Boolean) : [];
|
|
124
|
+
return normalizeSegments(base.concat(t.split("/")));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Collapse "." / ".." segments in a split path into a clean slash-joined string.
|
|
128
|
+
function normalizeSegments(parts) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const seg of parts) {
|
|
131
|
+
if (seg === "" || seg === ".") continue;
|
|
132
|
+
if (seg === "..") {
|
|
133
|
+
out.pop();
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
out.push(seg);
|
|
137
|
+
}
|
|
138
|
+
return out.join("/");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Decode the five predefined XML entities plus numeric character references
|
|
142
|
+
// (`é`, `é`). Ampersand is resolved LAST so a decoded `&` from a
|
|
143
|
+
// numeric ref can't be re-read as the head of another entity.
|
|
144
|
+
export function decodeXmlEntities(s) {
|
|
145
|
+
if (s == null) return s;
|
|
146
|
+
return String(s)
|
|
147
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => codePoint(parseInt(h, 16)))
|
|
148
|
+
.replace(/&#(\d+);/g, (_, d) => codePoint(parseInt(d, 10)))
|
|
149
|
+
.replace(/</g, "<")
|
|
150
|
+
.replace(/>/g, ">")
|
|
151
|
+
.replace(/"/g, '"')
|
|
152
|
+
.replace(/'/g, "'")
|
|
153
|
+
.replace(/&/g, "&");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function codePoint(n) {
|
|
157
|
+
if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return "";
|
|
158
|
+
try {
|
|
159
|
+
return String.fromCodePoint(n);
|
|
160
|
+
} catch {
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Read one double-quoted attribute off a start tag, or null if absent.
|
|
166
|
+
function attr(tag, name) {
|
|
167
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(tag);
|
|
168
|
+
return m ? m[1] : null;
|
|
169
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Host-free `slidemind read <file.pptx>`: parse a .pptx on disk into the SAME
|
|
2
|
+
// `{ ok, mode, slideCount, slides[] }` shape the add-in returns for an open
|
|
3
|
+
// presentation (test/read-presentation.test.mjs), so the CLI printer is shared.
|
|
4
|
+
// Everything runs locally over the unzipped entry map — no bridge, no host.
|
|
5
|
+
//
|
|
6
|
+
// All fields are computed regardless of mode; the CLI printer selects what to
|
|
7
|
+
// show. `notes` is additive for `--json` consumers and is always populated here.
|
|
8
|
+
|
|
9
|
+
import { unzip, readText } from "./zip.mjs";
|
|
10
|
+
import {
|
|
11
|
+
slideOrderPaths,
|
|
12
|
+
relsFor,
|
|
13
|
+
resolvePartPath,
|
|
14
|
+
decodeXmlEntities,
|
|
15
|
+
} from "./ooxml.mjs";
|
|
16
|
+
|
|
17
|
+
// Parse a .pptx buffer. Throws a clear, CLI-printable message when the bytes
|
|
18
|
+
// aren't a .pptx (missing presentation.xml) or aren't a zip at all — the CLI
|
|
19
|
+
// prints error.message verbatim.
|
|
20
|
+
export function readLocalPptx(buffer, { mode = "raw" } = {}) {
|
|
21
|
+
let files;
|
|
22
|
+
try {
|
|
23
|
+
files = unzip(buffer);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// fflate's own reason ("invalid zip data") — surface it, framed as .pptx.
|
|
26
|
+
throw new Error(`not a valid .pptx: ${err.message}`);
|
|
27
|
+
}
|
|
28
|
+
if (!files["ppt/presentation.xml"]) {
|
|
29
|
+
throw new Error("not a valid .pptx (missing ppt/presentation.xml)");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const order = slideOrderPaths(files);
|
|
33
|
+
const slides = order.map((slidePath, i) => {
|
|
34
|
+
const xml = readText(files, slidePath) ?? "";
|
|
35
|
+
const { title, subtitle, text } = slideText(xml);
|
|
36
|
+
return {
|
|
37
|
+
index: i + 1,
|
|
38
|
+
layout: layoutName(files, slidePath),
|
|
39
|
+
title,
|
|
40
|
+
subtitle,
|
|
41
|
+
text,
|
|
42
|
+
notes: notesText(files, slidePath),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return { ok: true, mode, slideCount: slides.length, slides };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Text extraction
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
// Split a slide's XML into its text and pull title/subtitle/body. The host
|
|
54
|
+
// read's raw `text` is the FULL slide text, so a title/subtitle run counts in
|
|
55
|
+
// both the dedicated field and in `text`.
|
|
56
|
+
// - title: a shape whose <p:ph type="title"|"ctrTitle">.
|
|
57
|
+
// - subtitle: a shape whose <p:ph type="subTitle">.
|
|
58
|
+
// - text: every paragraph's runs, one line per <a:p>, tables tab/newline
|
|
59
|
+
// joined, empty paragraphs dropped, entities decoded.
|
|
60
|
+
export function slideText(slideXml) {
|
|
61
|
+
// Drop <mc:Fallback> branches up front: an AlternateContent block stores the
|
|
62
|
+
// same shape twice (Choice = canonical, Fallback = legacy rendering), so a
|
|
63
|
+
// text-bearing fallback would double-print every run.
|
|
64
|
+
slideXml = stripFallbacks(slideXml);
|
|
65
|
+
let title = null;
|
|
66
|
+
let subtitle = null;
|
|
67
|
+
|
|
68
|
+
// Title/subtitle come from placeholder shapes (<p:sp> carrying <p:ph>). They
|
|
69
|
+
// are single outline labels: an <a:br/> or paragraph break inside the
|
|
70
|
+
// placeholder is visual line-wrapping, not content structure — collapse to one
|
|
71
|
+
// line so the summary printer stays one line per slide. Body `text` keeps its
|
|
72
|
+
// breaks.
|
|
73
|
+
for (const sp of matchAll(slideXml, /<p:sp\b[\s\S]*?<\/p:sp>/g)) {
|
|
74
|
+
const ph = /<p:ph\b[^>]*\btype\s*=\s*"([^"]+)"/.exec(sp);
|
|
75
|
+
const phType = ph ? ph[1] : null;
|
|
76
|
+
if (phType === "title" || phType === "ctrTitle") {
|
|
77
|
+
if (title === null) title = outlineLabel(sp);
|
|
78
|
+
} else if (phType === "subTitle") {
|
|
79
|
+
if (subtitle === null) subtitle = outlineLabel(sp);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Full slide text = every text-bearing container in document order: shapes
|
|
84
|
+
// (<p:sp>) and tables (<a:tbl> inside <p:graphicFrame>). Walking the raw XML
|
|
85
|
+
// once keeps shapes and tables in their on-slide order.
|
|
86
|
+
const lines = [];
|
|
87
|
+
const containerRe = /<p:sp\b[\s\S]*?<\/p:sp>|<a:tbl\b[\s\S]*?<\/a:tbl>/g;
|
|
88
|
+
for (const chunk of matchAll(slideXml, containerRe)) {
|
|
89
|
+
if (chunk.startsWith("<a:tbl")) {
|
|
90
|
+
const t = tableText(chunk);
|
|
91
|
+
if (t) lines.push(t);
|
|
92
|
+
} else {
|
|
93
|
+
for (const line of shapeParagraphText(chunk)) lines.push(line);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { title, subtitle, text: lines.join("\n") };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A placeholder's text flattened to a single-line label (line breaks → spaces,
|
|
101
|
+
// runs of whitespace collapsed), or null when empty.
|
|
102
|
+
function outlineLabel(spXml) {
|
|
103
|
+
const label = shapeParagraphText(spXml)
|
|
104
|
+
.join(" ")
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
return label || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The non-empty paragraph lines of a text body: one line per <a:p>, its <a:t>
|
|
111
|
+
// runs concatenated with <a:br/> rendered as a newline, entities decoded.
|
|
112
|
+
// Whitespace-only paragraphs are dropped.
|
|
113
|
+
function shapeParagraphText(xml) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const p of matchAll(xml, /<a:p\b[\s\S]*?<\/a:p>/g)) {
|
|
116
|
+
const line = paragraphLine(p);
|
|
117
|
+
if (line.trim() !== "") out.push(line);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One paragraph → its text, with <a:br/> as newline. We walk runs and breaks in
|
|
123
|
+
// document order so a break lands between the right runs.
|
|
124
|
+
function paragraphLine(pXml) {
|
|
125
|
+
let s = "";
|
|
126
|
+
const re = /<a:t>([\s\S]*?)<\/a:t>|<a:br\b[^>]*\/?>/g;
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = re.exec(pXml)) !== null) {
|
|
129
|
+
if (m[1] !== undefined) s += decodeXmlEntities(m[1]);
|
|
130
|
+
else s += "\n";
|
|
131
|
+
}
|
|
132
|
+
return s;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A table (<a:tbl>): cells tab-joined within a row, rows newline-joined. Each
|
|
136
|
+
// cell's own text is its paragraph lines joined by newline (rare but possible).
|
|
137
|
+
function tableText(tblXml) {
|
|
138
|
+
const rows = [];
|
|
139
|
+
for (const tr of matchAll(tblXml, /<a:tr\b[\s\S]*?<\/a:tr>/g)) {
|
|
140
|
+
const cells = [];
|
|
141
|
+
for (const tc of matchAll(tr, /<a:tc\b[\s\S]*?<\/a:tc>/g)) {
|
|
142
|
+
cells.push(shapeParagraphText(tc).join("\n"));
|
|
143
|
+
}
|
|
144
|
+
rows.push(cells.join("\t"));
|
|
145
|
+
}
|
|
146
|
+
return rows.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Layout + notes (resolved through the slide's rels)
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
// The slide's layout name: follow the slide → slideLayout relationship, then
|
|
154
|
+
// read the layout's <p:cSld name="…">. Null when there's no layout rel or the
|
|
155
|
+
// layout has no name.
|
|
156
|
+
export function layoutName(files, slidePath) {
|
|
157
|
+
const layoutPath = relTarget(files, slidePath, "/slideLayout");
|
|
158
|
+
if (!layoutPath) return null;
|
|
159
|
+
const xml = readText(files, layoutPath);
|
|
160
|
+
if (!xml) return null;
|
|
161
|
+
const m = /<p:cSld\b[^>]*\bname\s*=\s*"([^"]*)"/.exec(xml);
|
|
162
|
+
return m ? decodeXmlEntities(m[1]) : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The slide's speaker notes text, or null when no notesSlide is attached.
|
|
166
|
+
// Extracted with the same paragraph/table rules as the slide body.
|
|
167
|
+
export function notesText(files, slidePath) {
|
|
168
|
+
const notesPath = relTarget(files, slidePath, "/notesSlide");
|
|
169
|
+
if (!notesPath) return null;
|
|
170
|
+
let xml = readText(files, notesPath);
|
|
171
|
+
if (!xml) return null;
|
|
172
|
+
xml = stripFallbacks(xml);
|
|
173
|
+
const lines = [];
|
|
174
|
+
for (const sp of matchAll(xml, /<p:sp\b[\s\S]*?<\/p:sp>/g)) {
|
|
175
|
+
for (const line of shapeParagraphText(sp)) lines.push(line);
|
|
176
|
+
}
|
|
177
|
+
return lines.join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Resolve the single related part whose Type ends with `typeSuffix`
|
|
181
|
+
// (e.g. "/slideLayout", "/notesSlide") to a normalized zip path, or null.
|
|
182
|
+
function relTarget(files, slidePath, typeSuffix) {
|
|
183
|
+
const fromDir = slidePath.slice(0, slidePath.lastIndexOf("/"));
|
|
184
|
+
for (const rel of relsFor(files, slidePath)) {
|
|
185
|
+
if (rel.type && rel.type.endsWith(typeSuffix)) {
|
|
186
|
+
return resolvePartPath(fromDir, rel.target);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Remove every <mc:Fallback>…</mc:Fallback> block. Fallbacks re-encode their
|
|
193
|
+
// AlternateContent's Choice branch for older consumers, so their runs are
|
|
194
|
+
// duplicates by definition — extracting both would double the text.
|
|
195
|
+
function stripFallbacks(xml) {
|
|
196
|
+
if (!xml.includes("<mc:Fallback")) return xml;
|
|
197
|
+
return xml.replace(/<mc:Fallback>[\s\S]*?<\/mc:Fallback>/g, "");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// String.prototype.matchAll over a global regex, yielding the full match text.
|
|
201
|
+
function matchAll(str, re) {
|
|
202
|
+
const out = [];
|
|
203
|
+
let m;
|
|
204
|
+
while ((m = re.exec(str)) !== null) out.push(m[0]);
|
|
205
|
+
return out;
|
|
206
|
+
}
|