@notion-headless-cms/core 0.5.1 → 0.5.3
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/README.md +3 -2
- package/dist/cache/memory.d.mts +1 -1
- package/dist/cache/memory.mjs +0 -5
- package/dist/cache/memory.mjs.map +1 -1
- package/dist/{cache-v9jTMnYd.d.mts → cache-BnC6kxoU.d.mts} +7 -1
- package/dist/{config-i99tKRhN.d.mts → config-RnU3jrFb.d.mts} +10 -3
- package/dist/{errors-DcNErfYk.d.mts → errors-DPD5yq9S.d.mts} +3 -2
- package/dist/errors.d.mts +1 -1
- package/dist/errors.mjs +4 -0
- package/dist/errors.mjs.map +1 -1
- package/dist/hooks.d.mts +1 -1
- package/dist/index.d.mts +70 -10
- package/dist/index.mjs +334 -89
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-BmrOz8T6.d.mts → plugin-BPhaVO6-.d.mts} +2 -2
- package/dist/preset/node.d.mts +2 -2
- package/dist/source-author.d.mts +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -19,7 +19,6 @@ function isStale(cachedAt, ttlMs) {
|
|
|
19
19
|
}
|
|
20
20
|
//#endregion
|
|
21
21
|
//#region src/cache/noop.ts
|
|
22
|
-
/** 何もキャッシュしないドキュメントオペレーション。常に null を返す。 */
|
|
23
22
|
const noopDoc = {
|
|
24
23
|
getList(_collection) {
|
|
25
24
|
return Promise.resolve(null);
|
|
@@ -43,7 +42,6 @@ const noopDoc = {
|
|
|
43
42
|
return Promise.resolve();
|
|
44
43
|
}
|
|
45
44
|
};
|
|
46
|
-
/** 何もキャッシュしない画像オペレーション。 */
|
|
47
45
|
const noopImg = {
|
|
48
46
|
get(_hash) {
|
|
49
47
|
return Promise.resolve(null);
|
|
@@ -59,6 +57,69 @@ const noopImg = {
|
|
|
59
57
|
const noopDocOps = noopDoc;
|
|
60
58
|
const noopImgOps = noopImg;
|
|
61
59
|
//#endregion
|
|
60
|
+
//#region src/page-index.ts
|
|
61
|
+
/**
|
|
62
|
+
* Notion ページ ID を比較用に正規化する。
|
|
63
|
+
* Notion の ID は文脈によりダッシュの有無・大文字小文字が揺れるため、
|
|
64
|
+
* ダッシュ除去 + 小文字化して突き合わせる。
|
|
65
|
+
*
|
|
66
|
+
* react-renderer 側も同一実装で `pageLinks` を引くため、変更時は両方を揃えること。
|
|
67
|
+
*/
|
|
68
|
+
function normalizePageId(id) {
|
|
69
|
+
return id.replace(/-/g, "").toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
function asCollectionClient(source, name) {
|
|
72
|
+
const client = source[name];
|
|
73
|
+
if (!client || typeof client.list !== "function") return void 0;
|
|
74
|
+
return client;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 全コレクションを `list()` で走査し、pageId → {collection, slug, title} の逆引きマップを構築する。
|
|
78
|
+
* Notion 内部リンク(link_to_page / page mention など)を自サイト URL へ解決するための材料。
|
|
79
|
+
*
|
|
80
|
+
* `list()` は SWR ドキュメントキャッシュ経由のためウォーム後は安価。
|
|
81
|
+
*/
|
|
82
|
+
async function buildPageIndex(source, opts) {
|
|
83
|
+
const names = opts?.collections ?? source.collections;
|
|
84
|
+
const index = /* @__PURE__ */ new Map();
|
|
85
|
+
if (!names || typeof names[Symbol.iterator] !== "function") return index;
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const client = asCollectionClient(source, name);
|
|
88
|
+
if (!client) continue;
|
|
89
|
+
const items = await client.list();
|
|
90
|
+
if (!Array.isArray(items)) continue;
|
|
91
|
+
for (const item of items) {
|
|
92
|
+
const key = normalizePageId(item.id);
|
|
93
|
+
if (!index.has(key)) index.set(key, {
|
|
94
|
+
collection: name,
|
|
95
|
+
slug: item.slug,
|
|
96
|
+
title: item.title
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return index;
|
|
101
|
+
}
|
|
102
|
+
const defaultUrl = (entry) => `/${entry.collection}/${entry.slug}`;
|
|
103
|
+
/**
|
|
104
|
+
* Notion 内部リンクを「正規化 pageId → {href, title}」のプレーンマップに解決する。
|
|
105
|
+
* サーバ側(loader / RSC / route handler)で 1 回構築し、`<NotionRenderer pageLinks={...} />`
|
|
106
|
+
* に渡す。プレーンオブジェクトなのでシリアライズ境界(RSC / loader)を越えられる。
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* const pageLinks = await buildPageLinkMap(cms);
|
|
110
|
+
* <NotionRenderer blocks={blocks} pageLinks={pageLinks} />;
|
|
111
|
+
*/
|
|
112
|
+
async function buildPageLinkMap(source, opts) {
|
|
113
|
+
const index = opts?.index ?? await buildPageIndex(source, opts);
|
|
114
|
+
const toUrl = opts?.url ?? defaultUrl;
|
|
115
|
+
const map = {};
|
|
116
|
+
for (const [key, entry] of index) map[key] = {
|
|
117
|
+
href: toUrl(entry, key),
|
|
118
|
+
title: entry.title
|
|
119
|
+
};
|
|
120
|
+
return map;
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
62
123
|
//#region src/image.ts
|
|
63
124
|
/**
|
|
64
125
|
* レスポンスの Content-Type ヘッダから画像の MIME タイプを取り出す。
|
|
@@ -332,9 +393,22 @@ var CollectionClientImpl = class {
|
|
|
332
393
|
this.cache = {
|
|
333
394
|
invalidate: () => this.invalidateImpl(),
|
|
334
395
|
invalidateItem: (slug) => this.invalidateItemImpl(slug),
|
|
335
|
-
warm: (opts) => this.warmImpl(opts)
|
|
396
|
+
warm: (opts) => this.warmImpl(opts),
|
|
397
|
+
prime: (slug) => this.primeImpl(slug)
|
|
336
398
|
};
|
|
337
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Notion ページ ID で該当アイテムを解決し、単件ウォーム + リストキャッシュを更新する。
|
|
402
|
+
* このコレクションに属さない page id の場合は何もせず `null` を返す。
|
|
403
|
+
* 一致した場合は温めた slug を返す(公式 webhook から `cms.warmByPageId` 経由で呼ばれる)。
|
|
404
|
+
*/
|
|
405
|
+
async warmByPageId(pageId) {
|
|
406
|
+
const item = await this.resolveByPageId(pageId);
|
|
407
|
+
if (!item) return null;
|
|
408
|
+
await this.primeItem(item);
|
|
409
|
+
await this.refreshList();
|
|
410
|
+
return item.slug;
|
|
411
|
+
}
|
|
338
412
|
async find(slug, opts = {}) {
|
|
339
413
|
if (opts.bypassCache) {
|
|
340
414
|
this.ctx.hooks.onCacheMiss?.(slug);
|
|
@@ -450,9 +524,7 @@ var CollectionClientImpl = class {
|
|
|
450
524
|
const chunk = items.slice(i, i + concurrency);
|
|
451
525
|
await Promise.all(chunk.map(async (item) => {
|
|
452
526
|
try {
|
|
453
|
-
await this.
|
|
454
|
-
const content = await buildCachedItemContent(item, this.ctx.render);
|
|
455
|
-
await this.ctx.docCache.setContent(this.ctx.collection, item.slug, content);
|
|
527
|
+
await this.primeItem(item);
|
|
456
528
|
ok++;
|
|
457
529
|
} catch (err) {
|
|
458
530
|
failed.push({
|
|
@@ -477,6 +549,47 @@ var CollectionClientImpl = class {
|
|
|
477
549
|
failed
|
|
478
550
|
};
|
|
479
551
|
}
|
|
552
|
+
async primeImpl(slug) {
|
|
553
|
+
const item = await this.fetchRaw(slug);
|
|
554
|
+
if (!item) return;
|
|
555
|
+
await this.primeItem(item);
|
|
556
|
+
}
|
|
557
|
+
/** 取得済みアイテムからメタ・本文キャッシュを作り直す(warm / prime / warmByPageId 共通)。 */
|
|
558
|
+
async primeItem(item) {
|
|
559
|
+
await this.persistMeta(item.slug, item);
|
|
560
|
+
const content = await buildCachedItemContent(item, this.ctx.render);
|
|
561
|
+
await this.ctx.docCache.setContent(this.ctx.collection, item.slug, content);
|
|
562
|
+
}
|
|
563
|
+
/** リストキャッシュを最新の取得結果で作り直す。 */
|
|
564
|
+
async refreshList() {
|
|
565
|
+
const items = await this.fetchListRaw();
|
|
566
|
+
await this.ctx.docCache.setList(this.ctx.collection, {
|
|
567
|
+
items,
|
|
568
|
+
cachedAt: Date.now()
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/** Notion page id からアクセス可能なアイテムを解決する。`findById` 優先、無ければ list を走査。 */
|
|
572
|
+
async resolveByPageId(pageId) {
|
|
573
|
+
const target = normalizePageId(pageId);
|
|
574
|
+
const findById = this.ctx.source.findById?.bind(this.ctx.source);
|
|
575
|
+
let item;
|
|
576
|
+
if (findById) item = await withRetry(() => findById(pageId), {
|
|
577
|
+
...this.ctx.retryConfig,
|
|
578
|
+
onRetry: (attempt, status, delayMs) => {
|
|
579
|
+
this.ctx.logger?.warn?.("findById() リトライ中", {
|
|
580
|
+
attempt,
|
|
581
|
+
status,
|
|
582
|
+
pageId,
|
|
583
|
+
backoffMs: delayMs
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
else item = (await this.fetchListRaw()).find((i) => normalizePageId(i.id) === target) ?? null;
|
|
588
|
+
if (!item) return null;
|
|
589
|
+
if (item.isArchived || item.isInTrash) return null;
|
|
590
|
+
if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return null;
|
|
591
|
+
return item;
|
|
592
|
+
}
|
|
480
593
|
async persistMeta(slug, item, opts = {}) {
|
|
481
594
|
let meta = buildCachedItemMeta(item, this.ctx.source);
|
|
482
595
|
if (this.ctx.hooks.beforeCacheMeta) meta = await this.ctx.hooks.beforeCacheMeta(meta);
|
|
@@ -805,30 +918,85 @@ function makeComparator(sort) {
|
|
|
805
918
|
const DEFAULT_OPTS = {
|
|
806
919
|
basePath: "/api/cms",
|
|
807
920
|
imagesPath: "/images",
|
|
808
|
-
revalidatePath: "/revalidate"
|
|
921
|
+
revalidatePath: "/revalidate",
|
|
922
|
+
versionsPath: "/versions",
|
|
923
|
+
checkPath: "/check",
|
|
924
|
+
notionWebhookPath: "/notion-webhook"
|
|
809
925
|
};
|
|
810
|
-
|
|
811
|
-
|
|
926
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
927
|
+
/** HMAC-SHA256 を hex で返す。core はゼロ依存のため import せずグローバル `crypto.subtle` を使う。 */
|
|
928
|
+
async function hmacSha256Hex(secret, message) {
|
|
929
|
+
const enc = new TextEncoder();
|
|
930
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), {
|
|
931
|
+
name: "HMAC",
|
|
932
|
+
hash: "SHA-256"
|
|
933
|
+
}, false, ["sign"]);
|
|
934
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
|
|
935
|
+
let hex = "";
|
|
936
|
+
for (const b of new Uint8Array(sig)) hex += b.toString(16).padStart(2, "0");
|
|
937
|
+
return hex;
|
|
938
|
+
}
|
|
939
|
+
/** タイミング攻撃を避ける定数時間文字列比較。 */
|
|
940
|
+
function timingSafeEqual(a, b) {
|
|
941
|
+
if (a.length !== b.length) return false;
|
|
942
|
+
let diff = 0;
|
|
943
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
944
|
+
return diff === 0;
|
|
945
|
+
}
|
|
946
|
+
function jsonResponse(body, status) {
|
|
947
|
+
return new Response(JSON.stringify(body), {
|
|
948
|
+
status,
|
|
949
|
+
headers: JSON_HEADERS
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
function httpStatusForError(code) {
|
|
812
953
|
if (code === "webhook/signature_invalid") return 401;
|
|
813
954
|
if (code === "webhook/not_implemented") return 501;
|
|
814
955
|
if (code === "webhook/unknown_collection") return 404;
|
|
815
956
|
if (code === "webhook/payload_invalid") return 400;
|
|
957
|
+
if (code === "handler/unknown_collection") return 404;
|
|
816
958
|
return null;
|
|
817
959
|
}
|
|
960
|
+
function errorResponse(err) {
|
|
961
|
+
if (!isCMSError(err)) return null;
|
|
962
|
+
const status = httpStatusForError(err.code);
|
|
963
|
+
if (status === null) return null;
|
|
964
|
+
return new Response(JSON.stringify({
|
|
965
|
+
ok: false,
|
|
966
|
+
code: err.code
|
|
967
|
+
}), {
|
|
968
|
+
status,
|
|
969
|
+
headers: JSON_HEADERS
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
function splitCollectionSlug(sub) {
|
|
973
|
+
const slashIndex = sub.indexOf("/");
|
|
974
|
+
if (slashIndex <= 0 || slashIndex === sub.length - 1) return null;
|
|
975
|
+
return {
|
|
976
|
+
collection: sub.slice(0, slashIndex),
|
|
977
|
+
slug: sub.slice(slashIndex + 1)
|
|
978
|
+
};
|
|
979
|
+
}
|
|
818
980
|
/**
|
|
819
981
|
* Web Standard な Request → Response ルーター。
|
|
820
982
|
* Next.js / React Router / Hono / Cloudflare Workers いずれでも使える。
|
|
821
983
|
*
|
|
822
984
|
* ルート:
|
|
823
|
-
* - GET
|
|
824
|
-
* -
|
|
985
|
+
* - GET `{basePath}/images/:hash` — 画像プロキシ
|
|
986
|
+
* - GET `{basePath}/versions/:collection/:slug` — peekVersion(更新検知ポーリング)
|
|
987
|
+
* - GET/POST `{basePath}/check/:collection/:slug?v=` — check(更新を実照会してキャッシュ更新)
|
|
988
|
+
* - POST `{basePath}/revalidate/:collection` — Webhook 受信 + $revalidate()
|
|
825
989
|
*/
|
|
826
990
|
function createHandler(adapter, opts = {}) {
|
|
827
991
|
const basePath = trimTrailingSlash(opts.basePath ?? DEFAULT_OPTS.basePath);
|
|
828
992
|
const imagesPath = opts.imagesPath ?? DEFAULT_OPTS.imagesPath;
|
|
829
993
|
const revalidatePath = opts.revalidatePath ?? DEFAULT_OPTS.revalidatePath;
|
|
994
|
+
const versionsPath = opts.versionsPath ?? DEFAULT_OPTS.versionsPath;
|
|
995
|
+
const checkPath = opts.checkPath ?? DEFAULT_OPTS.checkPath;
|
|
996
|
+
const notionWebhookPath = opts.notionWebhookPath ?? DEFAULT_OPTS.notionWebhookPath;
|
|
830
997
|
return async (req) => {
|
|
831
|
-
const
|
|
998
|
+
const url = new URL(req.url);
|
|
999
|
+
const path = url.pathname;
|
|
832
1000
|
if (!path.startsWith(basePath)) return new Response("Not Found", { status: 404 });
|
|
833
1001
|
const rel = path.slice(basePath.length) || "/";
|
|
834
1002
|
if (req.method === "GET" && rel.startsWith(`${imagesPath}/`)) {
|
|
@@ -841,6 +1009,103 @@ function createHandler(adapter, opts = {}) {
|
|
|
841
1009
|
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
842
1010
|
return new Response(object.data, { headers });
|
|
843
1011
|
}
|
|
1012
|
+
if (req.method === "GET" && rel.startsWith(`${versionsPath}/`)) {
|
|
1013
|
+
const target = splitCollectionSlug(rel.slice(versionsPath.length + 1));
|
|
1014
|
+
if (!target) return new Response(JSON.stringify({
|
|
1015
|
+
ok: false,
|
|
1016
|
+
reason: "collection and slug required"
|
|
1017
|
+
}), {
|
|
1018
|
+
status: 400,
|
|
1019
|
+
headers: JSON_HEADERS
|
|
1020
|
+
});
|
|
1021
|
+
try {
|
|
1022
|
+
const version = await adapter.peekVersionFor(target.collection, target.slug);
|
|
1023
|
+
return new Response(JSON.stringify(version), {
|
|
1024
|
+
status: 200,
|
|
1025
|
+
headers: JSON_HEADERS
|
|
1026
|
+
});
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
const res = errorResponse(err);
|
|
1029
|
+
if (res) return res;
|
|
1030
|
+
throw err;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if ((req.method === "GET" || req.method === "POST") && rel.startsWith(`${checkPath}/`)) {
|
|
1034
|
+
const target = splitCollectionSlug(rel.slice(checkPath.length + 1));
|
|
1035
|
+
const currentVersion = url.searchParams.get("v");
|
|
1036
|
+
if (!target || !currentVersion) return new Response(JSON.stringify({
|
|
1037
|
+
ok: false,
|
|
1038
|
+
reason: "collection, slug and ?v= are required"
|
|
1039
|
+
}), {
|
|
1040
|
+
status: 400,
|
|
1041
|
+
headers: JSON_HEADERS
|
|
1042
|
+
});
|
|
1043
|
+
try {
|
|
1044
|
+
const result = await adapter.checkFor(target.collection, target.slug, currentVersion);
|
|
1045
|
+
if (result === null) return new Response(JSON.stringify({
|
|
1046
|
+
ok: false,
|
|
1047
|
+
reason: "not found"
|
|
1048
|
+
}), {
|
|
1049
|
+
status: 404,
|
|
1050
|
+
headers: JSON_HEADERS
|
|
1051
|
+
});
|
|
1052
|
+
return new Response(JSON.stringify({ stale: result.stale }), {
|
|
1053
|
+
status: 200,
|
|
1054
|
+
headers: JSON_HEADERS
|
|
1055
|
+
});
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
const res = errorResponse(err);
|
|
1058
|
+
if (res) return res;
|
|
1059
|
+
throw err;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (req.method === "POST" && rel === notionWebhookPath) {
|
|
1063
|
+
const raw = await req.text();
|
|
1064
|
+
let payload;
|
|
1065
|
+
try {
|
|
1066
|
+
payload = JSON.parse(raw);
|
|
1067
|
+
} catch {
|
|
1068
|
+
return jsonResponse({
|
|
1069
|
+
ok: false,
|
|
1070
|
+
reason: "invalid json"
|
|
1071
|
+
}, 400);
|
|
1072
|
+
}
|
|
1073
|
+
if (payload && typeof payload === "object" && "verification_token" in payload) {
|
|
1074
|
+
const token = String(payload.verification_token);
|
|
1075
|
+
opts.notionWebhook?.onVerificationToken?.(token);
|
|
1076
|
+
return jsonResponse({
|
|
1077
|
+
ok: true,
|
|
1078
|
+
verification_token: token
|
|
1079
|
+
}, 200);
|
|
1080
|
+
}
|
|
1081
|
+
const secret = opts.notionWebhook?.secret ?? adapter.notionWebhookSecret;
|
|
1082
|
+
if (!secret) return jsonResponse({
|
|
1083
|
+
ok: false,
|
|
1084
|
+
reason: "notion webhook secret not configured"
|
|
1085
|
+
}, 503);
|
|
1086
|
+
if (!timingSafeEqual(req.headers.get("X-Notion-Signature") ?? "", `sha256=${await hmacSha256Hex(secret, raw)}`)) return jsonResponse({
|
|
1087
|
+
ok: false,
|
|
1088
|
+
code: "webhook/signature_invalid"
|
|
1089
|
+
}, 401);
|
|
1090
|
+
const entity = payload.entity;
|
|
1091
|
+
const pageId = entity?.type === "page" ? entity.id : void 0;
|
|
1092
|
+
if (!pageId) return jsonResponse({
|
|
1093
|
+
ok: true,
|
|
1094
|
+
skipped: "no page entity"
|
|
1095
|
+
}, 200);
|
|
1096
|
+
if (adapter.scheduleBackground) {
|
|
1097
|
+
adapter.scheduleBackground(adapter.warmByPageId(pageId));
|
|
1098
|
+
return jsonResponse({
|
|
1099
|
+
ok: true,
|
|
1100
|
+
pageId
|
|
1101
|
+
}, 200);
|
|
1102
|
+
}
|
|
1103
|
+
return jsonResponse({
|
|
1104
|
+
ok: true,
|
|
1105
|
+
pageId,
|
|
1106
|
+
result: await adapter.warmByPageId(pageId)
|
|
1107
|
+
}, 200);
|
|
1108
|
+
}
|
|
844
1109
|
if (req.method === "POST" && rel.startsWith(`${revalidatePath}/`)) {
|
|
845
1110
|
const collection = rel.slice(revalidatePath.length + 1);
|
|
846
1111
|
if (!collection || collection.includes("/")) return new Response(JSON.stringify({
|
|
@@ -848,7 +1113,7 @@ function createHandler(adapter, opts = {}) {
|
|
|
848
1113
|
reason: "collection required"
|
|
849
1114
|
}), {
|
|
850
1115
|
status: 400,
|
|
851
|
-
headers:
|
|
1116
|
+
headers: JSON_HEADERS
|
|
852
1117
|
});
|
|
853
1118
|
try {
|
|
854
1119
|
const scope = await adapter.parseWebhookFor(collection, req, opts.webhookSecret);
|
|
@@ -858,19 +1123,11 @@ function createHandler(adapter, opts = {}) {
|
|
|
858
1123
|
scope
|
|
859
1124
|
}), {
|
|
860
1125
|
status: 200,
|
|
861
|
-
headers:
|
|
1126
|
+
headers: JSON_HEADERS
|
|
862
1127
|
});
|
|
863
1128
|
} catch (err) {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
if (status !== null) return new Response(JSON.stringify({
|
|
867
|
-
ok: false,
|
|
868
|
-
code: err.code
|
|
869
|
-
}), {
|
|
870
|
-
status,
|
|
871
|
-
headers: { "content-type": "application/json" }
|
|
872
|
-
});
|
|
873
|
-
}
|
|
1129
|
+
const res = errorResponse(err);
|
|
1130
|
+
if (res) return res;
|
|
874
1131
|
throw err;
|
|
875
1132
|
}
|
|
876
1133
|
}
|
|
@@ -1011,6 +1268,7 @@ function createClient(opts) {
|
|
|
1011
1268
|
};
|
|
1012
1269
|
const collectionNames = [];
|
|
1013
1270
|
const collections = {};
|
|
1271
|
+
const collectionImpls = {};
|
|
1014
1272
|
for (const [name, def] of Object.entries(collectionsInput)) {
|
|
1015
1273
|
collectionNames.push(name);
|
|
1016
1274
|
const source = def.source;
|
|
@@ -1030,7 +1288,7 @@ function createClient(opts) {
|
|
|
1030
1288
|
hooks: collectionHooks,
|
|
1031
1289
|
logger
|
|
1032
1290
|
};
|
|
1033
|
-
|
|
1291
|
+
const impl = new CollectionClientImpl({
|
|
1034
1292
|
collection: name,
|
|
1035
1293
|
source,
|
|
1036
1294
|
docCache: cacheRes.doc,
|
|
@@ -1046,6 +1304,8 @@ function createClient(opts) {
|
|
|
1046
1304
|
waitUntil,
|
|
1047
1305
|
slugField: def.slugField
|
|
1048
1306
|
});
|
|
1307
|
+
collections[name] = impl;
|
|
1308
|
+
collectionImpls[name] = impl;
|
|
1049
1309
|
}
|
|
1050
1310
|
const globalOps = {
|
|
1051
1311
|
collections: collectionNames,
|
|
@@ -1097,9 +1357,30 @@ function createClient(opts) {
|
|
|
1097
1357
|
});
|
|
1098
1358
|
await cacheRes.doc.invalidate(scope ?? "all");
|
|
1099
1359
|
},
|
|
1360
|
+
async warmByPageId(pageId) {
|
|
1361
|
+
for (const name of collectionNames) {
|
|
1362
|
+
const slug = await collectionImpls[name]?.warmByPageId(pageId);
|
|
1363
|
+
if (slug) {
|
|
1364
|
+
logger?.debug?.("warmByPageId: ページを再ウォーム", {
|
|
1365
|
+
operation: "warmByPageId",
|
|
1366
|
+
collection: name,
|
|
1367
|
+
slug,
|
|
1368
|
+
pageId
|
|
1369
|
+
});
|
|
1370
|
+
return {
|
|
1371
|
+
collection: name,
|
|
1372
|
+
slug
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return null;
|
|
1377
|
+
},
|
|
1100
1378
|
handler(handlerOpts) {
|
|
1101
1379
|
return createHandler({
|
|
1102
1380
|
imageCache: cacheRes.img,
|
|
1381
|
+
warmByPageId: (pageId) => globalOps.warmByPageId(pageId),
|
|
1382
|
+
notionWebhookSecret: opts.notionWebhookSecret,
|
|
1383
|
+
scheduleBackground: waitUntil,
|
|
1103
1384
|
async parseWebhookFor(collection, req, webhookSecret) {
|
|
1104
1385
|
const def = collectionsInput[collection];
|
|
1105
1386
|
if (!def) throw new CMSError({
|
|
@@ -1121,7 +1402,34 @@ function createClient(opts) {
|
|
|
1121
1402
|
});
|
|
1122
1403
|
return ds.parseWebhook(req, { secret: webhookSecret });
|
|
1123
1404
|
},
|
|
1124
|
-
revalidate: (scope) => globalOps.invalidate(scope)
|
|
1405
|
+
revalidate: (scope) => globalOps.invalidate(scope),
|
|
1406
|
+
peekVersionFor(collection, slug) {
|
|
1407
|
+
const client = collections[collection];
|
|
1408
|
+
if (!client) throw new CMSError({
|
|
1409
|
+
code: "handler/unknown_collection",
|
|
1410
|
+
message: `Unknown collection: ${collection}`,
|
|
1411
|
+
context: {
|
|
1412
|
+
operation: "peekVersionFor",
|
|
1413
|
+
collection,
|
|
1414
|
+
slug
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
return client.peekVersion(slug);
|
|
1418
|
+
},
|
|
1419
|
+
async checkFor(collection, slug, currentVersion) {
|
|
1420
|
+
const client = collections[collection];
|
|
1421
|
+
if (!client) throw new CMSError({
|
|
1422
|
+
code: "handler/unknown_collection",
|
|
1423
|
+
message: `Unknown collection: ${collection}`,
|
|
1424
|
+
context: {
|
|
1425
|
+
operation: "checkFor",
|
|
1426
|
+
collection,
|
|
1427
|
+
slug
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
const result = await client.check(slug, currentVersion);
|
|
1431
|
+
return result === null ? null : { stale: result.stale };
|
|
1432
|
+
}
|
|
1125
1433
|
}, handlerOpts);
|
|
1126
1434
|
},
|
|
1127
1435
|
getCachedImage(hash) {
|
|
@@ -1131,69 +1439,6 @@ function createClient(opts) {
|
|
|
1131
1439
|
return Object.assign(Object.create(null), collections, globalOps);
|
|
1132
1440
|
}
|
|
1133
1441
|
//#endregion
|
|
1134
|
-
//#region src/page-index.ts
|
|
1135
|
-
/**
|
|
1136
|
-
* Notion ページ ID を比較用に正規化する。
|
|
1137
|
-
* Notion の ID は文脈によりダッシュの有無・大文字小文字が揺れるため、
|
|
1138
|
-
* ダッシュ除去 + 小文字化して突き合わせる。
|
|
1139
|
-
*
|
|
1140
|
-
* react-renderer 側も同一実装で `pageLinks` を引くため、変更時は両方を揃えること。
|
|
1141
|
-
*/
|
|
1142
|
-
function normalizePageId(id) {
|
|
1143
|
-
return id.replace(/-/g, "").toLowerCase();
|
|
1144
|
-
}
|
|
1145
|
-
function asCollectionClient(source, name) {
|
|
1146
|
-
const client = source[name];
|
|
1147
|
-
if (!client || typeof client.list !== "function") return void 0;
|
|
1148
|
-
return client;
|
|
1149
|
-
}
|
|
1150
|
-
/**
|
|
1151
|
-
* 全コレクションを `list()` で走査し、pageId → {collection, slug, title} の逆引きマップを構築する。
|
|
1152
|
-
* Notion 内部リンク(link_to_page / page mention など)を自サイト URL へ解決するための材料。
|
|
1153
|
-
*
|
|
1154
|
-
* `list()` は SWR ドキュメントキャッシュ経由のためウォーム後は安価。
|
|
1155
|
-
*/
|
|
1156
|
-
async function buildPageIndex(source, opts) {
|
|
1157
|
-
const names = opts?.collections ?? source.collections;
|
|
1158
|
-
const index = /* @__PURE__ */ new Map();
|
|
1159
|
-
if (!names || typeof names[Symbol.iterator] !== "function") return index;
|
|
1160
|
-
for (const name of names) {
|
|
1161
|
-
const client = asCollectionClient(source, name);
|
|
1162
|
-
if (!client) continue;
|
|
1163
|
-
const items = await client.list();
|
|
1164
|
-
if (!Array.isArray(items)) continue;
|
|
1165
|
-
for (const item of items) {
|
|
1166
|
-
const key = normalizePageId(item.id);
|
|
1167
|
-
if (!index.has(key)) index.set(key, {
|
|
1168
|
-
collection: name,
|
|
1169
|
-
slug: item.slug,
|
|
1170
|
-
title: item.title
|
|
1171
|
-
});
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
return index;
|
|
1175
|
-
}
|
|
1176
|
-
const defaultUrl = (entry) => `/${entry.collection}/${entry.slug}`;
|
|
1177
|
-
/**
|
|
1178
|
-
* Notion 内部リンクを「正規化 pageId → {href, title}」のプレーンマップに解決する。
|
|
1179
|
-
* サーバ側(loader / RSC / route handler)で 1 回構築し、`<NotionRenderer pageLinks={...} />`
|
|
1180
|
-
* に渡す。プレーンオブジェクトなのでシリアライズ境界(RSC / loader)を越えられる。
|
|
1181
|
-
*
|
|
1182
|
-
* @example
|
|
1183
|
-
* const pageLinks = await buildPageLinkMap(cms);
|
|
1184
|
-
* <NotionRenderer blocks={blocks} pageLinks={pageLinks} />;
|
|
1185
|
-
*/
|
|
1186
|
-
async function buildPageLinkMap(source, opts) {
|
|
1187
|
-
const index = opts?.index ?? await buildPageIndex(source, opts);
|
|
1188
|
-
const toUrl = opts?.url ?? defaultUrl;
|
|
1189
|
-
const map = {};
|
|
1190
|
-
for (const [key, entry] of index) map[key] = {
|
|
1191
|
-
href: toUrl(entry, key),
|
|
1192
|
-
title: entry.title
|
|
1193
|
-
};
|
|
1194
|
-
return map;
|
|
1195
|
-
}
|
|
1196
|
-
//#endregion
|
|
1197
1442
|
//#region src/types/config.ts
|
|
1198
1443
|
/**
|
|
1199
1444
|
* `RateLimiterConfig` のデフォルト値 (Issue #313 / M2)。
|