@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.js CHANGED
@@ -127,18 +127,38 @@ function resolvePostCollection(post, collections) {
127
127
  return collections.find((c) => c.key === post.collectionKey) ?? null;
128
128
  }
129
129
  function resolvePostUrl(siteUrl, post, collections) {
130
- const collection = resolvePostCollection(post, collections);
131
- if (!collection) return null;
132
- if (collection.detail === false) return null;
133
- if (!collection.basePath) return null;
134
- return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
130
+ const path = resolvePostPath(post, collections);
131
+ return path ? `${trimTrailingSlash(siteUrl)}${path}` : null;
132
+ }
133
+ function resolvePostCategorySlug(post, collection) {
134
+ const allowed = new Set(collection.categories);
135
+ const slugs = [
136
+ ...new Set(
137
+ post.terms.filter(
138
+ (term) => term.taxonomy === "category" && allowed.has(term.slug)
139
+ ).map((term) => term.slug)
140
+ )
141
+ ];
142
+ return slugs.length === 1 ? slugs[0] : null;
143
+ }
144
+ function resolveCategoryPath(collection, categorySlug) {
145
+ if (!collection.archives || !collection.basePath || !categorySlug) return null;
146
+ const basePath = trimTrailingSlash(collection.basePath);
147
+ const encodedSlug = encodeURIComponent(categorySlug);
148
+ return collection.categoryPath === "direct" ? `${basePath}/${encodedSlug}` : `${basePath}/categories/${encodedSlug}`;
135
149
  }
136
150
  function resolvePostPath(post, collections) {
137
151
  const collection = resolvePostCollection(post, collections);
138
152
  if (!collection) return null;
139
153
  if (collection.detail === false) return null;
140
154
  if (!collection.basePath) return null;
141
- return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
155
+ const basePath = trimTrailingSlash(collection.basePath);
156
+ const encodedPostSlug = encodeURIComponent(post.slug);
157
+ if (collection.detailPath !== "category") {
158
+ return `${basePath}/${encodedPostSlug}`;
159
+ }
160
+ const categorySlug = resolvePostCategorySlug(post, collection);
161
+ return categorySlug ? `${basePath}/${encodeURIComponent(categorySlug)}/${encodedPostSlug}` : null;
142
162
  }
143
163
  function postsForCollection(posts, collections, key) {
144
164
  return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);
@@ -178,10 +198,87 @@ function validateCollections(collections) {
178
198
  }
179
199
  categoryOwner.set(slug, c.key);
180
200
  }
201
+ if (c.detailPath === "category") {
202
+ if (!c.basePath) {
203
+ problems.push(
204
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 basePath \uAC00 \uD544\uC694\uD568: "${c.key}"`
205
+ );
206
+ }
207
+ if (c.detail === false) {
208
+ problems.push(
209
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 \uAE00\uBCC4 \uC0C1\uC138 \uD398\uC774\uC9C0\uAC00 \uD544\uC694\uD568: "${c.key}"`
210
+ );
211
+ }
212
+ if (!c.archives) {
213
+ problems.push(
214
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 \uCE74\uD14C\uACE0\uB9AC \uC544\uCE74\uC774\uBE0C\uAC00 \uD544\uC694\uD568: "${c.key}"`
215
+ );
216
+ }
217
+ if (c.categoryCardinality !== "exactly-one") {
218
+ problems.push(
219
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 categoryCardinality "exactly-one" \uC774 \uD544\uC694\uD568: "${c.key}"`
220
+ );
221
+ }
222
+ }
181
223
  }
182
224
  return problems;
183
225
  }
184
226
 
227
+ // src/category-tree.ts
228
+ function normalizeBasePath(basePath) {
229
+ return basePath.replace(/\/+$/, "");
230
+ }
231
+ function resolveCategoryTreeChain(presentation, leafId, terms) {
232
+ if (!Number.isInteger(presentation.categoryDepth) || presentation.categoryDepth < 1) {
233
+ return null;
234
+ }
235
+ const termsById = new Map(terms.map((term) => [term.id, term]));
236
+ if (termsById.size !== terms.length) return null;
237
+ if (terms.some((term) => term.parentId === leafId)) return null;
238
+ const reversed = [];
239
+ const visited = /* @__PURE__ */ new Set();
240
+ let currentId = leafId;
241
+ while (currentId) {
242
+ if (visited.has(currentId)) return null;
243
+ visited.add(currentId);
244
+ const current = termsById.get(currentId);
245
+ if (!current || !current.slug) return null;
246
+ reversed.push(current);
247
+ currentId = current.parentId ?? null;
248
+ }
249
+ if (reversed.length !== presentation.categoryDepth) return null;
250
+ return reversed.reverse();
251
+ }
252
+ function resolveCategoryTreeHubPaths(presentation, termId, terms) {
253
+ const chain = resolveCategoryTreeChain(presentation, termId, terms);
254
+ const basePath = normalizeBasePath(presentation.basePath);
255
+ if (!chain || !basePath) return null;
256
+ const paths = [basePath];
257
+ const segments = [];
258
+ for (const term of chain) {
259
+ segments.push(encodeURIComponent(term.slug));
260
+ paths.push(`${basePath}/${segments.join("/")}`);
261
+ }
262
+ return paths;
263
+ }
264
+ function resolveCategoryTreeEntryPath(presentation, entry, terms) {
265
+ if (!entry.slug) return null;
266
+ const knownIds = new Set(terms.map((term) => term.id));
267
+ const categoryIds = [
268
+ ...new Set(
269
+ entry.terms.filter((term) => term.taxonomy === "category" && knownIds.has(term.id)).map((term) => term.id)
270
+ )
271
+ ];
272
+ if (categoryIds.length !== 1) return null;
273
+ const hubs = resolveCategoryTreeHubPaths(
274
+ presentation,
275
+ categoryIds[0],
276
+ terms
277
+ );
278
+ const leafPath = hubs?.at(-1);
279
+ return leafPath ? `${leafPath}/${encodeURIComponent(entry.slug)}` : null;
280
+ }
281
+
185
282
  // src/delimiter.ts
