@roottale/cms-core 0.65.1 → 0.67.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @roottale/cms-core
2
2
 
3
+ ## 0.67.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 30d26ac: 공통 메타데이터 해석기·고유 경로 조회·Next SSR 빌더와 캐시 태그를 추가합니다. 기존 canonical과 강제 robots 정책을 보존하고 공유 이미지 대체 텍스트를 OG·X에 동일하게 적용합니다. 분류의 공유 전용 설정과 메타데이터 조회·수정 MCP 도구, Pro 하향 후 공개 적용 유지 계약을 문서와 예제에 반영합니다. 분류 색인 전환을 선언한 사이트가 공개 분류 응답의 `category_index_activated`를 확인한 뒤에만 분류 검색 제외를 적용하도록 안내합니다.
8
+
9
+ ## 0.66.0
10
+
11
+ ### Minor Changes
12
+
13
+ - a58c5d2: 계층형 분류 모델(`category_tree`)에 `entryCategory: "leaf"`를 추가한다. 하위 분류가 없는
14
+ 분류면 단계와 무관하게 글을 붙일 수 있고(`categoryDepth`는 최대 단계), 새로 고를 때는
15
+ `resolveCategoryTreeSelection`으로 하위가 없는지 확인한다. 생략하면 기존처럼
16
+ `categoryDepth` 단계 말단에만 붙는다.
17
+
18
+ ### Patch Changes
19
+
20
+ - d5ef48e: `basePath: "/"`인 계층형 분류 모델(`category_tree`)도 허브·상세 주소를 계산합니다. 이전에는 루트를 빈 basePath(주소 없음)와 같게 보아 `resolveCategoryTreeHubPaths`·`resolveCategoryTreeEntryPath`·`resolvePostPublicPath`가 `null`을 돌려주었고, 웹훅에 상세 경로가 빠져 글 상세 캐시가 갱신되지 않았습니다.
21
+
3
22
  ## 0.65.1
4
23
 
5
24
  ## 0.65.0
