@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.js CHANGED
@@ -369,6 +369,478 @@ function faqPageJsonLd(entries) {
369
369
  };
370
370
  }
371
371
 
372
+ // src/image-alt.ts
373
+ var IMAGE_ALT_MAX_LENGTH = 160;
374
+ function normalizeImageAlt(value) {
375
+ if (typeof value !== "string") return "";
376
+ const normalized = value.replace(/\s+/g, " ").trim();
377
+ const characters = Array.from(normalized);
378
+ if (characters.length <= IMAGE_ALT_MAX_LENGTH) return normalized;
379
+ const visible = characters.slice(0, IMAGE_ALT_MAX_LENGTH - 1).join("").trimEnd();
380
+ return `${visible}\u2026`;
381
+ }
382
+
383
+ // src/imported-html-sanitize.ts
384
+ import postcss from "postcss";
385
+ import sanitizeHtmlLibrary from "sanitize-html";
386
+ 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;
387
+ var ALLOWED_AT_RULES = /* @__PURE__ */ new Set([
388
+ "container",
389
+ "font-face",
390
+ "keyframes",
391
+ "-webkit-keyframes",
392
+ "layer",
393
+ "media",
394
+ "supports"
395
+ ]);
396
+ var URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "src"]);
397
+ var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
398
+ var UNSAFE_CSS_VALUE_RE = /(?:expression\s*\(|javascript\s*:|vbscript\s*:|@import|<\/?(?:style|script)|\\0|\\0000)/i;
399
+ var UNSAFE_CSS_PROPERTY_RE = /^(?:behavior|-moz-binding)$/i;
400
+ var FIXED_POSITION_RE = /^(?:fixed|sticky)$/i;
401
+ var ORIGINAL_H1_ATTRIBUTE = "data-rt-original-heading";
402
+ var SCOPE_ROOT_BOUNDARY_PROPERTIES = /* @__PURE__ */ new Set([
403
+ "bottom",
404
+ "inset",
405
+ "inset-block",
406
+ "inset-inline",
407
+ "left",
408
+ "position",
409
+ "right",
410
+ "rotate",
411
+ "scale",
412
+ "top",
413
+ "transform",
414
+ "translate",
415
+ "z-index"
416
+ ]);
417
+ function fromCodePointSafe(value, radix) {
418
+ const codePoint = Number.parseInt(value, radix);
419
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 1114111) {
420
+ return "";
421
+ }
422
+ try {
423
+ return String.fromCodePoint(codePoint);
424
+ } catch {
425
+ return "";
426
+ }
427
+ }
428
+ function isSafeUrl(tagName, attributeName, value) {
429
+ 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();
430
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized)?.[1];
431
+ if (!scheme) return true;
432
+ if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
433
+ return tagName === "img" && attributeName === "src" && scheme === "data" && normalized.startsWith("data:image/") && !normalized.startsWith("data:image/svg");
434
+ }
435
+ function cssUrlsAreSafe(value, allowedAssetUrls) {
436
+ const urlRe = /url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi;
437
+ let matched = false;
438
+ for (const match of value.matchAll(urlRe)) {
439
+ matched = true;
440
+ const url = match[2]?.trim() ?? "";
441
+ if (!url.startsWith("#") && !allowedAssetUrls.has(url)) return false;
442
+ }
443
+ return !/url\s*\(/i.test(value) || matched;
444
+ }
445
+ function declarationIsSafe(property, value, allowedAssetUrls) {
446
+ if (UNSAFE_CSS_PROPERTY_RE.test(property) || UNSAFE_CSS_VALUE_RE.test(value)) {
447
+ return false;
448
+ }
449
+ if (!cssUrlsAreSafe(value, allowedAssetUrls)) return false;
450
+ if (property === "position" && /\b(?:fixed|sticky)\b/i.test(value)) {
451
+ return false;
452
+ }
453
+ if (property.startsWith("--") && FIXED_POSITION_RE.test(value.trim())) {
454
+ return false;
455
+ }
456
+ if (property === "z-index") {
457
+ const normalized = value.trim().toLowerCase();
458
+ if (normalized !== "auto") {
459
+ const zIndex = Number(normalized);
460
+ if (!Number.isInteger(zIndex) || Math.abs(zIndex) > 100) return false;
461
+ }
462
+ }
463
+ for (const match of value.matchAll(/(-?\d+(?:\.\d+)?)(px|vh|vw)/gi)) {
464
+ const magnitude = Math.abs(Number(match[1]));
465
+ const limit = match[2].toLowerCase() === "px" ? 4e3 : 200;
466
+ if (magnitude > limit) return false;
467
+ }
468
+ return true;
469
+ }
470
+ function cleanCssContainer(container, allowedAssetUrls) {
471
+ container.walkComments((comment) => {
472
+ comment.remove();
473
+ });
474
+ container.walkAtRules((atRule) => {
475
+ const name = atRule.name.toLowerCase();
476
+ const isKeyframes = name === "keyframes" || name === "-webkit-keyframes";
477
+ 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)) {
478
+ atRule.remove();
479
+ }
480
+ });
481
+ container.walkDecls((declaration) => {
482
+ const property = declaration.prop.trim().toLowerCase();
483
+ if (!property || !declarationIsSafe(property, declaration.value, allowedAssetUrls)) {
484
+ declaration.remove();
485
+ return;
486
+ }
487
+ });
488
+ container.walkRules((rule) => {
489
+ if (!rule.selector.trim() || !rule.nodes?.length || /<\/?(?:style|script)/i.test(rule.selector)) {
490
+ rule.remove();
491
+ }
492
+ });
493
+ container.walkAtRules("font-face", (fontFace) => {
494
+ const declarations = fontFace.nodes ?? [];
495
+ const hasFamily = declarations.some(
496
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
497
+ );
498
+ const hasSource = declarations.some(
499
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src"
500
+ );
501
+ if (!hasFamily || !hasSource) fontFace.remove();
502
+ });
503
+ }
504
+ function sanitizeImportedCss(css, allowedAssetUrls = []) {
505
+ if (!css.trim()) return "";
506
+ try {
507
+ const root = postcss.parse(css);
508
+ cleanCssContainer(root, new Set(allowedAssetUrls));
509
+ return root.toString().trim();
510
+ } catch {
511
+ return "";
512
+ }
513
+ }
514
+ function sanitizeInlineStyle(style, allowedAssetUrls) {
515
+ try {
516
+ const root = postcss.parse(`x{${style}}`);
517
+ cleanCssContainer(root, allowedAssetUrls);
518
+ const rule = root.first;
519
+ if (!rule || rule.type !== "rule") return "";
520
+ return rule.nodes.map((node) => node.toString()).join(";");
521
+ } catch {
522
+ return "";
523
+ }
524
+ }
525
+ function sanitizeImportedAttributes(tagName, attributes, allowedAssetUrls) {
526
+ const next = {};
527
+ for (const [name, value] of Object.entries(attributes)) {
528
+ const attributeName = name.toLowerCase();
529
+ if (attributeName === "style") {
530
+ const style = sanitizeInlineStyle(value, allowedAssetUrls);
531
+ if (style) next[name] = style;
532
+ continue;
533
+ }
534
+ if (attributeName === "srcset") {
535
+ const srcset = sanitizeSrcset(value);
536
+ if (srcset) next[name] = srcset;
537
+ continue;
538
+ }
539
+ if ((attributeName === "fill" || attributeName === "stroke") && /url\s*\(/i.test(value) && !/^url\(#[a-z_][\w:.-]*\)$/i.test(value.trim())) {
540
+ continue;
541
+ }
542
+ if ((tagName === "use" || tagName === "image") && (attributeName === "href" || attributeName === "xlink:href") && !value.trim().startsWith("#")) {
543
+ continue;
544
+ }
545
+ next[name] = URL_ATTRIBUTES.has(attributeName) && !isSafeUrl(tagName, attributeName, value) ? "#" : value;
546
+ }
547
+ if (tagName === "a" && next.target === "_blank") {
548
+ next.rel = "noopener noreferrer";
549
+ }
550
+ if (tagName === "h1") {
551
+ next[ORIGINAL_H1_ATTRIBUTE] = "h1";
552
+ return { tagName: "h2", attribs: next };
553
+ }
554
+ return { tagName, attribs: next };
555
+ }
556
+ function sanitizeSrcset(value) {
557
+ const candidates = [];
558
+ for (const rawCandidate of value.split(",")) {
559
+ const candidate = rawCandidate.trim();
560
+ if (!candidate) continue;
561
+ const [url, ...descriptorParts] = candidate.split(/\s+/);
562
+ if (!url || !isSafeUrl("img", "src", url)) continue;
563
+ const descriptor = descriptorParts.join(" ");
564
+ if (descriptor && !/^(?:\d+(?:\.\d+)?x|\d+w)$/.test(descriptor)) continue;
565
+ candidates.push(descriptor ? `${url} ${descriptor}` : url);
566
+ }
567
+ return candidates.join(", ");
568
+ }
569
+ var IMPORTED_HTML_OPTIONS = {
570
+ allowedTags: [
571
+ "a",
572
+ "abbr",
573
+ "address",
574
+ "article",
575
+ "aside",
576
+ "b",
577
+ "bdi",
578
+ "bdo",
579
+ "blockquote",
580
+ "br",
581
+ "caption",
582
+ "cite",
583
+ "code",
584
+ "col",
585
+ "colgroup",
586
+ "dd",
587
+ "del",
588
+ "details",
589
+ "dfn",
590
+ "div",
591
+ "dl",
592
+ "dt",
593
+ "em",
594
+ "figcaption",
595
+ "figure",
596
+ "footer",
597
+ "h1",
598
+ "h2",
599
+ "h3",
600
+ "h4",
601
+ "h5",
602
+ "h6",
603
+ "header",
604
+ "hr",
605
+ "i",
606
+ "img",
607
+ "kbd",
608
+ "li",
609
+ "main",
610
+ "mark",
611
+ "nav",
612
+ "ol",
613
+ "p",
614
+ "picture",
615
+ "pre",
616
+ "q",
617
+ "s",
618
+ "samp",
619
+ "section",
620
+ "small",
621
+ "source",
622
+ "svg",
623
+ "g",
624
+ "path",
625
+ "circle",
626
+ "ellipse",
627
+ "line",
628
+ "polyline",
629
+ "polygon",
630
+ "rect",
631
+ "defs",
632
+ "lineargradient",
633
+ "radialgradient",
634
+ "stop",
635
+ "use",
636
+ "clippath",
637
+ "mask",
638
+ "pattern",
639
+ "title",
640
+ "desc",
641
+ "span",
642
+ "strong",
643
+ "sub",
644
+ "summary",
645
+ "sup",
646
+ "table",
647
+ "tbody",
648
+ "td",
649
+ "tfoot",
650
+ "th",
651
+ "thead",
652
+ "time",
653
+ "tr",
654
+ "u",
655
+ "ul",
656
+ "var",
657
+ "wbr"
658
+ ],
659
+ allowedAttributes: {
660
+ "*": ["aria-*", "class", "data-*", "dir", "id", "lang", "role", "style", "title"],
661
+ a: [
662
+ "href",
663
+ { name: "rel", multiple: true, values: ["noopener", "noreferrer"] },
664
+ { name: "target", values: ["_blank"] }
665
+ ],
666
+ col: ["span", "style"],
667
+ img: ["alt", "height", "loading", "sizes", "src", "srcset", "style", "width"],
668
+ source: ["height", "media", "sizes", "src", "srcset", "type", "width"],
669
+ svg: ["aria-*", "class", "fill", "height", "preserveAspectRatio", "role", "stroke", "style", "viewBox", "viewbox", "width", "xmlns"],
670
+ g: ["class", "fill", "stroke", "stroke-width", "style", "transform"],
671
+ path: ["class", "d", "fill", "fill-rule", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
672
+ circle: ["class", "cx", "cy", "fill", "r", "stroke", "stroke-width", "style", "transform"],
673
+ ellipse: ["class", "cx", "cy", "fill", "rx", "ry", "stroke", "stroke-width", "style", "transform"],
674
+ line: ["class", "stroke", "stroke-linecap", "stroke-width", "style", "transform", "x1", "x2", "y1", "y2"],
675
+ polyline: ["class", "fill", "points", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
676
+ polygon: ["class", "fill", "points", "stroke", "stroke-width", "style", "transform"],
677
+ rect: ["class", "fill", "height", "rx", "ry", "stroke", "stroke-width", "style", "transform", "width", "x", "y"],
678
+ lineargradient: ["gradientTransform", "gradientUnits", "id", "spreadMethod", "x1", "x2", "y1", "y2"],
679
+ radialgradient: ["cx", "cy", "fx", "fy", "gradientTransform", "gradientUnits", "id", "r", "spreadMethod"],
680
+ stop: ["offset", "stop-color", "stop-opacity", "style"],
681
+ use: ["fill", "height", "href", "stroke", "style", "width", "x", "xlink:href", "y"],
682
+ clippath: ["clipPathUnits", "id", "transform"],
683
+ mask: ["height", "id", "maskContentUnits", "maskUnits", "width", "x", "y"],
684
+ pattern: ["height", "id", "patternContentUnits", "patternTransform", "patternUnits", "preserveAspectRatio", "viewBox", "width", "x", "y"],
685
+ td: ["colspan", "rowspan", "style"],
686
+ th: [
687
+ "colspan",
688
+ "rowspan",
689
+ "style",
690
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
691
+ ],
692
+ time: ["datetime"]
693
+ },
694
+ allowedSchemes: ["http", "https", "mailto", "tel"],
695
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
696
+ allowedSchemesAppliedToAttributes: ["href", "src"],
697
+ allowProtocolRelative: false,
698
+ nonTextTags: ["script", "style", "textarea", "option", "iframe", "object", "embed", "foreignobject", "math", "template"]
699
+ };
700
+ function sanitizeImportedHtml(html, allowedAssetUrls = []) {
701
+ if (!html) return html;
702
+ const allowedSet = new Set(allowedAssetUrls);
703
+ return sanitizeHtmlLibrary(html, {
704
+ ...IMPORTED_HTML_OPTIONS,
705
+ transformTags: {
706
+ "*": (tagName, attributes) => sanitizeImportedAttributes(tagName, attributes, allowedSet)
707
+ }
708
+ });
709
+ }
710
+ function ensureImportedHtmlDocumentRoots(html, allowedAssetUrls = []) {
711
+ const safeHtml = sanitizeImportedHtml(html, allowedAssetUrls).trim();
712
+ if (!safeHtml) return safeHtml;
713
+ if (/^<div\b[^>]*\bdata-rt-import-document(?:=(?:""|'')|(?=[\s>]))[^>]*>\s*<div\b[^>]*\bdata-rt-import-body(?:=(?:""|'')|(?=[\s>]))/i.test(
714
+ safeHtml
715
+ )) {
716
+ return safeHtml;
717
+ }
718
+ return `<div data-rt-import-document><div data-rt-import-body>${safeHtml}</div></div>`;
719
+ }
720
+ function normalizeDocumentSelector(selector) {
721
+ const typeBoundary = "(?=$|[\\s>+~,.#:\\[\\)])";
722
+ 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(
723
+ new RegExp(`(^|[\\s>+~,\\(])h1${typeBoundary}`, "gi"),
724
+ `$1[${ORIGINAL_H1_ATTRIBUTE}="h1"]`
725
+ ).replace(/:root/g, "[data-rt-import-document]");
726
+ }
727
+ function selectorTargetsDocumentBoundary(selector) {
728
+ return selector.split(",").some((part) => {
729
+ const normalized = part.trim();
730
+ const targetsDocumentRoot = normalized.startsWith("[data-rt-import-document]") && !/[\s>+~]/.test(normalized.slice("[data-rt-import-document]".length));
731
+ const targetsBodyRoot = /\[data-rt-import-body\](?:[.#:][\w-]+|\[[^\]]+\])*\s*$/.test(
732
+ normalized
733
+ );
734
+ return targetsDocumentRoot || targetsBodyRoot;
735
+ });
736
+ }
737
+ function replaceAnimationName(value, names) {
738
+ let next = value;
739
+ for (const [name, scopedName] of names) {
740
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
741
+ next = next.replace(new RegExp(`(^|[^\\w-])${escaped}(?=$|[^\\w-])`, "g"), `$1${scopedName}`);
742
+ }
743
+ return next;
744
+ }
745
+ function unquoteCssIdentifier(value) {
746
+ const normalized = value.trim();
747
+ const match = /^(?:"([^"]+)"|'([^']+)'|([a-z_][\w -]*))$/i.exec(normalized);
748
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim() || null;
749
+ }
750
+ function replaceFontFamilyName(value, names) {
751
+ let next = value;
752
+ for (const [name, scopedName] of names) {
753
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
754
+ next = next.replace(
755
+ new RegExp(`(["'])${escaped}\\1|(^|[,\\s])${escaped}(?=$|[,\\s/])`, "gi"),
756
+ (matched, quote, prefix) => quote ? `${quote}${scopedName}${quote}` : `${prefix ?? ""}${scopedName}`
757
+ );
758
+ }
759
+ return next;
760
+ }
761
+ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
762
+ if (!IMPORT_SCOPE_RE.test(scope)) return "";
763
+ const safeCss = sanitizeImportedCss(css, allowedAssetUrls);
764
+ if (!safeCss) return "";
765
+ try {
766
+ const root = postcss.parse(safeCss);
767
+ const keyframeNames = /* @__PURE__ */ new Map();
768
+ const fontFamilyNames = /* @__PURE__ */ new Map();
769
+ const fontFaces = root.nodes.filter(
770
+ (node) => node.type === "atrule" && node.name.toLowerCase() === "font-face"
771
+ );
772
+ for (const fontFace of fontFaces) {
773
+ if (fontFace.type !== "atrule") continue;
774
+ const family = fontFace.nodes?.find(
775
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
776
+ );
777
+ if (!family || family.type !== "decl") {
778
+ fontFace.remove();
779
+ continue;
780
+ }
781
+ const originalName = unquoteCssIdentifier(family.value);
782
+ const hasSupportedSource = fontFace.nodes?.some(
783
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src" && /url\(\s*["']?https:\/\/[^)'"\s]+\.woff2?(?:[?#][^)'"\s]*)?["']?\s*\)/i.test(
784
+ node.value
785
+ )
786
+ );
787
+ if (!originalName || !hasSupportedSource) {
788
+ fontFace.remove();
789
+ continue;
790
+ }
791
+ const scopedName = `${scope}-${originalName}`;
792
+ fontFamilyNames.set(originalName, scopedName);
793
+ family.value = family.value.replace(originalName, scopedName);
794
+ }
795
+ root.walkAtRules((atRule) => {
796
+ const name = atRule.name.toLowerCase();
797
+ if (name !== "keyframes" && name !== "-webkit-keyframes") return;
798
+ const originalName = atRule.params.trim();
799
+ const scopedName = `${scope}-${originalName}`;
800
+ keyframeNames.set(originalName, scopedName);
801
+ atRule.params = scopedName;
802
+ });
803
+ root.walkDecls((declaration) => {
804
+ const property = declaration.prop.trim().toLowerCase();
805
+ if (property === "animation" || property === "animation-name") {
806
+ declaration.value = replaceAnimationName(declaration.value, keyframeNames);
807
+ }
808
+ if (property === "font-family" || property === "font") {
809
+ declaration.value = replaceFontFamilyName(
810
+ declaration.value,
811
+ fontFamilyNames
812
+ );
813
+ }
814
+ });
815
+ root.walkRules((rule) => {
816
+ const parent = rule.parent;
817
+ if (parent?.type === "atrule" && (parent.name.toLowerCase() === "keyframes" || parent.name.toLowerCase() === "-webkit-keyframes")) {
818
+ return;
819
+ }
820
+ rule.selector = normalizeDocumentSelector(rule.selector);
821
+ if (selectorTargetsDocumentBoundary(rule.selector)) {
822
+ rule.walkDecls((declaration) => {
823
+ if (SCOPE_ROOT_BOUNDARY_PROPERTIES.has(
824
+ declaration.prop.trim().toLowerCase()
825
+ )) {
826
+ declaration.remove();
827
+ }
828
+ });
829
+ }
830
+ });
831
+ const scoped = postcss.atRule({
832
+ name: "scope",
833
+ params: `([data-rt-import-scope="${scope}"])`
834
+ });
835
+ const preservedFontFaces = fontFaces.filter((fontFace) => fontFace.parent);
836
+ for (const fontFace of preservedFontFaces) fontFace.remove();
837
+ scoped.append(root.nodes);
838
+ return [...preservedFontFaces.map((fontFace) => fontFace.toString()), scoped.toString()].filter(Boolean).join("\n");
839
+ } catch {
840
+ return "";
841
+ }
842
+ }
843
+
372
844
  // src/preview-token.ts
373
845
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
374
846
  var DELIMITER = ".";
@@ -404,15 +876,58 @@ function verifyPreviewToken(secret, token) {
404
876
  }
405
877
 
406
878
  // src/sanitize.ts
407
- import sanitizeHtmlLibrary from "sanitize-html";
879
+ import sanitizeHtmlLibrary2 from "sanitize-html";
408
880
  var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "xlink:href"]);
