@roottale/cms-core 0.53.0 → 0.55.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/index.cjs CHANGED
@@ -43,9 +43,11 @@ __export(src_exports, {
43
43
  boundFieldValue: () => boundFieldValue,
44
44
  chainResolvers: () => chainResolvers,
45
45
  createFieldBindingResolver: () => createFieldBindingResolver,
46
+ ensureImportedHtmlDocumentRoots: () => ensureImportedHtmlDocumentRoots,
46
47
  ensureStableIds: () => ensureStableIds,
47
48
  extractFaqEntries: () => extractFaqEntries,
48
49
  faqPageJsonLd: () => faqPageJsonLd,
50
+ htmlToPlainText: () => htmlToPlainText,
49
51
  injectStableIdsTree: () => injectStableIdsTree,
50
52
  isAllowedEmbedSrc: () => isAllowedEmbedSrc,
51
53
  isAllowedMapEmbedSrc: () => isAllowedMapEmbedSrc,
@@ -58,10 +60,18 @@ __export(src_exports, {
58
60
  postsForCollection: () => postsForCollection,
59
61
  postsInAnyCollection: () => postsInAnyCollection,
60
62
  postsNotInAnyCollection: () => postsNotInAnyCollection,
63
+ resolveCategoryPath: () => resolveCategoryPath,
64
+ resolveCategoryTreeChain: () => resolveCategoryTreeChain,
65
+ resolveCategoryTreeEntryPath: () => resolveCategoryTreeEntryPath,
66
+ resolveCategoryTreeHubPaths: () => resolveCategoryTreeHubPaths,
67
+ resolvePostCategorySlug: () => resolvePostCategorySlug,
61
68
  resolvePostCollection: () => resolvePostCollection,
62
69
  resolvePostPath: () => resolvePostPath,
63
70
  resolvePostUrl: () => resolvePostUrl,
64
71
  sanitizeHtml: () => sanitizeHtml,
72
+ sanitizeImportedCss: () => sanitizeImportedCss,
73
+ sanitizeImportedHtml: () => sanitizeImportedHtml,
74
+ scopeImportedCss: () => scopeImportedCss,
65
75
  serializeBlock: () => serializeBlock,
66
76
  validateCollections: () => validateCollections,
67
77
  verifyPreviewToken: () => verifyPreviewToken
@@ -197,18 +207,38 @@ function resolvePostCollection(post, collections) {
197
207
  return collections.find((c) => c.key === post.collectionKey) ?? null;
198
208
  }
199
209
  function resolvePostUrl(siteUrl, post, collections) {
200
- const collection = resolvePostCollection(post, collections);
201
- if (!collection) return null;
202
- if (collection.detail === false) return null;
203
- if (!collection.basePath) return null;
204
- return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
210
+ const path = resolvePostPath(post, collections);
211
+ return path ? `${trimTrailingSlash(siteUrl)}${path}` : null;
212
+ }
213
+ function resolvePostCategorySlug(post, collection) {
214
+ const allowed = new Set(collection.categories);
215
+ const slugs = [
216
+ ...new Set(
217
+ post.terms.filter(
218
+ (term) => term.taxonomy === "category" && allowed.has(term.slug)
219
+ ).map((term) => term.slug)
220
+ )
221
+ ];
222
+ return slugs.length === 1 ? slugs[0] : null;
223
+ }
224
+ function resolveCategoryPath(collection, categorySlug) {
225
+ if (!collection.archives || !collection.basePath || !categorySlug) return null;
226
+ const basePath = trimTrailingSlash(collection.basePath);
227
+ const encodedSlug = encodeURIComponent(categorySlug);
228
+ return collection.categoryPath === "direct" ? `${basePath}/${encodedSlug}` : `${basePath}/categories/${encodedSlug}`;
205
229
  }
206
230
  function resolvePostPath(post, collections) {
207
231
  const collection = resolvePostCollection(post, collections);
208
232
  if (!collection) return null;
209
233
  if (collection.detail === false) return null;
210
234
  if (!collection.basePath) return null;
211
- return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
235
+ const basePath = trimTrailingSlash(collection.basePath);
236
+ const encodedPostSlug = encodeURIComponent(post.slug);
237
+ if (collection.detailPath !== "category") {
238
+ return `${basePath}/${encodedPostSlug}`;
239
+ }
240
+ const categorySlug = resolvePostCategorySlug(post, collection);
241
+ return categorySlug ? `${basePath}/${encodeURIComponent(categorySlug)}/${encodedPostSlug}` : null;
212
242
  }
213
243
  function postsForCollection(posts, collections, key) {
214
244
  return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);
@@ -248,10 +278,87 @@ function validateCollections(collections) {
248
278
  }
249
279
  categoryOwner.set(slug, c.key);
250
280
  }
281
+ if (c.detailPath === "category") {
282
+ if (!c.basePath) {
283
+ problems.push(
284
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 basePath \uAC00 \uD544\uC694\uD568: "${c.key}"`
285
+ );
286
+ }
287
+ if (c.detail === false) {
288
+ problems.push(
289
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 \uAE00\uBCC4 \uC0C1\uC138 \uD398\uC774\uC9C0\uAC00 \uD544\uC694\uD568: "${c.key}"`
290
+ );
291
+ }
292
+ if (!c.archives) {
293
+ problems.push(
294
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 \uCE74\uD14C\uACE0\uB9AC \uC544\uCE74\uC774\uBE0C\uAC00 \uD544\uC694\uD568: "${c.key}"`
295
+ );
296
+ }
297
+ if (c.categoryCardinality !== "exactly-one") {
298
+ problems.push(
299
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 categoryCardinality "exactly-one" \uC774 \uD544\uC694\uD568: "${c.key}"`
300
+ );
301
+ }
302
+ }
251
303
  }
252
304
  return problems;
253
305
  }
254
306
 
307
+ // src/category-tree.ts
308
+ function normalizeBasePath(basePath) {
309
+ return basePath.replace(/\/+$/, "");
310
+ }
311
+ function resolveCategoryTreeChain(presentation, leafId, terms) {
312
+ if (!Number.isInteger(presentation.categoryDepth) || presentation.categoryDepth < 1) {
313
+ return null;
314
+ }
315
+ const termsById = new Map(terms.map((term) => [term.id, term]));
316
+ if (termsById.size !== terms.length) return null;
317
+ if (terms.some((term) => term.parentId === leafId)) return null;
318
+ const reversed = [];
319
+ const visited = /* @__PURE__ */ new Set();
320
+ let currentId = leafId;
321
+ while (currentId) {
322
+ if (visited.has(currentId)) return null;
323
+ visited.add(currentId);
324
+ const current = termsById.get(currentId);
325
+ if (!current || !current.slug) return null;
326
+ reversed.push(current);
327
+ currentId = current.parentId ?? null;
328
+ }
329
+ if (reversed.length !== presentation.categoryDepth) return null;
330
+ return reversed.reverse();
331
+ }
332
+ function resolveCategoryTreeHubPaths(presentation, termId, terms) {
333
+ const chain = resolveCategoryTreeChain(presentation, termId, terms);
334
+ const basePath = normalizeBasePath(presentation.basePath);
335
+ if (!chain || !basePath) return null;
336
+ const paths = [basePath];
337
+ const segments = [];
338
+ for (const term of chain) {
339
+ segments.push(encodeURIComponent(term.slug));
340
+ paths.push(`${basePath}/${segments.join("/")}`);
341
+ }
342
+ return paths;
343
+ }
344
+ function resolveCategoryTreeEntryPath(presentation, entry, terms) {
345
+ if (!entry.slug) return null;
346
+ const knownIds = new Set(terms.map((term) => term.id));
347
+ const categoryIds = [
348
+ ...new Set(
349
+ entry.terms.filter((term) => term.taxonomy === "category" && knownIds.has(term.id)).map((term) => term.id)
350
+ )
351
+ ];
352
+ if (categoryIds.length !== 1) return null;
353
+ const hubs = resolveCategoryTreeHubPaths(
354
+ presentation,
355
+ categoryIds[0],
356
+ terms
357
+ );
358
+ const leafPath = hubs?.at(-1);
359
+ return leafPath ? `${leafPath}/${encodeURIComponent(entry.slug)}` : null;
360
+ }
361
+
255
362
  // src/delimiter.ts
256
363
  function serializeBlock(block) {
257
364
  const exportable = sanitizeAttributesForExport(block);
@@ -450,6 +557,467 @@ function normalizeImageAlt(value) {
450
557
  return `${visible}\u2026`;
451
558
  }
452
559
 
560
+ // src/imported-html-sanitize.ts
561
+ var import_postcss = __toESM(require("postcss"), 1);
562
+ var import_sanitize_html = __toESM(require("sanitize-html"), 1);
563
+ var IMPORT_SCOPE_RE = /^rt-import-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
564
+ var ALLOWED_AT_RULES = /* @__PURE__ */ new Set([
565
+ "container",
566
+ "font-face",
567
+ "keyframes",
568
+ "-webkit-keyframes",
569
+ "layer",
570
+ "media",
571
+ "supports"
572
+ ]);
573
+ var URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "src"]);
574
+ var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
575
+ var UNSAFE_CSS_VALUE_RE = /(?:expression\s*\(|javascript\s*:|vbscript\s*:|@import|<\/?(?:style|script)|\\0|\\0000)/i;
576
+ var UNSAFE_CSS_PROPERTY_RE = /^(?:behavior|-moz-binding)$/i;
577
+ var FIXED_POSITION_RE = /^(?:fixed|sticky)$/i;
578
+ var ORIGINAL_H1_ATTRIBUTE = "data-rt-original-heading";
579
+ var SCOPE_ROOT_BOUNDARY_PROPERTIES = /* @__PURE__ */ new Set([
580
+ "bottom",
581
+ "inset",
582
+ "inset-block",
583
+ "inset-inline",
584
+ "left",
585
+ "position",
586
+ "right",
587
+ "rotate",
588
+ "scale",
589
+ "top",
590
+ "transform",
591
+ "translate",
592
+ "z-index"
593
+ ]);
594
+ function fromCodePointSafe(value, radix) {
595
+ const codePoint = Number.parseInt(value, radix);
596
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 1114111) {
597
+ return "";
598
+ }
599
+ try {
600
+ return String.fromCodePoint(codePoint);
601
+ } catch {
602
+ return "";
603
+ }
604
+ }
605
+ function isSafeUrl(tagName, attributeName, value) {
606
+ const normalized = value.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => fromCodePointSafe(hex, 16)).replace(/&#(\d+);?/g, (_, decimal) => fromCodePointSafe(decimal, 10)).replace(/[\u0000-\u0020\u007f]/g, "").toLowerCase();
607
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized)?.[1];
608
+ if (!scheme) return true;
609
+ if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
610
+ return tagName === "img" && attributeName === "src" && scheme === "data" && normalized.startsWith("data:image/") && !normalized.startsWith("data:image/svg");
611
+ }
612
+ function cssUrlsAreSafe(value, allowedAssetUrls) {
613
+ const urlRe = /url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi;
614
+ let matched = false;
615
+ for (const match of value.matchAll(urlRe)) {
616
+ matched = true;
617
+ const url = match[2]?.trim() ?? "";
618
+ if (!url.startsWith("#") && !allowedAssetUrls.has(url)) return false;
619
+ }
620
+ return !/url\s*\(/i.test(value) || matched;
621
+ }
622
+ function declarationIsSafe(property, value, allowedAssetUrls) {
623
+ if (UNSAFE_CSS_PROPERTY_RE.test(property) || UNSAFE_CSS_VALUE_RE.test(value)) {
624
+ return false;
625
+ }
626
+ if (!cssUrlsAreSafe(value, allowedAssetUrls)) return false;
627
+ if (property === "position" && /\b(?:fixed|sticky)\b/i.test(value)) {
628
+ return false;
629
+ }
630
+ if (property.startsWith("--") && FIXED_POSITION_RE.test(value.trim())) {
631
+ return false;
632
+ }
633
+ if (property === "z-index") {
634
+ const normalized = value.trim().toLowerCase();
635
+ if (normalized !== "auto") {
636
+ const zIndex = Number(normalized);
637
+ if (!Number.isInteger(zIndex) || Math.abs(zIndex) > 100) return false;
638
+ }
639
+ }
640
+ for (const match of value.matchAll(/(-?\d+(?:\.\d+)?)(px|vh|vw)/gi)) {
641
+ const magnitude = Math.abs(Number(match[1]));
642
+ const limit = match[2].toLowerCase() === "px" ? 4e3 : 200;
643
+ if (magnitude > limit) return false;
644
+ }
645
+ return true;
646
+ }
647
+ function cleanCssContainer(container, allowedAssetUrls) {
648
+ container.walkComments((comment) => {
649
+ comment.remove();
650
+ });
651
+ container.walkAtRules((atRule) => {
652
+ const name = atRule.name.toLowerCase();
653
+ const isKeyframes = name === "keyframes" || name === "-webkit-keyframes";
654
+ if (!ALLOWED_AT_RULES.has(name) || !atRule.nodes?.length || name === "font-face" && atRule.parent?.type !== "root" || isKeyframes && !/^[a-z_][\w-]*$/i.test(atRule.params.trim()) || /<\/?(?:style|script)/i.test(atRule.params)) {
655
+ atRule.remove();
656
+ }
657
+ });
658
+ container.walkDecls((declaration) => {
659
+ const property = declaration.prop.trim().toLowerCase();
660
+ if (!property || !declarationIsSafe(property, declaration.value, allowedAssetUrls)) {
661
+ declaration.remove();
662
+ return;
663
+ }
664
+ });
665
+ container.walkRules((rule) => {
666
+ if (!rule.selector.trim() || !rule.nodes?.length || /<\/?(?:style|script)/i.test(rule.selector)) {
667
+ rule.remove();
668
+ }
669
+ });
670
+ container.walkAtRules("font-face", (fontFace) => {
671
+ const declarations = fontFace.nodes ?? [];
672
+ const hasFamily = declarations.some(
673
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
674
+ );
675
+ const hasSource = declarations.some(
676
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src"
677
+ );
678
+ if (!hasFamily || !hasSource) fontFace.remove();
679
+ });
680
+ }
681
+ function sanitizeImportedCss(css, allowedAssetUrls = []) {
682
+ if (!css.trim()) return "";
683
+ try {
684
+ const root = import_postcss.default.parse(css);
685
+ cleanCssContainer(root, new Set(allowedAssetUrls));
686
+ return root.toString().trim();
687
+ } catch {
688
+ return "";
689
+ }
690
+ }
691
+ function sanitizeInlineStyle(style, allowedAssetUrls) {
692
+ try {
693
+ const root = import_postcss.default.parse(`x{${style}}`);
694
+ cleanCssContainer(root, allowedAssetUrls);
695
+ const rule = root.first;
696
+ if (!rule || rule.type !== "rule") return "";
697
+ return rule.nodes.map((node) => node.toString()).join(";");
698
+ } catch {
699
+ return "";
700
+ }
701
+ }
702
+ function sanitizeImportedAttributes(tagName, attributes, allowedAssetUrls) {
703
+ const next = {};
704
+ for (const [name, value] of Object.entries(attributes)) {
705
+ const attributeName = name.toLowerCase();
706
+ if (attributeName === "style") {
707
+ const style = sanitizeInlineStyle(value, allowedAssetUrls);
708
+ if (style) next[name] = style;
709
+ continue;
710
+ }
711
+ if (attributeName === "srcset") {
712
+ const srcset = sanitizeSrcset(value);
713
+ if (srcset) next[name] = srcset;
714
+ continue;
715
+ }
716
+ if ((attributeName === "fill" || attributeName === "stroke") && /url\s*\(/i.test(value) && !/^url\(#[a-z_][\w:.-]*\)$/i.test(value.trim())) {
717
+ continue;
718
+ }
719
+ if ((tagName === "use" || tagName === "image") && (attributeName === "href" || attributeName === "xlink:href") && !value.trim().startsWith("#")) {
720
+ continue;
721
+ }
722
+ next[name] = URL_ATTRIBUTES.has(attributeName) && !isSafeUrl(tagName, attributeName, value) ? "#" : value;
723
+ }
724
+ if (tagName === "a" && next.target === "_blank") {
725
+ next.rel = "noopener noreferrer";
726
+ }
727
+ if (tagName === "h1") {
728
+ next[ORIGINAL_H1_ATTRIBUTE] = "h1";
729
+ return { tagName: "h2", attribs: next };
730
+ }
731
+ return { tagName, attribs: next };
732
+ }
733
+ function sanitizeSrcset(value) {
734
+ const candidates = [];
735
+ for (const rawCandidate of value.split(",")) {
736
+ const candidate = rawCandidate.trim();
737
+ if (!candidate) continue;
738
+ const [url, ...descriptorParts] = candidate.split(/\s+/);
739
+ if (!url || !isSafeUrl("img", "src", url)) continue;
740
+ const descriptor = descriptorParts.join(" ");
741
+ if (descriptor && !/^(?:\d+(?:\.\d+)?x|\d+w)$/.test(descriptor)) continue;
742
+ candidates.push(descriptor ? `${url} ${descriptor}` : url);
743
+ }
744
+ return candidates.join(", ");
745
+ }
746
+ var IMPORTED_HTML_OPTIONS = {
747
+ allowedTags: [
748
+ "a",
749
+ "abbr",
750
+ "address",
751
+ "article",
752
+ "aside",
753
+ "b",
754
+ "bdi",
755
+ "bdo",
756
+ "blockquote",
757
+ "br",
758
+ "caption",
759
+ "cite",
760
+ "code",
761
+ "col",
762
+ "colgroup",
763
+ "dd",
764
+ "del",
765
+ "details",
766
+ "dfn",
767
+ "div",
768
+ "dl",
769
+ "dt",
770
+ "em",
771
+ "figcaption",
772
+ "figure",
773
+ "footer",
774
+ "h1",
775
+ "h2",
776
+ "h3",
777
+ "h4",
778
+ "h5",
779
+ "h6",
780
+ "header",
781
+ "hr",
782
+ "i",
783
+ "img",
784
+ "kbd",
785
+ "li",
786
+ "main",
787
+ "mark",
788
+ "nav",
789
+ "ol",
790
+ "p",
791
+ "picture",
792
+ "pre",
793
+ "q",
794
+ "s",
795
+ "samp",
796
+ "section",
797
+ "small",
798
+ "source",
799
+ "svg",
800
+ "g",
801
+ "path",
802
+ "circle",
803
+ "ellipse",
804
+ "line",
805
+ "polyline",
806
+ "polygon",
807
+ "rect",
808
+ "defs",
809
+ "lineargradient",
810
+ "radialgradient",
811
+ "stop",
812
+ "use",
813
+ "clippath",
814
+ "mask",
815
+ "pattern",
816
+ "title",
817
+ "desc",
818
+ "span",
819
+ "strong",
820
+ "sub",
821
+ "summary",
822
+ "sup",
823
+ "table",
824
+ "tbody",
825
+ "td",
826
+ "tfoot",
827
+ "th",
828
+ "thead",
829
+ "time",
830
+ "tr",
831
+ "u",
832
+ "ul",
833
+ "var",
834
+ "wbr"
835
+ ],
836
+ allowedAttributes: {
837
+ "*": ["aria-*", "class", "data-*", "dir", "id", "lang", "role", "style", "title"],
838
+ a: [
839
+ "href",
840
+ { name: "rel", multiple: true, values: ["noopener", "noreferrer"] },
841
+ { name: "target", values: ["_blank"] }
842
+ ],
843
+ col: ["span", "style"],
844
+ img: ["alt", "height", "loading", "sizes", "src", "srcset", "style", "width"],
845
+ source: ["height", "media", "sizes", "src", "srcset", "type", "width"],
846
+ svg: ["aria-*", "class", "fill", "height", "preserveAspectRatio", "role", "stroke", "style", "viewBox", "viewbox", "width", "xmlns"],
847
+ g: ["class", "fill", "stroke", "stroke-width", "style", "transform"],
848
+ path: ["class", "d", "fill", "fill-rule", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
849
+ circle: ["class", "cx", "cy", "fill", "r", "stroke", "stroke-width", "style", "transform"],
850
+ ellipse: ["class", "cx", "cy", "fill", "rx", "ry", "stroke", "stroke-width", "style", "transform"],
851
+ line: ["class", "stroke", "stroke-linecap", "stroke-width", "style", "transform", "x1", "x2", "y1", "y2"],
852
+ polyline: ["class", "fill", "points", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
853
+ polygon: ["class", "fill", "points", "stroke", "stroke-width", "style", "transform"],
854
+ rect: ["class", "fill", "height", "rx", "ry", "stroke", "stroke-width", "style", "transform", "width", "x", "y"],
855
+ lineargradient: ["gradientTransform", "gradientUnits", "id", "spreadMethod", "x1", "x2", "y1", "y2"],
856
+ radialgradient: ["cx", "cy", "fx", "fy", "gradientTransform", "gradientUnits", "id", "r", "spreadMethod"],
857
+ stop: ["offset", "stop-color", "stop-opacity", "style"],
858
+ use: ["fill", "height", "href", "stroke", "style", "width", "x", "xlink:href", "y"],
859
+ clippath: ["clipPathUnits", "id", "transform"],
860
+ mask: ["height", "id", "maskContentUnits", "maskUnits", "width", "x", "y"],
861
+ pattern: ["height", "id", "patternContentUnits", "patternTransform", "patternUnits", "preserveAspectRatio", "viewBox", "width", "x", "y"],
862
+ td: ["colspan", "rowspan", "style"],
863
+ th: [
864
+ "colspan",
865
+ "rowspan",
866
+ "style",
867
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
868
+ ],
869
+ time: ["datetime"]
870
+ },
871
+ allowedSchemes: ["http", "https", "mailto", "tel"],
872
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
873
+ allowedSchemesAppliedToAttributes: ["href", "src"],
874
+ allowProtocolRelative: false,
875
+ nonTextTags: ["script", "style", "textarea", "option", "iframe", "object", "embed", "foreignobject", "math", "template"]
876
+ };
877
+ function sanitizeImportedHtml(html, allowedAssetUrls = []) {
878
+ if (!html) return html;
879
+ const allowedSet = new Set(allowedAssetUrls);
880
+ return (0, import_sanitize_html.default)(html, {
881
+ ...IMPORTED_HTML_OPTIONS,
882
+ transformTags: {
883
+ "*": (tagName, attributes) => sanitizeImportedAttributes(tagName, attributes, allowedSet)
884
+ }
885
+ });
886
+ }
887
+ function ensureImportedHtmlDocumentRoots(html, allowedAssetUrls = []) {
888
+ const safeHtml = sanitizeImportedHtml(html, allowedAssetUrls).trim();
889
+ if (!safeHtml) return safeHtml;
890
+ if (/^<div\b[^>]*\bdata-rt-import-document(?:=(?:""|'')|(?=[\s>]))[^>]*>\s*<div\b[^>]*\bdata-rt-import-body(?:=(?:""|'')|(?=[\s>]))/i.test(
891
+ safeHtml
892
+ )) {
893
+ return safeHtml;
894
+ }
895
+ return `<div data-rt-import-document><div data-rt-import-body>${safeHtml}</div></div>`;
896
+ }
897
+ function normalizeDocumentSelector(selector) {
898
+ const typeBoundary = "(?=$|[\\s>+~,.#:\\[\\)])";
899
+ return selector.replace(/html\s*>\s*body/gi, "[data-rt-import-document] > [data-rt-import-body]").replace(/html\s+body/gi, "[data-rt-import-document] [data-rt-import-body]").replace(new RegExp(`(^|[\\s>+~,\\(])html${typeBoundary}`, "gi"), "$1[data-rt-import-document]").replace(new RegExp(`(^|[\\s>+~,\\(])body${typeBoundary}`, "gi"), "$1[data-rt-import-body]").replace(
900
+ new RegExp(`(^|[\\s>+~,\\(])h1${typeBoundary}`, "gi"),
901
+ `$1[${ORIGINAL_H1_ATTRIBUTE}="h1"]`
902
+ ).replace(/:root/g, "[data-rt-import-document]");
903
+ }
904
+ function selectorTargetsDocumentBoundary(selector) {
905
+ return selector.split(",").some((part) => {
906
+ const normalized = part.trim();
907
+ const targetsDocumentRoot = normalized.startsWith("[data-rt-import-document]") && !/[\s>+~]/.test(normalized.slice("[data-rt-import-document]".length));
908
+ const targetsBodyRoot = /\[data-rt-import-body\](?:[.#:][\w-]+|\[[^\]]+\])*\s*$/.test(
909
+ normalized
910
+ );
911
+ return targetsDocumentRoot || targetsBodyRoot;
912
+ });
913
+ }
914
+ function replaceAnimationName(value, names) {
915
+ let next = value;
916
+ for (const [name, scopedName] of names) {
917
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
918
+ next = next.replace(new RegExp(`(^|[^\\w-])${escaped}(?=$|[^\\w-])`, "g"), `$1${scopedName}`);
919
+ }
920
+ return next;
921
+ }
922
+ function unquoteCssIdentifier(value) {
923
+ const normalized = value.trim();
924
+ const match = /^(?:"([^"]+)"|'([^']+)'|([a-z_][\w -]*))$/i.exec(normalized);
925
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim() || null;
926
+ }
927
+ function replaceFontFamilyName(value, names) {
928
+ let next = value;
929
+ for (const [name, scopedName] of names) {
930
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
931
+ next = next.replace(
932
+ new RegExp(`(["'])${escaped}\\1|(^|[,\\s])${escaped}(?=$|[,\\s/])`, "gi"),
933
+ (matched, quote, prefix) => quote ? `${quote}${scopedName}${quote}` : `${prefix ?? ""}${scopedName}`
934
+ );
935
+ }
936
+ return next;
937
+ }
938
+ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
939
+ if (!IMPORT_SCOPE_RE.test(scope)) return "";
940
+ const safeCss = sanitizeImportedCss(css, allowedAssetUrls);
941
+ if (!safeCss) return "";
942
+ try {
943
+ const root = import_postcss.default.parse(safeCss);
944
+ const keyframeNames = /* @__PURE__ */ new Map();
945
+ const fontFamilyNames = /* @__PURE__ */ new Map();
946
+ const fontFaces = root.nodes.filter(
947
+ (node) => node.type === "atrule" && node.name.toLowerCase() === "font-face"
948
+ );
949
+ for (const fontFace of fontFaces) {
950
+ if (fontFace.type !== "atrule") continue;
951
+ const family = fontFace.nodes?.find(
952
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
953
+ );
954
+ if (!family || family.type !== "decl") {
955
+ fontFace.remove();
956
+ continue;
957
+ }
958
+ const originalName = unquoteCssIdentifier(family.value);
959
+ const hasSupportedSource = fontFace.nodes?.some(
960
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src" && /url\(\s*["']?https:\/\/[^)'"\s]+\.woff2?(?:[?#][^)'"\s]*)?["']?\s*\)/i.test(
961
+ node.value
962
+ )
963
+ );
964
+ if (!originalName || !hasSupportedSource) {
965
+ fontFace.remove();
966
+ continue;
967
+ }
968
+ const scopedName = `${scope}-${originalName}`;
969
+ fontFamilyNames.set(originalName, scopedName);
970
+ family.value = family.value.replace(originalName, scopedName);
971
+ }
972
+ root.walkAtRules((atRule) => {
973
+ const name = atRule.name.toLowerCase();
974
+ if (name !== "keyframes" && name !== "-webkit-keyframes") return;
975
+ const originalName = atRule.params.trim();
976
+ const scopedName = `${scope}-${originalName}`;
977
+ keyframeNames.set(originalName, scopedName);
978
+ atRule.params = scopedName;
979
+ });
980
+ root.walkDecls((declaration) => {
981
+ const property = declaration.prop.trim().toLowerCase();
982
+ if (property === "animation" || property === "animation-name") {
983
+ declaration.value = replaceAnimationName(declaration.value, keyframeNames);
984
+ }
985
+ if (property === "font-family" || property === "font") {
986
+ declaration.value = replaceFontFamilyName(
987
+ declaration.value,
988
+ fontFamilyNames
989
+ );
990
+ }
991
+ });
992
+ root.walkRules((rule) => {
993
+ const parent = rule.parent;
994
+ if (parent?.type === "atrule" && (parent.name.toLowerCase() === "keyframes" || parent.name.toLowerCase() === "-webkit-keyframes")) {
995
+ return;
996
+ }
997
+ rule.selector = normalizeDocumentSelector(rule.selector);
998
+ if (selectorTargetsDocumentBoundary(rule.selector)) {
999
+ rule.walkDecls((declaration) => {
1000
+ if (SCOPE_ROOT_BOUNDARY_PROPERTIES.has(
1001
+ declaration.prop.trim().toLowerCase()
1002
+ )) {
1003
+ declaration.remove();
1004
+ }
1005
+ });
1006
+ }
1007
+ });
1008
+ const scoped = import_postcss.default.atRule({
1009
+ name: "scope",
1010
+ params: `([data-rt-import-scope="${scope}"])`
1011
+ });
1012
+ const preservedFontFaces = fontFaces.filter((fontFace) => fontFace.parent);
1013
+ for (const fontFace of preservedFontFaces) fontFace.remove();
1014
+ scoped.append(root.nodes);
1015
+ return [...preservedFontFaces.map((fontFace) => fontFace.toString()), scoped.toString()].filter(Boolean).join("\n");
1016
+ } catch {
1017
+ return "";
1018
+ }
1019
+ }
1020
+
453
1021
  // src/preview-token.ts
454
1022
  var import_node_crypto = require("crypto");
455
1023
  var DELIMITER = ".";
@@ -485,15 +1053,58 @@ function verifyPreviewToken(secret, token) {
485
1053
  }
486
1054
 
487
1055
  // src/sanitize.ts
488
- var import_sanitize_html = __toESM(require("sanitize-html"), 1);
1056
+ var import_sanitize_html2 = __toESM(require("sanitize-html"), 1);
489
1057
  var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "xlink:href"]);