package/dist/index.cjs CHANGED
@@ -45,6 +45,14 @@ __export(src_exports, {
45
45
  INTERNAL_CONTENT_LINK_PENDING_CLASS: () => INTERNAL_CONTENT_LINK_PENDING_CLASS,
46
46
  INTERNAL_CONTENT_LINK_TOKEN_SOURCE: () => INTERNAL_CONTENT_LINK_TOKEN_SOURCE,
47
47
  LEGACY_BLOG_DETAIL_PATH: () => LEGACY_BLOG_DETAIL_PATH,
48
+ METADATA_FIELDS: () => METADATA_FIELDS,
49
+ MetadataDefaultRulesSchema: () => MetadataDefaultRulesSchema,
50
+ MetadataFieldSchema: () => MetadataFieldSchema,
51
+ MetadataPatchSchema: () => MetadataPatchSchema,
52
+ MetadataRobotsPolicySchema: () => MetadataRobotsPolicySchema,
53
+ MetadataTargetKindSchema: () => MetadataTargetKindSchema,
54
+ MetadataTextRuleSchema: () => MetadataTextRuleSchema,
55
+ MetadataValuesSchema: () => MetadataValuesSchema,
48
56
  PHASE_1_CORE_BLOCKS: () => PHASE_1_CORE_BLOCKS,
49
57
  PHASE_1_CSP: () => PHASE_1_CSP,
50
58
  POST_FOOTER_SLOT: () => POST_FOOTER_SLOT,
@@ -73,6 +81,7 @@ __export(src_exports, {
73
81
  findInternalContentLinkTokens: () => findInternalContentLinkTokens,
74
82
  findSectionLockViolations: () => findSectionLockViolations,
75
83
  findSitePatternPlacementRule: () => findSitePatternPlacementRule,
84
+ formatMetadataDefaults: () => formatMetadataDefaults,
76
85
  formatSectionLockViolations: () => formatSectionLockViolations,
77
86
  htmlToPlainText: () => htmlToPlainText,
78
87
  injectStableIdsTree: () => injectStableIdsTree,
@@ -82,18 +91,25 @@ __export(src_exports, {
82
91
  isAllowedEmbedSrc: () => isAllowedEmbedSrc,
83
92
  isAllowedMapEmbedSrc: () => isAllowedMapEmbedSrc,
84
93
  isInCollectionMode: () => isInCollectionMode,
94
+ isProtectedRedirectPath: () => isProtectedRedirectPath,
85
95
  isSectionContentPropPath: () => isSectionContentPropPath,
86
96
  isSitePatternSlotKey: () => isSitePatternSlotKey,
87
97
  isValidBlock: () => isValidBlock,
88
98
  isValidSitePatternKey: () => isValidSitePatternKey,
89
99
  issuePreviewToken: () => issuePreviewToken,
100
+ mergeMetadataPatch: () => mergeMetadataPatch,
101
+ metadataContentImageUrl: () => metadataContentImageUrl,
90
102
  normalizeEmbed: () => normalizeEmbed,
91
103
  normalizeHexColor: () => normalizeHexColor,
92
104
  normalizeImageAlt: () => normalizeImageAlt,
105
+ normalizeRedirectPath: () => normalizeRedirectPath,
106
+ normalizeRedirectSource: () => normalizeRedirectSource,
107
+ normalizeRedirectTarget: () => normalizeRedirectTarget,
93
108
  parseBlock: () => parseBlock,
94
109
  postsForCollection: () => postsForCollection,
95
110
  postsInAnyCollection: () => postsInAnyCollection,
96
111
  postsNotInAnyCollection: () => postsNotInAnyCollection,
112
+ readMetadataValues: () => readMetadataValues,
97
113
  readSitePatternPlacements: () => readSitePatternPlacements,
98
114
  readSitePatternPresentation: () => readSitePatternPresentation,
99
115
  referencedSitePatternKeys: () => referencedSitePatternKeys,
@@ -103,6 +119,8 @@ __export(src_exports, {
103
119
  resolveCategoryTreeChain: () => resolveCategoryTreeChain,
104
120
  resolveCategoryTreeEntryPath: () => resolveCategoryTreeEntryPath,
105
121
  resolveCategoryTreeHubPaths: () => resolveCategoryTreeHubPaths,
122
+ resolveCategoryTreeSelection: () => resolveCategoryTreeSelection,
123
+ resolveMetadata: () => resolveMetadata,
106
124
  resolvePatternSlots: () => resolvePatternSlots,
107
125
  resolvePostCategorySlug: () => resolvePostCategorySlug,
108
126
  resolvePostCollection: () => resolvePostCollection,
@@ -349,39 +367,51 @@ function validateCollections(collections) {
349
367
  }
350
368
 
351
369
  // src/category-tree.ts
352
- function normalizeBasePath(basePath) {
370
+ function basePathPrefix(basePath) {
371
+ if (!basePath.trim()) return null;
353
372
  return basePath.replace(/\/+$/, "");
354
373
  }
355
- function resolveCategoryTreeChain(presentation, leafId, terms) {
356
- if (!Number.isInteger(presentation.categoryDepth) || presentation.categoryDepth < 1) {
357
- return null;
358
- }
374
+ function categoryTreeAncestry(categoryDepth, termId, terms) {
375
+ if (!Number.isInteger(categoryDepth) || categoryDepth < 1) return null;
359
376
  const termsById = new Map(terms.map((term) => [term.id, term]));
360
377
  if (termsById.size !== terms.length) return null;
361
- if (terms.some((term) => term.parentId === leafId)) return null;
362
378
  const reversed = [];
363
379
  const visited = /* @__PURE__ */ new Set();
364
- let currentId = leafId;
380
+ let currentId = termId;
365
381
  while (currentId) {
366
382
  if (visited.has(currentId)) return null;
367
383
  visited.add(currentId);
368
384
  const current = termsById.get(currentId);
369
385
  if (!current || !current.slug) return null;
370
386
  reversed.push(current);
387
+ if (reversed.length > categoryDepth) return null;
371
388
  currentId = current.parentId ?? null;
372
389
  }
373
- if (reversed.length !== presentation.categoryDepth) return null;
374
390
  return reversed.reverse();
375
391
  }
392
+ function hasChildCategory(termId, terms) {
393
+ return terms.some((term) => term.parentId === termId);
394
+ }
395
+ function resolveCategoryTreeChain(presentation, termId, terms) {
396
+ const chain = categoryTreeAncestry(presentation.categoryDepth, termId, terms);
397
+ if (!chain) return null;
398
+ if (presentation.entryCategory === "leaf") return chain;
399
+ if (hasChildCategory(termId, terms)) return null;
400
+ return chain.length === presentation.categoryDepth ? chain : null;
401
+ }
402
+ function resolveCategoryTreeSelection(presentation, termId, terms) {
403
+ if (hasChildCategory(termId, terms)) return null;
404
+ return resolveCategoryTreeChain(presentation, termId, terms);
405
+ }
376
406
  function resolveCategoryTreeHubPaths(presentation, termId, terms) {
377
407
  const chain = resolveCategoryTreeChain(presentation, termId, terms);
378
- const basePath = normalizeBasePath(presentation.basePath);
379
- if (!chain || !basePath) return null;
380
- const paths = [basePath];
408
+ const prefix = basePathPrefix(presentation.basePath);
409
+ if (!chain || prefix === null) return null;
410
+ const paths = [prefix || "/"];
381
411
  const segments = [];
382
412
  for (const term of chain) {
383
413
  segments.push(encodeURIComponent(term.slug));
384
- paths.push(`${basePath}/${segments.join("/")}`);
414
+ paths.push(`${prefix}/${segments.join("/")}`);
385
415
  }
386
416
  return paths;
387
417
  }
@@ -1085,10 +1115,10 @@ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
1085
1115
 
1086
1116
  // src/internal-content-links.ts
1087
1117
  var INTERNAL_CONTENT_LINK_TOKEN_SOURCE = String.raw`\[\[internal:([a-z0-9가-힣]+(?:[.-][a-z0-9가-힣]+)*)\|([^\]\n|]+)\]\]`;
1088
- function findInternalContentLinkTokens(text) {
1118
+ function findInternalContentLinkTokens(text2) {
1089
1119
  const pattern = new RegExp(INTERNAL_CONTENT_LINK_TOKEN_SOURCE, "giu");
1090
1120
  const tokens = [];
1091
- for (const match of text.matchAll(pattern)) {
1121
+ for (const match of text2.matchAll(pattern)) {
1092
1122
  const [raw, key, label] = match;
1093
1123
  if (!key || !label || match.index === void 0) continue;
1094
1124
  tokens.push({
@@ -1147,19 +1177,19 @@ function escapeHtmlText(value) {
1147
1177
  var INTERNAL_CONTENT_LINK_CLASS = "rt-internal-link";
1148
1178
  var INTERNAL_CONTENT_LINK_PENDING_CLASS = "rt-internal-link--pending";
1149
1179
  var INTERNAL_CONTENT_LINK_PENDING_ATTR = "data-rt-internal-link-pending";
1150
- function renderInternalContentLinksInText(text, resolve) {
1151
- const tokens = findInternalContentLinkTokens(text);
1152
- if (tokens.length === 0) return { html: text, hasTokens: false };
1180
+ function renderInternalContentLinksInText(text2, resolve) {
1181
+ const tokens = findInternalContentLinkTokens(text2);
1182
+ if (tokens.length === 0) return { html: text2, hasTokens: false };
1153
1183
  let cursor = 0;
1154
1184
  let html = "";
1155
1185
  for (const token of tokens) {
1156
- html += text.slice(cursor, token.index);
1186
+ html += text2.slice(cursor, token.index);
1157
1187
  const path = resolve(token.key);
1158
1188
  const label = escapeHtmlText(token.label);
1159
1189
  html += isSafeInternalPath(path) ? `<a href="${escapeHtmlText(path)}" class="${INTERNAL_CONTENT_LINK_CLASS}">${label}</a>` : `<span class="${INTERNAL_CONTENT_LINK_CLASS} ${INTERNAL_CONTENT_LINK_PENDING_CLASS}" ${INTERNAL_CONTENT_LINK_PENDING_ATTR}="${escapeHtmlText(token.key)}">${label}</span>`;
1160
1190
  cursor = token.index + token.raw.length;
1161
1191
  }
1162
- html += text.slice(cursor);
1192
+ html += text2.slice(cursor);
1163
1193
  return { html, hasTokens: true };
1164
1194
  }
1165
1195
  var INTERNAL_LINK_BLOCKED_HTML_TAGS = /* @__PURE__ */ new Set([
@@ -1193,12 +1223,12 @@ function renderInternalContentLinksInHtml(html, resolve) {
1193
1223
  let cursor = 0;
1194
1224
  let out = "";
1195
1225
  let hasTokens = false;
1196
- const emitText = (text) => {
1197
- if (blockedDepth > 0 || !text) {
1198
- out += text;
1226
+ const emitText = (text2) => {
1227
+ if (blockedDepth > 0 || !text2) {
1228
+ out += text2;
1199
1229
  return;
1200
1230
  }
1201
- const rendered = renderInternalContentLinksInText(text, resolve);
1231
+ const rendered = renderInternalContentLinksInText(text2, resolve);
1202
1232
  hasTokens ||= rendered.hasTokens;
1203
1233
  out += rendered.html;
1204
1234
  };
@@ -1337,8 +1367,8 @@ function fromCodePointSafe2(n) {
1337
1367
  }
1338
1368
  }
1339
1369
  function decodeUrlProbe(value) {
1340
- 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);
1341
- return [...decoded].filter((c) => {
1370
+ const decoded2 = 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);
1371
+ return [...decoded2].filter((c) => {
1342
1372
  const n = c.codePointAt(0) ?? 0;
1343
1373
  return n > 32 && n !== 127;
1344
1374
  }).join("").toLowerCase();
@@ -1962,6 +1992,287 @@ function formatSectionLockViolations(violations) {
1962
1992
  return `${label} \uAD6C\uC870 \uBCC0\uACBD(${violation.field})`;
1963
1993
  }).join(" / ");
1964
1994
  }
1995
+
1996
+ // src/metadata-resolver.ts
1997
+ function text(value) {
1998
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
1999
+ }
2000
+ function imageUrl(value, base) {
2001
+ const candidate = text(value);
2002
+ if (!candidate || candidate.startsWith("//") || /[\\\u0000-\u0020\u007f]/u.test(candidate)) return void 0;
2003
+ if (candidate.startsWith("/") && !base) return candidate;
2004
+ try {
2005
+ const url = new URL(candidate, base);
2006
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return void 0;
2007
+ return url.href;
2008
+ } catch {
2009
+ return void 0;
2010
+ }
2011
+ }
2012
+ function resolveMetadata(input) {
2013
+ const layers = [input.overrides, input.defaults, input.siteDefaults, input.fallback];
2014
+ const result = {};
2015
+ for (const field of ["title", "description", "ogImageAlt"]) {
2016
+ for (const layer of layers) {
2017
+ const value = text(layer?.[field]);
2018
+ if (value !== void 0) {
2019
+ result[field] = value;
2020
+ break;
2021
+ }
2022
+ }
2023
+ }
2024
+ for (const layer of layers) {
2025
+ const value = imageUrl(layer?.ogImage, input.imageBaseUrl);
2026
+ if (value !== void 0) {
2027
+ result.ogImage = value;
2028
+ break;
2029
+ }
2030
+ }
2031
+ for (const field of ["noindex", "nofollow"]) {
2032
+ for (const layer of layers) {
2033
+ const value = layer?.[field];
2034
+ if (typeof value === "boolean") {
2035
+ result[field] = value;
2036
+ break;
2037
+ }
2038
+ }
2039
+ }
2040
+ if (input.robots?.forceNoindex) result.noindex = true;
2041
+ if (input.robots?.forceNofollow) result.nofollow = true;
2042
+ return result;
2043
+ }
2044
+
2045
+ // src/metadata-contract.ts
2046
+ var import_zod2 = require("zod");
2047
+ var METADATA_FIELDS = ["title", "description", "ogImage", "ogImageAlt", "noindex", "nofollow"];
2048
+ var MetadataFieldSchema = import_zod2.z.enum(METADATA_FIELDS);
2049
+ var MetadataTargetKindSchema = import_zod2.z.enum(["route", "post", "term"]);
2050
+ var textValue = (max) => import_zod2.z.string().trim().min(1).max(max);
2051
+ var imageValue = textValue(2048).refine((value) => {
2052
+ if (/[\\\u0000-\u0020\u007f]/u.test(value) || value.startsWith("//")) return false;
2053
+ if (value.startsWith("/")) return true;
2054
+ try {
2055
+ const url = new URL(value);
2056
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
2057
+ } catch {
2058
+ return false;
2059
+ }
2060
+ }, "\uACF5\uC720 \uC774\uBBF8\uC9C0\uC5D0\uB294 \uC0AC\uC774\uD2B8 \uB0B4\uBD80 \uACBD\uB85C\uB098 HTTP/HTTPS \uC8FC\uC18C\uB97C \uC785\uB825\uD574 \uC8FC\uC138\uC694");
2061
+ var metadataFields = {
2062
+ // Existing content titles (500) plus a site title template (400) remain valid automatic values.
2063
+ title: textValue(1e3),
2064
+ description: textValue(2e3),
2065
+ ogImage: imageValue,
2066
+ // Automatic alt text may use the complete automatic title and its site suffix.
2067
+ ogImageAlt: textValue(1e3),
2068
+ noindex: import_zod2.z.boolean(),
2069
+ nofollow: import_zod2.z.boolean()
2070
+ };
2071
+ var MetadataValuesSchema = import_zod2.z.object(metadataFields).partial().strict();
2072
+ var MetadataPatchSchema = import_zod2.z.object({
2073
+ title: textValue(300).nullable().optional(),
2074
+ description: metadataFields.description.nullable().optional(),
2075
+ ogImage: metadataFields.ogImage.nullable().optional(),
2076
+ ogImageAlt: textValue(500).nullable().optional(),
2077
+ noindex: metadataFields.noindex.nullable().optional(),
2078
+ nofollow: metadataFields.nofollow.nullable().optional()
2079
+ }).strict();
2080
+ var MetadataRobotsPolicySchema = import_zod2.z.object({
2081
+ forceNoindex: import_zod2.z.boolean().optional(),
2082
+ forceNofollow: import_zod2.z.boolean().optional()
2083
+ }).strict();
2084
+ function readMetadataValues(input) {
2085
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
2086
+ const record = input;
2087
+ const output = {};
2088
+ for (const field of METADATA_FIELDS) {
2089
+ const parsed = metadataFields[field].safeParse(record[field]);
2090
+ if (parsed.success) output[field] = parsed.data;
2091
+ }
2092
+ return MetadataValuesSchema.parse(output);
2093
+ }
2094
+ function mergeMetadataPatch(current, input) {
2095
+ const patch = MetadataPatchSchema.parse(input);
2096
+ const next = { ...current };
2097
+ for (const field of METADATA_FIELDS) {
2098
+ if (patch[field] === null) delete next[field];
2099
+ else if (patch[field] !== void 0) next[field] = patch[field];
2100
+ }
2101
+ return next;
2102
+ }
2103
+
2104
+ // src/metadata-defaults.ts
2105
+ var import_zod3 = require("zod");
2106
+ var fallbackText = import_zod3.z.string().max(2e3);
2107
+ var imageOrigin = import_zod3.z.string().max(2048).refine((value) => {
2108
+ try {
2109
+ const url = new URL(value);
2110
+ return url.protocol === "https:" && url.origin === value;
2111
+ } catch {
2112
+ return false;
2113
+ }
2114
+ }, "\uC774\uBBF8\uC9C0 \uD5C8\uC6A9 \uBAA9\uB85D\uC5D0\uB294 \uACBD\uB85C \uC5C6\uB294 HTTPS origin\uB9CC \uC120\uC5B8\uD569\uB2C8\uB2E4");
2115
+ var MetadataTextRuleSchema = import_zod3.z.object({
2116
+ source: import_zod3.z.enum(["content", "fallback"]).optional(),
2117
+ stripLeadingLabel: import_zod3.z.enum(["question", "answer"]).optional(),
2118
+ replaceLineBreakTokens: import_zod3.z.boolean().optional(),
2119
+ removeEmDashes: import_zod3.z.boolean().optional(),
2120
+ normalizeWhitespace: import_zod3.z.boolean().optional(),
2121
+ maxLength: import_zod3.z.number().int().min(1).max(2e3).optional(),
2122
+ ellipsis: import_zod3.z.string().max(10).optional(),
2123
+ fallback: fallbackText.optional(),
2124
+ fallbackWithParent: fallbackText.optional(),
2125
+ fallbackWithoutParent: fallbackText.optional(),
2126
+ fallbackBySlug: import_zod3.z.record(import_zod3.z.string().max(200), fallbackText).optional()
2127
+ }).strict().refine((rule) => !rule.ellipsis || rule.maxLength !== void 0 && rule.ellipsis.length < rule.maxLength, "\uB9D0\uC904\uC784\uD45C\uC5D0\uB294 \uB354 \uAE34 \uCD5C\uB300 \uAE38\uC774\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4");
2128
+ var MetadataDefaultRulesSchema = import_zod3.z.object({
2129
+ title: MetadataTextRuleSchema.optional(),
2130
+ description: MetadataTextRuleSchema.optional(),
2131
+ useFirstBodyImage: import_zod3.z.boolean().optional(),
2132
+ useTermImage: import_zod3.z.boolean().optional(),
2133
+ imageAltSource: import_zod3.z.enum(["content", "title"]).optional(),
2134
+ legacyImageSource: import_zod3.z.literal("legacy-thumbnail").optional(),
2135
+ imageAllowedOrigins: import_zod3.z.array(imageOrigin).min(1).max(32).optional(),
2136
+ legacyImageAllowedOrigins: import_zod3.z.array(imageOrigin).min(1).max(32).optional(),
2137
+ normalizeCloudflareImages: import_zod3.z.boolean().optional(),
2138
+ fallback: MetadataValuesSchema.optional()
2139
+ }).strict().refine((rule) => !rule.legacyImageSource || Boolean(rule.legacyImageAllowedOrigins?.length), "\uC774\uC804 \uC774\uBBF8\uC9C0 \uC6D0\uBCF8\uC5D0\uB294 \uD5C8\uC6A9 origin \uC120\uC5B8\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
2140
+ function cleanText(value, rule) {
2141
+ let text2 = value.trim();
2142
+ if (rule?.stripLeadingLabel === "question") text2 = text2.replace(/^\s*Q[..:]?\s*/iu, "");
2143
+ if (rule?.stripLeadingLabel === "answer") text2 = text2.replace(/^\s*A[..:]?\s*/iu, "");
2144
+ if (rule?.replaceLineBreakTokens) text2 = text2.replace(/\[\[br(?::[^\]]*)?\]\]/giu, " ");
2145
+ if (rule?.removeEmDashes) text2 = text2.replaceAll("\u2014", "");
2146
+ if (rule?.normalizeWhitespace) text2 = text2.replace(/\s+/gu, " ");
2147
+ return text2.trim();
2148
+ }
2149
+ function formatText(value, rule, input, name) {
2150
+ let text2 = rule?.source === "fallback" ? "" : cleanText(value ?? "", rule);
2151
+ if (!text2) {
2152
+ const fallback = input.parentId ? rule?.fallbackWithParent ?? rule?.fallback : (input.slug ? rule?.fallbackBySlug?.[input.slug] : void 0) ?? rule?.fallbackWithoutParent ?? rule?.fallback;
2153
+ text2 = cleanText((fallback ?? "").replaceAll("%s", () => name), rule);
2154
+ }
2155
+ if (!text2) return void 0;
2156
+ if (rule?.maxLength && text2.length > rule.maxLength) {
2157
+ const suffix = rule.ellipsis ?? "";
2158
+ const head = text2.slice(0, rule.maxLength - suffix.length);
2159
+ return suffix ? head.trimEnd() + suffix : head;
2160
+ }
2161
+ return text2;
2162
+ }
2163
+ function metadataContentImageUrl(value, normalizeCloudflareImages = false) {
2164
+ if (!normalizeCloudflareImages) return value;
2165
+ try {
2166
+ const url = new URL(value);
2167
+ const [account, id, variant] = url.pathname.split("/").filter(Boolean);
2168
+ if (url.protocol === "https:" && url.hostname === "imagedelivery.net" && account && id && variant) {
2169
+ url.pathname = `/${account}/${id}/lg`;
2170
+ return url.href;
2171
+ }
2172
+ } catch {
2173
+ }
2174
+ return value;
2175
+ }
2176
+ function legacyImageUrl(value, rule) {
2177
+ if (!value || rule?.legacyImageSource !== "legacy-thumbnail") return void 0;
2178
+ try {
2179
+ const url = new URL(value);
2180
+ if (!rule.legacyImageAllowedOrigins?.includes(url.origin)) return void 0;
2181
+ return readMetadataValues({ ogImage: url.href }).ogImage;
2182
+ } catch {
2183
+ return void 0;
2184
+ }
2185
+ }
2186
+ function contentCandidateAllowed(value, input, rule) {
2187
+ if (!rule?.imageAllowedOrigins) return true;
2188
+ if (value.startsWith("/") && !value.startsWith("//")) return true;
2189
+ try {
2190
+ const url = new URL(value);
2191
+ if (url.protocol !== "https:") return false;
2192
+ const origins = [...rule.imageAllowedOrigins];
2193
+ if (input.imageBaseUrl) origins.push(new URL(input.imageBaseUrl).origin);
2194
+ return origins.includes(url.origin);
2195
+ } catch {
2196
+ return false;
2197
+ }
2198
+ }
2199
+ function formatMetadataDefaults(input, declaration) {
2200
+ const dynamic = declaration[input.kind];
2201
+ const rule = dynamic?.defaultRules?.[input.modelKey];
2202
+ const name = cleanText(input.title, rule?.title);
2203
+ const title = formatText(input.title, rule?.title, input, name);
2204
+ const template = dynamic?.titleTemplates?.[input.modelKey];
2205
+ const description = formatText(input.description, rule?.description, input, name);
2206
+ const bodyImage = input.firstBodyImage && (input.firstBodyImage.startsWith("https://") || /^\/(?!\/)/u.test(input.firstBodyImage)) ? input.firstBodyImage : void 0;
2207
+ const candidateImage = input.kind === "terms" && rule?.useTermImage === false ? void 0 : input.featuredImage || (rule?.useFirstBodyImage ? bodyImage : void 0) || legacyImageUrl(input.legacyImage, rule);
2208
+ const ogImage = candidateImage && contentCandidateAllowed(candidateImage, input, rule) ? readMetadataValues({ ogImage: metadataContentImageUrl(candidateImage, rule?.normalizeCloudflareImages) }).ogImage : void 0;
2209
+ return {
2210
+ ...title ? { title: template ? template.replace("%s", () => title) : title } : {},
2211
+ ...description ? { description } : {},
2212
+ ...ogImage ? { ogImage } : {}
2213
+ };
2214
+ }
2215
+
2216
+ // src/redirect-paths.ts
2217
+ var CONTROL_OR_BACKSLASH = /[\\\u0000-\u001f\u007f]/u;
2218
+ var PROTECTED_PREFIX = /^\/(?:api|_next|_vercel|auth|admin|login|logout|revalidate|preview|assets|static|images|fonts|\.well-known)(?:\/|$)/i;
2219
+ var STATIC_EXTENSION = /\.(?:ico|png|jpe?g|gif|svg|webp|avif|css|js|txt|xml|json|woff2?|map)$/i;
2220
+ function decoded(value) {
2221
+ try {
2222
+ return decodeURIComponent(value);
2223
+ } catch {
2224
+ return null;
2225
+ }
2226
+ }
2227
+ function trimInternalPath(path) {
2228
+ if (!path.startsWith("/")) path = `/${path}`;
2229
+ if (path.length > 1) path = path.replace(/\/+$/, "");
2230
+ return path || "/";
2231
+ }
2232
+ function normalizeRedirectPath(raw) {
2233
+ return trimInternalPath((decoded(raw) ?? raw).split(/[?#]/, 1)[0] ?? "");
2234
+ }
2235
+ function isProtectedRedirectPath(path) {
2236
+ const normalized = normalizeRedirectPath(path);
2237
+ return PROTECTED_PREFIX.test(normalized) || STATIC_EXTENSION.test(normalized);
2238
+ }
2239
+ function unsafePath(path) {
2240
+ return CONTROL_OR_BACKSLASH.test(path) || path.startsWith("//") || /(?:^|\/)\.{1,2}(?:\/|$)/.test(path) || /[%](?:2f|5c|0[0-9a-f]|1[0-9a-f]|7f)/i.test(path);
2241
+ }
2242
+ function normalizeRedirectSource(raw) {
2243
+ const invalid = { ok: false, code: "invalid_source_path" };
2244
+ if (CONTROL_OR_BACKSLASH.test(raw)) return invalid;
2245
+ const trimmed = raw.trim();
2246
+ if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return invalid;
2247
+ const rawPath = trimmed.split(/[?#]/, 1)[0] ?? "";
2248
+ const path = decoded(rawPath);
2249
+ if (!path || unsafePath(path) || /[?#*]/.test(path)) return invalid;
2250
+ const normalized = trimInternalPath(path);
2251
+ if (isProtectedRedirectPath(normalized)) return invalid;
2252
+ return { ok: true, value: normalized };
2253
+ }
2254
+ function normalizeRedirectTarget(raw) {
2255
+ const invalid = { ok: false, code: "invalid_target" };
2256
+ const decodedRaw = decoded(raw);
2257
+ if (decodedRaw === null || CONTROL_OR_BACKSLASH.test(raw) || CONTROL_OR_BACKSLASH.test(decodedRaw)) return invalid;
2258
+ const trimmed = raw.trim();
2259
+ if (!trimmed || trimmed.startsWith("//")) return invalid;
2260
+ if (trimmed.startsWith("/")) {
2261
+ const suffixIndex = trimmed.search(/[?#]/);
2262
+ const rawPath = suffixIndex === -1 ? trimmed : trimmed.slice(0, suffixIndex);
2263
+ const path = decoded(rawPath);
2264
+ if (!path || unsafePath(path) || /[?#]/.test(path)) return invalid;
2265
+ const suffix = suffixIndex === -1 ? "" : trimmed.slice(suffixIndex);
2266
+ return { ok: true, value: `${trimInternalPath(path)}${suffix}`, kind: "internal" };
2267
+ }
2268
+ try {
2269
+ const url = new URL(trimmed);
2270
+ if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password) return invalid;
2271
+ return { ok: true, value: url.toString(), kind: "external" };
2272
+ } catch {
2273
+ return invalid;
2274
+ }
2275
+ }
1965
2276
  // Annotate the CommonJS export names for ESM import in node:
1966
2277
  0 && (module.exports = {
1967
2278
  BLOCK_NAME_PATTERN,
@@ -1979,6 +2290,14 @@ function formatSectionLockViolations(violations) {
1979
2290
  INTERNAL_CONTENT_LINK_PENDING_CLASS,
1980
2291
  INTERNAL_CONTENT_LINK_TOKEN_SOURCE,
1981
2292
  LEGACY_BLOG_DETAIL_PATH,
2293
+ METADATA_FIELDS,
2294
+ MetadataDefaultRulesSchema,
2295
+ MetadataFieldSchema,
2296
+ MetadataPatchSchema,
2297
+ MetadataRobotsPolicySchema,
2298
+ MetadataTargetKindSchema,
2299
+ MetadataTextRuleSchema,
2300
+ MetadataValuesSchema,
1982
2301
  PHASE_1_CORE_BLOCKS,
1983
2302
  PHASE_1_CSP,
1984
2303
  POST_FOOTER_SLOT,
@@ -2007,6 +2326,7 @@ function formatSectionLockViolations(violations) {
2007
2326
  findInternalContentLinkTokens,
2008
2327
  findSectionLockViolations,
2009
2328
  findSitePatternPlacementRule,
2329
+ formatMetadataDefaults,
2010
2330
  formatSectionLockViolations,
2011
2331
  htmlToPlainText,
2012
2332
  injectStableIdsTree,
@@ -2016,18 +2336,25 @@ function formatSectionLockViolations(violations) {
2016
2336
  isAllowedEmbedSrc,
2017
2337
  isAllowedMapEmbedSrc,
2018
2338
  isInCollectionMode,
2339
+ isProtectedRedirectPath,
2019
2340
  isSectionContentPropPath,
2020
2341
  isSitePatternSlotKey,
2021
2342
  isValidBlock,
2022
2343
  isValidSitePatternKey,
2023
2344
  issuePreviewToken,
2345
+ mergeMetadataPatch,
2346
+ metadataContentImageUrl,
2024
2347
  normalizeEmbed,
2025
2348
  normalizeHexColor,
2026
2349
  normalizeImageAlt,
2350
+ normalizeRedirectPath,
2351
+ normalizeRedirectSource,
2352
+ normalizeRedirectTarget,
2027
2353
  parseBlock,
2028
2354
  postsForCollection,
2029
2355
  postsInAnyCollection,
2030
2356
  postsNotInAnyCollection,
2357
+ readMetadataValues,
2031
2358
  readSitePatternPlacements,
2032
2359
  readSitePatternPresentation,
2033
2360
  referencedSitePatternKeys,
@@ -2037,6 +2364,8 @@ function formatSectionLockViolations(violations) {
2037
2364
  resolveCategoryTreeChain,
2038
2365
  resolveCategoryTreeEntryPath,
2039
2366
  resolveCategoryTreeHubPaths,
2367
+ resolveCategoryTreeSelection,
2368
+ resolveMetadata,
2040
2369
  resolvePatternSlots,
2041
2370
  resolvePostCategorySlug,
2042
2371
  resolvePostCollection,