@roottale/cms-renderer-next 0.47.0 → 0.51.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/blog-filter-BKCGM0qd.d.ts +29 -0
  3. package/dist/{chunk-6MFHA555.js → chunk-27RT6B3O.js} +1 -1
  4. package/dist/chunk-27RT6B3O.js.map +1 -0
  5. package/dist/chunk-2QU4JU46.js +3927 -0
  6. package/dist/chunk-2QU4JU46.js.map +1 -0
  7. package/dist/chunk-4RQ3SEDL.js +106 -0
  8. package/dist/chunk-4RQ3SEDL.js.map +1 -0
  9. package/dist/{chunk-OMXEU4LS.js → chunk-QYNEOFL7.js} +140 -136
  10. package/dist/chunk-QYNEOFL7.js.map +1 -0
  11. package/dist/{chunk-FUA5H6JE.js → chunk-U2IM6OCQ.js} +46 -2
  12. package/dist/chunk-U2IM6OCQ.js.map +1 -0
  13. package/dist/cms-public.css +137 -18
  14. package/dist/concerns-client.d.ts +1 -1
  15. package/dist/expertise-client.d.ts +1 -1
  16. package/dist/expertise-client.js +1 -1
  17. package/dist/hero-carousel-client.js +1 -1
  18. package/dist/routes.d.ts +377 -21
  19. package/dist/routes.js +273 -106
  20. package/dist/routes.js.map +1 -1
  21. package/dist/seo-asset-hosts.d.ts +36 -0
  22. package/dist/seo-asset-hosts.js +391 -0
  23. package/dist/seo-asset-hosts.js.map +1 -0
  24. package/dist/server.d.ts +34 -216
  25. package/dist/server.js +7 -7
  26. package/dist/{site-config-Dicv57oY.d.ts → site-config-DNpvlqA5.d.ts} +201 -201
  27. package/dist/spaces-client.d.ts +1 -1
  28. package/dist/team-expanding-stage-client.d.ts +1 -1
  29. package/dist/team-expanding-stage-client.js +1 -1
  30. package/dist/templates.d.ts +64 -25
  31. package/dist/templates.js +14 -7
  32. package/dist/templates.js.map +1 -1
  33. package/package.json +7 -2
  34. package/dist/chunk-6MFHA555.js.map +0 -1
  35. package/dist/chunk-FUA5H6JE.js.map +0 -1
  36. package/dist/chunk-FZK3EXYZ.js +0 -103
  37. package/dist/chunk-FZK3EXYZ.js.map +0 -1
  38. package/dist/chunk-OMXEU4LS.js.map +0 -1
  39. package/dist/chunk-ZHXYCABN.js +0 -3584
  40. package/dist/chunk-ZHXYCABN.js.map +0 -1
