@reddoorla/maintenance 0.75.0 → 0.76.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/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
- package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
- package/dist/{blux-C24OVDDW.js → blux-RIJBRVCF.js} +1867 -277
- package/dist/blux-RIJBRVCF.js.map +1 -0
- package/dist/chunk-37S6L6G4.js +170 -0
- package/dist/chunk-37S6L6G4.js.map +1 -0
- package/dist/{chunk-PERP7LSS.js → chunk-3U3IGVAQ.js} +89 -13
- package/dist/chunk-3U3IGVAQ.js.map +1 -0
- package/dist/{chunk-IDBMYDVH.js → chunk-4VAUSRSS.js} +5 -3
- package/dist/{chunk-IDBMYDVH.js.map → chunk-4VAUSRSS.js.map} +1 -1
- package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
- package/dist/chunk-6DOFNDXU.js.map +1 -0
- package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
- package/dist/chunk-6SZBNALD.js.map +1 -0
- package/dist/{chunk-6OYICQSJ.js → chunk-CZAMYVOP.js} +9 -4
- package/dist/chunk-CZAMYVOP.js.map +1 -0
- package/dist/chunk-DRDAEELJ.js +197 -0
- package/dist/chunk-DRDAEELJ.js.map +1 -0
- package/dist/{chunk-KKL3RYFQ.js → chunk-DYKG53IR.js} +2 -2
- package/dist/{chunk-HBFMTM2E.js → chunk-JTRTG5CU.js} +3 -3
- package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
- package/dist/chunk-MNE374CZ.js.map +1 -0
- package/dist/chunk-MPBXOJ3N.js +51 -0
- package/dist/chunk-MPBXOJ3N.js.map +1 -0
- package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
- package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
- package/dist/chunk-UWJZXL2D.js +9 -0
- package/dist/chunk-UWJZXL2D.js.map +1 -0
- package/dist/cli/bin.js +36 -9
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +1 -1
- package/dist/{digest-LEMZS5ZK.js → digest-75FWSC74.js} +9 -6
- package/dist/{digest-LEMZS5ZK.js.map → digest-75FWSC74.js.map} +1 -1
- package/dist/favicon-SRHSJZ62.js +42 -0
- package/dist/favicon-SRHSJZ62.js.map +1 -0
- package/dist/freeze-Y5OJFOST.js +442 -0
- package/dist/freeze-Y5OJFOST.js.map +1 -0
- package/dist/header-image-H4N54DHN.js +95 -0
- package/dist/header-image-H4N54DHN.js.map +1 -0
- package/dist/index.d.ts +63 -1
- package/dist/index.js +64 -16
- package/dist/index.js.map +1 -1
- package/dist/{init-7PTP4O2K.js → init-2KOISBCZ.js} +3 -3
- package/dist/{launch-W33FWWOZ.js → launch-K4EHKTXC.js} +7 -5
- package/dist/{launch-W33FWWOZ.js.map → launch-K4EHKTXC.js.map} +1 -1
- package/dist/{orchestrate-NYRYEKQM.js → orchestrate-CPR6KVS2.js} +4 -3
- package/dist/{preflight-JCQFKB6M.js → preflight-QRRMPI7K.js} +5 -4
- package/dist/{preflight-JCQFKB6M.js.map → preflight-QRRMPI7K.js.map} +1 -1
- package/dist/{report-POHQEV6F.js → report-SLGBDJ6O.js} +8 -6
- package/dist/{report-POHQEV6F.js.map → report-SLGBDJ6O.js.map} +1 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/run-migration-WP57U5XO.js +9 -0
- package/dist/run-migration-WP57U5XO.js.map +1 -0
- package/dist/{selftest-OO2WDSDM.js → selftest-RVASRO3Z.js} +8 -6
- package/dist/{selftest-OO2WDSDM.js.map → selftest-RVASRO3Z.js.map} +1 -1
- package/dist/webflow-624FEBFD.js +548 -0
- package/dist/webflow-624FEBFD.js.map +1 -0
- package/package.json +3 -3
- package/dist/blux-C24OVDDW.js.map +0 -1
- package/dist/chunk-6OYICQSJ.js.map +0 -1
- package/dist/chunk-JCITWZQR.js.map +0 -1
- package/dist/chunk-NUIBQPB3.js.map +0 -1
- package/dist/chunk-PERP7LSS.js.map +0 -1
- package/dist/run-migration-LJNNZWIV.js.map +0 -1
- /package/dist/{chunk-KKL3RYFQ.js.map → chunk-DYKG53IR.js.map} +0 -0
- /package/dist/{chunk-HBFMTM2E.js.map → chunk-JTRTG5CU.js.map} +0 -0
- /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
- /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
- /package/dist/{init-7PTP4O2K.js.map → init-2KOISBCZ.js.map} +0 -0
- /package/dist/{orchestrate-NYRYEKQM.js.map → orchestrate-CPR6KVS2.js.map} +0 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runMigration
|
|
3
|
+
} from "./chunk-6DOFNDXU.js";
|
|
4
|
+
import {
|
|
5
|
+
assetRef
|
|
6
|
+
} from "./chunk-UWJZXL2D.js";
|
|
7
|
+
|
|
8
|
+
// src/cli/commands/webflow.ts
|
|
9
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
10
|
+
import { dirname, join } from "path";
|
|
11
|
+
|
|
12
|
+
// src/webflow/extract.ts
|
|
13
|
+
import { parse } from "node-html-parser";
|
|
14
|
+
|
|
15
|
+
// src/webflow/text.ts
|
|
16
|
+
var clean = (s) => s?.replace(/\s+/g, " ").trim() || void 0;
|
|
17
|
+
|
|
18
|
+
// src/webflow/extract.ts
|
|
19
|
+
var CONTENT_CDN = "/64b1c843b071dc32170ea053/";
|
|
20
|
+
var text = (el) => clean(el?.structuredText);
|
|
21
|
+
var contentImg = (scope, anchor) => {
|
|
22
|
+
if (!scope) {
|
|
23
|
+
console.warn(`webflow extract: anchor "${anchor}" matched nothing`);
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
const img = scope.querySelectorAll("img").find((i) => (i.getAttribute("src") ?? "").includes(CONTENT_CDN));
|
|
27
|
+
const url = img?.getAttribute("src");
|
|
28
|
+
const alt = img?.getAttribute("alt") || void 0;
|
|
29
|
+
return url ? { url, ...alt ? { alt } : {} } : void 0;
|
|
30
|
+
};
|
|
31
|
+
function extractTeamMember(html, slug) {
|
|
32
|
+
const root = parse(html);
|
|
33
|
+
const name = text(root.querySelector("h1"));
|
|
34
|
+
if (!name) throw new Error(`team ${slug}: no h1`);
|
|
35
|
+
const bioHtml = root.querySelector(".w-richtext")?.querySelectorAll("p").map((p) => `<p>${p.innerHTML}</p>`).join("") ?? "";
|
|
36
|
+
if (!bioHtml) throw new Error(`team ${slug}: no .w-richtext bio`);
|
|
37
|
+
const role = text(root.querySelector("h4"));
|
|
38
|
+
const photo = contentImg(root.querySelector(".hero-cols"), `team ${slug} .hero-cols`);
|
|
39
|
+
return {
|
|
40
|
+
slug,
|
|
41
|
+
name,
|
|
42
|
+
...role !== void 0 ? { role } : {},
|
|
43
|
+
bioHtml,
|
|
44
|
+
...photo !== void 0 ? { photo } : {}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function extractService(html, slug) {
|
|
48
|
+
const root = parse(html);
|
|
49
|
+
const title = text(root.querySelector(".service-page-title-subtitle-section h2"));
|
|
50
|
+
if (!title) throw new Error(`service ${slug}: no .service-page-title-subtitle-section h2`);
|
|
51
|
+
const labels = root.querySelectorAll(".service-page-label");
|
|
52
|
+
const category = text(labels[labels.length - 1]);
|
|
53
|
+
if (!category) throw new Error(`service ${slug}: no .service-page-label`);
|
|
54
|
+
const bodyHtml = root.querySelector(".w-richtext")?.innerHTML ?? "";
|
|
55
|
+
if (!bodyHtml.trim()) throw new Error(`service ${slug}: no .w-richtext body`);
|
|
56
|
+
const iframe = root.querySelectorAll("iframe").map((f) => f.getAttribute("src") ?? "").find((src) => src.includes("youtube.com/embed/"));
|
|
57
|
+
const subtitle = text(root.querySelector("h5.text-body-large"));
|
|
58
|
+
const hero = contentImg(root.querySelector(".hero"), `service ${slug} .hero`);
|
|
59
|
+
return {
|
|
60
|
+
slug,
|
|
61
|
+
title,
|
|
62
|
+
category,
|
|
63
|
+
...subtitle !== void 0 ? { subtitle } : {},
|
|
64
|
+
bodyHtml,
|
|
65
|
+
...hero !== void 0 ? { hero } : {},
|
|
66
|
+
...iframe !== void 0 ? { youtubeUrl: iframe } : {}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function extractQuestion(html, slug, order) {
|
|
70
|
+
const root = parse(html);
|
|
71
|
+
const title = text(root.querySelector("h2.heading-30"));
|
|
72
|
+
if (!title) throw new Error(`question ${slug}: no h2.heading-30`);
|
|
73
|
+
const bodyHtml = root.querySelector(".dynamic-content-body.w-richtext")?.innerHTML ?? "";
|
|
74
|
+
if (!bodyHtml.trim()) throw new Error(`question ${slug}: no .dynamic-content-body.w-richtext`);
|
|
75
|
+
const lead = text(root.querySelector("h5.text-body-large"));
|
|
76
|
+
const image = contentImg(root.querySelector(".hero"), `question ${slug} .hero`);
|
|
77
|
+
return {
|
|
78
|
+
slug,
|
|
79
|
+
title,
|
|
80
|
+
...lead !== void 0 ? { lead } : {},
|
|
81
|
+
bodyHtml,
|
|
82
|
+
...image !== void 0 ? { image } : {},
|
|
83
|
+
order
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/webflow/indexes.ts
|
|
88
|
+
import { parse as parse2 } from "node-html-parser";
|
|
89
|
+
var slugOf = (href, prefix) => href.slice(prefix.length);
|
|
90
|
+
var uniq = (xs) => [...new Set(xs)];
|
|
91
|
+
function extractTeamOrder(html) {
|
|
92
|
+
const slugs = uniq(
|
|
93
|
+
parse2(html).querySelectorAll('a[href^="/team-members/"]').map((a) => slugOf(a.getAttribute("href") ?? "", "/team-members/"))
|
|
94
|
+
);
|
|
95
|
+
if (slugs.length === 0)
|
|
96
|
+
throw new Error("team order: no /team-members/ links found \u2014 page shape may have changed");
|
|
97
|
+
return slugs;
|
|
98
|
+
}
|
|
99
|
+
function extractQuestionOrder(html) {
|
|
100
|
+
const slugs = uniq(
|
|
101
|
+
parse2(html).querySelectorAll('a[href^="/questions/"]').map((a) => slugOf(a.getAttribute("href") ?? "", "/questions/"))
|
|
102
|
+
);
|
|
103
|
+
if (slugs.length === 0)
|
|
104
|
+
throw new Error("question order: no /questions/ links found \u2014 page shape may have changed");
|
|
105
|
+
return slugs;
|
|
106
|
+
}
|
|
107
|
+
function extractServiceCategories(html) {
|
|
108
|
+
const nodes = parse2(html).querySelectorAll('h3.mb-4, p.para-20px, a[href^="/services/"]');
|
|
109
|
+
const categories = [];
|
|
110
|
+
let current;
|
|
111
|
+
let introTaken = false;
|
|
112
|
+
for (const node of nodes) {
|
|
113
|
+
if (node.tagName === "H3") {
|
|
114
|
+
const name = clean(node.structuredText);
|
|
115
|
+
if (!name) continue;
|
|
116
|
+
current = { name, slugs: [] };
|
|
117
|
+
introTaken = false;
|
|
118
|
+
categories.push(current);
|
|
119
|
+
} else if (node.tagName === "P") {
|
|
120
|
+
if (!current || introTaken) continue;
|
|
121
|
+
const intro = clean(node.structuredText);
|
|
122
|
+
if (intro) current.intro = intro;
|
|
123
|
+
introTaken = true;
|
|
124
|
+
} else if (node.tagName === "A") {
|
|
125
|
+
if (!current) continue;
|
|
126
|
+
const slug = slugOf(node.getAttribute("href") ?? "", "/services/");
|
|
127
|
+
if (slug && !current.slugs.includes(slug)) current.slugs.push(slug);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const populated = categories.filter((c) => c.slugs.length > 0);
|
|
131
|
+
if (populated.length === 0)
|
|
132
|
+
throw new Error(
|
|
133
|
+
"service categories: no h3.mb-4 group with /services/ links found \u2014 page shape may have changed"
|
|
134
|
+
);
|
|
135
|
+
return populated;
|
|
136
|
+
}
|
|
137
|
+
function extractReviews(html) {
|
|
138
|
+
const items = parse2(html).querySelectorAll(".big-review-item");
|
|
139
|
+
if (items.length === 0)
|
|
140
|
+
console.warn("webflow indexes: no .big-review-item matched \u2014 page shape may have changed");
|
|
141
|
+
return items.map((item) => {
|
|
142
|
+
const photo = item.querySelector("img.reviewer-photo")?.getAttribute("src");
|
|
143
|
+
const reviewerPlace = clean(item.querySelector(".reviewer-place")?.structuredText);
|
|
144
|
+
const reviewUrl = item.querySelector("a")?.getAttribute("href");
|
|
145
|
+
return {
|
|
146
|
+
quote: clean(item.querySelector("p.text-body")?.structuredText) ?? "",
|
|
147
|
+
reviewerName: clean(item.querySelector(".reviewer-name")?.structuredText) ?? "",
|
|
148
|
+
...reviewerPlace !== void 0 ? { reviewerPlace } : {},
|
|
149
|
+
...photo ? { reviewerPhoto: { url: photo } } : {},
|
|
150
|
+
...reviewUrl !== void 0 ? { reviewUrl } : {}
|
|
151
|
+
};
|
|
152
|
+
}).filter((r) => r.quote);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/webflow/crawl.ts
|
|
156
|
+
function liveFetcher(baseUrl) {
|
|
157
|
+
return async (path) => {
|
|
158
|
+
for (let i = 0; ; i++) {
|
|
159
|
+
const res = await fetch(baseUrl + path, {
|
|
160
|
+
headers: { "User-Agent": "Mozilla/5.0 (reddoor webflow-import)" }
|
|
161
|
+
});
|
|
162
|
+
if (res.ok) {
|
|
163
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
164
|
+
return res.text();
|
|
165
|
+
}
|
|
166
|
+
if (i >= 2) throw new Error(`${path}: ${res.status}`);
|
|
167
|
+
await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function crawlSite(baseUrl, fetchPage, now = () => (/* @__PURE__ */ new Date()).toISOString(), log = () => {
|
|
172
|
+
}) {
|
|
173
|
+
const [home, teamIndex, servicesIndex, atd] = await Promise.all([
|
|
174
|
+
fetchPage("/"),
|
|
175
|
+
fetchPage("/our-team"),
|
|
176
|
+
fetchPage("/services"),
|
|
177
|
+
fetchPage("/ask-the-doctor")
|
|
178
|
+
]);
|
|
179
|
+
const teamSlugs = extractTeamOrder(teamIndex);
|
|
180
|
+
const serviceCategories = extractServiceCategories(servicesIndex);
|
|
181
|
+
const serviceSlugs = serviceCategories.flatMap((c) => c.slugs);
|
|
182
|
+
const questionSlugs = extractQuestionOrder(atd);
|
|
183
|
+
const team = [];
|
|
184
|
+
for (const [i, slug] of teamSlugs.entries()) {
|
|
185
|
+
log(`team ${i + 1}/${teamSlugs.length}: ${slug}`);
|
|
186
|
+
team.push(extractTeamMember(await fetchPage(`/team-members/${slug}`), slug));
|
|
187
|
+
}
|
|
188
|
+
const services = [];
|
|
189
|
+
for (const [i, slug] of serviceSlugs.entries()) {
|
|
190
|
+
log(`service ${i + 1}/${serviceSlugs.length}: ${slug}`);
|
|
191
|
+
services.push(extractService(await fetchPage(`/services/${slug}`), slug));
|
|
192
|
+
}
|
|
193
|
+
const questions = [];
|
|
194
|
+
for (const [i, slug] of questionSlugs.entries()) {
|
|
195
|
+
log(`question ${i + 1}/${questionSlugs.length}: ${slug}`);
|
|
196
|
+
questions.push(extractQuestion(await fetchPage(`/questions/${slug}`), slug, i));
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
baseUrl,
|
|
200
|
+
capturedAt: now(),
|
|
201
|
+
team,
|
|
202
|
+
services,
|
|
203
|
+
serviceCategories,
|
|
204
|
+
questions,
|
|
205
|
+
reviews: extractReviews(home)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function assetFilename(url) {
|
|
209
|
+
const lastSegment = url.split("/").pop();
|
|
210
|
+
if (!lastSegment) throw new Error(`assetFilename: url has no path segment: ${url}`);
|
|
211
|
+
return lastSegment.split("?")[0] ?? lastSegment;
|
|
212
|
+
}
|
|
213
|
+
function collectAssets(ir) {
|
|
214
|
+
const urls = [
|
|
215
|
+
...ir.team.map((t) => t.photo?.url),
|
|
216
|
+
...ir.services.map((s) => s.hero?.url),
|
|
217
|
+
...ir.questions.map((q) => q.image?.url),
|
|
218
|
+
...ir.reviews.map((r) => r.reviewerPhoto?.url)
|
|
219
|
+
].filter((u) => !!u);
|
|
220
|
+
return [...new Set(urls)].map((url) => ({ url, filename: assetFilename(url) }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/webflow/to-plan.ts
|
|
224
|
+
function isAssetPlaceholder(v) {
|
|
225
|
+
return "__asset" in v && typeof v.__asset === "string";
|
|
226
|
+
}
|
|
227
|
+
function rewriteAssets(value, altByFilename) {
|
|
228
|
+
if (Array.isArray(value)) return value.map((v) => rewriteAssets(v, altByFilename));
|
|
229
|
+
if (value && typeof value === "object") {
|
|
230
|
+
if (isAssetPlaceholder(value)) {
|
|
231
|
+
const { __asset, alt } = value;
|
|
232
|
+
if (alt && !altByFilename.has(__asset)) altByFilename.set(__asset, alt);
|
|
233
|
+
return assetRef(__asset);
|
|
234
|
+
}
|
|
235
|
+
return Object.fromEntries(
|
|
236
|
+
Object.entries(value).map(([k, v]) => [k, rewriteAssets(v, altByFilename)])
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
function webflowToPlan(input) {
|
|
242
|
+
const altByFilename = /* @__PURE__ */ new Map();
|
|
243
|
+
const documents = input.docs.map((doc) => ({
|
|
244
|
+
type: doc.type,
|
|
245
|
+
uid: doc.uid,
|
|
246
|
+
data: rewriteAssets(doc.data, altByFilename)
|
|
247
|
+
}));
|
|
248
|
+
const assets = input.assets.map((a) => ({
|
|
249
|
+
id: a.filename,
|
|
250
|
+
url: a.url,
|
|
251
|
+
alt: altByFilename.get(a.filename) ?? ""
|
|
252
|
+
}));
|
|
253
|
+
return { customTypes: [], documents, assets, stylesManifest: [], diagnostics: [] };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/webflow/html-to-richtext.ts
|
|
257
|
+
import { parse as parse3 } from "node-html-parser";
|
|
258
|
+
function htmlToRichText(html) {
|
|
259
|
+
const root = parse3(html);
|
|
260
|
+
const blocks = [];
|
|
261
|
+
collectBlocks(root, void 0, blocks);
|
|
262
|
+
return blocks;
|
|
263
|
+
}
|
|
264
|
+
var HEADING_RE = /^h[1-6]$/;
|
|
265
|
+
var ZERO_WIDTH_CODES = [8203, 8204, 8205, 65279];
|
|
266
|
+
function isZeroWidth(ch) {
|
|
267
|
+
return ZERO_WIDTH_CODES.includes(ch.charCodeAt(0));
|
|
268
|
+
}
|
|
269
|
+
function normalizeBlock(text2, spans) {
|
|
270
|
+
const map = new Array(text2.length + 1);
|
|
271
|
+
let out = "";
|
|
272
|
+
for (let i = 0; i < text2.length; i++) {
|
|
273
|
+
map[i] = out.length;
|
|
274
|
+
const ch = text2.charAt(i);
|
|
275
|
+
if (/\s/.test(ch)) {
|
|
276
|
+
if (out === "" || out.endsWith(" ")) continue;
|
|
277
|
+
out += " ";
|
|
278
|
+
} else if (isZeroWidth(ch) && out === "") {
|
|
279
|
+
continue;
|
|
280
|
+
} else {
|
|
281
|
+
out += ch;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
map[text2.length] = out.length;
|
|
285
|
+
let end = out.length;
|
|
286
|
+
while (end > 0 && (/\s/.test(out.charAt(end - 1)) || isZeroWidth(out.charAt(end - 1)))) end--;
|
|
287
|
+
out = out.slice(0, end);
|
|
288
|
+
const remapped = [];
|
|
289
|
+
for (const span of spans) {
|
|
290
|
+
const start = Math.min(map[span.start] ?? out.length, out.length);
|
|
291
|
+
const spanEnd = Math.min(map[span.end] ?? out.length, out.length);
|
|
292
|
+
if (spanEnd > start) remapped.push({ ...span, start, end: spanEnd });
|
|
293
|
+
}
|
|
294
|
+
return { text: out, spans: remapped };
|
|
295
|
+
}
|
|
296
|
+
function isElement(node) {
|
|
297
|
+
return Boolean(node.rawTagName);
|
|
298
|
+
}
|
|
299
|
+
function collectBlocks(parent, listType, blocks) {
|
|
300
|
+
for (const node of parent.childNodes) {
|
|
301
|
+
if (!isElement(node)) continue;
|
|
302
|
+
const tag = node.rawTagName.toLowerCase();
|
|
303
|
+
if (tag === "p" || HEADING_RE.test(tag)) {
|
|
304
|
+
addBlock(node, "paragraph", blocks);
|
|
305
|
+
} else if (tag === "ul") {
|
|
306
|
+
collectBlocks(node, "list-item", blocks);
|
|
307
|
+
} else if (tag === "ol") {
|
|
308
|
+
collectBlocks(node, "o-list-item", blocks);
|
|
309
|
+
} else if (tag === "li" && listType) {
|
|
310
|
+
addBlock(node, listType, blocks);
|
|
311
|
+
} else {
|
|
312
|
+
collectBlocks(node, listType, blocks);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function addBlock(el, type, blocks) {
|
|
317
|
+
const raw = walkInline(el);
|
|
318
|
+
const { text: text2, spans } = normalizeBlock(raw.text, raw.spans);
|
|
319
|
+
if (text2 === "") return;
|
|
320
|
+
blocks.push({ type, text: text2, spans: mergeAdjacentSpans(spans) });
|
|
321
|
+
}
|
|
322
|
+
function walkInline(el) {
|
|
323
|
+
let text2 = "";
|
|
324
|
+
const spans = [];
|
|
325
|
+
for (const node of el.childNodes) {
|
|
326
|
+
if (!isElement(node)) {
|
|
327
|
+
text2 += decodeEntities(node.rawText).replace(/\s+/g, " ");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const tag = node.rawTagName.toLowerCase();
|
|
331
|
+
if (tag === "br") {
|
|
332
|
+
text2 += " ";
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const start = text2.length;
|
|
336
|
+
const inner = walkInline(node);
|
|
337
|
+
for (const span of inner.spans) {
|
|
338
|
+
spans.push(shiftSpan(span, start));
|
|
339
|
+
}
|
|
340
|
+
text2 += inner.text;
|
|
341
|
+
const end = text2.length;
|
|
342
|
+
if (tag === "strong" || tag === "b") {
|
|
343
|
+
spans.push({ start, end, type: "strong" });
|
|
344
|
+
} else if (tag === "em" || tag === "i") {
|
|
345
|
+
spans.push({ start, end, type: "em" });
|
|
346
|
+
} else if (tag === "a") {
|
|
347
|
+
spans.push({
|
|
348
|
+
start,
|
|
349
|
+
end,
|
|
350
|
+
type: "hyperlink",
|
|
351
|
+
data: { link_type: "Web", url: node.getAttribute("href") ?? "" }
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { text: text2, spans };
|
|
356
|
+
}
|
|
357
|
+
function shiftSpan(span, offset) {
|
|
358
|
+
return { ...span, start: span.start + offset, end: span.end + offset };
|
|
359
|
+
}
|
|
360
|
+
function mergeAdjacentSpans(spans) {
|
|
361
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
362
|
+
const merged = [];
|
|
363
|
+
for (const span of sorted) {
|
|
364
|
+
const prev = merged[merged.length - 1];
|
|
365
|
+
if (prev && prev.type === span.type && prev.end === span.start && sameSpanData(prev, span)) {
|
|
366
|
+
merged[merged.length - 1] = { ...prev, end: span.end };
|
|
367
|
+
} else {
|
|
368
|
+
merged.push(span);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return merged;
|
|
372
|
+
}
|
|
373
|
+
function sameSpanData(a, b) {
|
|
374
|
+
if (a.type === "hyperlink" && b.type === "hyperlink") return a.data.url === b.data.url;
|
|
375
|
+
return a.type === b.type;
|
|
376
|
+
}
|
|
377
|
+
var NAMED_ENTITIES = {
|
|
378
|
+
amp: "&",
|
|
379
|
+
lt: "<",
|
|
380
|
+
gt: ">",
|
|
381
|
+
quot: '"',
|
|
382
|
+
apos: "'",
|
|
383
|
+
nbsp: " ",
|
|
384
|
+
rsquo: "\u2019",
|
|
385
|
+
lsquo: "\u2018",
|
|
386
|
+
ldquo: "\u201C",
|
|
387
|
+
rdquo: "\u201D",
|
|
388
|
+
mdash: "\u2014",
|
|
389
|
+
ndash: "\u2013",
|
|
390
|
+
hellip: "\u2026"
|
|
391
|
+
};
|
|
392
|
+
function decodeEntities(raw) {
|
|
393
|
+
return raw.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, entity) => {
|
|
394
|
+
if (entity.startsWith("#")) {
|
|
395
|
+
const isHex = entity[1] === "x" || entity[1] === "X";
|
|
396
|
+
const code = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
397
|
+
return Number.isNaN(code) ? match : String.fromCodePoint(code);
|
|
398
|
+
}
|
|
399
|
+
return NAMED_ENTITIES[entity] ?? match;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/webflow/to-docs.ts
|
|
404
|
+
var heading1 = (text2) => [{ type: "heading1", text: text2, spans: [] }];
|
|
405
|
+
var paragraph = (text2) => [{ type: "paragraph", text: text2, spans: [] }];
|
|
406
|
+
var uid = (slug) => slug.toLowerCase();
|
|
407
|
+
var asset = (img, fallbackAlt) => {
|
|
408
|
+
if (!img) return void 0;
|
|
409
|
+
const alt = img.alt || fallbackAlt;
|
|
410
|
+
return { __asset: assetFilename(img.url), ...alt ? { alt } : {} };
|
|
411
|
+
};
|
|
412
|
+
var questionDate = (order) => {
|
|
413
|
+
const d = new Date(Date.UTC(2026, 5, 30));
|
|
414
|
+
d.setUTCDate(d.getUTCDate() - order);
|
|
415
|
+
return d.toISOString().slice(0, 10);
|
|
416
|
+
};
|
|
417
|
+
function irToDocs(ir) {
|
|
418
|
+
const people = ir.team.map((t) => {
|
|
419
|
+
const media = asset(t.photo, t.name);
|
|
420
|
+
return {
|
|
421
|
+
type: "person",
|
|
422
|
+
uid: uid(t.slug),
|
|
423
|
+
lang: "en-us",
|
|
424
|
+
data: {
|
|
425
|
+
title: heading1(t.name),
|
|
426
|
+
body: htmlToRichText(t.bioHtml),
|
|
427
|
+
// The shared field is always present on fleet docs (CollectionList
|
|
428
|
+
// renders tags verbatim); missing vs empty are equivalent downstream,
|
|
429
|
+
// but this is the ONE place we assign "" rather than omitting the
|
|
430
|
+
// key, since role itself may legitimately be absent.
|
|
431
|
+
tags: t.role ?? "",
|
|
432
|
+
...media ? { media } : {}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
const services = ir.services.map((s) => {
|
|
437
|
+
const media = asset(s.hero, s.title);
|
|
438
|
+
return {
|
|
439
|
+
type: "collection_item",
|
|
440
|
+
uid: uid(s.slug),
|
|
441
|
+
lang: "en-us",
|
|
442
|
+
data: {
|
|
443
|
+
title: heading1(s.title),
|
|
444
|
+
body: [...s.subtitle ? paragraph(s.subtitle) : [], ...htmlToRichText(s.bodyHtml)],
|
|
445
|
+
tags: s.category,
|
|
446
|
+
...media ? { media } : {},
|
|
447
|
+
...s.youtubeUrl ? { link: { link_type: "Web", url: s.youtubeUrl } } : {}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
const questions = ir.questions.map((q) => {
|
|
452
|
+
const media = asset(q.image, q.title);
|
|
453
|
+
return {
|
|
454
|
+
type: "news_article",
|
|
455
|
+
uid: uid(q.slug),
|
|
456
|
+
lang: "en-us",
|
|
457
|
+
data: {
|
|
458
|
+
title: heading1(q.title),
|
|
459
|
+
body: [...q.lead ? paragraph(q.lead) : [], ...htmlToRichText(q.bodyHtml)],
|
|
460
|
+
date: questionDate(q.order),
|
|
461
|
+
...media ? { media } : {}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
return [...people, ...services, ...questions];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/cli/commands/webflow.ts
|
|
469
|
+
async function runWebflowCommand(action, target, opts = {}) {
|
|
470
|
+
if (action === "capture") {
|
|
471
|
+
if (!target) return { output: "webflow capture needs a base url.", code: 1 };
|
|
472
|
+
const base = target.replace(/\/$/, "");
|
|
473
|
+
const out = opts.out ?? "webflow-out";
|
|
474
|
+
await mkdir(out, { recursive: true });
|
|
475
|
+
const ir = await crawlSite(
|
|
476
|
+
base,
|
|
477
|
+
liveFetcher(base),
|
|
478
|
+
void 0,
|
|
479
|
+
(line) => process.stderr.write(`${line}
|
|
480
|
+
`)
|
|
481
|
+
);
|
|
482
|
+
const irPath = join(out, "ir.json");
|
|
483
|
+
await writeFile(irPath, JSON.stringify(ir, null, 2));
|
|
484
|
+
return {
|
|
485
|
+
output: `captured ${ir.team.length} team / ${ir.services.length} services / ${ir.questions.length} questions / ${ir.reviews.length} reviews \u2192 ${irPath}`,
|
|
486
|
+
code: 0
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (action === "docs") {
|
|
490
|
+
if (!target) return { output: "webflow docs needs an ir.json path.", code: 1 };
|
|
491
|
+
let ir;
|
|
492
|
+
try {
|
|
493
|
+
ir = JSON.parse(await readFile(target, "utf-8"));
|
|
494
|
+
} catch (err) {
|
|
495
|
+
return {
|
|
496
|
+
output: `could not read ir.json at ${target}: ${err.message}`,
|
|
497
|
+
code: 1
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (!ir || !Array.isArray(ir.team) || !Array.isArray(ir.services) || !Array.isArray(ir.questions)) {
|
|
501
|
+
return { output: `not a webflow IR file: ${target}`, code: 1 };
|
|
502
|
+
}
|
|
503
|
+
const out = opts.out ?? dirname(target);
|
|
504
|
+
await mkdir(out, { recursive: true });
|
|
505
|
+
const docs = irToDocs(ir);
|
|
506
|
+
const assets = collectAssets(ir);
|
|
507
|
+
await writeFile(join(out, "docs.json"), JSON.stringify(docs, null, 2));
|
|
508
|
+
await writeFile(join(out, "assets.json"), JSON.stringify(assets, null, 2));
|
|
509
|
+
return { output: `${docs.length} docs, ${assets.length} assets \u2192 ${out}`, code: 0 };
|
|
510
|
+
}
|
|
511
|
+
if (action === "migrate") {
|
|
512
|
+
if (!target) return { output: "webflow migrate needs a capture dir.", code: 1 };
|
|
513
|
+
let docs;
|
|
514
|
+
let assets;
|
|
515
|
+
try {
|
|
516
|
+
docs = JSON.parse(await readFile(join(target, "docs.json"), "utf-8"));
|
|
517
|
+
} catch (err) {
|
|
518
|
+
return {
|
|
519
|
+
output: `could not read docs.json in ${target}: ${err.message}`,
|
|
520
|
+
code: 1
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
assets = JSON.parse(await readFile(join(target, "assets.json"), "utf-8"));
|
|
525
|
+
} catch (err) {
|
|
526
|
+
return {
|
|
527
|
+
output: `could not read assets.json in ${target}: ${err.message}`,
|
|
528
|
+
code: 1
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (!Array.isArray(docs) || !Array.isArray(assets)) {
|
|
532
|
+
return { output: `not a webflow capture dir (docs.json / assets.json): ${target}`, code: 1 };
|
|
533
|
+
}
|
|
534
|
+
const plan = webflowToPlan({ docs, assets });
|
|
535
|
+
const r = await runMigration(plan, (line) => process.stderr.write(`${line}
|
|
536
|
+
`));
|
|
537
|
+
const miss = r.missingAssets.length === 0 ? "0 Missing asset" : `${r.missingAssets.length} Missing asset: ${r.missingAssets.join(", ")}`;
|
|
538
|
+
return {
|
|
539
|
+
output: `${r.docsCreated} created, ${r.docsUpdated} updated, ${r.assetsUploaded} assets uploaded, ${r.assetsReused} reused, ${miss}`,
|
|
540
|
+
code: r.missingAssets.length === 0 ? 0 : 1
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return { output: `unknown webflow action '${action}'. Use: capture, docs, migrate.`, code: 1 };
|
|
544
|
+
}
|
|
545
|
+
export {
|
|
546
|
+
runWebflowCommand
|
|
547
|
+
};
|
|
548
|
+
//# sourceMappingURL=webflow-624FEBFD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli/commands/webflow.ts","../src/webflow/extract.ts","../src/webflow/text.ts","../src/webflow/indexes.ts","../src/webflow/crawl.ts","../src/webflow/to-plan.ts","../src/webflow/html-to-richtext.ts","../src/webflow/to-docs.ts"],"sourcesContent":["import { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { runMigration } from \"../../blux/emit/run-migration.js\";\nimport type { AssetRef } from \"../../webflow/crawl.js\";\nimport { collectAssets, crawlSite, liveFetcher } from \"../../webflow/crawl.js\";\nimport { webflowToPlan } from \"../../webflow/to-plan.js\";\nimport { irToDocs } from \"../../webflow/to-docs.js\";\nimport type { WfDoc } from \"../../webflow/to-docs.js\";\nimport type { WebflowIR } from \"../../webflow/types.js\";\n\nexport type WebflowCommandOptions = {\n /** Output directory for capture (default: webflow-out) / docs (default: dirname of the ir.json). */\n out?: string;\n};\n\n/** `webflow <action> [target]` — capture: live-crawl a Webflow site (baseUrl)\n * into <out>/ir.json, progress streamed to stderr (a ~80-fetch crawl with a\n * per-fetch courtesy delay takes about a minute; silence reads as a hang).\n * docs: convert a saved ir.json into docs.json (Prismic entity docs, Task 6's\n * irToDocs) + assets.json (the dedupe'd content-image manifest) beside it, or\n * in --out. migrate: read a capture dir's docs.json + assets.json, build a\n * MigrationPlan (to-plan), and push it via the shared runMigration runner —\n * LIVE Prismic I/O, needs PRISMIC_REPOSITORY_NAME + PRISMIC_WRITE_TOKEN (read\n * by runMigration itself); output ends with the Missing-asset count (the live\n * acceptance gate is `0 Missing asset`). */\nexport async function runWebflowCommand(\n action: string,\n target: string | undefined,\n opts: WebflowCommandOptions = {},\n): Promise<{ output: string; code: number }> {\n if (action === \"capture\") {\n if (!target) return { output: \"webflow capture needs a base url.\", code: 1 };\n const base = target.replace(/\\/$/, \"\");\n const out = opts.out ?? \"webflow-out\";\n await mkdir(out, { recursive: true });\n // Progress to stderr, mirroring runMigration(plan, log) in\n // src/blux/emit/run-migration.ts.\n const ir = await crawlSite(base, liveFetcher(base), undefined, (line) =>\n process.stderr.write(`${line}\\n`),\n );\n const irPath = join(out, \"ir.json\");\n await writeFile(irPath, JSON.stringify(ir, null, 2));\n return {\n output:\n `captured ${ir.team.length} team / ${ir.services.length} services / ` +\n `${ir.questions.length} questions / ${ir.reviews.length} reviews → ${irPath}`,\n code: 0,\n };\n }\n\n if (action === \"docs\") {\n if (!target) return { output: \"webflow docs needs an ir.json path.\", code: 1 };\n let ir: WebflowIR;\n try {\n ir = JSON.parse(await readFile(target, \"utf-8\")) as WebflowIR;\n } catch (err) {\n return {\n output: `could not read ir.json at ${target}: ${(err as Error).message}`,\n code: 1,\n };\n }\n // Shape guard: a wrong-shape JSON (e.g. a migration plan, or docs.json\n // itself) would otherwise die in irToDocs/collectAssets with an\n // uncontextualized TypeError.\n if (\n !ir ||\n !Array.isArray(ir.team) ||\n !Array.isArray(ir.services) ||\n !Array.isArray(ir.questions)\n ) {\n return { output: `not a webflow IR file: ${target}`, code: 1 };\n }\n const out = opts.out ?? dirname(target);\n await mkdir(out, { recursive: true });\n const docs = irToDocs(ir);\n const assets = collectAssets(ir);\n await writeFile(join(out, \"docs.json\"), JSON.stringify(docs, null, 2));\n await writeFile(join(out, \"assets.json\"), JSON.stringify(assets, null, 2));\n return { output: `${docs.length} docs, ${assets.length} assets → ${out}`, code: 0 };\n }\n\n if (action === \"migrate\") {\n if (!target) return { output: \"webflow migrate needs a capture dir.\", code: 1 };\n let docs: WfDoc[];\n let assets: AssetRef[];\n try {\n docs = JSON.parse(await readFile(join(target, \"docs.json\"), \"utf-8\")) as WfDoc[];\n } catch (err) {\n return {\n output: `could not read docs.json in ${target}: ${(err as Error).message}`,\n code: 1,\n };\n }\n try {\n assets = JSON.parse(await readFile(join(target, \"assets.json\"), \"utf-8\")) as AssetRef[];\n } catch (err) {\n return {\n output: `could not read assets.json in ${target}: ${(err as Error).message}`,\n code: 1,\n };\n }\n // Shape guard, mirroring the docs action: a wrong-shape JSON would otherwise\n // die deep inside webflowToPlan/runMigration with an uncontextualized error.\n if (!Array.isArray(docs) || !Array.isArray(assets)) {\n return { output: `not a webflow capture dir (docs.json / assets.json): ${target}`, code: 1 };\n }\n // runMigration reads PRISMIC_REPOSITORY_NAME + PRISMIC_WRITE_TOKEN itself and\n // throws a clear \"Set …\" error before any I/O when they're unset; runOrExit\n // surfaces that message. Progress to stderr, mirroring the capture action.\n const plan = webflowToPlan({ docs, assets });\n const r = await runMigration(plan, (line) => process.stderr.write(`${line}\\n`));\n // Keep the exact `0 Missing asset` string for the zero case — it's the\n // documented acceptance-gate grep; the nonzero case names the culprits.\n const miss =\n r.missingAssets.length === 0\n ? \"0 Missing asset\"\n : `${r.missingAssets.length} Missing asset: ${r.missingAssets.join(\", \")}`;\n return {\n output:\n `${r.docsCreated} created, ${r.docsUpdated} updated, ` +\n `${r.assetsUploaded} assets uploaded, ${r.assetsReused} reused, ${miss}`,\n code: r.missingAssets.length === 0 ? 0 : 1,\n };\n }\n\n return { output: `unknown webflow action '${action}'. Use: capture, docs, migrate.`, code: 1 };\n}\n","/** Detail-page extractors — parse a rendered Webflow detail page (team\n * member, service, ask-the-doctor question) into its Wf* IR record. */\nimport { parse, type HTMLElement } from \"node-html-parser\";\nimport { clean } from \"./text.js\";\nimport type { WfImage, WfQuestion, WfService, WfTeamMember } from \"./types.js\";\n\nconst CONTENT_CDN = \"/64b1c843b071dc32170ea053/\"; // CMS images; chrome lives on the other site id\n\n// A matched-but-EMPTY element (e.g. <h4></h4>) yields undefined, so optional\n// fields are omitted rather than stored as \"\" (see clean's JSDoc).\nconst text = (el: HTMLElement | null | undefined) => clean(el?.structuredText);\n\n/** First content-CDN <img> within `scope`. Callers pass the structural\n * container that provably holds the SEMANTIC image for their page type —\n * never the whole document, because decorative backgrounds (e.g. the team\n * page's beach photo) also live on the content CDN and can precede it.\n * A null scope means the structural anchor itself didn't match (a page-shape\n * drift worth surfacing), so it warns; a matched anchor with no content-CDN\n * img is the benign no-image case and stays silent. */\nconst contentImg = (scope: HTMLElement | null, anchor: string): WfImage | undefined => {\n if (!scope) {\n console.warn(`webflow extract: anchor \"${anchor}\" matched nothing`);\n return undefined;\n }\n const img = scope\n .querySelectorAll(\"img\")\n .find((i) => (i.getAttribute(\"src\") ?? \"\").includes(CONTENT_CDN));\n const url = img?.getAttribute(\"src\");\n const alt = img?.getAttribute(\"alt\") || undefined;\n // `exactOptionalPropertyTypes`: omit the key rather than set undefined.\n return url ? { url, ...(alt ? { alt } : {}) } : undefined;\n};\n\n/** Extract a team member from a rendered /team-members/<slug> page. */\nexport function extractTeamMember(html: string, slug: string): WfTeamMember {\n const root = parse(html);\n const name = text(root.querySelector(\"h1\"));\n if (!name) throw new Error(`team ${slug}: no h1`);\n // The bio is the page's sole .w-richtext, holding only <p> children\n // (fixture-verified). Re-wrap each <p>'s innerHTML rather than taking the\n // container's innerHTML verbatim so any wrapper attributes/divs Webflow\n // adds around paragraphs never leak into bioHtml.\n const bioHtml =\n root\n .querySelector(\".w-richtext\")\n ?.querySelectorAll(\"p\")\n .map((p) => `<p>${p.innerHTML}</p>`)\n .join(\"\") ?? \"\";\n if (!bioHtml) throw new Error(`team ${slug}: no .w-richtext bio`);\n const role = text(root.querySelector(\"h4\"));\n // Anchor: `.hero-cols` — the column pair holding the h1 and the headshot\n // (img.member-page-headshot in .col-1-of-3). The decorative beach background\n // (img.hero-dynamic-image) sits directly under section.hero OUTSIDE this\n // container, and precedes the headshot in document order — an unscoped\n // first-match would pick it.\n const photo = contentImg(root.querySelector(\".hero-cols\"), `team ${slug} .hero-cols`);\n return {\n slug,\n name,\n ...(role !== undefined ? { role } : {}),\n bioHtml,\n ...(photo !== undefined ? { photo } : {}),\n };\n}\n\n/** Extract a service from a rendered /services/<slug> page. The breadcrumb\n * (\".service-page-label\") repeats 3x — \"Services\", \"/\", category — so the\n * category is the LAST match, not the first. */\nexport function extractService(html: string, slug: string): WfService {\n const root = parse(html);\n const title = text(root.querySelector(\".service-page-title-subtitle-section h2\"));\n if (!title) throw new Error(`service ${slug}: no .service-page-title-subtitle-section h2`);\n const labels = root.querySelectorAll(\".service-page-label\");\n const category = text(labels[labels.length - 1]);\n if (!category) throw new Error(`service ${slug}: no .service-page-label`);\n const bodyHtml = root.querySelector(\".w-richtext\")?.innerHTML ?? \"\";\n if (!bodyHtml.trim()) throw new Error(`service ${slug}: no .w-richtext body`);\n const iframe = root\n .querySelectorAll(\"iframe\")\n .map((f) => f.getAttribute(\"src\") ?? \"\")\n .find((src) => src.includes(\"youtube.com/embed/\"));\n const subtitle = text(root.querySelector(\"h5.text-body-large\"));\n // Anchor: section `.hero` — its img.hero-dynamic-image is the CMS-bound\n // service image (fixture filenames match the slug: dental-crowns.jpg,\n // mi-paste.jpg) and is the only content-CDN img on the page.\n const hero = contentImg(root.querySelector(\".hero\"), `service ${slug} .hero`);\n return {\n slug,\n title,\n category,\n ...(subtitle !== undefined ? { subtitle } : {}),\n bodyHtml,\n ...(hero !== undefined ? { hero } : {}),\n ...(iframe !== undefined ? { youtubeUrl: iframe } : {}),\n };\n}\n\n/** Extract a Q&A entry from a rendered /ask-the-doctor/<slug> page. `order` is\n * the 0-based position on the /ask-the-doctor index and is caller-supplied. */\nexport function extractQuestion(html: string, slug: string, order: number): WfQuestion {\n const root = parse(html);\n const title = text(root.querySelector(\"h2.heading-30\"));\n if (!title) throw new Error(`question ${slug}: no h2.heading-30`);\n const bodyHtml = root.querySelector(\".dynamic-content-body.w-richtext\")?.innerHTML ?? \"\";\n if (!bodyHtml.trim()) throw new Error(`question ${slug}: no .dynamic-content-body.w-richtext`);\n const lead = text(root.querySelector(\"h5.text-body-large\"));\n // Anchor: section `.hero` — its img.hero-dynamic-image is the CMS-bound\n // question illustration (fixture: chipped-tooth.jpg for the \"tooth broke\n // off\" question) and is the only content-CDN img on the page.\n const image = contentImg(root.querySelector(\".hero\"), `question ${slug} .hero`);\n return {\n slug,\n title,\n ...(lead !== undefined ? { lead } : {}),\n bodyHtml,\n ...(image !== undefined ? { image } : {}),\n order,\n };\n}\n","/** Shared text normalization for the webflow extractors. */\n\n/** Collapse runs of whitespace and trim. A value that is blank AFTER trimming\n * collapses to `undefined` (the `|| undefined`), not `\"\"` — every optional IR\n * field is emitted via conditional spread under exactOptionalPropertyTypes,\n * so \"matched but empty\" and \"didn't match\" both mean \"omit the key\". A \"\"\n * role/subtitle/intro is never useful downstream and would otherwise\n * propagate into Prismic docs as an empty-but-present field. */\nexport const clean = (s: string | undefined): string | undefined =>\n s?.replace(/\\s+/g, \" \").trim() || undefined;\n","/** Index-page extractors — parse a rendered Webflow index/listing page\n * (team roster, services grid, ask-the-doctor list, homepage reviews) into\n * the ordering and grouping data that drives detail-page crawl order and\n * page assembly. */\nimport { parse } from \"node-html-parser\";\nimport { clean } from \"./text.js\";\nimport type { WfReview, WfServiceCategory } from \"./types.js\";\n\nconst slugOf = (href: string, prefix: string) => href.slice(prefix.length);\nconst uniq = <T>(xs: T[]) => [...new Set(xs)];\n\n/** Team roster order from `/our-team`, deduped (each member's card links to\n * their detail page multiple times — photo + name + button). An empty result\n * throws: zero roster links means the page shape changed, and the crawler\n * must not silently proceed with an empty site inventory. */\nexport function extractTeamOrder(html: string): string[] {\n const slugs = uniq(\n parse(html)\n .querySelectorAll('a[href^=\"/team-members/\"]')\n .map((a) => slugOf(a.getAttribute(\"href\") ?? \"\", \"/team-members/\")),\n );\n if (slugs.length === 0)\n throw new Error(\"team order: no /team-members/ links found — page shape may have changed\");\n return slugs;\n}\n\n/** Ask-the-doctor question order from `/ask-the-doctor`, deduped, in page\n * order — feeds each WfQuestion's 0-based `order` field. An empty result\n * throws (see extractTeamOrder). */\nexport function extractQuestionOrder(html: string): string[] {\n const slugs = uniq(\n parse(html)\n .querySelectorAll('a[href^=\"/questions/\"]')\n .map((a) => slugOf(a.getAttribute(\"href\") ?? \"\", \"/questions/\")),\n );\n if (slugs.length === 0)\n throw new Error(\"question order: no /questions/ links found — page shape may have changed\");\n return slugs;\n}\n\n/** Group service links under the nearest preceding `h3.mb-4` category\n * heading on `/services`. Flat document-order scan (not a recursive DOM\n * walk, which would double-visit nested nodes): pull `h3.mb-4`, `p.para-20px`,\n * and `a[href^=\"/services/\"]` in one query, then iterate once — an `h3.mb-4`\n * starts a new category, the first `p.para-20px` after it becomes the\n * intro, and each service link appends (deduped, page order) to the\n * current category. Categories with no slugs are dropped; when nothing\n * populated survives, throws (see extractTeamOrder). */\nexport function extractServiceCategories(html: string): WfServiceCategory[] {\n const nodes = parse(html).querySelectorAll('h3.mb-4, p.para-20px, a[href^=\"/services/\"]');\n const categories: WfServiceCategory[] = [];\n let current: WfServiceCategory | undefined;\n let introTaken = false;\n for (const node of nodes) {\n if (node.tagName === \"H3\") {\n const name = clean(node.structuredText);\n if (!name) continue;\n current = { name, slugs: [] };\n introTaken = false;\n categories.push(current);\n } else if (node.tagName === \"P\") {\n if (!current || introTaken) continue;\n const intro = clean(node.structuredText);\n if (intro) current.intro = intro;\n introTaken = true;\n } else if (node.tagName === \"A\") {\n if (!current) continue;\n const slug = slugOf(node.getAttribute(\"href\") ?? \"\", \"/services/\");\n if (slug && !current.slugs.includes(slug)) current.slugs.push(slug);\n }\n }\n const populated = categories.filter((c) => c.slugs.length > 0);\n if (populated.length === 0)\n throw new Error(\n \"service categories: no h3.mb-4 group with /services/ links found — page shape may have changed\",\n );\n return populated;\n}\n\n/** Homepage review carousel entries (`.big-review-item`). Each item holds\n * exactly one anchor (the `.social-logo-big-review` link out to the review\n * source — Yelp or Google Maps), so a plain first-anchor query is safe.\n * Zero matches warns (contentImg-style) but still returns [] — a missing\n * carousel degrades the homepage, it doesn't invalidate the crawl. */\nexport function extractReviews(html: string): WfReview[] {\n const items = parse(html).querySelectorAll(\".big-review-item\");\n if (items.length === 0)\n console.warn(\"webflow indexes: no .big-review-item matched — page shape may have changed\");\n return items\n .map((item) => {\n const photo = item.querySelector(\"img.reviewer-photo\")?.getAttribute(\"src\");\n const reviewerPlace = clean(item.querySelector(\".reviewer-place\")?.structuredText);\n const reviewUrl = item.querySelector(\"a\")?.getAttribute(\"href\");\n return {\n quote: clean(item.querySelector(\"p.text-body\")?.structuredText) ?? \"\",\n reviewerName: clean(item.querySelector(\".reviewer-name\")?.structuredText) ?? \"\",\n ...(reviewerPlace !== undefined ? { reviewerPlace } : {}),\n ...(photo ? { reviewerPhoto: { url: photo } } : {}),\n ...(reviewUrl !== undefined ? { reviewUrl } : {}),\n };\n })\n .filter((r) => r.quote);\n}\n","/** Site crawler — composes the index + detail extractors into a full\n * WebflowIR, and derives the deduped content-image asset manifest the\n * migration step downloads. The fetch strategy is injected so tests never\n * touch the network; `liveFetcher` is the real implementation used by the\n * CLI (Task 7). */\nimport { extractQuestion, extractService, extractTeamMember } from \"./extract.js\";\nimport {\n extractQuestionOrder,\n extractReviews,\n extractServiceCategories,\n extractTeamOrder,\n} from \"./indexes.js\";\nimport type { WebflowIR, WfService, WfTeamMember, WfQuestion } from \"./types.js\";\n\nexport type FetchPage = (path: string) => Promise<string>;\n\n/** Live fetcher: 3 tries, 500ms courtesy delay, real UA. */\nexport function liveFetcher(baseUrl: string): FetchPage {\n return async (path) => {\n for (let i = 0; ; i++) {\n const res = await fetch(baseUrl + path, {\n headers: { \"User-Agent\": \"Mozilla/5.0 (reddoor webflow-import)\" },\n });\n if (res.ok) {\n await new Promise((r) => setTimeout(r, 500));\n return res.text();\n }\n if (i >= 2) throw new Error(`${path}: ${res.status}`);\n await new Promise((r) => setTimeout(r, 1500 * (i + 1)));\n }\n };\n}\n\n/** Crawl a live (or fake-fetched) Webflow site into a WebflowIR: fetch the\n * four index pages in parallel to learn ordering/grouping, then fetch every\n * detail page (team member, service, question) in that order. `log` fires\n * once per detail-page fetch (phase + running count + slug, e.g.\n * \"team 3/11: stacey\") — mirrors runMigration(plan, log) in\n * src/blux/emit/run-migration.ts: a ~80-fetch crawl with a per-fetch\n * courtesy delay takes about a minute, and silence reads as a hang. Default\n * no-op keeps existing offline/test call-sites unchanged. */\nexport async function crawlSite(\n baseUrl: string,\n fetchPage: FetchPage,\n now: () => string = () => new Date().toISOString(),\n log: (line: string) => void = () => {},\n): Promise<WebflowIR> {\n const [home, teamIndex, servicesIndex, atd] = await Promise.all([\n fetchPage(\"/\"),\n fetchPage(\"/our-team\"),\n fetchPage(\"/services\"),\n fetchPage(\"/ask-the-doctor\"),\n ]);\n const teamSlugs = extractTeamOrder(teamIndex);\n const serviceCategories = extractServiceCategories(servicesIndex);\n const serviceSlugs = serviceCategories.flatMap((c) => c.slugs);\n const questionSlugs = extractQuestionOrder(atd);\n\n // Sequential, not Promise.all — liveFetcher's courtesy delay is per-call;\n // don't hammer the live site.\n const team: WfTeamMember[] = [];\n for (const [i, slug] of teamSlugs.entries()) {\n log(`team ${i + 1}/${teamSlugs.length}: ${slug}`);\n team.push(extractTeamMember(await fetchPage(`/team-members/${slug}`), slug));\n }\n const services: WfService[] = [];\n for (const [i, slug] of serviceSlugs.entries()) {\n log(`service ${i + 1}/${serviceSlugs.length}: ${slug}`);\n services.push(extractService(await fetchPage(`/services/${slug}`), slug));\n }\n const questions: WfQuestion[] = [];\n for (const [i, slug] of questionSlugs.entries()) {\n log(`question ${i + 1}/${questionSlugs.length}: ${slug}`);\n questions.push(extractQuestion(await fetchPage(`/questions/${slug}`), slug, i));\n }\n\n return {\n baseUrl,\n capturedAt: now(),\n team,\n services,\n serviceCategories,\n questions,\n reviews: extractReviews(home),\n };\n}\n\nexport type AssetRef = { filename: string; url: string };\n\n/** Filename = last url path segment (query stripped), byte-for-byte the same\n * derivation as src/blux/emit/run-migration.ts (`url.split(\"/\").pop()` then\n * `.split(\"?\")[0]`) — it's the Prismic asset-library dedupe key runMigration\n * matches against, so percent-encoded names (logo%3Dwhite.svg) must stay\n * ENCODED, never decoded here. Exported so to-docs's asset placeholder\n * filename and this manifest's filename can never drift apart. */\nexport function assetFilename(url: string): string {\n const lastSegment = url.split(\"/\").pop();\n if (!lastSegment) throw new Error(`assetFilename: url has no path segment: ${url}`);\n return lastSegment.split(\"?\")[0] ?? lastSegment;\n}\n\n/** Every content image in the IR, deduped by url. */\nexport function collectAssets(ir: WebflowIR): AssetRef[] {\n const urls = [\n ...ir.team.map((t) => t.photo?.url),\n ...ir.services.map((s) => s.hero?.url),\n ...ir.questions.map((q) => q.image?.url),\n ...ir.reviews.map((r) => r.reviewerPhoto?.url),\n ].filter((u): u is string => !!u);\n return [...new Set(urls)].map((url) => ({ url, filename: assetFilename(url) }));\n}\n","/** Webflow capture → blux MigrationPlan adapter, so the Webflow import rides\n * the SAME live push runner as the Blux pipeline (src/blux/emit/run-migration).\n * Two shape gaps to bridge:\n * - Media: to-docs emits `{ __asset: <filename>, alt? }` placeholders; the\n * runner's resolveDocData resolves `{ __asset_id: <key> }` against a map\n * keyed by PlanAsset.id. We rewrite each placeholder to `assetRef(filename)`\n * and set PlanAsset.id = the SAME filename, so the two ends meet on the\n * assetFilename key run-migration already dedupes the media library by.\n * - Custom types: the site (beachfront) repo's types are pushed by Slice\n * Machine from the site repo, not by this migrate — so customTypes is []\n * (runMigration never reads it anyway; pushCustomTypes is a separate call).\n * stylesManifest/diagnostics are design-pass/plan-time only and unread by the\n * runner, so they're empty here too. */\nimport { assetRef } from \"../blux/emit/plan.js\";\nimport type { MigrationPlan, PlanAsset, PlanDocument } from \"../blux/emit/plan.js\";\nimport type { AssetRef } from \"./crawl.js\";\nimport type { AssetPlaceholder, WfDoc } from \"./to-docs.js\";\n\nexport type WebflowPlanInput = { docs: WfDoc[]; assets: AssetRef[] };\n\n/** An `{ __asset, alt? }` media placeholder (to-docs' AssetPlaceholder).\n * `__asset` is emitted only by to-docs' asset(), never by content — scraped\n * text lives inside RtBlock `text` strings, so a key-shaped match here can't\n * be a false positive from site copy. */\nfunction isAssetPlaceholder(v: object): v is AssetPlaceholder {\n return \"__asset\" in v && typeof (v as { __asset: unknown }).__asset === \"string\";\n}\n\n/** Recursively rewrite Webflow `{ __asset, alt? }` placeholders into the blux\n * `{ __asset_id }` marker resolveDocData resolves, keyed by filename. Records\n * each placeholder's alt into `altByFilename` (first alt wins) so the asset\n * upload carries it. Per-doc alt is architecturally unavailable through this\n * runner: resolveDocData turns the marker into a bare `{ id }` (no alt slot),\n * so an asset referenced by several docs with differing alts carries exactly\n * ONE library-level alt — first-wins picks it deterministically (doc order). */\nfunction rewriteAssets(value: unknown, altByFilename: Map<string, string>): unknown {\n if (Array.isArray(value)) return value.map((v) => rewriteAssets(v, altByFilename));\n if (value && typeof value === \"object\") {\n if (isAssetPlaceholder(value)) {\n const { __asset, alt } = value;\n if (alt && !altByFilename.has(__asset)) altByFilename.set(__asset, alt);\n return assetRef(__asset);\n }\n return Object.fromEntries(\n Object.entries(value).map(([k, v]) => [k, rewriteAssets(v, altByFilename)] as const),\n );\n }\n return value;\n}\n\n/** Build the MigrationPlan runMigration consumes from a Webflow capture's docs\n * + asset manifest. Pure — no network, no env. */\nexport function webflowToPlan(input: WebflowPlanInput): MigrationPlan {\n const altByFilename = new Map<string, string>();\n const documents: PlanDocument[] = input.docs.map((doc) => ({\n type: doc.type,\n uid: doc.uid,\n data: rewriteAssets(doc.data, altByFilename) as Record<string, unknown>,\n }));\n const assets: PlanAsset[] = input.assets.map((a) => ({\n id: a.filename,\n url: a.url,\n alt: altByFilename.get(a.filename) ?? \"\",\n }));\n return { customTypes: [], documents, assets, stylesManifest: [], diagnostics: [] };\n}\n","import { parse, type HTMLElement, type Node } from \"node-html-parser\";\nimport type { RtBlock, RtSpan } from \"./types.js\";\n\n/** Convert simple Webflow rich-text HTML to Prismic StructuredText blocks.\n * Supported: p, ul/ol>li, h1-h6 (→ paragraph — the entity `body` field allows\n * no headings), strong/b, em/i, a, br (→ single space). Unknown wrappers\n * recurse; unknown leaves contribute their text. Zero-width/blank blocks are\n * dropped, and each block's text is trimmed (leading/trailing whitespace and\n * zero-width chars) with span offsets remapped to match.\n * Known limitations: bare top-level text outside any block element is\n * dropped; nested lists flatten (neither occurs in Webflow rich-text\n * output). */\nexport function htmlToRichText(html: string): RtBlock[] {\n const root = parse(html);\n const blocks: RtBlock[] = [];\n collectBlocks(root, undefined, blocks);\n return blocks;\n}\n\nconst HEADING_RE = /^h[1-6]$/;\n// Webflow spacer paragraphs are a lone zero-width space/non-joiner/joiner or\n// BOM character; strip those before deciding whether a block is \"empty\".\n// Built from char codes (not a literal/regex-escaped class) to keep these\n// invisible code points out of the source text.\nconst ZERO_WIDTH_CODES = [0x200b, 0x200c, 0x200d, 0xfeff];\n\nfunction isZeroWidth(ch: string): boolean {\n return ZERO_WIDTH_CODES.includes(ch.charCodeAt(0));\n}\n\n/** Clean a block's accumulated text — drop leading whitespace/zero-width\n * chars, collapse whitespace runs left at node boundaries, trim the tail —\n * remapping every span's offsets onto the cleaned text. Dropped characters\n * map to the position of the next kept character, so a span that begins on a\n * collapsed space starts on the following word; spans that collapse to\n * nothing are dropped. */\nfunction normalizeBlock(text: string, spans: RtSpan[]): { text: string; spans: RtSpan[] } {\n // map[i] = index in the cleaned string where old position i lands.\n const map: number[] = new Array<number>(text.length + 1);\n let out = \"\";\n for (let i = 0; i < text.length; i++) {\n map[i] = out.length;\n const ch = text.charAt(i);\n if (/\\s/.test(ch)) {\n if (out === \"\" || out.endsWith(\" \")) continue; // trim leading + collapse runs\n out += \" \";\n } else if (isZeroWidth(ch) && out === \"\") {\n continue; // leading zero-width spacer chars\n } else {\n out += ch;\n }\n }\n map[text.length] = out.length;\n let end = out.length;\n while (end > 0 && (/\\s/.test(out.charAt(end - 1)) || isZeroWidth(out.charAt(end - 1)))) end--;\n out = out.slice(0, end);\n const remapped: RtSpan[] = [];\n for (const span of spans) {\n const start = Math.min(map[span.start] ?? out.length, out.length);\n const spanEnd = Math.min(map[span.end] ?? out.length, out.length);\n if (spanEnd > start) remapped.push({ ...span, start, end: spanEnd });\n }\n return { text: out, spans: remapped };\n}\n\n/** Element check: TextNode's rawTagName is always \"\" (falsy) in\n * node-html-parser, so a truthy rawTagName means an HTMLElement. */\nfunction isElement(node: Node): node is HTMLElement {\n return Boolean(node.rawTagName);\n}\n\n/** Walk the DOM looking for block-level elements (p/h1-6, li under a ul/ol).\n * `listType` tracks which list-item variant an enclosing <ul>/<ol> implies;\n * unrecognized wrappers (div, section, …) are recursed into so nested\n * markup still yields blocks. */\nfunction collectBlocks(\n parent: HTMLElement,\n listType: \"list-item\" | \"o-list-item\" | undefined,\n blocks: RtBlock[],\n): void {\n for (const node of parent.childNodes) {\n if (!isElement(node)) continue;\n const tag = node.rawTagName.toLowerCase();\n if (tag === \"p\" || HEADING_RE.test(tag)) {\n addBlock(node, \"paragraph\", blocks);\n } else if (tag === \"ul\") {\n collectBlocks(node, \"list-item\", blocks);\n } else if (tag === \"ol\") {\n collectBlocks(node, \"o-list-item\", blocks);\n } else if (tag === \"li\" && listType) {\n addBlock(node, listType, blocks);\n } else {\n collectBlocks(node, listType, blocks);\n }\n }\n}\n\nfunction addBlock(el: HTMLElement, type: RtBlock[\"type\"], blocks: RtBlock[]): void {\n const raw = walkInline(el);\n const { text, spans } = normalizeBlock(raw.text, raw.spans);\n if (text === \"\") return;\n blocks.push({ type, text, spans: mergeAdjacentSpans(spans) });\n}\n\n/** Walk a block's inline content, accumulating the final text string and\n * spans whose offsets index into that final string. Recurses into nested\n * elements first so a span's start/end are computed from the length of the\n * text already accumulated (start = length before, end = length after). */\nfunction walkInline(el: HTMLElement): { text: string; spans: RtSpan[] } {\n let text = \"\";\n const spans: RtSpan[] = [];\n\n for (const node of el.childNodes) {\n if (!isElement(node)) {\n text += decodeEntities(node.rawText).replace(/\\s+/g, \" \");\n continue;\n }\n\n const tag = node.rawTagName.toLowerCase();\n if (tag === \"br\") {\n // line break → single space; normalizeBlock collapses any run this\n // creates against adjacent whitespace, so no double space survives.\n text += \" \";\n continue;\n }\n const start = text.length;\n const inner = walkInline(node);\n for (const span of inner.spans) {\n spans.push(shiftSpan(span, start));\n }\n text += inner.text;\n const end = text.length;\n\n if (tag === \"strong\" || tag === \"b\") {\n spans.push({ start, end, type: \"strong\" });\n } else if (tag === \"em\" || tag === \"i\") {\n spans.push({ start, end, type: \"em\" });\n } else if (tag === \"a\") {\n spans.push({\n start,\n end,\n type: \"hyperlink\",\n data: { link_type: \"Web\", url: node.getAttribute(\"href\") ?? \"\" },\n });\n }\n // unknown wrapper (span, br, …): its text/spans are already merged in above\n }\n\n return { text, spans };\n}\n\nfunction shiftSpan(span: RtSpan, offset: number): RtSpan {\n return { ...span, start: span.start + offset, end: span.end + offset };\n}\n\n/** Collapse contiguous same-type spans (e.g. two adjacent <strong> runs)\n * into one, so downstream consumers never see touching duplicate spans. */\nfunction mergeAdjacentSpans(spans: RtSpan[]): RtSpan[] {\n const sorted = [...spans].sort((a, b) => a.start - b.start || a.end - b.end);\n const merged: RtSpan[] = [];\n for (const span of sorted) {\n const prev = merged[merged.length - 1];\n if (prev && prev.type === span.type && prev.end === span.start && sameSpanData(prev, span)) {\n merged[merged.length - 1] = { ...prev, end: span.end };\n } else {\n merged.push(span);\n }\n }\n return merged;\n}\n\nfunction sameSpanData(a: RtSpan, b: RtSpan): boolean {\n if (a.type === \"hyperlink\" && b.type === \"hyperlink\") return a.data.url === b.data.url;\n return a.type === b.type;\n}\n\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: \"&\",\n lt: \"<\",\n gt: \">\",\n quot: '\"',\n apos: \"'\",\n nbsp: \" \",\n rsquo: \"’\",\n lsquo: \"‘\",\n ldquo: \"“\",\n rdquo: \"”\",\n mdash: \"—\",\n ndash: \"–\",\n hellip: \"…\",\n};\n\n/** Decode the entities Webflow's rich text emits: named (& < >\n * " ' plus the typographic set — curly quotes, dashes,\n * ellipsis) and numeric (' '). Unrecognized entities are left\n * as-is. */\nfunction decodeEntities(raw: string): string {\n return raw.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, entity: string) => {\n if (entity.startsWith(\"#\")) {\n const isHex = entity[1] === \"x\" || entity[1] === \"X\";\n const code = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);\n return Number.isNaN(code) ? match : String.fromCodePoint(code);\n }\n return NAMED_ENTITIES[entity] ?? match;\n });\n}\n","/** IR → Prismic entity docs. Maps the crawled WebflowIR onto the shared\n * fleet entity types (person / news_article / collection_item), which all\n * share the `uid, title, body, media, gallery, tags, date, link` shape:\n * team→person (tags=role), services→collection_item (tags=category,\n * link=YouTube url), questions→news_article (synthetic DESCENDING dates\n * preserve /ask-the-doctor order when date-sorted). */\nimport { assetFilename } from \"./crawl.js\";\nimport { htmlToRichText } from \"./html-to-richtext.js\";\nimport type { RtBlock, WebflowIR, WfImage } from \"./types.js\";\n\n/** Asset placeholder — resolved to a real Prismic asset id at push time\n * (Task 8 aligns this with resolveDocData's contract in src/blux/emit). */\nexport type AssetPlaceholder = { __asset: string; alt?: string };\n\nexport type WfDoc = {\n type: \"person\" | \"news_article\" | \"collection_item\" | \"page\"; // \"page\" used only by Task 9's assembly\n uid: string;\n lang: \"en-us\";\n data: Record<string, unknown>;\n};\n\n/** A single Prismic `heading1` rich-text block — doc titles and slice headings\n * (page-doc assembly, src/webflow/sites/*) share this so the render layer\n * reads one shape. */\nexport const heading1 = (text: string): RtBlock[] => [{ type: \"heading1\", text, spans: [] }];\n/** A single `paragraph` rich-text block — same sharing contract as heading1\n * (subtitle/lead leads in this file, body copy in the page-doc assemblies). */\nexport const paragraph = (text: string): RtBlock[] => [{ type: \"paragraph\", text, spans: [] }];\nconst uid = (slug: string) => slug.toLowerCase(); // Migration API rejects capitals (proven 2026-07-23)\n\n/** Build the asset placeholder for an optional WfImage. `fallbackAlt` is used\n * when the source image carried no alt text of its own (e.g. the doc's own\n * title/name), matching extract.ts's contentImg alt-omission convention. */\nconst asset = (img: WfImage | undefined, fallbackAlt?: string): AssetPlaceholder | undefined => {\n if (!img) return undefined;\n const alt = img.alt || fallbackAlt;\n return { __asset: assetFilename(img.url), ...(alt ? { alt } : {}) };\n};\n\n/** Question order → descending dates so date-sorted lists match the live\n * site's /ask-the-doctor order. Anchor date is arbitrary but fixed\n * (determinism > realism). */\nconst questionDate = (order: number): string => {\n const d = new Date(Date.UTC(2026, 5, 30));\n d.setUTCDate(d.getUTCDate() - order);\n return d.toISOString().slice(0, 10);\n};\n\nexport function irToDocs(ir: WebflowIR): WfDoc[] {\n const people: WfDoc[] = ir.team.map((t) => {\n const media = asset(t.photo, t.name);\n return {\n type: \"person\",\n uid: uid(t.slug),\n lang: \"en-us\",\n data: {\n title: heading1(t.name),\n body: htmlToRichText(t.bioHtml),\n // The shared field is always present on fleet docs (CollectionList\n // renders tags verbatim); missing vs empty are equivalent downstream,\n // but this is the ONE place we assign \"\" rather than omitting the\n // key, since role itself may legitimately be absent.\n tags: t.role ?? \"\",\n ...(media ? { media } : {}),\n },\n };\n });\n\n const services: WfDoc[] = ir.services.map((s) => {\n const media = asset(s.hero, s.title);\n return {\n type: \"collection_item\",\n uid: uid(s.slug),\n lang: \"en-us\",\n data: {\n title: heading1(s.title),\n body: [...(s.subtitle ? paragraph(s.subtitle) : []), ...htmlToRichText(s.bodyHtml)],\n tags: s.category,\n ...(media ? { media } : {}),\n ...(s.youtubeUrl ? { link: { link_type: \"Web\", url: s.youtubeUrl } } : {}),\n },\n };\n });\n\n const questions: WfDoc[] = ir.questions.map((q) => {\n const media = asset(q.image, q.title);\n return {\n type: \"news_article\",\n uid: uid(q.slug),\n lang: \"en-us\",\n data: {\n title: heading1(q.title),\n body: [...(q.lead ? paragraph(q.lead) : []), ...htmlToRichText(q.bodyHtml)],\n date: questionDate(q.order),\n ...(media ? { media } : {}),\n },\n };\n });\n\n return [...people, ...services, ...questions];\n}\n"],"mappings":"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","names":["parse","parse","parse","text","text"]}
|