186
283
  function serializeBlock(block) {
187
284
  const exportable = sanitizeAttributesForExport(block);
@@ -380,6 +477,467 @@ function normalizeImageAlt(value) {
380
477
  return `${visible}\u2026`;
381
478
  }
382
479
 
480
+ // src/imported-html-sanitize.ts
481
+ import postcss from "postcss";
482
+ import sanitizeHtmlLibrary from "sanitize-html";
483
+ 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;
484
+ var ALLOWED_AT_RULES = /* @__PURE__ */ new Set([
485
+ "container",
486
+ "font-face",
487
+ "keyframes",
488
+ "-webkit-keyframes",
489
+ "layer",
490
+ "media",
491
+ "supports"
492
+ ]);
493
+ var URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "src"]);
494
+ var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
495
+ var UNSAFE_CSS_VALUE_RE = /(?:expression\s*\(|javascript\s*:|vbscript\s*:|@import|<\/?(?:style|script)|\\0|\\0000)/i;
496
+ var UNSAFE_CSS_PROPERTY_RE = /^(?:behavior|-moz-binding)$/i;
497
+ var FIXED_POSITION_RE = /^(?:fixed|sticky)$/i;
498
+ var ORIGINAL_H1_ATTRIBUTE = "data-rt-original-heading";
499
+ var SCOPE_ROOT_BOUNDARY_PROPERTIES = /* @__PURE__ */ new Set([
500
+ "bottom",
501
+ "inset",
502
+ "inset-block",
503
+ "inset-inline",
504
+ "left",
505
+ "position",
506
+ "right",
507
+ "rotate",
508
+ "scale",
509
+ "top",
510
+ "transform",
511
+ "translate",
512
+ "z-index"
513
+ ]);
514
+ function fromCodePointSafe(value, radix) {
515
+ const codePoint = Number.parseInt(value, radix);
516
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 1114111) {
517
+ return "";
518
+ }
519
+ try {
520
+ return String.fromCodePoint(codePoint);
521
+ } catch {
522
+ return "";
523
+ }
524
+ }
525
+ function isSafeUrl(tagName, attributeName, value) {
526
+ 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();
527
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(normalized)?.[1];
528
+ if (!scheme) return true;
529
+ if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
530
+ return tagName === "img" && attributeName === "src" && scheme === "data" && normalized.startsWith("data:image/") && !normalized.startsWith("data:image/svg");
531
+ }
532
+ function cssUrlsAreSafe(value, allowedAssetUrls) {
533
+ const urlRe = /url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi;
534
+ let matched = false;
535
+ for (const match of value.matchAll(urlRe)) {
536
+ matched = true;
537
+ const url = match[2]?.trim() ?? "";
538
+ if (!url.startsWith("#") && !allowedAssetUrls.has(url)) return false;
539
+ }
540
+ return !/url\s*\(/i.test(value) || matched;
541
+ }
542
+ function declarationIsSafe(property, value, allowedAssetUrls) {
543
+ if (UNSAFE_CSS_PROPERTY_RE.test(property) || UNSAFE_CSS_VALUE_RE.test(value)) {
544
+ return false;
545
+ }
546
+ if (!cssUrlsAreSafe(value, allowedAssetUrls)) return false;
547
+ if (property === "position" && /\b(?:fixed|sticky)\b/i.test(value)) {
548
+ return false;
549
+ }
550
+ if (property.startsWith("--") && FIXED_POSITION_RE.test(value.trim())) {
551
+ return false;
552
+ }
553
+ if (property === "z-index") {
554
+ const normalized = value.trim().toLowerCase();
555
+ if (normalized !== "auto") {
556
+ const zIndex = Number(normalized);
557
+ if (!Number.isInteger(zIndex) || Math.abs(zIndex) > 100) return false;
558
+ }
559
+ }
560
+ for (const match of value.matchAll(/(-?\d+(?:\.\d+)?)(px|vh|vw)/gi)) {
561
+ const magnitude = Math.abs(Number(match[1]));
562
+ const limit = match[2].toLowerCase() === "px" ? 4e3 : 200;
563
+ if (magnitude > limit) return false;
564
+ }
565
+ return true;
566
+ }
567
+ function cleanCssContainer(container, allowedAssetUrls) {
568
+ container.walkComments((comment) => {
569
+ comment.remove();
570
+ });
571
+ container.walkAtRules((atRule) => {
572
+ const name = atRule.name.toLowerCase();
573
+ const isKeyframes = name === "keyframes" || name === "-webkit-keyframes";
574
+ 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)) {
575
+ atRule.remove();
576
+ }
577
+ });
578
+ container.walkDecls((declaration) => {
579
+ const property = declaration.prop.trim().toLowerCase();
580
+ if (!property || !declarationIsSafe(property, declaration.value, allowedAssetUrls)) {
581
+ declaration.remove();
582
+ return;
583
+ }
584
+ });
585
+ container.walkRules((rule) => {
586
+ if (!rule.selector.trim() || !rule.nodes?.length || /<\/?(?:style|script)/i.test(rule.selector)) {
587
+ rule.remove();
588
+ }
589
+ });
590
+ container.walkAtRules("font-face", (fontFace) => {
591
+ const declarations = fontFace.nodes ?? [];
592
+ const hasFamily = declarations.some(
593
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
594
+ );
595
+ const hasSource = declarations.some(
596
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src"
597
+ );
598
+ if (!hasFamily || !hasSource) fontFace.remove();
599
+ });
600
+ }
601
+ function sanitizeImportedCss(css, allowedAssetUrls = []) {
602
+ if (!css.trim()) return "";
603
+ try {
604
+ const root = postcss.parse(css);
605
+ cleanCssContainer(root, new Set(allowedAssetUrls));
606
+ return root.toString().trim();
607
+ } catch {
608
+ return "";
609
+ }
610
+ }
611
+ function sanitizeInlineStyle(style, allowedAssetUrls) {
612
+ try {
613
+ const root = postcss.parse(`x{${style}}`);
614
+ cleanCssContainer(root, allowedAssetUrls);
615
+ const rule = root.first;
616
+ if (!rule || rule.type !== "rule") return "";
617
+ return rule.nodes.map((node) => node.toString()).join(";");
618
+ } catch {
619
+ return "";
620
+ }
621
+ }
622
+ function sanitizeImportedAttributes(tagName, attributes, allowedAssetUrls) {
623
+ const next = {};
624
+ for (const [name, value] of Object.entries(attributes)) {
625
+ const attributeName = name.toLowerCase();
626
+ if (attributeName === "style") {
627
+ const style = sanitizeInlineStyle(value, allowedAssetUrls);
628
+ if (style) next[name] = style;
629
+ continue;
630
+ }
631
+ if (attributeName === "srcset") {
632
+ const srcset = sanitizeSrcset(value);
633
+ if (srcset) next[name] = srcset;
634
+ continue;
635
+ }
636
+ if ((attributeName === "fill" || attributeName === "stroke") && /url\s*\(/i.test(value) && !/^url\(#[a-z_][\w:.-]*\)$/i.test(value.trim())) {
637
+ continue;
638
+ }
639
+ if ((tagName === "use" || tagName === "image") && (attributeName === "href" || attributeName === "xlink:href") && !value.trim().startsWith("#")) {
640
+ continue;
641
+ }
642
+ next[name] = URL_ATTRIBUTES.has(attributeName) && !isSafeUrl(tagName, attributeName, value) ? "#" : value;
643
+ }
644
+ if (tagName === "a" && next.target === "_blank") {
645
+ next.rel = "noopener noreferrer";
646
+ }
647
+ if (tagName === "h1") {
648
+ next[ORIGINAL_H1_ATTRIBUTE] = "h1";
649
+ return { tagName: "h2", attribs: next };
650
+ }
651
+ return { tagName, attribs: next };
652
+ }
653
+ function sanitizeSrcset(value) {
654
+ const candidates = [];
655
+ for (const rawCandidate of value.split(",")) {
656
+ const candidate = rawCandidate.trim();
657
+ if (!candidate) continue;
658
+ const [url, ...descriptorParts] = candidate.split(/\s+/);
659
+ if (!url || !isSafeUrl("img", "src", url)) continue;
660
+ const descriptor = descriptorParts.join(" ");
661
+ if (descriptor && !/^(?:\d+(?:\.\d+)?x|\d+w)$/.test(descriptor)) continue;
662
+ candidates.push(descriptor ? `${url} ${descriptor}` : url);
663
+ }
664
+ return candidates.join(", ");
665
+ }
666
+ var IMPORTED_HTML_OPTIONS = {
667
+ allowedTags: [
668
+ "a",
669
+ "abbr",
670
+ "address",
671
+ "article",
672
+ "aside",
673
+ "b",
674
+ "bdi",
675
+ "bdo",
676
+ "blockquote",
677
+ "br",
678
+ "caption",
679
+ "cite",
680
+ "code",
681
+ "col",
682
+ "colgroup",
683
+ "dd",
684
+ "del",
685
+ "details",
686
+ "dfn",
687
+ "div",
688
+ "dl",
689
+ "dt",
690
+ "em",
691
+ "figcaption",
692
+ "figure",
693
+ "footer",
694
+ "h1",
695
+ "h2",
696
+ "h3",
697
+ "h4",
698
+ "h5",
699
+ "h6",
700
+ "header",
701
+ "hr",
702
+ "i",
703
+ "img",
704
+ "kbd",
705
+ "li",
706
+ "main",
707
+ "mark",
708
+ "nav",
709
+ "ol",
710
+ "p",
711
+ "picture",
712
+ "pre",
713
+ "q",
714
+ "s",
715
+ "samp",
716
+ "section",
717
+ "small",
718
+ "source",
719
+ "svg",
720
+ "g",
721
+ "path",
722
+ "circle",
723
+ "ellipse",
724
+ "line",
725
+ "polyline",
726
+ "polygon",
727
+ "rect",
728
+ "defs",
729
+ "lineargradient",
730
+ "radialgradient",
731
+ "stop",
732
+ "use",
733
+ "clippath",
734
+ "mask",
735
+ "pattern",
736
+ "title",
737
+ "desc",
738
+ "span",
739
+ "strong",
740
+ "sub",
741
+ "summary",
742
+ "sup",
743
+ "table",
744
+ "tbody",
745
+ "td",
746
+ "tfoot",
747
+ "th",
748
+ "thead",
749
+ "time",
750
+ "tr",
751
+ "u",
752
+ "ul",
753
+ "var",
754
+ "wbr"
755
+ ],
756
+ allowedAttributes: {
757
+ "*": ["aria-*", "class", "data-*", "dir", "id", "lang", "role", "style", "title"],
758
+ a: [
759
+ "href",
760
+ { name: "rel", multiple: true, values: ["noopener", "noreferrer"] },
761
+ { name: "target", values: ["_blank"] }
762
+ ],
763
+ col: ["span", "style"],
764
+ img: ["alt", "height", "loading", "sizes", "src", "srcset", "style", "width"],
765
+ source: ["height", "media", "sizes", "src", "srcset", "type", "width"],
766
+ svg: ["aria-*", "class", "fill", "height", "preserveAspectRatio", "role", "stroke", "style", "viewBox", "viewbox", "width", "xmlns"],
767
+ g: ["class", "fill", "stroke", "stroke-width", "style", "transform"],
768
+ path: ["class", "d", "fill", "fill-rule", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
769
+ circle: ["class", "cx", "cy", "fill", "r", "stroke", "stroke-width", "style", "transform"],
770
+ ellipse: ["class", "cx", "cy", "fill", "rx", "ry", "stroke", "stroke-width", "style", "transform"],
771
+ line: ["class", "stroke", "stroke-linecap", "stroke-width", "style", "transform", "x1", "x2", "y1", "y2"],
772
+ polyline: ["class", "fill", "points", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "style", "transform"],
773
+ polygon: ["class", "fill", "points", "stroke", "stroke-width", "style", "transform"],
774
+ rect: ["class", "fill", "height", "rx", "ry", "stroke", "stroke-width", "style", "transform", "width", "x", "y"],
775
+ lineargradient: ["gradientTransform", "gradientUnits", "id", "spreadMethod", "x1", "x2", "y1", "y2"],
776
+ radialgradient: ["cx", "cy", "fx", "fy", "gradientTransform", "gradientUnits", "id", "r", "spreadMethod"],
777
+ stop: ["offset", "stop-color", "stop-opacity", "style"],
778
+ use: ["fill", "height", "href", "stroke", "style", "width", "x", "xlink:href", "y"],
779
+ clippath: ["clipPathUnits", "id", "transform"],
780
+ mask: ["height", "id", "maskContentUnits", "maskUnits", "width", "x", "y"],
781
+ pattern: ["height", "id", "patternContentUnits", "patternTransform", "patternUnits", "preserveAspectRatio", "viewBox", "width", "x", "y"],
782
+ td: ["colspan", "rowspan", "style"],
783
+ th: [
784
+ "colspan",
785
+ "rowspan",
786
+ "style",
787
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
788
+ ],
789
+ time: ["datetime"]
790
+ },
791
+ allowedSchemes: ["http", "https", "mailto", "tel"],
792
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
793
+ allowedSchemesAppliedToAttributes: ["href", "src"],
794
+ allowProtocolRelative: false,
795
+ nonTextTags: ["script", "style", "textarea", "option", "iframe", "object", "embed", "foreignobject", "math", "template"]
796
+ };
797
+ function sanitizeImportedHtml(html, allowedAssetUrls = []) {
798
+ if (!html) return html;
799
+ const allowedSet = new Set(allowedAssetUrls);
800
+ return sanitizeHtmlLibrary(html, {
801
+ ...IMPORTED_HTML_OPTIONS,
802
+ transformTags: {
803
+ "*": (tagName, attributes) => sanitizeImportedAttributes(tagName, attributes, allowedSet)
804
+ }
805
+ });
806
+ }
807
+ function ensureImportedHtmlDocumentRoots(html, allowedAssetUrls = []) {
808
+ const safeHtml = sanitizeImportedHtml(html, allowedAssetUrls).trim();
809
+ if (!safeHtml) return safeHtml;
810
+ if (/^<div\b[^>]*\bdata-rt-import-document(?:=(?:""|'')|(?=[\s>]))[^>]*>\s*<div\b[^>]*\bdata-rt-import-body(?:=(?:""|'')|(?=[\s>]))/i.test(
811
+ safeHtml
812
+ )) {
813
+ return safeHtml;
814
+ }
815
+ return `<div data-rt-import-document><div data-rt-import-body>${safeHtml}</div></div>`;
816
+ }
817
+ function normalizeDocumentSelector(selector) {
818
+ const typeBoundary = "(?=$|[\\s>+~,.#:\\[\\)])";
819
+ 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(
820
+ new RegExp(`(^|[\\s>+~,\\(])h1${typeBoundary}`, "gi"),
821
+ `$1[${ORIGINAL_H1_ATTRIBUTE}="h1"]`
822
+ ).replace(/:root/g, "[data-rt-import-document]");
823
+ }
824
+ function selectorTargetsDocumentBoundary(selector) {
825
+ return selector.split(",").some((part) => {
826
+ const normalized = part.trim();
827
+ const targetsDocumentRoot = normalized.startsWith("[data-rt-import-document]") && !/[\s>+~]/.test(normalized.slice("[data-rt-import-document]".length));
828
+ const targetsBodyRoot = /\[data-rt-import-body\](?:[.#:][\w-]+|\[[^\]]+\])*\s*$/.test(
829
+ normalized
830
+ );
831
+ return targetsDocumentRoot || targetsBodyRoot;
832
+ });
833
+ }
834
+ function replaceAnimationName(value, names) {
835
+ let next = value;
836
+ for (const [name, scopedName] of names) {
837
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
838
+ next = next.replace(new RegExp(`(^|[^\\w-])${escaped}(?=$|[^\\w-])`, "g"), `$1${scopedName}`);
839
+ }
840
+ return next;
841
+ }
842
+ function unquoteCssIdentifier(value) {
843
+ const normalized = value.trim();
844
+ const match = /^(?:"([^"]+)"|'([^']+)'|([a-z_][\w -]*))$/i.exec(normalized);
845
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim() || null;
846
+ }
847
+ function replaceFontFamilyName(value, names) {
848
+ let next = value;
849
+ for (const [name, scopedName] of names) {
850
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
851
+ next = next.replace(
852
+ new RegExp(`(["'])${escaped}\\1|(^|[,\\s])${escaped}(?=$|[,\\s/])`, "gi"),
853
+ (matched, quote, prefix) => quote ? `${quote}${scopedName}${quote}` : `${prefix ?? ""}${scopedName}`
854
+ );
855
+ }
856
+ return next;
857
+ }
858
+ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
859
+ if (!IMPORT_SCOPE_RE.test(scope)) return "";
860
+ const safeCss = sanitizeImportedCss(css, allowedAssetUrls);
861
+ if (!safeCss) return "";
862
+ try {
863
+ const root = postcss.parse(safeCss);
864
+ const keyframeNames = /* @__PURE__ */ new Map();
865
+ const fontFamilyNames = /* @__PURE__ */ new Map();
866
+ const fontFaces = root.nodes.filter(
867
+ (node) => node.type === "atrule" && node.name.toLowerCase() === "font-face"
868
+ );
869
+ for (const fontFace of fontFaces) {
870
+ if (fontFace.type !== "atrule") continue;
871
+ const family = fontFace.nodes?.find(
872
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "font-family"
873
+ );
874
+ if (!family || family.type !== "decl") {
875
+ fontFace.remove();
876
+ continue;
877
+ }
878
+ const originalName = unquoteCssIdentifier(family.value);
879
+ const hasSupportedSource = fontFace.nodes?.some(
880
+ (node) => node.type === "decl" && node.prop.toLowerCase() === "src" && /url\(\s*["']?https:\/\/[^)'"\s]+\.woff2?(?:[?#][^)'"\s]*)?["']?\s*\)/i.test(
881
+ node.value
882
+ )
883
+ );
884
+ if (!originalName || !hasSupportedSource) {
885
+ fontFace.remove();
886
+ continue;
887
+ }
888
+ const scopedName = `${scope}-${originalName}`;
889
+ fontFamilyNames.set(originalName, scopedName);
890
+ family.value = family.value.replace(originalName, scopedName);
891
+ }
892
+ root.walkAtRules((atRule) => {
893
+ const name = atRule.name.toLowerCase();
894
+ if (name !== "keyframes" && name !== "-webkit-keyframes") return;
895
+ const originalName = atRule.params.trim();
896
+ const scopedName = `${scope}-${originalName}`;
897
+ keyframeNames.set(originalName, scopedName);
898
+ atRule.params = scopedName;
899
+ });
900
+ root.walkDecls((declaration) => {
901
+ const property = declaration.prop.trim().toLowerCase();
902
+ if (property === "animation" || property === "animation-name") {
903
+ declaration.value = replaceAnimationName(declaration.value, keyframeNames);
904
+ }
905
+ if (property === "font-family" || property === "font") {
906
+ declaration.value = replaceFontFamilyName(
907
+ declaration.value,
908
+ fontFamilyNames
909
+ );
910
+ }
911
+ });
912
+ root.walkRules((rule) => {
913
+ const parent = rule.parent;
914
+ if (parent?.type === "atrule" && (parent.name.toLowerCase() === "keyframes" || parent.name.toLowerCase() === "-webkit-keyframes")) {
915
+ return;
916
+ }
917
+ rule.selector = normalizeDocumentSelector(rule.selector);
918
+ if (selectorTargetsDocumentBoundary(rule.selector)) {
919
+ rule.walkDecls((declaration) => {
920
+ if (SCOPE_ROOT_BOUNDARY_PROPERTIES.has(
921
+ declaration.prop.trim().toLowerCase()
922
+ )) {
923
+ declaration.remove();
924
+ }
925
+ });
926
+ }
927
+ });
928
+ const scoped = postcss.atRule({
929
+ name: "scope",
930
+ params: `([data-rt-import-scope="${scope}"])`
931
+ });
932
+ const preservedFontFaces = fontFaces.filter((fontFace) => fontFace.parent);
933
+ for (const fontFace of preservedFontFaces) fontFace.remove();
934
+ scoped.append(root.nodes);
935
+ return [...preservedFontFaces.map((fontFace) => fontFace.toString()), scoped.toString()].filter(Boolean).join("\n");
936
+ } catch {
937
+ return "";
938
+ }
939
+ }
940
+
383
941
  // src/preview-token.ts
384
942
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
385
943
  var DELIMITER = ".";
@@ -415,15 +973,58 @@ function verifyPreviewToken(secret, token) {
415
973
  }
416
974
 
417
975
  // src/sanitize.ts
418
- import sanitizeHtmlLibrary from "sanitize-html";
976
+ import sanitizeHtmlLibrary2 from "sanitize-html";
419
977
  var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "xlink:href"]);
420
- var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
978
+ var ALLOWED_URL_SCHEMES2 = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
421
979
  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;
422
980
  var SLASH_EVENT_ATTR_IN_URL_RE = /\/on[a-z]+=/i;
423
981
  var ROOTTALE_CLASS_RE = /^rt-[a-z0-9_-]+$/;
424
982
  var HEX_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i;
425
983
  var TEXT_ALIGN_RE = /^(left|right|center|justify)$/;
426
984
  var ASPECT_RATIO_RE = /^\d+(?:\.\d+)?\s*\/\s*\d+(?:\.\d+)?$/;
985
+ var CSS_LENGTH_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))$/i;
986
+ var CSS_LENGTH_LIST_RE = /^(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|em|rem|%))){0,3}$/i;
987
+ var CSS_LINE_HEIGHT_RE = /^(?:normal|\d+(?:\.\d+)?(?:px|em|rem|%)?)$/i;
988
+ var CSS_FONT_WEIGHT_RE = /^(?:normal|bold|[1-9]00)$/i;
989
+ 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;
990
+ var CSS_TEXT_DECORATION_RE = /^(?:none|underline|line-through)$/i;
991
+ var CSS_MAGNITUDE_LIMITS = {
992
+ "font-size": { "": 0, px: 96, em: 5, rem: 5, "%": 200 },
993
+ "line-height": { "": 5, px: 96, em: 5, rem: 5, "%": 500 },
994
+ margin: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
995
+ "margin-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
996
+ "margin-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
997
+ "margin-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
998
+ "margin-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
999
+ padding: { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1000
+ "padding-bottom": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1001
+ "padding-left": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1002
+ "padding-right": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1003
+ "padding-top": { "": 0, px: 2e3, em: 20, rem: 20, "%": 200 },
1004
+ width: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
1005
+ "max-width": { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 },
1006
+ height: { "": 0, px: 4e3, em: 100, rem: 100, "%": 100 }
1007
+ };
1008
+ function hasSafeCssMagnitude(property, value) {
1009
+ const limits = CSS_MAGNITUDE_LIMITS[property];
1010
+ if (!limits) return true;
1011
+ const numericTokens = value.matchAll(/(\d+(?:\.\d+)?)(px|em|rem|%)?/gi);
1012
+ for (const match of numericTokens) {
1013
+ const unit = (match[2] ?? "").toLowerCase();
1014
+ const limit = limits[unit];
1015
+ if (limit === void 0 || Number(match[1]) > limit) return false;
1016
+ }
1017
+ return true;
1018
+ }
1019
+ function filterUnsafeCssMagnitudes(style) {
1020
+ return style.split(";").map((declaration) => declaration.trim()).filter(Boolean).filter((declaration) => {
1021
+ const colon = declaration.indexOf(":");
1022
+ if (colon < 1) return false;
1023
+ const property = declaration.slice(0, colon).trim().toLowerCase();
1024
+ const value = declaration.slice(colon + 1).trim();
1025
+ return hasSafeCssMagnitude(property, value);
1026
+ }).join(";");
1027
+ }
427
1028
  var NAMED_ENTITY = {
428
1029
  colon: ":",
429
1030
  tab: " ",
@@ -436,7 +1037,7 @@ var NAMED_ENTITY = {
436
1037
  lpar: "(",
437
1038
  rpar: ")"
438
1039
  };
439
- function fromCodePointSafe(n) {
1040
+ function fromCodePointSafe2(n) {
440
1041
  if (!Number.isFinite(n) || n < 0 || n > 1114111) return "";
441
1042
  try {
442
1043
  return String.fromCodePoint(n);
@@ -445,7 +1046,7 @@ function fromCodePointSafe(n) {
445
1046
  }
446
1047
  }
447
1048
  function decodeUrlProbe(value) {
448
- 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);
1049
+ 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);
449
1050
  return [...decoded].filter((c) => {
450
1051
  const n = c.codePointAt(0) ?? 0;
451
1052
  return n > 32 && n !== 127;
@@ -465,7 +1066,7 @@ function isSafeUrlValue(tag, attr, value) {
465
1066
  if (SLASH_EVENT_ATTR_IN_URL_RE.test(probe)) return false;
466
1067
  const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];
467
1068
  if (!scheme) return true;
468
- if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
1069
+ if (ALLOWED_URL_SCHEMES2.has(scheme)) return true;
469
1070
  if (scheme === "data" && tag === "img" && attr === "src" && probe.startsWith("data:image/")) {
470
1071
  return !probe.startsWith("data:image/svg");
471
1072
  }
@@ -475,8 +1076,16 @@ function sanitizeUrlAttributes(tagName, attribs) {
475
1076
  const next = {};
476
1077
  for (const [name, value] of Object.entries(attribs)) {
477
1078
  const attr = name.toLowerCase();
1079
+ if (attr === "style") {
1080
+ const style = filterUnsafeCssMagnitudes(value);
1081
+ if (style) next[name] = style;
1082
+ continue;
1083
+ }
478
1084
  next[name] = URL_ATTRS.has(attr) && !isSafeUrlValue(tagName, attr, value) ? "#" : value;
479
1085
  }
1086
+ if (tagName === "a" && next.target === "_blank") {
1087
+ next.rel = "noopener noreferrer";
1088
+ }
480
1089
  return { tagName, attribs: next };
481
1090
  }
482
1091
  var SANITIZE_OPTIONS = {
@@ -502,19 +1111,25 @@ var SANITIZE_OPTIONS = {
502
1111
  "p",
503
1112
  "pre",
504
1113
  "s",
1114
+ "section",
1115
+ "small",
505
1116
  "span",
506
1117
  "strong",
1118
+ "sub",
507
1119
  "summary",
1120
+ "sup",
508
1121
  "table",
509
1122
  "tbody",
510
1123
  "td",
1124
+ "tfoot",
511
1125
  "th",
1126
+ "thead",
512
1127
  "tr",
513
1128
  "u",
514
1129
  "ul"
515
1130
  ],
516
1131
  allowedAttributes: {
517
- "*": ["class", "data-*"],
1132
+ "*": ["class", "data-*", "style"],
518
1133
  a: [
519
1134
  "href",
520
1135
  "title",
@@ -531,7 +1146,14 @@ var SANITIZE_OPTIONS = {
531
1146
  p: ["class", "data-*", "style"],
532
1147
  span: ["class", "data-*", "style"],
533
1148
  td: ["colspan", "rowspan", "class", "data-*"],
534
- th: ["colspan", "rowspan", "class", "data-*"]
1149
+ // scope 는 표 머리행의 접근성 의미(스크린리더 열/행 연결) — 값 집합을 고정해 허용.
1150
+ th: [
1151
+ "colspan",
1152
+ "rowspan",
1153
+ "class",
1154
+ "data-*",
1155
+ { name: "scope", values: ["col", "row", "colgroup", "rowgroup"] }
1156
+ ]
535
1157
  },
536
1158
  allowedClasses: {
537
1159
  "*": [ROOTTALE_CLASS_RE]
@@ -541,8 +1163,31 @@ var SANITIZE_OPTIONS = {
541
1163
  "aspect-ratio": [ASPECT_RATIO_RE],
542
1164
  background: [HEX_COLOR_RE],
543
1165
  "background-color": [HEX_COLOR_RE],
1166
+ border: [CSS_BORDER_RE],
1167
+ "border-bottom": [CSS_BORDER_RE],
1168
+ "border-left": [CSS_BORDER_RE],
1169
+ "border-right": [CSS_BORDER_RE],
1170
+ "border-top": [CSS_BORDER_RE],
1171
+ "border-collapse": [/^(?:collapse|separate)$/i],
544
1172
  color: [HEX_COLOR_RE],
545
- "text-align": [TEXT_ALIGN_RE]
1173
+ "font-size": [CSS_LENGTH_RE],
1174
+ "font-weight": [CSS_FONT_WEIGHT_RE],
1175
+ height: [CSS_LENGTH_RE, /^auto$/i],
1176
+ "line-height": [CSS_LINE_HEIGHT_RE],
1177
+ margin: [CSS_LENGTH_LIST_RE],
1178
+ "margin-bottom": [CSS_LENGTH_RE],
1179
+ "margin-left": [CSS_LENGTH_RE],
1180
+ "margin-right": [CSS_LENGTH_RE],
1181
+ "margin-top": [CSS_LENGTH_RE],
1182
+ "max-width": [CSS_LENGTH_RE, /^none$/i],
1183
+ padding: [CSS_LENGTH_LIST_RE],
1184
+ "padding-bottom": [CSS_LENGTH_RE],
1185
+ "padding-left": [CSS_LENGTH_RE],
1186
+ "padding-right": [CSS_LENGTH_RE],
1187
+ "padding-top": [CSS_LENGTH_RE],
1188
+ "text-align": [TEXT_ALIGN_RE],
1189
+ "text-decoration": [CSS_TEXT_DECORATION_RE],
1190
+ width: [CSS_LENGTH_RE, /^auto$/i]
546
1191
  }
547
1192
  },
548
1193
  allowedSchemes: ["http", "https", "mailto", "tel"],
@@ -571,7 +1216,18 @@ var SANITIZE_OPTIONS = {
571
1216
  };
572
1217
  function sanitizeHtml(html) {
573
1218
  if (!html) return html;
574
- return sanitizeHtmlLibrary(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1219
+ return sanitizeHtmlLibrary2(stripMalformedDangerousTokens(html), SANITIZE_OPTIONS);
1220
+ }
1221
+ function htmlToPlainText(html) {
1222
+ const safeHtml = sanitizeHtml(html);
1223
+ const withBlockBoundaries = safeHtml.replace(
1224
+ /<(?:br|hr)\b[^>]*>|<\/(?:blockquote|details|div|figcaption|figure|h[2-4]|li|p|pre|section|summary|table|tbody|td|th|tr)>/gi,
1225
+ " "
1226
+ );
1227
+ return sanitizeHtmlLibrary2(withBlockBoundaries, {
1228
+ allowedTags: [],
1229
+ allowedAttributes: {}
1230
+ }).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
575
1231
  }
576
1232
  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'";
577
1233
 
@@ -603,9 +1259,11 @@ export {
603
1259
  boundFieldValue,
604
1260
  chainResolvers,
605
1261
  createFieldBindingResolver,
1262
+ ensureImportedHtmlDocumentRoots,
606
1263
  ensureStableIds,
607
1264
  extractFaqEntries,
608
1265
  faqPageJsonLd,
1266
+ htmlToPlainText,
609
1267
  injectStableIdsTree,
610
1268
  isAllowedEmbedSrc,
611
1269
  isAllowedMapEmbedSrc,
@@ -618,10 +1276,18 @@ export {
618
1276
  postsForCollection,
619
1277
  postsInAnyCollection,
620
1278
  postsNotInAnyCollection,
1279
+ resolveCategoryPath,
1280
+ resolveCategoryTreeChain,
1281
+ resolveCategoryTreeEntryPath,
1282
+ resolveCategoryTreeHubPaths,
1283
+ resolvePostCategorySlug,
621
1284
  resolvePostCollection,
622
1285
  resolvePostPath,
623
1286
  resolvePostUrl,
624
1287
  sanitizeHtml,
1288
+ sanitizeImportedCss,
1289
+ sanitizeImportedHtml,
1290
+ scopeImportedCss,
625
1291
  serializeBlock,
626
1292
  validateCollections,
627
1293
  verifyPreviewToken