@roottale/cms-core 0.56.0 → 0.57.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,32 @@
1
1
  # @roottale/cms-core
2
2
 
3
+ ## 0.57.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e222471: 본문 예약 내부 링크(`[[internal:키|문구]]`)를 공용 렌더러가 해석한다.
8
+ - `@roottale/cms-core`: 토큰 정규식·탐색(`findInternalContentLinkTokens`), 경로→키
9
+ (`internalContentKeyFromPath`), 발행 글 경로 색인(`internalContentPathIndex`, 옛 slug 포함),
10
+ 텍스트·정화된 HTML 안의 토큰을 링크로 바꾸는 `renderInternalContentLinksInText/InHtml`.
11
+ - `@roottale/cms-renderer-next`: `RenderTiptap`·`RootTaleBlogPost` 에 `internalLinks`
12
+ (Map·함수) 옵션. `RootTaleBlogPost` 는 기본 `"auto"` — 본문에 토큰이 있을 때만
13
+ `fetchInternalContentPathIndex` 로 발행 글 경로 색인을 만들어 링크로 렌더한다.
14
+ 대상이 없는 토큰은 문구만 남기고 `data-rt-internal-link-pending="키"` 를 단다.
15
+ `<code>`·`<pre>`·기존 링크 안은 건드리지 않는다.
16
+ - 문서: FRONT 가 토큰을 직접 해석하지 않아도 되는 경로를 안내한다.
17
+
18
+ - 92d0ad4: 공통 블록(Site Patterns) — 여러 글의 같은 자리(글 하단)에 붙는 재사용 본문.
19
+ - `@roottale/cms-client`: 글 응답 `patternSlots`(자리 key → 블록 key | null, 구 서버는 빈
20
+ 객체), `fetchSitePatterns`(발행 블록 목록, `GET /v1/cms/public/patterns`),
21
+ `selectSitePatternForSlot`, `SITE_PATTERNS_CACHE_TAG`(`SETTINGS_CACHE_TAGS` 에 포함 —
22
+ `theme.updated` 로 함께 갱신), `POST_FOOTER_PATTERN_SLOT`.
23
+ - `@roottale/cms-renderer-next`: `RootTaleBlogPost` 가 `patternSlots.post_footer` 블록을 본문
24
+ 바로 아래에 자동으로 그린다(목록 요청 실패는 블록만 생략). 자체 글 화면용
25
+ `RootTalePostPattern` 서버 컴포넌트와 `.rt-cms-post-pattern` 스타일 추가.
26
+ - `@roottale/cms-core`: 자리 선언·배치 규칙 정규화·`resolvePatternSlots` (플랫폼 서버·어드민과
27
+ 공유하는 순수 규칙).
28
+ - 문서: `theme-and-settings.md` "공통 블록", `api-reference.md` `GET /patterns`·`pattern_slots`.
29
+
3
30
  ## 0.56.0
4
31
 
5
32
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -31,12 +31,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
33
  BLOCK_NAME_PATTERN: () => BLOCK_NAME_PATTERN,
34
+ EMPTY_SITE_PATTERN_PLACEMENTS: () => EMPTY_SITE_PATTERN_PLACEMENTS,
34
35
  FIELD_BINDING_SOURCE: () => FIELD_BINDING_SOURCE,
35
36
  FIELD_BINDING_SOURCE_ALIAS: () => FIELD_BINDING_SOURCE_ALIAS,
36
37
  IMAGE_ALT_MAX_LENGTH: () => IMAGE_ALT_MAX_LENGTH,
38
+ INTERNAL_CONTENT_LINK_CLASS: () => INTERNAL_CONTENT_LINK_CLASS,
39
+ INTERNAL_CONTENT_LINK_PENDING_ATTR: () => INTERNAL_CONTENT_LINK_PENDING_ATTR,
40
+ INTERNAL_CONTENT_LINK_PENDING_CLASS: () => INTERNAL_CONTENT_LINK_PENDING_CLASS,
41
+ INTERNAL_CONTENT_LINK_TOKEN_SOURCE: () => INTERNAL_CONTENT_LINK_TOKEN_SOURCE,
37
42
  LEGACY_BLOG_DETAIL_PATH: () => LEGACY_BLOG_DETAIL_PATH,
