@roottale/cms-core 0.46.0 → 0.53.1

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
@@ -33,6 +33,7 @@ __export(src_exports, {
33
33
  BLOCK_NAME_PATTERN: () => BLOCK_NAME_PATTERN,
34
34
  FIELD_BINDING_SOURCE: () => FIELD_BINDING_SOURCE,
35
35
  FIELD_BINDING_SOURCE_ALIAS: () => FIELD_BINDING_SOURCE_ALIAS,
36
+ IMAGE_ALT_MAX_LENGTH: () => IMAGE_ALT_MAX_LENGTH,
36
37
  PHASE_1_CORE_BLOCKS: () => PHASE_1_CORE_BLOCKS,
37
38
  PHASE_1_CSP: () => PHASE_1_CSP,
38
39
  applyBlockBindings: () => applyBlockBindings,
@@ -42,9 +43,11 @@ __export(src_exports, {
42
43
  boundFieldValue: () => boundFieldValue,
43
44
  chainResolvers: () => chainResolvers,
44
45
  createFieldBindingResolver: () => createFieldBindingResolver,
46
+ ensureImportedHtmlDocumentRoots: () => ensureImportedHtmlDocumentRoots,
45
47
  ensureStableIds: () => ensureStableIds,
46
48
  extractFaqEntries: () => extractFaqEntries,
47
49
  faqPageJsonLd: () => faqPageJsonLd,
50
+ htmlToPlainText: () => htmlToPlainText,
48
51
  injectStableIdsTree: () => injectStableIdsTree,
49
52
  isAllowedEmbedSrc: () => isAllowedEmbedSrc,
50
53
  isAllowedMapEmbedSrc: () => isAllowedMapEmbedSrc,
@@ -52,6 +55,7 @@ __export(src_exports, {
52
55
  isValidBlock: () => isValidBlock,
53
56
  issuePreviewToken: () => issuePreviewToken,
54
57
  normalizeEmbed: () => normalizeEmbed,
58
+ normalizeImageAlt: () => normalizeImageAlt,
55
59
  parseBlock: () => parseBlock,
56
60
  postsForCollection: () => postsForCollection,
57
61
  postsInAnyCollection: () => postsInAnyCollection,
@@ -60,6 +64,9 @@ __export(src_exports, {
60
64
  resolvePostPath: () => resolvePostPath,
61
65
  resolvePostUrl: () => resolvePostUrl,
62
66
  sanitizeHtml: () => sanitizeHtml,
67
+ sanitizeImportedCss: () => sanitizeImportedCss,
68
+ sanitizeImportedHtml: () => sanitizeImportedHtml,
69
+ scopeImportedCss: () => scopeImportedCss,
63
70
  serializeBlock: () => serializeBlock,
64
71
  validateCollections: () => validateCollections,
65
72
  verifyPreviewToken: () => verifyPreviewToken
@@ -437,6 +444,478 @@ function faqPageJsonLd(entries) {
437
444
  };
438
445
  }
439
446
 
447
+ // src/image-alt.ts
448
+ var IMAGE_ALT_MAX_LENGTH = 160;
449
+ function normalizeImageAlt(value) {
450
+ if (typeof value !== "string") return "";
451
+ const normalized = value.replace(/\s+/g, " ").trim();
452
+ const characters = Array.from(normalized);
453
+ if (characters.length <= IMAGE_ALT_MAX_LENGTH) return normalized;
454
+ const visible = characters.slice(0, IMAGE_ALT_MAX_LENGTH - 1).join("").trimEnd();
455
+ return `${visible}\u2026`;
456
+ }
457
+
458
+ // src/imported-html-sanitize.ts
459
+ var import_postcss = __toESM(require("postcss"), 1);
460
+ var import_sanitize_html = __toESM(require("sanitize-html"), 1);
461
+ 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;
462
+ var ALLOWED_AT_RULES = /* @__PURE__ */ new Set([
463
+ "container",
464
+ "font-face",
465
+ "keyframes",
466
+ "-webkit-keyframes",
467
+ "layer",
468
+ "media",
469
+ "supports"
470
+ ]);
471
+ var URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "src"]);
472
+ var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
473
+ var UNSAFE_CSS_VALUE_RE = /(?:expression\s*\(|javascript\s*:|vbscript\s*:|@import|<\/?(?:style|script)|\\0|\\0000)/i;
474
+ var UNSAFE_CSS_PROPERTY_RE = /^(?:behavior|-moz-binding)$/i;
475
+ var FIXED_POSITION_RE = /^(?:fixed|sticky)$/i;
476
+ var ORIGINAL_H1_ATTRIBUTE = "data-rt-original-heading";
477
+ var SCOPE_ROOT_BOUNDARY_PROPERTIES = /* @__PURE__ */ new Set([
478
+ "bottom",
479
+ "inset",
480
+ "inset-block",
481
+ "inset-inline",
482
+ "left",
483
+ "position",
484
+ "right",
485
+ "rotate",
486
+ "scale",
487
+ "top",
488
+ "transform",
489
+ "translate",
490
+ "z-index"
491
+ ]);
492
+ function fromCodePointSafe(value, radix) {
493
+ const codePoint = Number.parseInt(value, radix);
494
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 1114111) {
495
+ return "";
496
+ }
497
+ try {
498
+ return String.fromCodePoint(codePoint);
499
+ } catch {
500
+ return "";
501
+ }
502
+ }
503
+ function isSafeUrl(tagName, attributeName, value) {
504
+ 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();
505
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized)?.[1];
506
+ if (!scheme) return true;
507
+ if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
508
+ return tagName === "img" && attributeName === "src" && scheme === "data" && normalized.startsWith("data:image/") && !normalized.startsWith("data:image/svg");
509
+ }
510
+ function cssUrlsAreSafe(value, allowedAssetUrls) {
511
+ const urlRe = /url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi;
512
+ let matched = false;
513
+ for (const match of value.matchAll(urlRe)) {
514
+ matched = true;
515
+ const url = match[2]?.trim() ?? "";
516
+ if (!url.startsWith("#") && !allowedAssetUrls.has(url)) return false;
517
+ }
518
+ return !/url\s*\(/i.test(value) || matched;
519
+ }
520
+ function declarationIsSafe(property, value, allowedAssetUrls) {
521
+ if (UNSAFE_CSS_PROPERTY_RE.test(property) || UNSAFE_CSS_VALUE_RE.test(value)) {
522
+ return false;
523
+ }
524
+ if (!cssUrlsAreSafe(value, allowedAssetUrls)) return false;
525
+ if (property === "position" && /\b(?:fixed|sticky)\b/i.test(value)) {
526
+ return false;
527
+ }
528
+ if (property.startsWith("--") && FIXED_POSITION_RE.test(value.trim())) {
529
+ return false;
530
+ }
531
+ if (property === "z-index") {
532
+ const normalized = value.trim().toLowerCase();
533
+ if (normalized !== "auto") {
534
+ const zIndex = Number(normalized);
535
+ if (!Number.isInteger(zIndex) || Math.abs(zIndex) > 100) return false;
536
+ }
537
+ }
538
+ for (const match of value.matchAll(/(-?\d+(?:\.\d+)?)(px|vh|vw)/gi)) {
539
+ const magnitude = Math.abs(Number(match[1]));
540
+ const limit = match[2].toLowerCase() === "px" ? 4e3 : 200;
541
+ if (magnitude > limit) return false;
542
+ }
543
+ return true;
544
+ }
545
+ function cleanCssContainer(container, allowedAssetUrls) {
546
+ container.walkComments((comment) => {
547
+ comment.remove();
548
+ });
549
+ container.walkAtRules((atRule) => {
550
+ const name = atRule.name.toLowerCase();
551
+ const isKeyframes = name === "keyframes" || name === "-webkit-keyframes";
552
+ 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)) {
553
+ atRule.remove();
554
+ }
555
+ });
556
+ container.walkDecls((declaration) => {
557
+ const property = declaration.prop.trim().toLowerCase();
558
+ if (!property || !declarationIsSafe(property, declaration.value, allowedAssetUrls)) {
559
+ declaration.remove();
560
+ return;
561
+ }
562
+ });
563
+ container.walkRules((rule) => {
564
+ if (!rule.selector.trim() || !rule.nodes?.length || /<\/?(?:style|script)/i.test(rule.selector)) {
565
+ rule.remove();
566
+ }
567
+ });
568
+ container.walkAtRules("font-face", (fontFace) => {
569
+ const declarations = fontFace.nodes ?? [];
570
+ const hasFamily = declarations.some(
571
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
572
+ );
573
+ const hasSource = declarations.some(
574
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src"
575
+ );
576
+ if (!hasFamily || !hasSource) fontFace.remove();
577
+ });
578
+ }
579
+ function sanitizeImportedCss(css, allowedAssetUrls = []) {
580
+ if (!css.trim()) return "";
581
+ try {
582
+ const root = import_postcss.default.parse(css);
583
+ cleanCssContainer(root, new Set(allowedAssetUrls));
584
+ return root.toString().trim();
585
+ } catch {
586
+ return "";
587
+ }
588
+ }
589
+ function sanitizeInlineStyle(style, allowedAssetUrls) {
590
+ try {
591
+ const root = import_postcss.default.parse(`x{${style}}`);
592
+ cleanCssContainer(root, allowedAssetUrls);
593
+ const rule = root.first;
594
+ if (!rule || rule.type !== "rule") return "";
595
+ return rule.nodes.map((node) => node.toString()).join(";");
596
+ } catch {
597
+ return "";
598
+ }
599
+ }
600
+ function sanitizeImportedAttributes(tagName, attributes, allowedAssetUrls) {
601
+ const next = {};
602
+ for (const [name, value] of Object.entries(attributes)) {
603
+ const attributeName = name.toLowerCase();
604
+ if (attributeName === "style") {
605
+ const style = sanitizeInlineStyle(value, allowedAssetUrls);
606
+ if (style) next[name] = style;
607
+ continue;
608
+ }
609
+ if (attributeName === "srcset") {
610
+ const srcset = sanitizeSrcset(value);
611
+ if (srcset) next[name] = srcset;
612
+ continue;
613
+ }
614
+ if ((attributeName === "fill" || attributeName === "stroke") && /url\s*\(/i.test(value) && !/^url\(#[a-z_][\w:.-]*\)$/i.test(value.trim())) {
615
+ continue;
616
+ }
617
+ if ((tagName === "use" || tagName === "image") && (attributeName === "href" || attributeName === "xlink:href") && !value.trim().startsWith("#")) {
618
+ continue;
619
+ }
620
+ next[name] = URL_ATTRIBUTES.has(attributeName) && !isSafeUrl(tagName, attributeName, value) ? "#" : value;
621
+ }
622
+ if (tagName === "a" && next.target === "_blank") {
623
+ next.rel = "noopener noreferrer";
624
+ }
625
+ if (tagName === "h1") {
626
+ next[ORIGINAL_H1_ATTRIBUTE] = "h1";
627
+ return { tagName: "h2", attribs: next };
628
+ }
629
+ return { tagName, attribs: next };
630
+ }
631
+ function sanitizeSrcset(value) {
632
+ const candidates = [];
633
+ for (const rawCandidate of value.split(",")) {
634
+ const candidate = rawCandidate.trim();
635
+ if (!candidate) continue;
636
+ const [url, ...descriptorParts] = candidate.split(/\s+/);
637
+ if (!url || !isSafeUrl("img", "src", url)) continue;
638
+ const descriptor = descriptorParts.join(" ");
639
+ if (descriptor && !/^(?:\d+(?:\.\d+)?x|\d+w)$/.test(descriptor)) continue;
640
+ candidates.push(descriptor ? `${url} ${descriptor}` : url);
641
+ }
642
+ return candidates.join(", ");
643
+ }
644
+ var IMPORTED_HTML_OPTIONS = {
645
+ allowedTags: [
646
+ "a",
647
+ "abbr",
648
+ "address",
649
+ "article",
650
+ "aside",
651
+ "b",
652
+ "bdi",
653
+ "bdo",
654
+ "blockquote",
655
+ "br",
656
+ "caption",
657
+ "cite",
658
+ "code",
659
+ "col",
660
+ "colgroup",
661
+ "dd",
662
+ "del",
663
+ "details",
664
+ "dfn",
665
+ "div",
666
+ "dl",
667
+ "dt",
668
+ "em",
669
+ "figcaption",
670
+ "figure",
671
+ "footer",
672
+ "h1",
673
+ "h2",
674
+ "h3",
675
+ "h4",
676
+ "h5",
677
+ "h6",
678
+ "header",
679
+ "hr",
680
+ "i",
681
+ "img",
682
+ "kbd",
683
+ "li",
684
+ "main",
685
+ "mark",
686
+ "nav",
687
+ "ol",
688
+ "p",
689
+ "picture",
690
+ "pre",
691
+ "q",
692
+ "s",
693
+ "samp",
694
+ "section",
695
+ "small",
696
+ "source",
697
+ "svg",
698
+ "g",
699
+ "path",
700
+ "circle",
701
+ "ellipse",
702
+ "line",
703
+ "polyline",
704
+ "polygon",
705
+ "rect",
706
+ "defs",
707
+ "lineargradient",
708
+ "radialgradient",
709
+ "stop",
710
+ "use",
711
+ "clippath",
712
+ "mask",
713
+ "pattern",
714
+ "title",
715
+ "desc",
716
+ "span",
717
+ "strong",
718
+ "sub",
719
+ "summary",
720
+ "sup",
721
+ "table",
722
+ "tbody",
723
+ "td",
724
+ "tfoot",
725
+ "th",
726
+ "thead",
727
+ "time",
728
+ "tr",
729
+ "u",
730
+ "ul",
731
+ "var",
732
+ "wbr"
733
+ ],
734
+ allowedAttributes: {
735
+ "*": ["aria-*", "class", "data-*", "dir", "id", "lang", "role", "style", "title"],
736
+ a: [
737
+ "href",
738
+ { name: "rel", multiple: true, values: ["noopener", "noreferrer"] },
739
+ { name: "target", values: ["_blank"] }
740
+ ],
741
+ col: ["span", "style"],
742
+ img: ["alt", "height", "loading", "sizes", "src", "srcset", "style", "width"],
743
+ source: ["height", "media", "sizes", "src", "srcset", "type", "width"],
744
+ svg: ["aria-*", "class", "fill", "height", "preserveAspectRatio", "role", "stroke", "style", "viewBox", "viewbox", "width", "xmlns"],
745
+ g: ["class", "fill", "stroke", "stroke-width", "style", "transform"],
746
+ path: ["class", "d", "fill", "fill-rule", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
747
+ circle: ["class", "cx", "cy", "fill", "r", "stroke", "stroke-width", "style", "transform"],
748
+ ellipse: ["class", "cx", "cy", "fill", "rx", "ry", "stroke", "stroke-width", "style", "transform"],
749
+ line: ["class", "stroke", "stroke-linecap", "stroke-width", "style", "transform", "x1", "x2", "y1", "y2"],
750
+ polyline: ["class", "fill", "points", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
751
+ polygon: ["class", "fill", "points", "stroke", "stroke-width", "style", "transform"],
752
+ rect: ["class", "fill", "height", "rx", "ry", "stroke", "stroke-width", "style", "transform", "width", "x", "y"],
753
+ lineargradient: ["gradientTransform", "gradientUnits", "id", "spreadMethod", "x1", "x2", "y1", "y2"],
754
+ radialgradient: ["cx", "cy", "fx", "fy", "gradientTransform", "gradientUnits", "id", "r", "spreadMethod"],
755
+ stop: ["offset", "stop-color", "stop-opacity", "style"],
756
+ use: ["fill", "height", "href", "stroke", "style", "width", "x", "xlink:href", "y"],
757
+ clippath: ["clipPathUnits", "id", "transform"],
758
+ mask: ["height", "id", "maskContentUnits", "maskUnits", "width", "x", "y"],
759
+ pattern: ["height", "id", "patternContentUnits", "patternTransform", "patternUnits", "preserveAspectRatio", "viewBox", "width", "x", "y"],
760
+ td: ["colspan", "rowspan", "style"],
761
+ th: [
762
+ "colspan",
763
+ "rowspan",
764
+ "style",
765
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
766
+ ],
767
+ time: ["datetime"]
768
+ },
769
+ allowedSchemes: ["http", "https", "mailto", "tel"],
770
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
771
+ allowedSchemesAppliedToAttributes: ["href", "src"],
772
+ allowProtocolRelative: false,
773
+ nonTextTags: ["script", "style", "textarea", "option", "iframe", "object", "embed", "foreignobject", "math", "template"]
774
+ };
775
+ function sanitizeImportedHtml(html, allowedAssetUrls = []) {
776
+ if (!html) return html;
777
+ const allowedSet = new Set(allowedAssetUrls);
778
+ return (0, import_sanitize_html.default)(html, {
779
+ ...IMPORTED_HTML_OPTIONS,
780
+ transformTags: {
781
+ "*": (tagName, attributes) => sanitizeImportedAttributes(tagName, attributes, allowedSet)
782
+ }
783
+ });
784
+ }
785
+ function ensureImportedHtmlDocumentRoots(html, allowedAssetUrls = []) {
786
+ const safeHtml = sanitizeImportedHtml(html, allowedAssetUrls).trim();
787
+ if (!safeHtml) return safeHtml;
788
+ if (/^<div\b[^>]*\bdata-rt-import-document(?:=(?:""|'')|(?=[\s>]))[^>]*>\s*<div\b[^>]*\bdata-rt-import-body(?:=(?:""|'')|(?=[\s>]))/i.test(
789
+ safeHtml
790
+ )) {
791
+ return safeHtml;
792
+ }
793
+ return `<div data-rt-import-document><div data-rt-import-body>${safeHtml}</div></div>`;
794
+ }
795
+ function normalizeDocumentSelector(selector) {
796
+ const typeBoundary = "(?=$|[\\s>+~,.#:\\[\\)])";
797
+ 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(
798
+ new RegExp(`(^|[\\s>+~,\\(])h1${typeBoundary}`, "gi"),
799
+ `$1[${ORIGINAL_H1_ATTRIBUTE}="h1"]`
800
+ ).replace(/:root/g, "[data-rt-import-document]");
801
+ }
802
+ function selectorTargetsDocumentBoundary(selector) {
803
+ return selector.split(",").some((part) => {
804
+ const normalized = part.trim();
805
+ const targetsDocumentRoot = normalized.startsWith("[data-rt-import-document]") && !/[\s>+~]/.test(normalized.slice("[data-rt-import-document]".length));
806
+ const targetsBodyRoot = /\[data-rt-import-body\](?:[.#:][\w-]+|\[[^\]]+\])*\s*$/.test(
807
+ normalized
808
+ );
809
+ return targetsDocumentRoot || targetsBodyRoot;
810
+ });
811
+ }
812
+ function replaceAnimationName(value, names) {
813
+ let next = value;
814
+ for (const [name, scopedName] of names) {
815
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
816
+ next = next.replace(new RegExp(`(^|[^\\w-])${escaped}(?=$|[^\\w-])`, "g"), `$1${scopedName}`);
817
+ }
818
+ return next;
819
+ }
820
+ function unquoteCssIdentifier(value) {
821
+ const normalized = value.trim();
822
+ const match = /^(?:"([^"]+)"|'([^']+)'|([a-z_][\w -]*))$/i.exec(normalized);
823
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim() || null;
824
+ }
825
+ function replaceFontFamilyName(value, names) {
826
+ let next = value;
827
+ for (const [name, scopedName] of names) {
828
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
829
+ next = next.replace(
830
+ new RegExp(`(["'])${escaped}\\1|(^|[,\\s])${escaped}(?=$|[,\\s/])`, "gi"),
831
+ (matched, quote, prefix) => quote ? `${quote}${scopedName}${quote}` : `${prefix ?? ""}${scopedName}`
832
+ );
833
+ }
834
+ return next;
835
+ }
836
+ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
837
+ if (!IMPORT_SCOPE_RE.test(scope)) return "";
838
+ const safeCss = sanitizeImportedCss(css, allowedAssetUrls);
839
+ if (!safeCss) return "";
840
+ try {
841
+ const root = import_postcss.default.parse(safeCss);
842
+ const keyframeNames = /* @__PURE__ */ new Map();
843
+ const fontFamilyNames = /* @__PURE__ */ new Map();
844
+ const fontFaces = root.nodes.filter(
845
+ (node) => node.type === "atrule" && node.name.toLowerCase() === "font-face"
846
+ );
847
+ for (const fontFace of fontFaces) {
848
+ if (fontFace.type !== "atrule") continue;
849
+ const family = fontFace.nodes?.find(
850
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
851
+ );
852
+ if (!family || family.type !== "decl") {
853
+ fontFace.remove();
854
+ continue;
855
+ }
856
+ const originalName = unquoteCssIdentifier(family.value);
857
+ const hasSupportedSource = fontFace.nodes?.some(
858
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src" && /url\(\s*["']?https:\/\/[^)'"\s]+\.woff2?(?:[?#][^)'"\s]*)?["']?\s*\)/i.test(
859
+ node.value
860
+ )
861
+ );
862
+ if (!originalName || !hasSupportedSource) {
863
+ fontFace.remove();
864
+ continue;
865
+ }
866
+ const scopedName = `${scope}-${originalName}`;
867
+ fontFamilyNames.set(originalName, scopedName);
868
+ family.value = family.value.replace(originalName, scopedName);
869
+ }
870
+ root.walkAtRules((atRule) => {
871
+ const name = atRule.name.toLowerCase();
872
+ if (name !== "keyframes" && name !== "-webkit-keyframes") return;
873
+ const originalName = atRule.params.trim();
874
+ const scopedName = `${scope}-${originalName}`;
875
+ keyframeNames.set(originalName, scopedName);
876
+ atRule.params = scopedName;
877
+ });
878
+ root.walkDecls((declaration) => {
879
+ const property = declaration.prop.trim().toLowerCase();
880
+ if (property === "animation" || property === "animation-name") {
881
+ declaration.value = replaceAnimationName(declaration.value, keyframeNames);
882
+ }
883
+ if (property === "font-family" || property === "font") {
884
+ declaration.value = replaceFontFamilyName(
885
+ declaration.value,
886
+ fontFamilyNames
887
+ );
888
+ }
889
+ });
890
+ root.walkRules((rule) => {
891
+ const parent = rule.parent;
892
+ if (parent?.type === "atrule" && (parent.name.toLowerCase() === "keyframes" || parent.name.toLowerCase() === "-webkit-keyframes")) {
893
+ return;
894
+ }
895
+ rule.selector = normalizeDocumentSelector(rule.selector);
896
+ if (selectorTargetsDocumentBoundary(rule.selector)) {
897
+ rule.walkDecls((declaration) => {
898
+ if (SCOPE_ROOT_BOUNDARY_PROPERTIES.has(
899
+ declaration.prop.trim().toLowerCase()
900
+ )) {
901
+ declaration.remove();
902
+ }
903
+ });
904
+ }
905
+ });
906
+ const scoped = import_postcss.default.atRule({
907
+ name: "scope",
908
+ params: `([data-rt-import-scope="${scope}"])`
909
+ });
910
+ const preservedFontFaces = fontFaces.filter((fontFace) => fontFace.parent);
911
+ for (const fontFace of preservedFontFaces) fontFace.remove();
912
+ scoped.append(root.nodes);
913
+ return [...preservedFontFaces.map((fontFace) => fontFace.toString()), scoped.toString()].filter(Boolean).join("\n");
914
+ } catch {
915
+ return "";
916
+ }
917
+ }
918
+
440
919
  // src/preview-token.ts
441
920
  var import_node_crypto = require("crypto");
442
921
  var DELIMITER = ".";
@@ -472,15 +951,58 @@ function verifyPreviewToken(secret, token) {
472
951
  }
473
952
 
474
953
  // src/sanitize.ts
475
- var import_sanitize_html = __toESM(require("sanitize-html"), 1);
954
+ var import_sanitize_html2 = __toESM(require("sanitize-html"), 1);
476
955
  var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "xlink:href"]);
477
- var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
956
+ var ALLOWED_URL_SCHEMES2 = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
478
957
  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;
479
958
  var SLASH_EVENT_ATTR_IN_URL_RE = /\/on[a-z]+=/i;
480
959
  var ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;
481
960
  var HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;
482
961
  var TEXT_ALIGN_RE = /^(left|right|center|justify)$/;
483
962
  var ASPECT_RATIO_RE = /^\d+(?:\.\d+)?\s*\/\s*\d+(?:\.\d+)?$/;
963
+ var CSS_LENGTH_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))$/i;
964
+ var CSS_LENGTH_LIST_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))){0,3}$/i;
965
+ var CSS_LINE_HEIGHT_RE = /^(?:normal|\d+(?:\.\d+)?(?:px|em|rem|%)?)$/i;
966
+ var CSS_FONT_WEIGHT_RE = /^(?:normal|bold|[1-9]00)$/i;
967
+ 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;
968
+ var CSS_TEXT_DECORATION_RE = /^(?:none|underline|line-through)$/i;
969
+ var CSS_MAGNITUDE_LIMITS = {
970
+ "font-size": { "": 0, px: 96, em: 5, rem: 5, "%": 200 },
971
+ "line-height": { "": 5, px: 96, em: 5, rem: 5, "%": 500 },
972
+ margin: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
973
+ "margin-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
974
+ "margin-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
975
+ "margin-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
976
+ "margin-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
977
+ padding: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
978
+ "padding-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
979
+ "padding-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
980
+ "padding-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
981
+ "padding-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
982
+ width: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
983
+ "max-width": { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
984
+ height: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 }
985
+ };
986
+ function hasSafeCssMagnitude(property, value) {
987
+ const limits = CSS_MAGNITUDE_LIMITS[property];
988
+ if (!limits) return true;
989
+ const numericTokens = value.matchAll(/(\d+(?:\.\d+)?)(px|em|rem|%)?/gi);
990
+ for (const match of numericTokens) {
991
+ const unit = (match[2] ?? "").toLowerCase();
992
+ const limit = limits[unit];
993
+ if (limit === void 0 || Number(match[1]) > limit) return false;
994
+ }
995
+ return true;
996
+ }
997
+ function filterUnsafeCssMagnitudes(style) {
998
+ return style.split(";").map((declaration) => declaration.trim()).filter(Boolean).filter((declaration) => {
999
+ const colon = declaration.indexOf(":");
1000
+ if (colon < 1) return false;
1001
+ const property = declaration.slice(0, colon).trim().toLowerCase();
1002
+ const value = declaration.slice(colon + 1).trim();
1003
+ return hasSafeCssMagnitude(property, value);
1004
+ }).join(";");
1005
+ }
484
1006
  var NAMED_ENTITY = {
485
1007
  colon: ":",
486
1008
  tab: " ",
@@ -493,7 +1015,7 @@ var NAMED_ENTITY = {
493
1015
  lpar: "(",
494
1016
  rpar: ")"
495
1017
  };
496
- function fromCodePointSafe(n) {
1018
+ function fromCodePointSafe2(n) {
497
1019
  if (!Number.isFinite(n) || n < 0 || n > 1114111) return "";
498
1020
  try {
499
1021
  return String.fromCodePoint(n);
@@ -502,7 +1024,7 @@ function fromCodePointSafe(n) {
502
1024
  }
503
1025
  }
504
1026
  function decodeUrlProbe(value) {
505
- 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);
1027
+ 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);
506
1028
  return [...decoded].filter((c) => {
507
1029
  const n = c.codePointAt(0) ?? 0;
508
1030
  return n > 32 && n !== 127;
@@ -522,7 +1044,7 @@ function isSafeUrlValue(tag, attr, value) {
522
1044
  if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;
523
1045
  const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];
524
1046
  if (!scheme) return true;
525
- if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
1047
+ if (ALLOWED_URL_SCHEMES2.has(scheme)) return true;
526
1048
  if (scheme === "data" && tag === "img" && attr === "src" && probe.startsWith("data:image/")) {
527
1049
  return !probe.startsWith("data:image/svg");
528
1050
  }
@@ -532,8 +1054,16 @@ function sanitizeUrlAttributes(tagName, attribs) {
532
1054
  const next = {};
533
1055
  for (const [name, value] of Object.entries(attribs)) {
534
1056
  const attr = name.toLowerCase();
1057
+ if (attr === "style") {
1058
+ const style = filterUnsafeCssMagnitudes(value);
1059
+ if (style) next[name] = style;
1060
+ continue;
1061
+ }
535
1062
  next[name] = URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? "#" : value;
536
1063
  }
1064
+ if (tagName === "a" && next.target === "_blank") {
1065
+ next.rel = "noopener noreferrer";
1066
+ }
537
1067
  return { tagName, attribs: next };
538
1068
  }
539
1069
  var SANITIZE_OPTIONS = {
@@ -559,19 +1089,25 @@ var SANITIZE_OPTIONS = {
559
1089
  "p",
560
1090
  "pre",
561
1091
  "s",
1092
+ "section",
1093
+ "small",
562
1094
  "span",
563
1095
  "strong",
1096
+ "sub",
564
1097
  "summary",
1098
+ "sup",
565
1099
  "table",
566
1100
  "tbody",
567
1101
  "td",
1102
+ "tfoot",
568
1103
  "th",
1104
+ "thead",
569
1105
  "tr",
570
1106
  "u",
571
1107
  "ul"
572
1108
  ],
573
1109
  allowedAttributes: {
574
- "*": ["class", "data-*"],
1110
+ "*": ["class", "data-*", "style"],
575
1111
  a: [
576
1112
  "href",
577
1113
  "title",
@@ -588,7 +1124,14 @@ var SANITIZE_OPTIONS = {
588
1124
  p: ["class", "data-*", "style"],
589
1125
  span: ["class", "data-*", "style"],
590
1126
  td: ["colspan", "rowspan", "class", "data-*"],
591
- th: ["colspan", "rowspan", "class", "data-*"]
1127
+ // scope 는 표 머리행의 접근성 의미(스크린리더 열/행 연결) — 값 집합을 고정해 허용.
1128
+ th: [
1129
+ "colspan",
1130
+ "rowspan",
1131
+ "class",
1132
+ "data-*",
1133
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
1134
+ ]
592
1135
  },
593
1136
  allowedClasses: {
594
1137
  "*": [ROOTTALE_CLASS_RE]
@@ -598,13 +1141,38 @@ var SANITIZE_OPTIONS = {
598
1141
  "aspect-ratio": [ASPECT_RATIO_RE],
599
1142
  background: [HEX_COLOR_RE],
600
1143
  "background-color": [HEX_COLOR_RE],
1144
+ border: [CSS_BORDER_RE],
1145
+ "border-bottom": [CSS_BORDER_RE],
1146
+ "border-left": [CSS_BORDER_RE],
1147
+ "border-right": [CSS_BORDER_RE],
1148
+ "border-top": [CSS_BORDER_RE],
1149
+ "border-collapse": [/^(?:collapse|separate)$/i],
601
1150
  color: [HEX_COLOR_RE],
602
- "text-align": [TEXT_ALIGN_RE]
1151
+ "font-size": [CSS_LENGTH_RE],
1152
+ "font-weight": [CSS_FONT_WEIGHT_RE],
1153
+ height: [CSS_LENGTH_RE, /^auto$/i],
1154
+ "line-height": [CSS_LINE_HEIGHT_RE],
1155
+ margin: [CSS_LENGTH_LIST_RE],
1156
+ "margin-bottom": [CSS_LENGTH_RE],
1157
+ "margin-left": [CSS_LENGTH_RE],
1158
+ "margin-right": [CSS_LENGTH_RE],
1159
+ "margin-top": [CSS_LENGTH_RE],
1160
+ "max-width": [CSS_LENGTH_RE, /^none$/i],
1161
+ padding: [CSS_LENGTH_LIST_RE],
1162
+ "padding-bottom": [CSS_LENGTH_RE],
1163
+ "padding-left": [CSS_LENGTH_RE],
1164
+ "padding-right": [CSS_LENGTH_RE],
1165
+ "padding-top": [CSS_LENGTH_RE],
1166
+ "text-align": [TEXT_ALIGN_RE],
1167
+ "text-decoration": [CSS_TEXT_DECORATION_RE],
1168
+ width: [CSS_LENGTH_RE, /^auto$/i]
603
1169
  }
604
1170
  },
605
1171
  allowedSchemes: ["http", "https", "mailto", "tel"],
606
1172
  allowedSchemesByTag: {
607
- img: ["data"]
1173
+ // tag별 목록은 전역 allowedSchemes를 덮어쓴다. data만 쓰면 정상적인
1174
+ // http/https 이미지 src까지 제거되므로 이미지가 사용할 전체 scheme을 명시한다.
1175
+ img: ["http", "https", "data"]
608
1176
  },
609
1177
  allowedSchemesAppliedToAttributes: ["href", "src", "xlink:href"],
610
1178
  allowProtocolRelative: false,
@@ -626,7 +1194,18 @@ var SANITIZE_OPTIONS = {
626
1194
  };
627
1195
  function sanitizeHtml(html) {
628
1196
  if (!html) return html;
629
- return (0, import_sanitize_html.default)(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1197
+ return (0, import_sanitize_html2.default)(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1198
+ }
1199
+ function htmlToPlainText(html) {
1200
+ const safeHtml = sanitizeHtml(html);
1201
+ const withBlockBoundaries = safeHtml.replace(
1202
+ /<(?:br|hr)\b[^>]*>|<\/(?:blockquote|details|div|figcaption|figure|h[2-4]|li|p|pre|section|summary|table|tbody|td|th|tr)>/gi,
1203
+ " "
1204
+ );
1205
+ return (0, import_sanitize_html2.default)(withBlockBoundaries, {
1206
+ allowedTags: [],
1207
+ allowedAttributes: {}
1208
+ }).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
630
1209
  }
631
1210
  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'";
632
1211
 
@@ -649,6 +1228,7 @@ function injectStableIdsTree(blocks) {
649
1228
  BLOCK_NAME_PATTERN,
650
1229
  FIELD_BINDING_SOURCE,
651
1230
  FIELD_BINDING_SOURCE_ALIAS,
1231
+ IMAGE_ALT_MAX_LENGTH,
652
1232
  PHASE_1_CORE_BLOCKS,
653
1233
  PHASE_1_CSP,
654
1234
  applyBlockBindings,
@@ -658,9 +1238,11 @@ function injectStableIdsTree(blocks) {
658
1238
  boundFieldValue,
659
1239
  chainResolvers,
660
1240
  createFieldBindingResolver,
1241
+ ensureImportedHtmlDocumentRoots,
661
1242
  ensureStableIds,
662
1243
  extractFaqEntries,
663
1244
  faqPageJsonLd,
1245
+ htmlToPlainText,
664
1246
  injectStableIdsTree,
665
1247
  isAllowedEmbedSrc,
666
1248
  isAllowedMapEmbedSrc,
@@ -668,6 +1250,7 @@ function injectStableIdsTree(blocks) {
668
1250
  isValidBlock,
669
1251
  issuePreviewToken,
670
1252
  normalizeEmbed,
1253
+ normalizeImageAlt,
671
1254
  parseBlock,
672
1255
  postsForCollection,
673
1256
  postsInAnyCollection,
@@ -676,6 +1259,9 @@ function injectStableIdsTree(blocks) {
676
1259
  resolvePostPath,
677
1260
  resolvePostUrl,
678
1261
  sanitizeHtml,
1262
+ sanitizeImportedCss,
1263
+ sanitizeImportedHtml,
1264
+ scopeImportedCss,
679
1265
  serializeBlock,
680
1266
  validateCollections,
681
1267
  verifyPreviewToken