@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/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.persistMeta(item.slug, item);
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
- /** Webhook 系の CMSError コードを HTTP ステータスへ写像する。未対応コードは null。 */
811
- function webhookErrorStatus(code) {
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 `{basePath}/images/:hash` — 画像プロキシ
824
- * - POST `{basePath}/revalidate/:collection` Webhook 受信 + $revalidate()
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 path = new URL(req.url).pathname;
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: { "content-type": "application/json" }
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: { "content-type": "application/json" }
1126
+ headers: JSON_HEADERS
862
1127
  });
863
1128
  } catch (err) {
864
- if (isCMSError(err)) {
865
- const status = webhookErrorStatus(err.code);
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
- collections[name] = new CollectionClientImpl({
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)。