@notion-headless-cms/core 0.3.11 → 0.3.13

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
@@ -59,44 +59,37 @@ const noopDocOps = noopDoc;
59
59
  const noopImgOps = noopImg;
60
60
  //#endregion
61
61
  //#region src/image.ts
62
- /** レスポンスヘッダまたはURLの拡張子からContent-Typeを推測する。 */
63
- function inferContentType(url, responseContentType) {
64
- if (responseContentType?.startsWith("image/")) return responseContentType.split(";")[0].trim();
65
- if (url.includes(".png")) return "image/png";
66
- if (url.includes(".gif")) return "image/gif";
67
- if (url.includes(".webp")) return "image/webp";
68
- return "image/jpeg";
69
- }
70
62
  /**
71
- * URL → SHA-256 hash のメモ化マップ。
72
- * Notion の画像 URL は同じ画像でも署名が時刻ごとに変わるが、
73
- * 1 リクエスト内では同一 URL が複数回現れることが多い (重複ハッシュ計算を回避)。
74
- *
75
- * メモリリーク防止に最大エントリ数を設けており、超過時は最古から削除する LRU。
63
+ * レスポンスの Content-Type ヘッダから画像の MIME タイプを取り出す。
64
+ * ヘッダがない、または image/* でない場合は CMSError を投げる。
65
+ * URL 拡張子からの推測や jpeg デフォルトは行わない。
76
66
  */
77
- const HASH_MEMO_LIMIT = 1024;
78
- const hashMemo = /* @__PURE__ */ new Map();
79
- async function memoSha256(url) {
80
- const cached = hashMemo.get(url);
81
- if (cached !== void 0) {
82
- hashMemo.delete(url);
83
- hashMemo.set(url, cached);
84
- return cached;
85
- }
86
- const hash = await sha256Hex(url);
87
- hashMemo.set(url, hash);
88
- if (hashMemo.size > HASH_MEMO_LIMIT) {
89
- const firstKey = hashMemo.keys().next().value;
90
- if (firstKey !== void 0) hashMemo.delete(firstKey);
91
- }
92
- return hash;
67
+ function pickImageContentType(headerValue, notionUrl) {
68
+ if (!headerValue) throw new CMSError({
69
+ code: "cache/image_invalid_content_type",
70
+ message: "Image response missing Content-Type header.",
71
+ context: {
72
+ operation: "fetchAndCacheImage:contentType",
73
+ notionUrl
74
+ }
75
+ });
76
+ const value = (headerValue.split(";")[0] ?? headerValue).trim().toLowerCase();
77
+ if (!value.startsWith("image/")) throw new CMSError({
78
+ code: "cache/image_invalid_content_type",
79
+ message: `Image response has non-image Content-Type: ${value}`,
80
+ context: {
81
+ operation: "fetchAndCacheImage:contentType",
82
+ notionUrl,
83
+ contentType: value
84
+ }
85
+ });
86
+ return value;
93
87
  }
94
88
  /**
95
89
  * Notion画像URLをfetchして ImageCacheOps にキャッシュし、プロキシURL を返す。
96
90
  * 既存キャッシュがあれば再fetchしない。
97
91
  */
98
- async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger) {
99
- const hash = await memoSha256(notionUrl);
92
+ async function fetchAndCacheImage(cache, cacheName, notionUrl, hash, imageProxyBase, logger) {
100
93
  const proxyUrl = `${imageProxyBase}/${hash}`;
101
94
  if (await cache.get(hash)) {
102
95
  logger?.debug?.("画像キャッシュヒット", {
@@ -123,7 +116,7 @@ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, l
123
116
  }
124
117
  });
125
118
  const data = await response.arrayBuffer();
126
- const contentType = inferContentType(notionUrl, response.headers.get("content-type"));
119
+ const contentType = pickImageContentType(response.headers.get("content-type"), notionUrl);
127
120
  await cache.set(hash, data, contentType);
128
121
  logger?.debug?.("画像をキャッシュに保存", {
129
122
  operation: "fetchAndCacheImage",
@@ -148,9 +141,20 @@ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, l
148
141
  * `ImageCacheOps` と `imageProxyBase` から `cacheImage` 関数を構築する。
149
142
  * 返り値は Notion 画像 URL を受け取り、SHA-256 ハッシュをキャッシュキーとして
150
143
  * {@link ImageCacheOps} に保存後、プロキシ URL を返す。
144
+ *
145
+ * ハッシュのメモ化はファクトリ呼び出し単位でスコープ化されており、
146
+ * インスタンス間でキャッシュを共有しない。
151
147
  */
152
148
  function buildCacheImageFn(cache, cacheName, imageProxyBase, logger) {
153
- return (notionUrl) => fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger);
149
+ const hashMemo = /* @__PURE__ */ new Map();
150
+ return async (notionUrl) => {
151
+ let hash = hashMemo.get(notionUrl);
152
+ if (hash === void 0) {
153
+ hash = await sha256Hex(notionUrl);
154
+ hashMemo.set(notionUrl, hash);
155
+ }
156
+ return fetchAndCacheImage(cache, cacheName, notionUrl, hash, imageProxyBase, logger);
157
+ };
154
158
  }
155
159
  //#endregion
156
160
  //#region src/rendering.ts
@@ -191,21 +195,26 @@ async function buildCachedItemContent(item, ctx) {
191
195
  }
192
196
  });
193
197
  }
