@roottale/cms-core 0.66.0 → 0.67.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @roottale/cms-core
2
2
 
3
+ ## 0.67.1
4
+
5
+ ## 0.67.0
6
+
7
+ ### Minor Changes
8
+
9
+ - 30d26ac: 공통 메타데이터 해석기·고유 경로 조회·Next SSR 빌더와 캐시 태그를 추가합니다. 기존 canonical과 강제 robots 정책을 보존하고 공유 이미지 대체 텍스트를 OG·X에 동일하게 적용합니다. 분류의 공유 전용 설정과 메타데이터 조회·수정 MCP 도구, Pro 하향 후 공개 적용 유지 계약을 문서와 예제에 반영합니다. 분류 색인 전환을 선언한 사이트가 공개 분류 응답의 `category_index_activated`를 확인한 뒤에만 분류 검색 제외를 적용하도록 안내합니다.
10
+
3
11
  ## 0.66.0
4
12
 
5
13
  ### Minor Changes
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,
@@ -104,6 +120,7 @@ __export(src_exports, {
104
120
  resolveCategoryTreeEntryPath: () => resolveCategoryTreeEntryPath,
105
121
  resolveCategoryTreeHubPaths: () => resolveCategoryTreeHubPaths,
106
122
  resolveCategoryTreeSelection: () => resolveCategoryTreeSelection,
123
+ resolveMetadata: () => resolveMetadata,
107
124
  resolvePatternSlots: () => resolvePatternSlots,
108
125
  resolvePostCategorySlug: () => resolvePostCategorySlug,
109
126
  resolvePostCollection: () => resolvePostCollection,
@@ -1098,10 +1115,10 @@ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
1098
1115
 
1099
1116
  // src/internal-content-links.ts
1100
1117
  var INTERNAL_CONTENT_LINK_TOKEN_SOURCE = String.raw`\[\[internal:([a-z0-9가-힣]+(?:[.-][a-z0-9가-힣]+)*)\|([^\]\n|]+)\]\]`;
1101
- function findInternalContentLinkTokens(text) {
1118
+ function findInternalContentLinkTokens(text2) {
1102
1119
  const pattern = new RegExp(INTERNAL_CONTENT_LINK_TOKEN_SOURCE, "giu");
1103
1120
  const tokens = [];
1104
- for (const match of text.matchAll(pattern)) {
1121
+ for (const match of text2.matchAll(pattern)) {
1105
1122
  const [raw, key, label] = match;
1106
1123
  if (!key || !label || match.index === void 0) continue;
1107
1124
  tokens.push({
@@ -1160,19 +1177,19 @@ function escapeHtmlText(value) {
1160
1177
  var INTERNAL_CONTENT_LINK_CLASS = "rt-internal-link";
1161
1178
  var INTERNAL_CONTENT_LINK_PENDING_CLASS = "rt-internal-link--pending";
1162
1179
  var INTERNAL_CONTENT_LINK_PENDING_ATTR = "data-rt-internal-link-pending";
1163
- function renderInternalContentLinksInText(text, resolve) {
1164
- const tokens = findInternalContentLinkTokens(text);
1165
- 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 };
1166
1183
  let cursor = 0;
1167
1184
  let html = "";
1168
1185
  for (const token of tokens) {
1169
- html += text.slice(cursor, token.index);
1186
+ html += text2.slice(cursor, token.index);
1170
1187
  const path = resolve(token.key);
1171
1188
  const label = escapeHtmlText(token.label);
1172
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>`;
1173
1190
  cursor = token.index + token.raw.length;
1174
1191
  }
1175
- html += text.slice(cursor);
1192
+ html += text2.slice(cursor);
1176
1193
  return { html, hasTokens: true };
1177
1194
  }
1178
1195
  var INTERNAL_LINK_BLOCKED_HTML_TAGS = /* @__PURE__ */ new Set([
@@ -1206,12 +1223,12 @@ function renderInternalContentLinksInHtml(html, resolve) {
1206
1223
  let cursor = 0;
1207
1224
  let out = "";
1208
1225
  let hasTokens = false;
1209
- const emitText = (text) => {
1210
- if (blockedDepth > 0 || !text) {
1211
- out += text;
1226
+ const emitText = (text2) => {
1227
+ if (blockedDepth > 0 || !text2) {
1228
+ out += text2;
1212
1229
  return;
1213
1230
  }
1214
- const rendered = renderInternalContentLinksInText(text, resolve);
1231
+ const rendered = renderInternalContentLinksInText(text2, resolve);
1215
1232
  hasTokens ||= rendered.hasTokens;
1216
1233
  out += rendered.html;
1217
1234
  };
@@ -1350,8 +1367,8 @@ function fromCodePointSafe2(n) {
1350
1367
  }
1351
1368
  }
1352
1369
  function decodeUrlProbe(value) {
1353
- 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);
1354
- 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) => {
1355
1372
  const n = c.codePointAt(0) ?? 0;
1356
1373
  return n > 32 && n !== 127;
1357
1374
  }).join("").toLowerCase();
@@ -1975,6 +1992,287 @@ function formatSectionLockViolations(violations) {
1975
1992
  return `${label} \uAD6C\uC870 \uBCC0\uACBD(${violation.field})`;
1976
1993
  }).join(" / ");
1977
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
+ }
1978
2276
  // Annotate the CommonJS export names for ESM import in node:
1979
2277
  0 && (module.exports = {
1980
2278
  BLOCK_NAME_PATTERN,
@@ -1992,6 +2290,14 @@ function formatSectionLockViolations(violations) {
1992
2290
  INTERNAL_CONTENT_LINK_PENDING_CLASS,
1993
2291
  INTERNAL_CONTENT_LINK_TOKEN_SOURCE,
1994
2292
  LEGACY_BLOG_DETAIL_PATH,
2293
+ METADATA_FIELDS,
2294
+ MetadataDefaultRulesSchema,
2295
+ MetadataFieldSchema,
2296
+ MetadataPatchSchema,
2297
+ MetadataRobotsPolicySchema,
2298
+ MetadataTargetKindSchema,
2299
+ MetadataTextRuleSchema,
2300
+ MetadataValuesSchema,
1995
2301
  PHASE_1_CORE_BLOCKS,
1996
2302
  PHASE_1_CSP,
1997
2303
  POST_FOOTER_SLOT,
@@ -2020,6 +2326,7 @@ function formatSectionLockViolations(violations) {
2020
2326
  findInternalContentLinkTokens,
2021
2327
  findSectionLockViolations,
2022
2328
  findSitePatternPlacementRule,
2329
+ formatMetadataDefaults,
2023
2330
  formatSectionLockViolations,
2024
2331
  htmlToPlainText,
2025
2332
  injectStableIdsTree,
@@ -2029,18 +2336,25 @@ function formatSectionLockViolations(violations) {
2029
2336
  isAllowedEmbedSrc,
2030
2337
  isAllowedMapEmbedSrc,
2031
2338
  isInCollectionMode,
2339
+ isProtectedRedirectPath,
2032
2340
  isSectionContentPropPath,
2033
2341
  isSitePatternSlotKey,
2034
2342
  isValidBlock,
2035
2343
  isValidSitePatternKey,
2036
2344
  issuePreviewToken,
2345
+ mergeMetadataPatch,
2346
+ metadataContentImageUrl,
2037
2347
  normalizeEmbed,
2038
2348
  normalizeHexColor,
2039
2349
  normalizeImageAlt,
2350
+ normalizeRedirectPath,
2351
+ normalizeRedirectSource,
2352
+ normalizeRedirectTarget,
2040
2353
  parseBlock,
2041
2354
  postsForCollection,
2042
2355
  postsInAnyCollection,
2043
2356
  postsNotInAnyCollection,
2357
+ readMetadataValues,
2044
2358
  readSitePatternPlacements,
2045
2359
  readSitePatternPresentation,
2046
2360
  referencedSitePatternKeys,
@@ -2051,6 +2365,7 @@ function formatSectionLockViolations(violations) {
2051
2365
  resolveCategoryTreeEntryPath,
2052
2366
  resolveCategoryTreeHubPaths,
2053
2367
  resolveCategoryTreeSelection,
2368
+ resolveMetadata,
2054
2369
  resolvePatternSlots,
2055
2370
  resolvePostCategorySlug,
2056
2371
  resolvePostCollection,