490
- var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
1058
+ var ALLOWED_URL_SCHEMES2 = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
491
1059
  var MALFORMED_DANGEROUS_TOKEN_RE = /<\/?[^>\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\s/]*(?:\s[^>]*)?>[\s\S]*?<\/?[^>\s/]*(?:script|style|iframe|object|embed|svg|math|template)[^>\s/]*(?:\s[^>]*)?>/gi;
492
1060
  var SLASH_EVENT_ATTR_IN_URL_RE = /\/on[a-z]+=/i;
493
1061
  var ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;
494
1062
  var HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;
495
1063
  var TEXT_ALIGN_RE = /^(left|right|center|justify)$/;
496
1064
  var ASPECT_RATIO_RE = /^\d+(?:\.\d+)?\s*\/\s*\d+(?:\.\d+)?$/;
1065
+ var CSS_LENGTH_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))$/i;
1066
+ var CSS_LENGTH_LIST_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))){0,3}$/i;
1067
+ var CSS_LINE_HEIGHT_RE = /^(?:normal|\d+(?:\.\d+)?(?:px|em|rem|%)?)$/i;
1068
+ var CSS_FONT_WEIGHT_RE = /^(?:normal|bold|[1-9]00)$/i;
1069
+ var CSS_BORDER_RE = /^(?:none|0|(?:\d(?:\.\d+)?|[12]\d(?:\.\d+)?|3[0-2](?:\.0+)?)px\s+(?:solid|dashed|dotted)\s+#[0-9a-f]{3}(?:[0-9a-f]{3})?)$/i;
1070
+ var CSS_TEXT_DECORATION_RE = /^(?:none|underline|line-through)$/i;
1071
+ var CSS_MAGNITUDE_LIMITS = {
1072
+ "font-size": { "": 0, px: 96, em: 5, rem: 5, "%": 200 },
1073
+ "line-height": { "": 5, px: 96, em: 5, rem: 5, "%": 500 },
1074
+ margin: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1075
+ "margin-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1076
+ "margin-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1077
+ "margin-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1078
+ "margin-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1079
+ padding: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1080
+ "padding-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1081
+ "padding-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1082
+ "padding-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1083
+ "padding-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1084
+ width: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
1085
+ "max-width": { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
1086
+ height: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 }
1087
+ };
1088
+ function hasSafeCssMagnitude(property, value) {
1089
+ const limits = CSS_MAGNITUDE_LIMITS[property];
1090
+ if (!limits) return true;
1091
+ const numericTokens = value.matchAll(/(\d+(?:\.\d+)?)(px|em|rem|%)?/gi);
1092
+ for (const match of numericTokens) {
1093
+ const unit = (match[2] ?? "").toLowerCase();
1094
+ const limit = limits[unit];
1095
+ if (limit === void 0 || Number(match[1]) > limit) return false;
1096
+ }
1097
+ return true;
1098
+ }
1099
+ function filterUnsafeCssMagnitudes(style) {
1100
+ return style.split(";").map((declaration) => declaration.trim()).filter(Boolean).filter((declaration) => {
1101
+ const colon = declaration.indexOf(":");
1102
+ if (colon < 1) return false;
1103
+ const property = declaration.slice(0, colon).trim().toLowerCase();
1104
+ const value = declaration.slice(colon + 1).trim();
1105
+ return hasSafeCssMagnitude(property, value);
1106
+ }).join(";");
1107
+ }
497
1108
  var NAMED_ENTITY = {
498
1109
  colon: ":",
499
1110
  tab: " ",
@@ -506,7 +1117,7 @@ var NAMED_ENTITY = {
506
1117
  lpar: "(",
507
1118
  rpar: ")"
508
1119
  };
509
- function fromCodePointSafe(n) {
1120
+ function fromCodePointSafe2(n) {
510
1121
  if (!Number.isFinite(n) || n < 0 || n > 1114111) return "";
511
1122
  try {
512
1123
  return String.fromCodePoint(n);
@@ -515,7 +1126,7 @@ function fromCodePointSafe(n) {
515
1126
  }
516
1127
  }
517
1128
  function decodeUrlProbe(value) {
518
- const decoded = value.replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16))).replace(/&#(\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10))).replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);
1129
+ const decoded = value.replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe2(parseInt(h, 16))).replace(/&#(\d+);?/g, (_, d) => fromCodePointSafe2(parseInt(d, 10))).replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);
519
1130
  return [...decoded].filter((c) => {
520
1131
  const n = c.codePointAt(0) ?? 0;
521
1132
  return n > 32 && n !== 127;
@@ -535,7 +1146,7 @@ function isSafeUrlValue(tag, attr, value) {
535
1146
  if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;
536
1147
  const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];
537
1148
  if (!scheme) return true;
538
- if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
1149
+ if (ALLOWED_URL_SCHEMES2.has(scheme)) return true;
539
1150
  if (scheme === "data" && tag === "img" && attr === "src" && probe.startsWith("data:image/")) {
540
1151
  return !probe.startsWith("data:image/svg");
541
1152
  }
@@ -545,8 +1156,16 @@ function sanitizeUrlAttributes(tagName, attribs) {
545
1156
  const next = {};
546
1157
  for (const [name, value] of Object.entries(attribs)) {
547
1158
  const attr = name.toLowerCase();
1159
+ if (attr === "style") {
1160
+ const style = filterUnsafeCssMagnitudes(value);
1161
+ if (style) next[name] = style;
1162
+ continue;
1163
+ }
548
1164
  next[name] = URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? "#" : value;
549
1165
  }
1166
+ if (tagName === "a" && next.target === "_blank") {
1167
+ next.rel = "noopener noreferrer";
1168
+ }
550
1169
  return { tagName, attribs: next };
551
1170
  }
552
1171
  var SANITIZE_OPTIONS = {
@@ -572,19 +1191,25 @@ var SANITIZE_OPTIONS = {
572
1191
  "p",
573
1192
  "pre",
574
1193
  "s",
1194
+ "section",
1195
+ "small",
575
1196
  "span",
576
1197
  "strong",
1198
+ "sub",
577
1199
  "summary",
1200
+ "sup",
578
1201
  "table",
579
1202
  "tbody",
580
1203
  "td",
1204
+ "tfoot",
581
1205
  "th",
1206
+ "thead",
582
1207
  "tr",
583
1208
  "u",
584
1209
  "ul"
585
1210
  ],
586
1211
  allowedAttributes: {
587
- "*": ["class", "data-*"],
1212
+ "*": ["class", "data-*", "style"],
588
1213
  a: [
589
1214
  "href",
590
1215
  "title",
@@ -601,7 +1226,14 @@ var SANITIZE_OPTIONS = {
601
1226
  p: ["class", "data-*", "style"],
602
1227
  span: ["class", "data-*", "style"],
603
1228
  td: ["colspan", "rowspan", "class", "data-*"],
604
- th: ["colspan", "rowspan", "class", "data-*"]
1229
+ // scope 는 표 머리행의 접근성 의미(스크린리더 열/행 연결) — 값 집합을 고정해 허용.
1230
+ th: [
1231
+ "colspan",
1232
+ "rowspan",
1233
+ "class",
1234
+ "data-*",
1235
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
1236
+ ]
605
1237
  },
606
1238
  allowedClasses: {
607
1239
  "*": [ROOTTALE_CLASS_RE]
@@ -611,8 +1243,31 @@ var SANITIZE_OPTIONS = {
611
1243
  "aspect-ratio": [ASPECT_RATIO_RE],
612
1244
  background: [HEX_COLOR_RE],
613
1245
  "background-color": [HEX_COLOR_RE],
1246
+ border: [CSS_BORDER_RE],
1247
+ "border-bottom": [CSS_BORDER_RE],
1248
+ "border-left": [CSS_BORDER_RE],
1249
+ "border-right": [CSS_BORDER_RE],
1250
+ "border-top": [CSS_BORDER_RE],
1251
+ "border-collapse": [/^(?:collapse|separate)$/i],
614
1252
  color: [HEX_COLOR_RE],
615
- "text-align": [TEXT_ALIGN_RE]
1253
+ "font-size": [CSS_LENGTH_RE],
1254
+ "font-weight": [CSS_FONT_WEIGHT_RE],
1255
+ height: [CSS_LENGTH_RE, /^auto$/i],
1256
+ "line-height": [CSS_LINE_HEIGHT_RE],
1257
+ margin: [CSS_LENGTH_LIST_RE],
1258
+ "margin-bottom": [CSS_LENGTH_RE],
1259
+ "margin-left": [CSS_LENGTH_RE],
1260
+ "margin-right": [CSS_LENGTH_RE],
1261
+ "margin-top": [CSS_LENGTH_RE],
1262
+ "max-width": [CSS_LENGTH_RE, /^none$/i],
1263
+ padding: [CSS_LENGTH_LIST_RE],
1264
+ "padding-bottom": [CSS_LENGTH_RE],
1265
+ "padding-left": [CSS_LENGTH_RE],
1266
+ "padding-right": [CSS_LENGTH_RE],
1267
+ "padding-top": [CSS_LENGTH_RE],
1268
+ "text-align": [TEXT_ALIGN_RE],
1269
+ "text-decoration": [CSS_TEXT_DECORATION_RE],
1270
+ width: [CSS_LENGTH_RE, /^auto$/i]
616
1271
  }
617
1272
  },
618
1273
  allowedSchemes: ["http", "https", "mailto", "tel"],
@@ -641,7 +1296,18 @@ var SANITIZE_OPTIONS = {
641
1296
  };
642
1297
  function sanitizeHtml(html) {
643
1298
  if (!html) return html;
644
- return (0, import_sanitize_html.default)(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1299
+ return (0, import_sanitize_html2.default)(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1300
+ }
1301
+ function htmlToPlainText(html) {
1302
+ const safeHtml = sanitizeHtml(html);
1303
+ const withBlockBoundaries = safeHtml.replace(
1304
+ /<(?:br|hr)\b[^>]*>|<\/(?:blockquote|details|div|figcaption|figure|h[2-4]|li|p|pre|section|summary|table|tbody|td|th|tr)>/gi,
1305
+ " "
1306
+ );
1307
+ return (0, import_sanitize_html2.default)(withBlockBoundaries, {
1308
+ allowedTags: [],
1309
+ allowedAttributes: {}
1310
+ }).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
645
1311
  }
646
1312
  var PHASE_1_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; font-src 'self' data:; connect-src 'self'; frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
647
1313
 
@@ -674,9 +1340,11 @@ function injectStableIdsTree(blocks) {
674
1340
  boundFieldValue,
675
1341
  chainResolvers,
676
1342
  createFieldBindingResolver,
1343
+ ensureImportedHtmlDocumentRoots,
677
1344
  ensureStableIds,
678
1345
  extractFaqEntries,
679
1346
  faqPageJsonLd,
1347
+ htmlToPlainText,
680
1348
  injectStableIdsTree,
681
1349
  isAllowedEmbedSrc,
682
1350
  isAllowedMapEmbedSrc,
@@ -689,10 +1357,18 @@ function injectStableIdsTree(blocks) {
689
1357
  postsForCollection,
690
1358
  postsInAnyCollection,
691
1359
  postsNotInAnyCollection,
1360
+ resolveCategoryPath,
1361
+ resolveCategoryTreeChain,
1362
+ resolveCategoryTreeEntryPath,
1363
+ resolveCategoryTreeHubPaths,
1364
+ resolvePostCategorySlug,
692
1365
  resolvePostCollection,
693
1366
  resolvePostPath,
694
1367
  resolvePostUrl,
695
1368
  sanitizeHtml,
1369
+ sanitizeImportedCss,
1370
+ sanitizeImportedHtml,
1371
+ scopeImportedCss,
696
1372
  serializeBlock,
697
1373
  validateCollections,
698
1374
  verifyPreviewToken