194
- let blocks = [];
198
+ let blocks;
195
199
  try {
196
200
  blocks = await ctx.source.loadBlocks(item);
197
201
  } catch (err) {
198
- ctx.logger?.warn?.("loadBlocks に失敗したため raw フォールバック", {
199
- slug: item.slug,
200
- error: err instanceof Error ? err.message : String(err)
202
+ if (isCMSError(err)) throw err;
203
+ throw new CMSError({
204
+ code: "source/load_blocks_failed",
205
+ message: "Failed to load blocks from source.",
206
+ cause: err,
207
+ context: {
208
+ operation: "buildCachedItemContent:loadBlocks",
209
+ pageId: item.id,
210
+ slug: item.slug
211
+ }
201
212
  });
202
- blocks = [];
203
213
  }
204
214
  const cacheImage = ctx.hasImageCache ? buildCacheImageFn(ctx.imgCache, ctx.imgCacheName, ctx.imageProxyBase, ctx.logger) : void 0;
205
215
  let html;
206
- const rendererFn = ctx.rendererFn ?? await loadDefaultRenderer();
207
216
  try {
208
- html = await rendererFn(markdown, {
217
+ html = await ctx.rendererFn(markdown, {
209
218
  imageProxyBase: ctx.imageProxyBase,
210
219
  cacheImage,
211
220
  remarkPlugins: ctx.contentConfig?.remarkPlugins,
@@ -241,23 +250,6 @@ async function buildCachedItemContent(item, ctx) {
241
250
  ctx.hooks.onRenderEnd?.(item.slug, durationMs);
242
251
  return result;
243
252
  }
244
- /**
245
- * renderer オプション未指定時のフォールバック。
246
- * @notion-headless-cms/renderer を動的 import する。
247
- * createCMS({ renderer }) で明示注入された場合はこのパスを通らない。
248
- */
249
- async function loadDefaultRenderer() {
250
- try {
251
- return (await import("@notion-headless-cms/renderer")).renderMarkdown;
252
- } catch (err) {
253
- throw new CMSError({
254
- code: "renderer/failed",
255
- message: "renderer オプションが未指定で @notion-headless-cms/renderer が見つかりません。 createCMS({ renderer }) でレンダラーを注入するか、@notion-headless-cms/renderer をインストールしてください。",
256
- cause: err,
257
- context: { operation: "loadDefaultRenderer" }
258
- });
259
- }
260
- }
261
253
  //#endregion
262
254
  //#region src/retry.ts
263
255
  const DEFAULT_RETRY_CONFIG = {
@@ -376,7 +368,7 @@ var CollectionClientImpl = class {
376
368
  async check(slug, currentVersion) {
377
369
  const raw = await this.findRaw(slug);
378
370
  if (!raw) return null;
379
- if (raw.updatedAt === currentVersion) return { stale: false };
371
+ if (raw.lastEditedTime === currentVersion) return { stale: false };
380
372
  const meta = await this.persistMeta(slug, raw);
381
373
  await this.invalidateContent(slug);
382
374
  return {
@@ -478,16 +470,31 @@ var CollectionClientImpl = class {
478
470
  this.ctx.hooks.onContentRevalidated?.(slug, fresh);
479
471
  return fresh;
480
472
  }
481
- /** メタ既知の状態で本文だけバックグラウンド再生成する。エラーは握りつぶす。 */
473
+ /** メタ既知の状態で本文だけバックグラウンド再生成する。エラーは onSwrError フックに通知する。 */
482
474
  async rebuildContentBg(slug, item) {
483
475
  try {
484
476
  const fresh = await buildCachedItemContent(item, this.ctx.render);
485
477
  await this.ctx.docCache.setContent(this.ctx.collection, slug, fresh);
486
478
  this.ctx.hooks.onContentRevalidated?.(slug, fresh);
487
479
  } catch (err) {
480
+ const cmsErr = isCMSError(err) ? err : new CMSError({
481
+ code: "swr/content_rebuild_failed",
482
+ message: "SWR background content rebuild failed.",
483
+ cause: err,
484
+ context: {
485
+ operation: "swr.rebuildContentBg",
486
+ collection: this.ctx.collection,
487
+ slug
488
+ }
489
+ });
490
+ this.ctx.hooks.onSwrError?.(cmsErr, {
491
+ phase: "item-content",
492
+ slug
493
+ });
488
494
  this.ctx.logger?.warn?.("本文のバックグラウンド再生成に失敗", {
489
495
  slug,
490
496
  collection: this.ctx.collection,
497
+ code: cmsErr.code,
491
498
  error: err instanceof Error ? err.message : String(err)
492
499
  });
493
500
  }
@@ -576,9 +583,24 @@ var CollectionClientImpl = class {
576
583
  });
577
584
  }
578
585
  } catch (err) {
586
+ const cmsErr = isCMSError(err) ? err : new CMSError({
587
+ code: "swr/item_check_failed",
588
+ message: "SWR background item check failed.",
589
+ cause: err,
590
+ context: {
591
+ operation: "swr.checkAndUpdateItemBg",
592
+ collection: this.ctx.collection,
593
+ slug
594
+ }
595
+ });
596
+ this.ctx.hooks.onSwrError?.(cmsErr, {
597
+ phase: "item-meta",
598
+ slug
599
+ });
579
600
  this.ctx.logger?.warn?.("SWR: アイテムのバックグラウンド差分チェックに失敗", {
580
601
  slug,
581
602
  collection: this.ctx.collection,
603
+ code: cmsErr.code,
582
604
  error: err instanceof Error ? err.message : String(err)
583
605
  });
584
606
  }
@@ -608,14 +630,25 @@ var CollectionClientImpl = class {
608
630
  });
609
631
  }
610
632
  } catch (err) {
633
+ const cmsErr = isCMSError(err) ? err : new CMSError({
634
+ code: "swr/list_check_failed",
635
+ message: "SWR background list check failed.",
636
+ cause: err,
637
+ context: {
638
+ operation: "swr.checkAndUpdateListBg",
639
+ collection: this.ctx.collection
640
+ }
641
+ });
642
+ this.ctx.hooks.onSwrError?.(cmsErr, { phase: "list" });
611
643
  this.ctx.logger?.warn?.("SWR: リストのバックグラウンド差分チェックに失敗", {
612
644
  collection: this.ctx.collection,
645
+ code: cmsErr.code,
613
646
  error: err instanceof Error ? err.message : String(err)
614
647
  });
615
648
  }
616
649
  }
617
- fetchListRaw() {
618
- return withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
650
+ async fetchListRaw() {
651
+ return (await withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
619
652
  ...this.ctx.retryConfig,
620
653
  onRetry: (attempt, status) => {
621
654
  this.ctx.logger?.warn?.("list() リトライ中", {
@@ -623,6 +656,10 @@ var CollectionClientImpl = class {
623
656
  status
624
657
  });
625
658
  }
659
+ })).filter((item) => {
660
+ if (item.isArchived || item.isInTrash) return false;
661
+ if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return false;
662
+ return true;
626
663
  });
627
664
  }
628
665
  async findRaw(slug) {
@@ -642,12 +679,23 @@ var CollectionClientImpl = class {
642
679
  if (notionPropName && findByProp) item = await withRetry(() => findByProp(notionPropName, slug), retryOpts);
643
680
  else item = (await withRetry(() => this.ctx.source.list(), retryOpts)).find((i) => i.slug === slug) ?? null;
644
681
  if (!item) return null;
682
+ if (item.isArchived || item.isInTrash) return null;
645
683
  if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return null;
646
684
  return item;
647
685
  }
648
686
  };
687
+ function matchesWhere(item, where) {
688
+ for (const key of Object.keys(where)) {
689
+ const expected = where[key];
690
+ const actual = item[key];
691
+ if (Array.isArray(expected)) {
692
+ if (!expected.includes(actual)) return false;
693
+ } else if (actual !== expected) return false;
694
+ }
695
+ return true;
696
+ }
649
697
  function applyListOptions(items, opts) {
650
- if (!opts) return items;
698
+ if (!opts) return sortByPublishedAtDesc(items);
651
699
  let result = items;
652
700
  if (opts.status) {
653
701
  const allow = new Set(Array.isArray(opts.status) ? opts.status : [opts.status]);
@@ -662,24 +710,46 @@ function applyListOptions(items, opts) {
662
710
  }
663
711
  if (opts.where) {
664
712
  const where = opts.where;
665
- result = result.filter((it) => Object.entries(where).every(([key, value]) => it[key] === value));
713
+ result = result.filter((it) => matchesWhere(it, where));
666
714
  }
715
+ if (opts.filter) result = result.filter(opts.filter);
667
716
  if (opts.sort) result = [...result].sort(makeComparator(opts.sort));
717
+ else result = sortByPublishedAtDesc(result);
668
718
  const skip = opts.skip ?? 0;
669
719
  const limit = opts.limit;
670
720
  if (skip > 0 || limit !== void 0) result = result.slice(skip, limit !== void 0 ? skip + limit : void 0);
671
721
  return result;
672
722
  }
723
+ /** publishedAt 降順、未設定の場合は lastEditedTime 降順でソートする。 */
724
+ function sortByPublishedAtDesc(items) {
725
+ return [...items].sort((a, b) => {
726
+ const av = a.publishedAt ?? a.lastEditedTime;
727
+ const bv = b.publishedAt ?? b.lastEditedTime;
728
+ if (av === bv) return 0;
729
+ return av > bv ? -1 : 1;
730
+ });
731
+ }
673
732
  function makeComparator(sort) {
733
+ if (sort.compare) return sort.compare;
674
734
  const by = sort.by;
675
735
  const dir = sort.dir === "asc" ? 1 : -1;
676
736
  return (a, b) => {
677
737
  const av = a[by];
678
738
  const bv = b[by];
679
739
  if (av === bv) return 0;
680
- if (av === void 0) return 1;
681
- if (bv === void 0) return -1;
682
- return av > bv ? dir : -dir;
740
+ if (av === void 0 || av === null) return 1;
741
+ if (bv === void 0 || bv === null) return -1;
742
+ if (typeof av === "string" && typeof bv === "string") return av > bv ? dir : -dir;
743
+ if (typeof av === "number" && typeof bv === "number") return av > bv ? dir : -dir;
744
+ throw new CMSError({
745
+ code: "core/sort_unsupported_type",
746
+ message: `"${String(by)}" フィールドの型 "${typeof av}" はソート非対応です。compare 関数を指定してください。`,
747
+ context: {
748
+ operation: "makeComparator",
749
+ field: String(by),
750
+ type: typeof av
751
+ }
752
+ });
683
753
  };
684
754
  }
685
755
  //#endregion
@@ -690,12 +760,23 @@ const DEFAULT_OPTS = {
690
760
  revalidatePath: "/revalidate"
691
761
  };
692
762
  /**
763
+ * CMSError のコードから HTTP ステータスコードを返す。
764
+ * 既知の webhook エラーコードのみ対応し、それ以外は null を返す。
765
+ */
766
+ function webhookErrorStatus(code) {
767
+ if (code === "webhook/signature_invalid") return 401;
768
+ if (code === "webhook/not_implemented") return 501;
769
+ if (code === "webhook/unknown_collection") return 404;
770
+ if (code === "webhook/payload_invalid") return 400;
771
+ return null;
772
+ }
773
+ /**
693
774
  * Web Standard な Request → Response ルーター。
694
775
  * Next.js / React Router / Hono / Cloudflare Workers いずれでも使える。
695
776
  *
696
777
  * ルート:
697
- * - GET `{basePath}/images/:hash` — 画像プロキシ
698
- * - POST `{basePath}/revalidate` — Webhook 受信 + $revalidate()
778
+ * - GET `{basePath}/images/:hash` — 画像プロキシ
779
+ * - POST `{basePath}/revalidate/:collection` — Webhook 受信 + $revalidate()
699
780
  */
700
781
  function createHandler(adapter, opts = {}) {
701
782
  const basePath = trimTrailingSlash(opts.basePath ?? DEFAULT_OPTS.basePath);
@@ -715,23 +796,38 @@ function createHandler(adapter, opts = {}) {
715
796
  headers.set("cache-control", "public, max-age=31536000, immutable");
716
797
  return new Response(object.data, { headers });
717
798
  }
718
- if (req.method === "POST" && rel === revalidatePath) {
719
- const scope = await adapter.parseWebhook(req, opts.webhookSecret);
720
- if (!scope) return new Response(JSON.stringify({
799
+ if (req.method === "POST" && rel.startsWith(`${revalidatePath}/`)) {
800
+ const collection = rel.slice(revalidatePath.length + 1);
801
+ if (!collection || collection.includes("/")) return new Response(JSON.stringify({
721
802
  ok: false,
722
- reason: "invalid"
803
+ reason: "collection required"
723
804
  }), {
724
805
  status: 400,
725
806
  headers: { "content-type": "application/json" }
726
807
  });
727
- await adapter.revalidate(scope);
728
- return new Response(JSON.stringify({
729
- ok: true,
730
- scope
731
- }), {
732
- status: 200,
733
- headers: { "content-type": "application/json" }
734
- });
808
+ try {
809
+ const scope = await adapter.parseWebhookFor(collection, req, opts.webhookSecret);
810
+ await adapter.revalidate(scope);
811
+ return new Response(JSON.stringify({
812
+ ok: true,
813
+ scope
814
+ }), {
815
+ status: 200,
816
+ headers: { "content-type": "application/json" }
817
+ });
818
+ } catch (err) {
819
+ if (isCMSError(err)) {
820
+ const status = webhookErrorStatus(err.code);
821
+ if (status !== null) return new Response(JSON.stringify({
822
+ ok: false,
823
+ code: err.code
824
+ }), {
825
+ status,
826
+ headers: { "content-type": "application/json" }
827
+ });
828
+ }
829
+ throw err;
830
+ }
735
831
  }
736
832
  return new Response("Not Found", { status: 404 });
737
833
  };
@@ -853,10 +949,10 @@ function createCMS(opts) {
853
949
  ...DEFAULT_RETRY_CONFIG,
854
950
  ...opts.rateLimiter ?? {}
855
951
  };
856
- const collectionNames = Object.keys(opts.collections);
952
+ const collectionNames = [];
857
953
  const collections = {};
858
- for (const name of collectionNames) {
859
- const def = opts.collections[name];
954
+ for (const [name, def] of Object.entries(opts.collections)) {
955
+ collectionNames.push(name);
860
956
  const source = def.source;
861
957
  const colHooks = def.hooks;
862
958
  const collectionHooks = colHooks ? mergeHooks([{
@@ -903,27 +999,26 @@ function createCMS(opts) {
903
999
  $handler(handlerOpts) {
904
1000
  return createHandler({
905
1001
  imageCache: cacheRes.img,
906
- parseWebhook: async (req, webhookSecret) => {
907
- for (const name of collectionNames) {
908
- const ds = opts.collections[name].source;
909
- if (ds.parseWebhook) try {
910
- return await ds.parseWebhook(req.clone(), { secret: webhookSecret });
911
- } catch (err) {
912
- logger?.warn?.("parseWebhook 失敗", {
913
- collection: name,
914
- error: err instanceof Error ? err.message : String(err)
915
- });
1002
+ async parseWebhookFor(collection, req, webhookSecret) {
1003
+ const def = opts.collections[collection];
1004
+ if (!def) throw new CMSError({
1005
+ code: "webhook/unknown_collection",
1006
+ message: `Unknown collection: ${collection}`,
1007
+ context: {
1008
+ operation: "parseWebhookFor",
1009
+ collection
1010
+ }
1011
+ });
1012
+ const ds = def.source;
1013
+ if (!ds.parseWebhook) throw new CMSError({
1014
+ code: "webhook/not_implemented",
1015
+ message: `Collection "${collection}" does not support webhooks.`,
1016
+ context: {
1017
+ operation: "parseWebhookFor",
1018
+ collection
916
1019
  }
917
- }
918
- try {
919
- const body = await req.json();
920
- if (body.slug && body.collection) return {
921
- collection: body.collection,
922
- slug: body.slug
923
- };
924
- if (body.collection) return { collection: body.collection };
925
- } catch {}
926
- return null;
1020
+ });
1021
+ return ds.parseWebhook(req, { secret: webhookSecret });
927
1022
  },
928
1023
  revalidate: (scope) => globalOps.$invalidate(scope)
929
1024
  }, handlerOpts);