409
- var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
881
+ var ALLOWED_URL_SCHEMES2 = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
410
882
  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;
411
883
  var SLASH_EVENT_ATTR_IN_URL_RE = /\/on[a-z]+=/i;
412
884
  var ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;
413
885
  var HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;
414
886
  var TEXT_ALIGN_RE = /^(left|right|center|justify)$/;
415
887
  var ASPECT_RATIO_RE = /^\d+(?:\.\d+)?\s*\/\s*\d+(?:\.\d+)?$/;
888
+ var CSS_LENGTH_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))$/i;
889
+ var CSS_LENGTH_LIST_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))){0,3}$/i;
890
+ var CSS_LINE_HEIGHT_RE = /^(?:normal|\d+(?:\.\d+)?(?:px|em|rem|%)?)$/i;
891
+ var CSS_FONT_WEIGHT_RE = /^(?:normal|bold|[1-9]00)$/i;
892
+ 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;
893
+ var CSS_TEXT_DECORATION_RE = /^(?:none|underline|line-through)$/i;
894
+ var CSS_MAGNITUDE_LIMITS = {
895
+ "font-size": { "": 0, px: 96, em: 5, rem: 5, "%": 200 },
896
+ "line-height": { "": 5, px: 96, em: 5, rem: 5, "%": 500 },
897
+ margin: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
898
+ "margin-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
899
+ "margin-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
900
+ "margin-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
901
+ "margin-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
902
+ padding: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
903
+ "padding-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
904
+ "padding-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
905
+ "padding-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
906
+ "padding-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
907
+ width: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
908
+ "max-width": { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
909
+ height: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 }
910
+ };
911
+ function hasSafeCssMagnitude(property, value) {
912
+ const limits = CSS_MAGNITUDE_LIMITS[property];
913
+ if (!limits) return true;
914
+ const numericTokens = value.matchAll(/(\d+(?:\.\d+)?)(px|em|rem|%)?/gi);
915
+ for (const match of numericTokens) {
916
+ const unit = (match[2] ?? "").toLowerCase();
917
+ const limit = limits[unit];
918
+ if (limit === void 0 || Number(match[1]) > limit) return false;
919
+ }
920
+ return true;
921
+ }
922
+ function filterUnsafeCssMagnitudes(style) {
923
+ return style.split(";").map((declaration) => declaration.trim()).filter(Boolean).filter((declaration) => {
924
+ const colon = declaration.indexOf(":");
925
+ if (colon < 1) return false;
926
+ const property = declaration.slice(0, colon).trim().toLowerCase();
927
+ const value = declaration.slice(colon + 1).trim();
928
+ return hasSafeCssMagnitude(property, value);
929
+ }).join(";");
930
+ }
416
931
  var NAMED_ENTITY = {
417
932
  colon: ":",
418
933
  tab: " ",
@@ -425,7 +940,7 @@ var NAMED_ENTITY = {
425
940
  lpar: "(",
426
941
  rpar: ")"
427
942
  };
428
- function fromCodePointSafe(n) {
943
+ function fromCodePointSafe2(n) {
429
944
  if (!Number.isFinite(n) || n < 0 || n > 1114111) return "";
430
945
  try {
431
946
  return String.fromCodePoint(n);
@@ -434,7 +949,7 @@ function fromCodePointSafe(n) {
434
949
  }
435
950
  }
436
951
  function decodeUrlProbe(value) {
437
- 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);
952
+ 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);
438
953
  return [...decoded].filter((c) => {
439
954
  const n = c.codePointAt(0) ?? 0;
440
955
  return n > 32 && n !== 127;
@@ -454,7 +969,7 @@ function isSafeUrlValue(tag, attr, value) {
454
969
  if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;
455
970
  const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];
456
971
  if (!scheme) return true;
457
- if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
972
+ if (ALLOWED_URL_SCHEMES2.has(scheme)) return true;
458
973
  if (scheme === "data" && tag === "img" && attr === "src" && probe.startsWith("data:image/")) {
459
974
  return !probe.startsWith("data:image/svg");
460
975
  }
@@ -464,8 +979,16 @@ function sanitizeUrlAttributes(tagName, attribs) {
464
979
  const next = {};
465
980
  for (const [name, value] of Object.entries(attribs)) {
466
981
  const attr = name.toLowerCase();
982
+ if (attr === "style") {
983
+ const style = filterUnsafeCssMagnitudes(value);
984
+ if (style) next[name] = style;
985
+ continue;
986
+ }
467
987
  next[name] = URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? "#" : value;
468
988
  }
989
+ if (tagName === "a" && next.target === "_blank") {
990
+ next.rel = "noopener noreferrer";
991
+ }
469
992
  return { tagName, attribs: next };
470
993
  }
471
994
  var SANITIZE_OPTIONS = {
@@ -491,19 +1014,25 @@ var SANITIZE_OPTIONS = {
491
1014
  "p",
492
1015
  "pre",
493
1016
  "s",
1017
+ "section",
1018
+ "small",
494
1019
  "span",
495
1020
  "strong",
1021
+ "sub",
496
1022
  "summary",
1023
+ "sup",
497
1024
  "table",
498
1025
  "tbody",
499
1026
  "td",
1027
+ "tfoot",
500
1028
  "th",
1029
+ "thead",
501
1030
  "tr",
502
1031
  "u",
503
1032
  "ul"
504
1033
  ],
505
1034
  allowedAttributes: {
506
- "*": ["class", "data-*"],
1035
+ "*": ["class", "data-*", "style"],
507
1036
  a: [
508
1037
  "href",
509
1038
  "title",
@@ -520,7 +1049,14 @@ var SANITIZE_OPTIONS = {
520
1049
  p: ["class", "data-*", "style"],
521
1050
  span: ["class", "data-*", "style"],
522
1051
  td: ["colspan", "rowspan", "class", "data-*"],
523
- th: ["colspan", "rowspan", "class", "data-*"]
1052
+ // scope 는 표 머리행의 접근성 의미(스크린리더 열/행 연결) — 값 집합을 고정해 허용.
1053
+ th: [
1054
+ "colspan",
1055
+ "rowspan",
1056
+ "class",
1057
+ "data-*",
1058
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
1059
+ ]
524
1060
  },
525
1061
  allowedClasses: {
526
1062
  "*": [ROOTTALE_CLASS_RE]
@@ -530,13 +1066,38 @@ var SANITIZE_OPTIONS = {
530
1066
  "aspect-ratio": [ASPECT_RATIO_RE],
531
1067
  background: [HEX_COLOR_RE],
532
1068
  "background-color": [HEX_COLOR_RE],
1069
+ border: [CSS_BORDER_RE],
1070
+ "border-bottom": [CSS_BORDER_RE],
1071
+ "border-left": [CSS_BORDER_RE],
1072
+ "border-right": [CSS_BORDER_RE],
1073
+ "border-top": [CSS_BORDER_RE],
1074
+ "border-collapse": [/^(?:collapse|separate)$/i],
533
1075
  color: [HEX_COLOR_RE],
534
- "text-align": [TEXT_ALIGN_RE]
1076
+ "font-size": [CSS_LENGTH_RE],
1077
+ "font-weight": [CSS_FONT_WEIGHT_RE],
1078
+ height: [CSS_LENGTH_RE, /^auto$/i],
1079
+ "line-height": [CSS_LINE_HEIGHT_RE],
1080
+ margin: [CSS_LENGTH_LIST_RE],
1081
+ "margin-bottom": [CSS_LENGTH_RE],
1082
+ "margin-left": [CSS_LENGTH_RE],
1083
+ "margin-right": [CSS_LENGTH_RE],
1084
+ "margin-top": [CSS_LENGTH_RE],
1085
+ "max-width": [CSS_LENGTH_RE, /^none$/i],
1086
+ padding: [CSS_LENGTH_LIST_RE],
1087
+ "padding-bottom": [CSS_LENGTH_RE],
1088
+ "padding-left": [CSS_LENGTH_RE],
1089
+ "padding-right": [CSS_LENGTH_RE],
1090
+ "padding-top": [CSS_LENGTH_RE],
1091
+ "text-align": [TEXT_ALIGN_RE],
1092
+ "text-decoration": [CSS_TEXT_DECORATION_RE],
1093
+ width: [CSS_LENGTH_RE, /^auto$/i]
535
1094
  }
536
1095
  },
537
1096
  allowedSchemes: ["http", "https", "mailto", "tel"],
538
1097
  allowedSchemesByTag: {
539
- img: ["data"]
1098
+ // tag별 목록은 전역 allowedSchemes를 덮어쓴다. data만 쓰면 정상적인
1099
+ // http/https 이미지 src까지 제거되므로 이미지가 사용할 전체 scheme을 명시한다.
1100
+ img: ["http", "https", "data"]
540
1101
  },
541
1102
  allowedSchemesAppliedToAttributes: ["href", "src", "xlink:href"],
542
1103
  allowProtocolRelative: false,
@@ -558,7 +1119,18 @@ var SANITIZE_OPTIONS = {
558
1119
  };
559
1120
  function sanitizeHtml(html) {
560
1121
  if (!html) return html;
561
- return sanitizeHtmlLibrary(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1122
+ return sanitizeHtmlLibrary2(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1123
+ }
1124
+ function htmlToPlainText(html) {
1125
+ const safeHtml = sanitizeHtml(html);
1126
+ const withBlockBoundaries = safeHtml.replace(
1127
+ /<(?:br|hr)\b[^>]*>|<\/(?:blockquote|details|div|figcaption|figure|h[2-4]|li|p|pre|section|summary|table|tbody|td|th|tr)>/gi,
1128
+ " "
1129
+ );
1130
+ return sanitizeHtmlLibrary2(withBlockBoundaries, {
1131
+ allowedTags: [],
1132
+ allowedAttributes: {}
1133
+ }).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
562
1134
  }
563
1135
  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'";
564
1136
 
@@ -580,6 +1152,7 @@ export {
580
1152
  BLOCK_NAME_PATTERN,
581
1153
  FIELD_BINDING_SOURCE,
582
1154
  FIELD_BINDING_SOURCE_ALIAS,
1155
+ IMAGE_ALT_MAX_LENGTH,
583
1156
  PHASE_1_CORE_BLOCKS,
584
1157
  PHASE_1_CSP,
585
1158
  applyBlockBindings,
@@ -589,9 +1162,11 @@ export {
589
1162
  boundFieldValue,
590
1163
  chainResolvers,
591
1164
  createFieldBindingResolver,
1165
+ ensureImportedHtmlDocumentRoots,
592
1166
  ensureStableIds,
593
1167
  extractFaqEntries,
594
1168
  faqPageJsonLd,
1169
+ htmlToPlainText,
595
1170
  injectStableIdsTree,
596
1171
  isAllowedEmbedSrc,
597
1172
  isAllowedMapEmbedSrc,
@@ -599,6 +1174,7 @@ export {
599
1174
  isValidBlock,
600
1175
  issuePreviewToken,
601
1176
  normalizeEmbed,
1177
+ normalizeImageAlt,
602
1178
  parseBlock,
603
1179
  postsForCollection,
604
1180
  postsInAnyCollection,
@@ -607,6 +1183,9 @@ export {
607
1183
  resolvePostPath,
608
1184
  resolvePostUrl,
609
1185
  sanitizeHtml,
1186
+ sanitizeImportedCss,
1187
+ sanitizeImportedHtml,
1188
+ scopeImportedCss,
610
1189
  serializeBlock,
611
1190
  validateCollections,
612
1191
  verifyPreviewToken