38
43
  PHASE_1_CORE_BLOCKS: () => PHASE_1_CORE_BLOCKS,
39
44
  PHASE_1_CSP: () => PHASE_1_CSP,
45
+ POST_FOOTER_SLOT: () => POST_FOOTER_SLOT,
46
+ SITE_PATTERN_KEY_PATTERN: () => SITE_PATTERN_KEY_PATTERN,
47
+ SITE_PATTERN_PLACEMENTS_KEY: () => SITE_PATTERN_PLACEMENTS_KEY,
48
+ SITE_PATTERN_SLOTS: () => SITE_PATTERN_SLOTS,
40
49
  applyBlockBindings: () => applyBlockBindings,
41
50
  bindingSrcValue: () => bindingSrcValue,
42
51
  bindingTextValue: () => bindingTextValue,
@@ -44,16 +53,24 @@ __export(src_exports, {
44
53
  boundFieldValue: () => boundFieldValue,
45
54
  chainResolvers: () => chainResolvers,
46
55
  createFieldBindingResolver: () => createFieldBindingResolver,
56
+ emptyPatternSlots: () => emptyPatternSlots,
47
57
  ensureImportedHtmlDocumentRoots: () => ensureImportedHtmlDocumentRoots,
48
58
  ensureStableIds: () => ensureStableIds,
49
59
  extractFaqEntries: () => extractFaqEntries,
50
60
  faqPageJsonLd: () => faqPageJsonLd,
61
+ findInternalContentLinkTokens: () => findInternalContentLinkTokens,
62
+ findSitePatternPlacementRule: () => findSitePatternPlacementRule,
51
63
  htmlToPlainText: () => htmlToPlainText,
52
64
  injectStableIdsTree: () => injectStableIdsTree,
65
+ internalContentKeyFromPath: () => internalContentKeyFromPath,
66
+ internalContentLinkResolverFrom: () => internalContentLinkResolverFrom,
67
+ internalContentPathIndex: () => internalContentPathIndex,
53
68
  isAllowedEmbedSrc: () => isAllowedEmbedSrc,
54
69
  isAllowedMapEmbedSrc: () => isAllowedMapEmbedSrc,
55
70
  isInCollectionMode: () => isInCollectionMode,
71
+ isSitePatternSlotKey: () => isSitePatternSlotKey,
56
72
  isValidBlock: () => isValidBlock,
73
+ isValidSitePatternKey: () => isValidSitePatternKey,
57
74
  issuePreviewToken: () => issuePreviewToken,
58
75
  normalizeEmbed: () => normalizeEmbed,
59
76
  normalizeImageAlt: () => normalizeImageAlt,
@@ -61,10 +78,15 @@ __export(src_exports, {
61
78
  postsForCollection: () => postsForCollection,
62
79
  postsInAnyCollection: () => postsInAnyCollection,
63
80
  postsNotInAnyCollection: () => postsNotInAnyCollection,
81
+ readSitePatternPlacements: () => readSitePatternPlacements,
82
+ referencedSitePatternKeys: () => referencedSitePatternKeys,
83
+ renderInternalContentLinksInHtml: () => renderInternalContentLinksInHtml,
84
+ renderInternalContentLinksInText: () => renderInternalContentLinksInText,
64
85
  resolveCategoryPath: () => resolveCategoryPath,
65
86
  resolveCategoryTreeChain: () => resolveCategoryTreeChain,
66
87
  resolveCategoryTreeEntryPath: () => resolveCategoryTreeEntryPath,
67
88
  resolveCategoryTreeHubPaths: () => resolveCategoryTreeHubPaths,
89
+ resolvePatternSlots: () => resolvePatternSlots,
68
90
  resolvePostCategorySlug: () => resolvePostCategorySlug,
69
91
  resolvePostCollection: () => resolvePostCollection,
70
92
  resolvePostPath: () => resolvePostPath,
@@ -75,6 +97,7 @@ __export(src_exports, {
75
97
  sanitizeImportedHtml: () => sanitizeImportedHtml,
76
98
  scopeImportedCss: () => scopeImportedCss,
77
99
  serializeBlock: () => serializeBlock,
100
+ setSitePatternPlacementRule: () => setSitePatternPlacementRule,
78
101
  validateCollections: () => validateCollections,
79
102
  verifyPreviewToken: () => verifyPreviewToken
80
103
  });
@@ -1041,6 +1064,152 @@ function scopeImportedCss(css, scope, allowedAssetUrls = []) {
1041
1064
  }
1042
1065
  }
1043
1066
 
1067
+ // src/internal-content-links.ts
1068
+ var INTERNAL_CONTENT_LINK_TOKEN_SOURCE = String.raw`\[\[internal:([a-z0-9가-힣]+(?:[.-][a-z0-9가-힣]+)*)\|([^\]\n|]+)\]\]`;
1069
+ function findInternalContentLinkTokens(text) {
1070
+ const pattern = new RegExp(INTERNAL_CONTENT_LINK_TOKEN_SOURCE, "giu");
1071
+ const tokens = [];
1072
+ for (const match of text.matchAll(pattern)) {
1073
+ const [raw, key, label] = match;
1074
+ if (!key || !label || match.index === void 0) continue;
1075
+ tokens.push({
1076
+ index: match.index,
1077
+ raw,
1078
+ key: key.toLowerCase(),
1079
+ label: label.trim()
1080
+ });
1081
+ }
1082
+ return tokens;
1083
+ }
1084
+ function internalContentKeyFromPath(path) {
1085
+ if (!path) return null;
1086
+ const segments = path.split("/").filter(Boolean).map((segment) => {
1087
+ try {
1088
+ return decodeURIComponent(segment).trim().toLowerCase();
1089
+ } catch {
1090
+ return "";
1091
+ }
1092
+ });
1093
+ if (segments.length === 0 || segments.some((segment) => segment === "" || segment.includes("."))) {
1094
+ return null;
1095
+ }
1096
+ return segments.join(".");
1097
+ }
1098
+ function internalContentPathIndex(entries) {
1099
+ const index = /* @__PURE__ */ new Map();
1100
+ const previous = /* @__PURE__ */ new Map();
1101
+ for (const entry of entries) {
1102
+ const path = entry.path;
1103
+ const key = internalContentKeyFromPath(path);
1104
+ if (!path || !key) continue;
1105
+ index.set(key, path);
1106
+ const segments = key.split(".");
1107
+ for (const oldSlug of entry.previousSlugs ?? []) {
1108
+ const oldKey = [...segments.slice(0, -1), oldSlug.trim().toLowerCase()].join(".");
1109
+ if (oldKey && !oldKey.includes("..")) previous.set(oldKey, path);
1110
+ }
1111
+ }
1112
+ for (const [key, path] of previous) {
1113
+ if (!index.has(key)) index.set(key, path);
1114
+ }
1115
+ return index;
1116
+ }
1117
+ function internalContentLinkResolverFrom(source) {
1118
+ if (!source) return () => null;
1119
+ if (typeof source === "function") return source;
1120
+ return (key) => source.get(key.toLowerCase()) ?? null;
1121
+ }
1122
+ function isSafeInternalPath(path) {
1123
+ return typeof path === "string" && path.startsWith("/") && !path.startsWith("//");
1124
+ }
1125
+ function escapeHtmlText(value) {
1126
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1127
+ }
1128
+ var INTERNAL_CONTENT_LINK_CLASS = "rt-internal-link";
1129
+ var INTERNAL_CONTENT_LINK_PENDING_CLASS = "rt-internal-link--pending";
1130
+ var INTERNAL_CONTENT_LINK_PENDING_ATTR = "data-rt-internal-link-pending";
1131
+ function renderInternalContentLinksInText(text, resolve) {
1132
+ const tokens = findInternalContentLinkTokens(text);
1133
+ if (tokens.length === 0) return { html: text, hasTokens: false };
1134
+ let cursor = 0;
1135
+ let html = "";
1136
+ for (const token of tokens) {
1137
+ html += text.slice(cursor, token.index);
1138
+ const path = resolve(token.key);
1139
+ const label = escapeHtmlText(token.label);
1140
+ 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>`;
1141
+ cursor = token.index + token.raw.length;
1142
+ }
1143
+ html += text.slice(cursor);
1144
+ return { html, hasTokens: true };
1145
+ }
1146
+ var INTERNAL_LINK_BLOCKED_HTML_TAGS = /* @__PURE__ */ new Set([
1147
+ "a",
1148
+ "code",
1149
+ "pre",
1150
+ "script",
1151
+ "style",
1152
+ "textarea"
1153
+ ]);
1154
+ var VOID_HTML_TAGS = /* @__PURE__ */ new Set(["br", "hr", "img", "input", "source", "wbr"]);
1155
+ function htmlTagEnd(html, start) {
1156
+ let quote = null;
1157
+ for (let index = start + 1; index < html.length; index += 1) {
1158
+ const character = html[index];
1159
+ if (quote) {
1160
+ if (character === quote) quote = null;
1161
+ continue;
1162
+ }
1163
+ if (character === '"' || character === "'") {
1164
+ quote = character;
1165
+ continue;
1166
+ }
1167
+ if (character === ">") return index;
1168
+ }
1169
+ return -1;
1170
+ }
1171
+ function renderInternalContentLinksInHtml(html, resolve) {
1172
+ if (!html.includes("[[internal:")) return { html, hasTokens: false };
1173
+ let blockedDepth = 0;
1174
+ let cursor = 0;
1175
+ let out = "";
1176
+ let hasTokens = false;
1177
+ const emitText = (text) => {
1178
+ if (blockedDepth > 0 || !text) {
1179
+ out += text;
1180
+ return;
1181
+ }
1182
+ const rendered = renderInternalContentLinksInText(text, resolve);
1183
+ hasTokens ||= rendered.hasTokens;
1184
+ out += rendered.html;
1185
+ };
1186
+ while (cursor < html.length) {
1187
+ const tagStart = html.indexOf("<", cursor);
1188
+ if (tagStart < 0) {
1189
+ emitText(html.slice(cursor));
1190
+ break;
1191
+ }
1192
+ emitText(html.slice(cursor, tagStart));
1193
+ const tagEnd = htmlTagEnd(html, tagStart);
1194
+ if (tagEnd < 0) {
1195
+ out += html.slice(tagStart);
1196
+ break;
1197
+ }
1198
+ const tag = html.slice(tagStart, tagEnd + 1);
1199
+ out += tag;
1200
+ const parsed = /^<\s*(\/?)\s*([a-z0-9-]+)/i.exec(tag);
1201
+ const tagName = parsed?.[2]?.toLowerCase();
1202
+ if (tagName && INTERNAL_LINK_BLOCKED_HTML_TAGS.has(tagName)) {
1203
+ const isClosing = parsed?.[1] === "/";
1204
+ const isVoid = VOID_HTML_TAGS.has(tagName) || /\/\s*>$/.test(tag);
1205
+ if (isClosing) blockedDepth = Math.max(0, blockedDepth - 1);
1206
+ else if (!isVoid) blockedDepth += 1;
1207
+ }
1208
+ cursor = tagEnd + 1;
1209
+ }
1210
+ return { html: out, hasTokens };
1211
+ }
1212
+
1044
1213
  // src/preview-token.ts
1045
1214
  var import_node_crypto = require("crypto");
1046
1215
  var DELIMITER = ".";
@@ -1348,15 +1517,115 @@ function ensureStableIds(block) {
1348
1517
  function injectStableIdsTree(blocks) {
1349
1518
  return blocks.map((b) => ensureStableIds(b));
1350
1519
  }
1520
+
1521
+ // src/site-patterns.ts
1522
+ var SITE_PATTERN_SLOTS = [
1523
+ { key: "post_footer", label: "\uAE00 \uD558\uB2E8", description: "\uAE00 \uBCF8\uBB38 \uBC14\uB85C \uC544\uB798" }
1524
+ ];
1525
+ var POST_FOOTER_SLOT = "post_footer";
1526
+ var SITE_PATTERN_PLACEMENTS_KEY = "site_pattern_placements";
1527
+ var SITE_PATTERN_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
1528
+ function isValidSitePatternKey(key) {
1529
+ return SITE_PATTERN_KEY_PATTERN.test(key);
1530
+ }
1531
+ function isSitePatternSlotKey(value) {
1532
+ return SITE_PATTERN_SLOTS.some((slot) => slot.key === value);
1533
+ }
1534
+ var EMPTY_SITE_PATTERN_PLACEMENTS = { rules: [] };
1535
+ function isRecord(value) {
1536
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1537
+ }
1538
+ function readNullableKey(value) {
1539
+ if (value === null) return null;
1540
+ if (typeof value === "string") {
1541
+ const trimmed = value.trim();
1542
+ return trimmed.length > 0 ? trimmed : null;
1543
+ }
1544
+ return void 0;
1545
+ }
1546
+ function readSitePatternPlacements(raw) {
1547
+ let value = raw;
1548
+ if (typeof value === "string") {
1549
+ try {
1550
+ value = JSON.parse(value);
1551
+ } catch {
1552
+ return EMPTY_SITE_PATTERN_PLACEMENTS;
1553
+ }
1554
+ }
1555
+ if (!isRecord(value) || !Array.isArray(value.rules)) {
1556
+ return EMPTY_SITE_PATTERN_PLACEMENTS;
1557
+ }
1558
+ const byTarget = /* @__PURE__ */ new Map();
1559
+ for (const item of value.rules) {
1560
+ if (!isRecord(item)) continue;
1561
+ const slot = typeof item.slot === "string" ? item.slot : "";
1562
+ if (!isSitePatternSlotKey(slot)) continue;
1563
+ const collectionKey = readNullableKey(item.collectionKey);
1564
+ if (collectionKey === void 0) continue;
1565
+ const patternKey = readNullableKey(item.patternKey);
1566
+ if (patternKey === void 0) continue;
1567
+ if (patternKey !== null && !isValidSitePatternKey(patternKey)) continue;
1568
+ byTarget.set(`${slot}\0${collectionKey ?? ""}`, {
1569
+ slot,
1570
+ collectionKey,
1571
+ patternKey
1572
+ });
1573
+ }
1574
+ return { rules: [...byTarget.values()] };
1575
+ }
1576
+ function findSitePatternPlacementRule(placements, slot, collectionKey) {
1577
+ return placements.rules.find(
1578
+ (rule) => rule.slot === slot && rule.collectionKey === collectionKey
1579
+ );
1580
+ }
1581
+ function setSitePatternPlacementRule(placements, target, patternKey) {
1582
+ const rest = placements.rules.filter(
1583
+ (rule) => !(rule.slot === target.slot && rule.collectionKey === target.collectionKey)
1584
+ );
1585
+ if (patternKey === void 0) return { rules: rest };
1586
+ return {
1587
+ rules: [
1588
+ ...rest,
1589
+ { slot: target.slot, collectionKey: target.collectionKey, patternKey }
1590
+ ]
1591
+ };
1592
+ }
1593
+ function emptyPatternSlots() {
1594
+ return Object.fromEntries(SITE_PATTERN_SLOTS.map((slot) => [slot.key, null]));
1595
+ }
1596
+ function resolvePatternSlots(post, placements, publishedKeys) {
1597
+ const slots = emptyPatternSlots();
1598
+ for (const slot of SITE_PATTERN_SLOTS) {
1599
+ const specific = post.collectionKey !== null ? findSitePatternPlacementRule(placements, slot.key, post.collectionKey) : void 0;
1600
+ const rule = specific ?? findSitePatternPlacementRule(placements, slot.key, null);
1601
+ const key = rule?.patternKey ?? null;
1602
+ slots[slot.key] = key !== null && publishedKeys.has(key) ? key : null;
1603
+ }
1604
+ return slots;
1605
+ }
1606
+ function referencedSitePatternKeys(placements) {
1607
+ return new Set(
1608
+ placements.rules.map((rule) => rule.patternKey).filter((key) => key !== null)
1609
+ );
1610
+ }
1351
1611
  // Annotate the CommonJS export names for ESM import in node:
1352
1612
  0 && (module.exports = {
1353
1613
  BLOCK_NAME_PATTERN,
1614
+ EMPTY_SITE_PATTERN_PLACEMENTS,
1354
1615
  FIELD_BINDING_SOURCE,
1355
1616
  FIELD_BINDING_SOURCE_ALIAS,
1356
1617
  IMAGE_ALT_MAX_LENGTH,
1618
+ INTERNAL_CONTENT_LINK_CLASS,
1619
+ INTERNAL_CONTENT_LINK_PENDING_ATTR,
1620
+ INTERNAL_CONTENT_LINK_PENDING_CLASS,
1621
+ INTERNAL_CONTENT_LINK_TOKEN_SOURCE,
1357
1622
  LEGACY_BLOG_DETAIL_PATH,
1358
1623
  PHASE_1_CORE_BLOCKS,
1359
1624
  PHASE_1_CSP,
1625
+ POST_FOOTER_SLOT,
1626
+ SITE_PATTERN_KEY_PATTERN,
1627
+ SITE_PATTERN_PLACEMENTS_KEY,
1628
+ SITE_PATTERN_SLOTS,
1360
1629
  applyBlockBindings,
1361
1630
  bindingSrcValue,
1362
1631
  bindingTextValue,
@@ -1364,16 +1633,24 @@ function injectStableIdsTree(blocks) {
1364
1633
  boundFieldValue,
1365
1634
  chainResolvers,
1366
1635
  createFieldBindingResolver,
1636
+ emptyPatternSlots,
1367
1637
  ensureImportedHtmlDocumentRoots,
1368
1638
  ensureStableIds,
1369
1639
  extractFaqEntries,
1370
1640
  faqPageJsonLd,
1641
+ findInternalContentLinkTokens,
1642
+ findSitePatternPlacementRule,
1371
1643
  htmlToPlainText,
1372
1644
  injectStableIdsTree,
1645
+ internalContentKeyFromPath,
1646
+ internalContentLinkResolverFrom,
1647
+ internalContentPathIndex,
1373
1648
  isAllowedEmbedSrc,
1374
1649
  isAllowedMapEmbedSrc,
1375
1650
  isInCollectionMode,
1651
+ isSitePatternSlotKey,
1376
1652
  isValidBlock,
1653
+ isValidSitePatternKey,
1377
1654
  issuePreviewToken,
1378
1655
  normalizeEmbed,
1379
1656
  normalizeImageAlt,
@@ -1381,10 +1658,15 @@ function injectStableIdsTree(blocks) {
1381
1658
  postsForCollection,
1382
1659
  postsInAnyCollection,
1383
1660
  postsNotInAnyCollection,
1661
+ readSitePatternPlacements,
1662
+ referencedSitePatternKeys,
1663
+ renderInternalContentLinksInHtml,
1664
+ renderInternalContentLinksInText,
1384
1665
  resolveCategoryPath,
1385
1666
  resolveCategoryTreeChain,
1386
1667
  resolveCategoryTreeEntryPath,
1387
1668
  resolveCategoryTreeHubPaths,
1669
+ resolvePatternSlots,
1388
1670
  resolvePostCategorySlug,
1389
1671
  resolvePostCollection,
1390
1672
  resolvePostPath,
@@ -1395,6 +1677,7 @@ function injectStableIdsTree(blocks) {
1395
1677
  sanitizeImportedHtml,
1396
1678
  scopeImportedCss,
1397
1679
  serializeBlock,
1680
+ setSitePatternPlacementRule,
1398
1681
  validateCollections,
1399
1682
  verifyPreviewToken
1400
1683
  });