@@ -0,0 +1,391 @@
1
+ // src/seo-asset-hosts-css.ts
2
+ function stripCssComments(css) {
3
+ return css.replace(/\/\*[\s\S]*?\*\//g, " ");
4
+ }
5
+ var URL_RE = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]+))\s*\)/gi;
6
+ var IMPORT_RE = /@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]+))\s*\)|"([^"]*)"|'([^']*)')/gi;
7
+ var IMAGE_SET_RE = /(?:-webkit-)?image-set\(\s*((?:[^()]|\([^()]*\))*)\)/gi;
8
+ function extractImageSetEntries(inner) {
9
+ const out = [];
10
+ for (const part of inner.split(",")) {
11
+ const trimmed = part.trim();
12
+ if (!trimmed) continue;
13
+ const urlMatch = /^url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]+))\s*\)/i.exec(trimmed);
14
+ if (urlMatch) {
15
+ const url = urlMatch[1] ?? urlMatch[2] ?? urlMatch[3];
16
+ if (url) {
17
+ out.push(url);
18
+ continue;
19
+ }
20
+ }
21
+ const dq = /^"([^"]*)"/.exec(trimmed);
22
+ const sq = /^'([^']*)'/.exec(trimmed);
23
+ const str = dq?.[1] ?? sq?.[1];
24
+ if (str !== void 0) out.push(str);
25
+ }
26
+ return out.filter(Boolean);
27
+ }
28
+ function extractCssUrls(rawCss) {
29
+ const css = stripCssComments(rawCss);
30
+ const out = [];
31
+ const masked = css.replace(IMPORT_RE, (_full, urlDq, urlSq, urlUnq, strDq, strSq) => {
32
+ const url = (urlDq ?? urlSq ?? urlUnq ?? strDq ?? strSq ?? "").trim();
33
+ if (url) out.push({ url, source: "css-import" });
34
+ return "";
35
+ });
36
+ IMAGE_SET_RE.lastIndex = 0;
37
+ let m;
38
+ while (m = IMAGE_SET_RE.exec(masked)) {
39
+ for (const url of extractImageSetEntries(m[1] ?? "")) {
40
+ out.push({ url, source: "css-url" });
41
+ }
42
+ }
43
+ URL_RE.lastIndex = 0;
44
+ while (m = URL_RE.exec(masked)) {
45
+ const url = (m[1] ?? m[2] ?? m[3] ?? "").trim();
46
+ if (url) out.push({ url, source: "css-url" });
47
+ }
48
+ return out;
49
+ }
50
+ var STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
51
+ function collectFromInlineStyles(html, out) {
52
+ let m;
53
+ STYLE_BLOCK_RE.lastIndex = 0;
54
+ while (m = STYLE_BLOCK_RE.exec(html)) {
55
+ out.push(...extractCssUrls(m[1] ?? ""));
56
+ }
57
+ }
58
+
59
+ // src/seo-asset-hosts-html.ts
60
+ var NAMED_ENTITIES = {
61
+ amp: "&",
62
+ lt: "<",
63
+ gt: ">",
64
+ quot: '"',
65
+ apos: "'",
66
+ colon: ":",
67
+ sol: "/",
68
+ lpar: "(",
69
+ rpar: ")",
70
+ num: "#",
71
+ commat: "@",
72
+ quest: "?",
73
+ equals: "=",
74
+ semi: ";",
75
+ period: ".",
76
+ ast: "*",
77
+ plus: "+",
78
+ dollar: "$",
79
+ percnt: "%",
80
+ nbsp: " "
81
+ };
82
+ function decodeHtmlEntities(value) {
83
+ return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]*);/gi, (match, entity) => {
84
+ if (entity[0] === "#") {
85
+ const isHex = entity[1] === "x" || entity[1] === "X";
86
+ const codePoint = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
87
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 1114111) return match;
88
+ try {
89
+ return String.fromCodePoint(codePoint);
90
+ } catch {
91
+ return match;
92
+ }
93
+ }
94
+ const replacement = NAMED_ENTITIES[entity.toLowerCase()];
95
+ return replacement ?? match;
96
+ });
97
+ }
98
+ function parseSrcset(value) {
99
+ return value.split(",").map((part) => part.trim().split(/\s+/)[0]).filter((u) => Boolean(u));
100
+ }
101
+ function parseAttrs(section) {
102
+ const attrs = /* @__PURE__ */ new Map();
103
+ const len = section.length;
104
+ let i = 0;
105
+ while (i < len) {
106
+ while (i < len && /[\s/]/.test(section[i] ?? "")) i++;
107
+ if (i >= len) break;
108
+ const nameStart = i;
109
+ while (i < len && !/[\s=/>]/.test(section[i] ?? "")) i++;
110
+ const name = section.slice(nameStart, i).toLowerCase();
111
+ if (!name) {
112
+ i++;
113
+ continue;
114
+ }
115
+ while (i < len && /\s/.test(section[i] ?? "")) i++;
116
+ if (section[i] === "=") {
117
+ i++;
118
+ while (i < len && /\s/.test(section[i] ?? "")) i++;
119
+ let value;
120
+ const q = section[i];
121
+ if (q === '"' || q === "'") {
122
+ i++;
123
+ const valStart = i;
124
+ while (i < len && section[i] !== q) i++;
125
+ value = section.slice(valStart, i);
126
+ if (i < len) i++;
127
+ } else {
128
+ const valStart = i;
129
+ while (i < len && !/\s/.test(section[i] ?? "")) i++;
130
+ value = section.slice(valStart, i);
131
+ }
132
+ if (!attrs.has(name)) attrs.set(name, decodeHtmlEntities(value));
133
+ } else if (!attrs.has(name)) {
134
+ attrs.set(name, "");
135
+ }
136
+ }
137
+ return attrs;
138
+ }
139
+ var RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "textarea", "title"]);
140
+ function skipRawTextBody(html, name, fromIndex) {
141
+ const len = html.length;
142
+ const lower = html.toLowerCase();
143
+ const closeTag = `</${name}`;
144
+ let idx = fromIndex;
145
+ while (idx < len) {
146
+ const found = lower.indexOf(closeTag, idx);
147
+ if (found === -1) return len;
148
+ const after = lower[found + closeTag.length];
149
+ if (after === void 0 || /[\s>/]/.test(after)) {
150
+ const gt = html.indexOf(">", found);
151
+ return gt === -1 ? len : gt + 1;
152
+ }
153
+ idx = found + closeTag.length;
154
+ }
155
+ return len;
156
+ }
157
+ function scanTags(html) {
158
+ const tags = [];
159
+ const len = html.length;
160
+ let i = 0;
161
+ while (i < len) {
162
+ if (html.charCodeAt(i) !== 60) {
163
+ i++;
164
+ continue;
165
+ }
166
+ if (html.startsWith("<!--", i)) {
167
+ const end = html.indexOf("-->", i + 4);
168
+ i = end === -1 ? len : end + 3;
169
+ continue;
170
+ }
171
+ const next = html[i + 1];
172
+ if (next === void 0) {
173
+ i++;
174
+ continue;
175
+ }
176
+ if (next === "!" || next === "?" || next === "/") {
177
+ const end = html.indexOf(">", i + 1);
178
+ i = end === -1 ? len : end + 1;
179
+ continue;
180
+ }
181
+ if (!/[a-zA-Z]/.test(next)) {
182
+ i++;
183
+ continue;
184
+ }
185
+ let j = i + 1;
186
+ while (j < len && /[a-zA-Z0-9:-]/.test(html[j] ?? "")) j++;
187
+ const name = html.slice(i + 1, j).toLowerCase();
188
+ let quote = null;
189
+ let k = j;
190
+ let tagEnd = -1;
191
+ while (k < len) {
192
+ const ch = html[k];
193
+ if (quote) {
194
+ if (ch === quote) quote = null;
195
+ k++;
196
+ continue;
197
+ }
198
+ if (ch === '"' || ch === "'") {
199
+ quote = ch;
200
+ k++;
201
+ continue;
202
+ }
203
+ if (ch === ">") {
204
+ tagEnd = k;
205
+ break;
206
+ }
207
+ k++;
208
+ }
209
+ if (tagEnd === -1) {
210
+ i = i + 1;
211
+ continue;
212
+ }
213
+ tags.push({ name, attrs: parseAttrs(html.slice(j, tagEnd)) });
214
+ i = RAW_TEXT_ELEMENTS.has(name) ? skipRawTextBody(html, name, tagEnd + 1) : tagEnd + 1;
215
+ }
216
+ return tags;
217
+ }
218
+ var ASSET_REL_TOKENS = /* @__PURE__ */ new Set([
219
+ "stylesheet",
220
+ "preload",
221
+ "modulepreload",
222
+ "prefetch",
223
+ "preconnect",
224
+ "dns-prefetch",
225
+ "icon",
226
+ "apple-touch-icon",
227
+ "mask-icon",
228
+ "manifest"
229
+ ]);
230
+ function relMatchesAssetTokens(relAttr) {
231
+ if (!relAttr) return false;
232
+ const tokens = relAttr.toLowerCase().split(/\s+/).filter(Boolean);
233
+ return tokens.some((t) => ASSET_REL_TOKENS.has(t));
234
+ }
235
+ function pushDecoded(out, raw, source) {
236
+ if (!raw) return;
237
+ out.push({ url: raw, source });
238
+ }
239
+ function pushSrcsetDecoded(out, raw, source) {
240
+ if (!raw) return;
241
+ for (const u of parseSrcset(raw)) out.push({ url: u, source });
242
+ }
243
+ function collectFromHtmlTags(html, out) {
244
+ for (const { name, attrs } of scanTags(html)) {
245
+ const styleAttr = attrs.get("style");
246
+ if (styleAttr) out.push(...extractCssUrls(styleAttr));
247
+ switch (name) {
248
+ case "img":
249
+ pushDecoded(out, attrs.get("src"), "img");
250
+ pushSrcsetDecoded(out, attrs.get("srcset"), "srcset");
251
+ break;
252
+ case "source":
253
+ pushDecoded(out, attrs.get("src"), "media");
254
+ pushSrcsetDecoded(out, attrs.get("srcset"), "srcset");
255
+ break;
256
+ case "link": {
257
+ const rel = attrs.get("rel");
258
+ if (relMatchesAssetTokens(rel)) {
259
+ pushDecoded(out, attrs.get("href"), "href");
260
+ pushSrcsetDecoded(out, attrs.get("imagesrcset"), "srcset");
261
+ }
262
+ break;
263
+ }
264
+ case "script":
265
+ pushDecoded(out, attrs.get("src"), "script");
266
+ break;
267
+ case "video":
268
+ pushDecoded(out, attrs.get("poster"), "poster");
269
+ pushDecoded(out, attrs.get("src"), "media");
270
+ break;
271
+ case "audio":
272
+ case "track":
273
+ pushDecoded(out, attrs.get("src"), "media");
274
+ break;
275
+ case "base":
276
+ pushDecoded(out, attrs.get("href"), "base");
277
+ break;
278
+ case "iframe":
279
+ pushDecoded(out, attrs.get("src"), "iframe");
280
+ break;
281
+ case "embed":
282
+ pushDecoded(out, attrs.get("src"), "object");
283
+ break;
284
+ case "object":
285
+ pushDecoded(out, attrs.get("data"), "object");
286
+ break;
287
+ case "input": {
288
+ if ((attrs.get("type") ?? "").toLowerCase() === "image") {
289
+ pushDecoded(out, attrs.get("src"), "img");
290
+ }
291
+ break;
292
+ }
293
+ default:
294
+ break;
295
+ }
296
+ }
297
+ }
298
+
299
+ // src/seo-asset-hosts.ts
300
+ var STAGING_HOST_PATTERNS = [
301
+ "*.kinsta.cloud",
302
+ "*.vercel.app",
303
+ "*.netlify.app",
304
+ "*.pages.dev",
305
+ "staging.*",
306
+ "stage.*",
307
+ "preview.*",
308
+ "dev.*",
309
+ "test.*",
310
+ "*.ngrok.io",
311
+ "*.ngrok-free.app",
312
+ "*.ngrok.app",
313
+ "*.trycloudflare.com",
314
+ "*.loca.lt",
315
+ "*.serveo.net",
316
+ "localhost",
317
+ "127.0.0.1"
318
+ ];
319
+ function patternToRegExp(pattern) {
320
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
321
+ return new RegExp(`^${escaped}$`, "i");
322
+ }
323
+ var STAGING_HOST_REGEXPS = STAGING_HOST_PATTERNS.map(patternToRegExp);
324
+ function isStagingHost(host) {
325
+ return STAGING_HOST_REGEXPS.some((re) => re.test(host));
326
+ }
327
+ function isAbsoluteUrl(url) {
328
+ const trimmed = url.trim();
329
+ if (trimmed === "") return false;
330
+ if (trimmed.startsWith("//")) return true;
331
+ return /^https?:\/\//i.test(trimmed);
332
+ }
333
+ var UNPARSEABLE_HOST = "(unparseable)";
334
+ function extractHost(url) {
335
+ const trimmed = url.trim();
336
+ const normalized = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
337
+ try {
338
+ const hostname = new URL(normalized).hostname.toLowerCase().replace(/\.$/, "");
339
+ return hostname === "" ? UNPARSEABLE_HOST : hostname;
340
+ } catch {
341
+ return UNPARSEABLE_HOST;
342
+ }
343
+ }
344
+ function normalizeHostSpec(raw) {
345
+ let s = raw.trim().toLowerCase();
346
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/^\/\//, "");
347
+ const slashIdx = s.indexOf("/");
348
+ if (slashIdx !== -1) s = s.slice(0, slashIdx);
349
+ const portIdx = s.indexOf(":");
350
+ if (portIdx !== -1) s = s.slice(0, portIdx);
351
+ return s.replace(/\.$/, "");
352
+ }
353
+ function hostMatchesSpec(host, spec) {
354
+ if (spec.startsWith(".")) return host === spec.slice(1) || host.endsWith(spec);
355
+ return host === spec;
356
+ }
357
+ function scanAssetHosts(html, allow, extraCss) {
358
+ const collected = [];
359
+ collectFromHtmlTags(html, collected);
360
+ collectFromInlineStyles(html, collected);
361
+ for (const css of extraCss ?? []) {
362
+ collected.push(...extractCssUrls(css));
363
+ }
364
+ const siteHostSpec = normalizeHostSpec(allow.siteHost);
365
+ const cdnHostSpecs = (allow.cdnHosts ?? []).map(normalizeHostSpec);
366
+ const seen = /* @__PURE__ */ new Set();
367
+ const violations = [];
368
+ for (const { url: rawUrl, source } of collected) {
369
+ const url = rawUrl.replace(/\\/g, "/");
370
+ if (!isAbsoluteUrl(url)) continue;
371
+ const host = extractHost(url);
372
+ const key = `${source}::${url}`;
373
+ if (seen.has(key)) continue;
374
+ seen.add(key);
375
+ if (isStagingHost(host)) {
376
+ violations.push({ url, host, source, staging: true });
377
+ continue;
378
+ }
379
+ const allowed = host !== UNPARSEABLE_HOST && (hostMatchesSpec(host, siteHostSpec) || cdnHostSpecs.some((spec) => hostMatchesSpec(host, spec)));
380
+ if (!allowed) {
381
+ violations.push({ url, host, source });
382
+ }
383
+ }
384
+ return violations;
385
+ }
386
+ export {
387
+ STAGING_HOST_PATTERNS,
388
+ isStagingHost,
389
+ scanAssetHosts
390
+ };
391
+ //# sourceMappingURL=seo-asset-hosts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/seo-asset-hosts-css.ts","../src/seo-asset-hosts-html.ts","../src/seo-asset-hosts.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/seo-asset-hosts` — CSS 파싱 층.\n *\n * 인라인 `<style>` 블록과 `style` 속성값(P0-B) 안의 `url()`/`@import`/\n * `image-set()`을 1-depth로 스캔한다(중첩 `@import` 재귀는 하지 않음 — 배럴\n * `seo-asset-hosts.ts` 상단 \"이 스캐너의 한계\" 주석 참고). 이 모듈은 npm 공개\n * export 없이 배럴 + HTML 층(`seo-asset-hosts-html.ts`)이 가져다 쓰는 내부\n * 구현이다(파일당 ≤400줄).\n */\n\nimport type { AssetHostViolation } from \"./seo-asset-hosts.js\";\n\n/** HTML/CSS 스캔 중 모아진 URL 후보 — 절대/상대 판별과 허용목록 매칭은 배럴이 한다. */\nexport interface CollectedUrl {\n url: string;\n source: AssetHostViolation[\"source\"];\n}\n\n// ── CSS: url()/@import/image-set() (1-depth) ─────────────────────────────────\n// 주석을 먼저 공백으로 치환한다(\"삭제\"가 아니라 \"치환\" — CSS 토크나이저는\n// 주석을 빈 문자열이 아니라 토큰 경계로 취급하므로, 문자 그대로 삭제하면\n// `@import/**/url(...)` 같은 경우 `@importurl(...)`로 합쳐져 매치가 깨진다).\n// 주석 제거 후: `/* @import \"x\"; */` 는 빈 문자열이 되어 더는 매치되지 않고\n// (거짓 위반 제거), `@import/**/\"x\";` 는 `@import \"x\";` 가 되어 정상 매치된다\n// (놓치던 케이스 해소).\nfunction stripCssComments(css: string): string {\n return css.replace(/\\/\\*[\\s\\S]*?\\*\\//g, \" \");\n}\n\n// 따옴표 있는 형태(`url(\"...\")`/`url('...')`)와 없는 형태(`url(...)`)를 분리해\n// 매치한다(P0-D) — 예전 `[^'\")]+` 문자 클래스는 따옴표 안의 `)` 도 URL\n// 종료로 오인했다(`url(\"https://x/a)b.png\")` → 미스캔). 따옴표 있는 형태는\n// 같은 따옴표가 다시 나올 때까지를 URL 로 보아 내부 `)` 를 허용하고,\n// 따옴표 없는 형태는 CSS 스펙대로 첫 `)` 까지만 본다(unquoted url-token 은\n// `)` 를 담을 수 없다 — 기존 동작 유지).\nconst URL_RE = /url\\(\\s*(?:\"([^\"]*)\"|'([^']*)'|([^'\")]+))\\s*\\)/gi;\nconst IMPORT_RE =\n /@import\\s+(?:url\\(\\s*(?:\"([^\"]*)\"|'([^']*)'|([^'\")]+))\\s*\\)|\"([^\"]*)\"|'([^']*)')/gi;\n// `layer(...)`/`supports(...)`/미디어쿼리 동반 형태(`@import url(x) layer(base)\n// supports(display: grid) screen;`)도 매치되도록 트레일링 `;` 캡처를 강제하지\n// 않는다 — url/문자열만 뽑아내면 충분하다.\nconst IMAGE_SET_RE = /(?:-webkit-)?image-set\\(\\s*((?:[^()]|\\([^()]*\\))*)\\)/gi;\n\nfunction extractImageSetEntries(inner: string): string[] {\n const out: string[] = [];\n for (const part of inner.split(\",\")) {\n const trimmed = part.trim();\n if (!trimmed) continue;\n // P0-D: 따옴표 있는/없는 형태 분리(URL_RE 와 동일 원칙 — 따옴표 안 `)` 허용).\n const urlMatch = /^url\\(\\s*(?:\"([^\"]*)\"|'([^']*)'|([^'\")]+))\\s*\\)/i.exec(trimmed);\n if (urlMatch) {\n const url = urlMatch[1] ?? urlMatch[2] ?? urlMatch[3];\n if (url) {\n out.push(url);\n continue;\n }\n }\n const dq = /^\"([^\"]*)\"/.exec(trimmed);\n const sq = /^'([^']*)'/.exec(trimmed);\n const str = dq?.[1] ?? sq?.[1];\n if (str !== undefined) out.push(str);\n }\n return out.filter(Boolean);\n}\n\nexport function extractCssUrls(rawCss: string): CollectedUrl[] {\n const css = stripCssComments(rawCss);\n const out: CollectedUrl[] = [];\n\n // `@import`를 먼저 걷어내(두 형태: `@import url(\"x\")`, `@import \"x\"`) 매치된\n // 구간을 지운 뒤 일반 `url()`을 스캔한다 — 그래야 `@import url(...)` 가\n // \"css-url\"·\"css-import\" 양쪽으로 중복 집계되지 않는다.\n const masked = css.replace(IMPORT_RE, (_full, urlDq, urlSq, urlUnq, strDq, strSq) => {\n const url = (urlDq ?? urlSq ?? urlUnq ?? strDq ?? strSq ?? \"\").trim();\n if (url) out.push({ url, source: \"css-import\" });\n return \"\";\n });\n\n IMAGE_SET_RE.lastIndex = 0;\n let m: RegExpExecArray | null;\n while ((m = IMAGE_SET_RE.exec(masked))) {\n for (const url of extractImageSetEntries(m[1] ?? \"\")) {\n out.push({ url, source: \"css-url\" });\n }\n }\n\n URL_RE.lastIndex = 0;\n while ((m = URL_RE.exec(masked))) {\n const url = (m[1] ?? m[2] ?? m[3] ?? \"\").trim();\n // image-set(url(...)) 안의 url()도 여기서 다시 매치되지만(같은\n // source/url 이라 배럴 scanAssetHosts 의 dedupe 로 자연스럽게 합쳐짐).\n if (url) out.push({ url, source: \"css-url\" });\n }\n return out;\n}\n\nconst STYLE_BLOCK_RE = /<style\\b[^>]*>([\\s\\S]*?)<\\/style>/gi;\n\nexport function collectFromInlineStyles(html: string, out: CollectedUrl[]): void {\n let m: RegExpExecArray | null;\n STYLE_BLOCK_RE.lastIndex = 0;\n while ((m = STYLE_BLOCK_RE.exec(html))) {\n out.push(...extractCssUrls(m[1] ?? \"\"));\n }\n}\n","/**\n * `@roottale/cms-renderer-next/seo-asset-hosts` — HTML 태그 스캔 층.\n *\n * quote-aware 태그 토크나이저(`scanTags`/`parseAttrs`, raw-text 요소 skip\n * 포함)와 HTML 엔티티 디코드, 태그별 에셋 속성 추출(`collectFromHtmlTags`)을\n * 담당한다. 이 모듈은 npm 공개 export 없이 배럴 `seo-asset-hosts.ts` 가\n * `collectFromHtmlTags` 하나만 가져다 쓰는 내부 구현이다(파일당 ≤400줄).\n */\n\nimport type { AssetHostViolation } from \"./seo-asset-hosts.js\";\nimport { extractCssUrls, type CollectedUrl } from \"./seo-asset-hosts-css.js\";\n\n// ── HTML 엔티티 디코드 ───────────────────────────────────────────────────────\n// 속성값은 URL 파싱 전에 HTML 엔티티를 디코드해야 한다 — 안 하면\n// `&#x38;&#x30;` 같은 인코딩으로 userinfo(`user:pass@host`) 트릭을 숨기거나\n// (배럴 extractHost의 fail-closed 파싱과 합쳐져야 완전히 막힌다),\n// `https&colon;&sol;&sol;evil.example/a` 처럼 named 엔티티로 스킴 구분자\n// 자체를 숨길 수 있다(P0-C). 디코드는 `parseAttrs` 가 속성값을 Map 에 넣는\n// **시점 한 번**에만 적용한다 — 그래야 `type`·`rel` 같은 판별용 속성값도\n// 자동으로 디코드되고(`type=\"im&#97;ge\"` → `image`), pushDecoded 류 호출부에서\n// 중복 디코드해 `&amp;amp;` 를 `&` 로 잘못 접는 이중 디코드 버그를 원천\n// 차단한다. `style` 속성값도 이 시점에 디코드되므로 CSS url() 파싱은 이미\n// 디코드된 문자열을 받는다(P0-B). CSS `url()`/`@import` 자체는 HTML 엔티티가\n// 아니라 CSS escape 문법을 쓰므로 CSS 파서 쪽에서 별도로 디코드하지 않는다\n// (배럴 상단 한계 설명 참고).\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: \"&\",\n lt: \"<\",\n gt: \">\",\n quot: '\"',\n apos: \"'\",\n colon: \":\",\n sol: \"/\",\n lpar: \"(\",\n rpar: \")\",\n num: \"#\",\n commat: \"@\",\n quest: \"?\",\n equals: \"=\",\n semi: \";\",\n period: \".\",\n ast: \"*\",\n plus: \"+\",\n dollar: \"$\",\n percnt: \"%\",\n nbsp: \" \",\n};\n\nfunction decodeHtmlEntities(value: string): string {\n return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]*);/gi, (match, entity: string) => {\n if (entity[0] === \"#\") {\n const isHex = entity[1] === \"x\" || entity[1] === \"X\";\n const codePoint = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);\n if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;\n try {\n return String.fromCodePoint(codePoint);\n } catch {\n return match;\n }\n }\n const replacement = NAMED_ENTITIES[entity.toLowerCase()];\n return replacement ?? match;\n });\n}\n\n// ── srcset 파싱 (쉼표 분리 다중 URL, `url 2x`/`url 600w` 서술자 제거) ────────\nfunction parseSrcset(value: string): string[] {\n return value\n .split(\",\")\n .map((part) => part.trim().split(/\\s+/)[0])\n .filter((u): u is string => Boolean(u));\n}\n\n// ── HTML 태그 토크나이저 (quote-aware) ───────────────────────────────────────\n// 정규식 `[^>]*` 방식(예전 SCANNED_TAGS_RE)은 속성값 안의 `>`(\"<img\n// alt=\\\">\\\" src=...>\")를 태그 종료로 오인하고, 따옴표 없는 속성값\n// (\"<img src=https://x/a>\")도 지원하지 않는다. 아래는 태그별로 따옴표\n// 상태를 직접 추적하며 진짜 태그 종료 `>`를 찾는 최소 토크나이저다 — 전체\n// DOM 트리를 구성하지 않고 \"태그 이름 + 속성 맵\"만 뽑아낸다.\ninterface ScannedTag {\n name: string;\n attrs: Map<string, string>;\n}\n\nfunction parseAttrs(section: string): Map<string, string> {\n const attrs = new Map<string, string>();\n const len = section.length;\n let i = 0;\n while (i < len) {\n while (i < len && /[\\s/]/.test(section[i] ?? \"\")) i++;\n if (i >= len) break;\n const nameStart = i;\n while (i < len && !/[\\s=/>]/.test(section[i] ?? \"\")) i++;\n const name = section.slice(nameStart, i).toLowerCase();\n if (!name) {\n i++;\n continue;\n }\n while (i < len && /\\s/.test(section[i] ?? \"\")) i++;\n if (section[i] === \"=\") {\n i++;\n while (i < len && /\\s/.test(section[i] ?? \"\")) i++;\n let value: string;\n const q = section[i];\n if (q === '\"' || q === \"'\") {\n i++;\n const valStart = i;\n while (i < len && section[i] !== q) i++;\n value = section.slice(valStart, i);\n if (i < len) i++; // closing quote\n } else {\n const valStart = i;\n while (i < len && !/\\s/.test(section[i] ?? \"\")) i++;\n value = section.slice(valStart, i);\n }\n // 디코드는 여기 한 곳에서만(P0-C) — 판별용 속성(type/rel 등)도 자동으로\n // 디코드되고, 호출부(pushDecoded 등)에서 다시 디코드하지 않아 이중\n // 디코드(`&amp;amp;` → `&`)를 막는다.\n if (!attrs.has(name)) attrs.set(name, decodeHtmlEntities(value));\n } else if (!attrs.has(name)) {\n attrs.set(name, \"\");\n }\n }\n return attrs;\n}\n\n// HTML 스펙상 raw text(script/style) · escapable raw text(textarea/title)\n// 요소 — 본문을 태그로 해석하면 안 된다(P0-A).\nconst RAW_TEXT_ELEMENTS = new Set([\"script\", \"style\", \"textarea\", \"title\"]);\n\n/**\n * raw text 요소의 본문을 건너뛴다 — 대응하는 닫는 태그(대소문자 무시) 바로\n * 뒤 위치를 반환한다. 닫는 태그를 못 찾으면(잘린 HTML) 문서 끝까지 본문으로\n * 간주하고 `html.length` 를 반환한다.\n */\nfunction skipRawTextBody(html: string, name: string, fromIndex: number): number {\n const len = html.length;\n const lower = html.toLowerCase();\n const closeTag = `</${name}`;\n let idx = fromIndex;\n while (idx < len) {\n const found = lower.indexOf(closeTag, idx);\n if (found === -1) return len;\n const after = lower[found + closeTag.length];\n // \"</script\" 다음이 태그 이름을 연장하는 문자가 아니어야(공백/`>`/`/`/EOF)\n // 진짜 닫는 태그다 — 그래야 `</scriptx>` 같은 우연한 부분열을 닫는\n // 태그로 오인하지 않는다.\n if (after === undefined || /[\\s>/]/.test(after)) {\n const gt = html.indexOf(\">\", found);\n return gt === -1 ? len : gt + 1;\n }\n idx = found + closeTag.length;\n }\n return len;\n}\n\nfunction scanTags(html: string): ScannedTag[] {\n const tags: ScannedTag[] = [];\n const len = html.length;\n let i = 0;\n while (i < len) {\n if (html.charCodeAt(i) !== 60 /* \"<\" */) {\n i++;\n continue;\n }\n if (html.startsWith(\"<!--\", i)) {\n const end = html.indexOf(\"-->\", i + 4);\n i = end === -1 ? len : end + 3;\n continue;\n }\n const next = html[i + 1];\n if (next === undefined) {\n i++;\n continue;\n }\n if (next === \"!\" || next === \"?\" || next === \"/\") {\n // doctype/declaration/closing tag — 속성 관심 없음, 다음 `>` 까지 skip.\n const end = html.indexOf(\">\", i + 1);\n i = end === -1 ? len : end + 1;\n continue;\n }\n if (!/[a-zA-Z]/.test(next)) {\n i++;\n continue;\n }\n let j = i + 1;\n while (j < len && /[a-zA-Z0-9:-]/.test(html[j] ?? \"\")) j++;\n const name = html.slice(i + 1, j).toLowerCase();\n\n let quote: string | null = null;\n let k = j;\n let tagEnd = -1;\n while (k < len) {\n const ch = html[k];\n if (quote) {\n if (ch === quote) quote = null;\n k++;\n continue;\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n k++;\n continue;\n }\n if (ch === \">\") {\n tagEnd = k;\n break;\n }\n k++;\n }\n if (tagEnd === -1) {\n // 종료 `>` 를 못 찾음(따옴표 짝이 안 맞는 태그 등) — 이 태그 하나만\n // 포기하고 다음 `<` 부터 스캔을 재개한다(P0-A #2). 예전엔 여기서 전체\n // 스캔을 중단해 이 태그 뒤 문서 전체가 무검사로 빠졌다.\n i = i + 1;\n continue;\n }\n tags.push({ name, attrs: parseAttrs(html.slice(j, tagEnd)) });\n i = RAW_TEXT_ELEMENTS.has(name) ? skipRawTextBody(html, name, tagEnd + 1) : tagEnd + 1;\n }\n return tags;\n}\n\n// ── rel 필터 (<link rel>) ────────────────────────────────────────────────────\n// 에셋을 실제로 로드/프리페치하는 rel 토큰만 검사한다. `canonical`·\n// `alternate`·`author`·`me` 등은 브라우저가 요청을 만들지 않으므로 대상에서\n// 제외 — 이걸 걸러내지 않으면 모든 <link href>가 오탐된다. rel 은 공백 구분\n// 다중값이라 토큰 단위로 검사한다(\"shortcut icon\"도 \"icon\" 토큰이 있어\n// 매치됨, 별도 케이스 불필요).\nconst ASSET_REL_TOKENS = new Set([\n \"stylesheet\",\n \"preload\",\n \"modulepreload\",\n \"prefetch\",\n \"preconnect\",\n \"dns-prefetch\",\n \"icon\",\n \"apple-touch-icon\",\n \"mask-icon\",\n \"manifest\",\n]);\n\nfunction relMatchesAssetTokens(relAttr: string | undefined): boolean {\n if (!relAttr) return false;\n const tokens = relAttr.toLowerCase().split(/\\s+/).filter(Boolean);\n return tokens.some((t) => ASSET_REL_TOKENS.has(t));\n}\n\n// `raw` 는 이미 `parseAttrs` 에서 디코드된 값이다 — 여기서 다시\n// `decodeHtmlEntities` 를 호출하면 `&amp;amp;` 가 `&` 로 잘못 접히는 이중\n// 디코드 버그가 생긴다(P0-C, 함수명은 호출부 호환을 위해 유지).\nfunction pushDecoded(\n out: CollectedUrl[],\n raw: string | undefined,\n source: AssetHostViolation[\"source\"],\n): void {\n if (!raw) return;\n out.push({ url: raw, source });\n}\n\nfunction pushSrcsetDecoded(\n out: CollectedUrl[],\n raw: string | undefined,\n source: AssetHostViolation[\"source\"],\n): void {\n if (!raw) return;\n for (const u of parseSrcset(raw)) out.push({ url: u, source });\n}\n\nexport function collectFromHtmlTags(html: string, out: CollectedUrl[]): void {\n for (const { name, attrs } of scanTags(html)) {\n // `style` 속성의 인라인 CSS는 태그 종류를 가리지 않고 전부 검사한다\n // (P0-B) — `<div style=\"background:url(...)\">` 류. 값은 parseAttrs 에서\n // 이미 HTML 엔티티 디코드된 상태라 CSS url() 파서에 그대로 넘긴다.\n const styleAttr = attrs.get(\"style\");\n if (styleAttr) out.push(...extractCssUrls(styleAttr));\n\n switch (name) {\n case \"img\":\n pushDecoded(out, attrs.get(\"src\"), \"img\");\n pushSrcsetDecoded(out, attrs.get(\"srcset\"), \"srcset\");\n break;\n case \"source\":\n // <source> 는 <picture>(srcset) 또는 <video>/<audio>(src) 자식으로\n // 쓰인다 — src 는 미디어 소스이므로 \"media\" 로 기록한다.\n pushDecoded(out, attrs.get(\"src\"), \"media\");\n pushSrcsetDecoded(out, attrs.get(\"srcset\"), \"srcset\");\n break;\n case \"link\": {\n const rel = attrs.get(\"rel\");\n if (relMatchesAssetTokens(rel)) {\n pushDecoded(out, attrs.get(\"href\"), \"href\");\n // rel=\"preload\" as=\"image\" imagesrcset=\"...\" — srcset 문법.\n pushSrcsetDecoded(out, attrs.get(\"imagesrcset\"), \"srcset\");\n }\n break;\n }\n case \"script\":\n pushDecoded(out, attrs.get(\"src\"), \"script\");\n break;\n case \"video\":\n pushDecoded(out, attrs.get(\"poster\"), \"poster\");\n pushDecoded(out, attrs.get(\"src\"), \"media\");\n break;\n case \"audio\":\n case \"track\":\n pushDecoded(out, attrs.get(\"src\"), \"media\");\n break;\n case \"base\":\n // 가장 위험 — 이후 모든 상대 URL 이 이 호스트를 기준으로 요청된다.\n pushDecoded(out, attrs.get(\"href\"), \"base\");\n break;\n case \"iframe\":\n pushDecoded(out, attrs.get(\"src\"), \"iframe\");\n break;\n case \"embed\":\n pushDecoded(out, attrs.get(\"src\"), \"object\");\n break;\n case \"object\":\n pushDecoded(out, attrs.get(\"data\"), \"object\");\n break;\n case \"input\": {\n if ((attrs.get(\"type\") ?? \"\").toLowerCase() === \"image\") {\n pushDecoded(out, attrs.get(\"src\"), \"img\");\n }\n break;\n }\n default:\n break;\n }\n }\n}\n","// 렌더 산출물 에셋 도메인 정합 스캐너 — 순수 함수, UI/next 의존성 0\n// (seo-checks.ts 패턴 — Tiptap 글 단위 체크와는 다른 층. 이 파일은 \"렌더된\n// HTML/CSS 산출물의 에셋 호스트가 운영 도메인·승인 CDN인지\"만 본다).\n//\n// seo-geo-contract v3.1 §1, roottale-docs\n// docs/design/seo-contract-v31-impl-spec.md §1 구현.\n// PR A codex 적대적 리뷰 반영(2026-07-27) — 정규식 태그 스캔이 놓치던 실제\n// 브라우저 요청 케이스를 quote-aware 토크나이저로 재작성.\n//\n// 구현은 `seo-asset-hosts-*.ts` 모듈로 분리되어 있고 본 파일은 공개 배럴 +\n// 조립(허용목록 정규화·매칭 + 최종 필터링)이다(파일당 ≤400줄, routes.ts 패턴).\n// HTML 태그 스캔은 `seo-asset-hosts-html.ts`, CSS `url()`/`@import`/\n// `image-set()` 파싱은 `seo-asset-hosts-css.ts` 참고. 두 모듈 다 npm 공개\n// export 는 없다 — 공개 표면은 아래 3개(+타입 2개)뿐이다.\n//\n// ── 이 스캐너의 한계 (상단에 명시) ───────────────────────────────────────────\n// - HTML 태그는 자체 작성한 quote-aware 토크나이저로 스캔한다(따옴표\n// 있음/없음 양쪽, 속성값 안의 `>` 안전 처리, 대소문자 무시). `<script>`/\n// `<style>`/`<textarea>`/`<title>` 는 HTML 스펙상 raw text/escapable raw\n// text 요소라 본문을 태그로 해석하지 않고 대응하는 닫는 태그까지 통째로\n// 건너뛴다(P0-A, 2026-07-27 codex 재현 반영) — 그래서 `<script>const s =\n// '<img src=...>'</script>` 같은 리터럴에 오탐되지 않는다. `<style>` 본문은\n// 별도로 `collectFromInlineStyles` 가 CSS 로 스캔하므로 검사 자체가\n// 빠지지는 않는다. 완전한 DOM 파서는 아니므로 100% 신뢰는 금물, 회귀\n// 게이트로 충분한 수준.\n// - CSS 파싱은 **1-depth만** 본다: 인라인 `<style>` 블록과 호출자가 넘긴\n// `extraCss` 문자열의 `url()`/`@import`/`image-set()`만 본다. `@import`로\n// 불러온 외부 CSS 파일의 내용까지 재귀적으로 받아와 파싱하지 않는다 — 그\n// 안에 중첩된 `@import`·`url()`은 이 스캐너 범위 밖이다(E2E 네트워크\n// 프로브가 보완).\n// - CSS escape 시퀀스(`\\3a `, `\\2f ` 등으로 인코딩된 URL)까지 디코드하는 건\n// 범위 밖이다 — 실제 CSS 토크나이저 없이 이걸 디코드하면 오탐 위험이 더\n// 커진다. HTML 엔티티 디코드는 하되(`seo-asset-hosts-html.ts`) CSS escape는\n// 안 한다.\n// - 런타임에 JS로 삽입되는 `<script src>`/이미지(예: `document.createElement`)\n// 는 정적 HTML에 나타나지 않으므로 이 스캐너로는 잡을 수 없다 — 이 역시\n// E2E 프로브(`page.on(\"request\")`)가 보완하는 영역이다.\n// - `dev.*`/`test.*`/`preview.*` 스테이징 패턴은 접두사가 겹치는 정상 운영\n// 호스트(예: `devportal.example.com`처럼 보이는 서브도메인)도 과잉 매치할\n// 수 있다 — 의도적이다: 운영 페이지가 이런 패턴의 호스트를 참조하면 그\n// 자체가 신호이기 때문에, 허용목록에 있어도 위반으로 잡는다(아래\n// `STAGING_HOST_PATTERNS`).\n\nimport { collectFromHtmlTags } from \"./seo-asset-hosts-html.js\";\nimport { collectFromInlineStyles, extractCssUrls, type CollectedUrl } from \"./seo-asset-hosts-css.js\";\n\nexport interface AssetHostViolation {\n url: string;\n host: string;\n source:\n | \"img\"\n | \"srcset\"\n | \"href\"\n | \"poster\"\n | \"base\"\n | \"script\"\n | \"iframe\"\n | \"media\"\n | \"object\"\n | \"css-url\"\n | \"css-import\";\n /** 스테이징/프리뷰 패턴이라 허용목록에 있어도 위반인 경우 true. */\n staging?: boolean;\n}\n\nexport interface AssetHostAllowList {\n siteHost: string;\n cdnHosts?: string[];\n}\n\n/**\n * 스테이징·프리뷰 패턴 — 허용목록(siteHost/cdnHosts)에 있더라도 항상 위반으로\n * 잡는다(운영 산출물에 잔류하면 안 되는 호스트). `*`는 임의 문자열(서브도메인\n * 포함)에 매치한다. 테스트가 이 목록을 직접 참조할 수 있게 named export.\n */\nexport const STAGING_HOST_PATTERNS: readonly string[] = [\n \"*.kinsta.cloud\",\n \"*.vercel.app\",\n \"*.netlify.app\",\n \"*.pages.dev\",\n \"staging.*\",\n \"stage.*\",\n \"preview.*\",\n \"dev.*\",\n \"test.*\",\n \"*.ngrok.io\",\n \"*.ngrok-free.app\",\n \"*.ngrok.app\",\n \"*.trycloudflare.com\",\n \"*.loca.lt\",\n \"*.serveo.net\",\n \"localhost\",\n \"127.0.0.1\",\n];\n\nfunction patternToRegExp(pattern: string): RegExp {\n const escaped = pattern\n .replace(/[.+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n .replace(/\\*/g, \".*\");\n return new RegExp(`^${escaped}$`, \"i\");\n}\n\nconst STAGING_HOST_REGEXPS = STAGING_HOST_PATTERNS.map(patternToRegExp);\n\n/** 스테이징/프리뷰 패턴 매치 여부(소문자 host 기준). */\nexport function isStagingHost(host: string): boolean {\n return STAGING_HOST_REGEXPS.some((re) => re.test(host));\n}\n\n// ── 절대 URL 판정 ────────────────────────────────────────────────────────────\n// http(s):// 와 프로토콜 상대(`//host/...`)만 판정 대상. 상대경로·data:·blob:·\n// about:·mailto:·tel:·#fragment 는 위반 대상에서 통과시킨다. 호출자는 이\n// 함수에 넘기기 전에 백슬래시 정규화(아래 scanAssetHosts 본문)를 거친\n// 문자열을 넘겨야 한다 — 브라우저가 특수 스킴에서 `\\`를 `/`로 취급하기\n// 때문에(`https:\\\\evil.example/a` 가 실제로는 `https://evil.example/a`로\n// 요청됨) 정규화 전 문자열로 판정하면 우회당한다.\nfunction isAbsoluteUrl(url: string): boolean {\n const trimmed = url.trim();\n if (trimmed === \"\") return false;\n if (trimmed.startsWith(\"//\")) return true;\n return /^https?:\\/\\//i.test(trimmed);\n}\n\nconst UNPARSEABLE_HOST = \"(unparseable)\";\n\n// fail-closed: 절대 URL 형태인데 `new URL()`이 실패(혹은 빈 hostname)하면\n// 정규식으로 호스트를 추측하지 않는다 — 추측 폴백은 userinfo 트릭\n// (`https://siteHost:80@evil.example/x`류, `#`가 폴백 정규식에서 fragment로\n// 오인되며 진짜 호스트 앞에서 잘려나가 siteHost가 호스트로 오판됨)을 통과\n// 시켜준다. 파싱 실패는 그 자체로 위반으로 보고한다(host: \"(unparseable)\").\nfunction extractHost(url: string): string {\n const trimmed = url.trim();\n const normalized = trimmed.startsWith(\"//\") ? `https:${trimmed}` : trimmed;\n try {\n const hostname = new URL(normalized).hostname.toLowerCase().replace(/\\.$/, \"\");\n return hostname === \"\" ? UNPARSEABLE_HOST : hostname;\n } catch {\n return UNPARSEABLE_HOST;\n }\n}\n\n// ── 허용목록 호스트 스펙 정규화·매칭 ──────────────────────────────────────────\n// 소문자화 + 스킴/트레일링 슬래시/포트/trailing dot 제거. 관대하게 파싱한다\n// (\"https://x.com\" 도 \"x.com\" 도 \"x.com.\" 도 같은 스펙으로 취급).\nfunction normalizeHostSpec(raw: string): string {\n let s = raw.trim().toLowerCase();\n s = s.replace(/^[a-z][a-z0-9+.-]*:\\/\\//, \"\").replace(/^\\/\\//, \"\");\n const slashIdx = s.indexOf(\"/\");\n if (slashIdx !== -1) s = s.slice(0, slashIdx);\n const portIdx = s.indexOf(\":\");\n if (portIdx !== -1) s = s.slice(0, portIdx);\n return s.replace(/\\.$/, \"\");\n}\n\n/** `.` 로 시작하는 스펙은 서브도메인 suffix 매칭(자기 자신 포함), 아니면 완전일치. */\nfunction hostMatchesSpec(host: string, spec: string): boolean {\n if (spec.startsWith(\".\")) return host === spec.slice(1) || host.endsWith(spec);\n return host === spec;\n}\n\n/**\n * 렌더된 HTML(+선택적 CSS 문자열)에서 절대 URL 에셋 호스트를 추출해 허용목록\n * (`siteHost`/`cdnHosts`) 밖의 호스트를 위반으로 보고한다. 스테이징/프리뷰\n * 패턴(`STAGING_HOST_PATTERNS`)은 허용목록에 있어도 항상 위반(`staging: true`).\n * `new URL()` 파싱에 실패하는 절대 URL은 호스트를 추측하지 않고 그 자체로\n * 위반 처리한다(`host: \"(unparseable)\"`, fail-closed — 상단 extractHost 주석\n * 참고).\n *\n * @param html 렌더된 페이지 HTML(인라인 `<style>` 블록 포함해서 함께 스캔).\n * @param allow 허용 호스트 — `siteHost` 는 필수, `cdnHosts` 는 선택.\n * @param extraCss 별도로 로드되는 CSS 파일 문자열(예: 빌드 산출물의 external\n * stylesheet). 여기 안의 `@import` 는 1-depth만 본다(중첩 `@import` 재귀는\n * 하지 않음 — 파일 상단 주석 참고).\n */\nexport function scanAssetHosts(\n html: string,\n allow: AssetHostAllowList,\n extraCss?: readonly string[],\n): AssetHostViolation[] {\n const collected: CollectedUrl[] = [];\n collectFromHtmlTags(html, collected);\n collectFromInlineStyles(html, collected);\n for (const css of extraCss ?? []) {\n collected.push(...extractCssUrls(css));\n }\n\n const siteHostSpec = normalizeHostSpec(allow.siteHost);\n const cdnHostSpecs = (allow.cdnHosts ?? []).map(normalizeHostSpec);\n\n const seen = new Set<string>();\n const violations: AssetHostViolation[] = [];\n\n for (const { url: rawUrl, source } of collected) {\n // 브라우저는 특수 스킴(http/https 등)에서 `\\`를 `/`로 취급한다\n // (`https:\\\\evil.example/a` → 실제 요청은 `https://evil.example/a`) —\n // isAbsoluteUrl/extractHost 에 넘기기 전에 정규화한다.\n const url = rawUrl.replace(/\\\\/g, \"/\");\n if (!isAbsoluteUrl(url)) continue;\n const host = extractHost(url);\n\n const key = `${source}::${url}`;\n if (seen.has(key)) continue;\n seen.add(key);\n\n if (isStagingHost(host)) {\n violations.push({ url, host, source, staging: true });\n continue;\n }\n\n const allowed =\n host !== UNPARSEABLE_HOST &&\n (hostMatchesSpec(host, siteHostSpec) ||\n cdnHostSpecs.some((spec) => hostMatchesSpec(host, spec)));\n if (!allowed) {\n violations.push({ url, host, source });\n }\n }\n\n return violations;\n}\n"],"mappings":"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","names":[]}