@notion-headless-cms/core 0.1.0 → 0.1.2

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.js CHANGED
@@ -1,3 +1,22 @@
1
+ import {
2
+ CMSError,
3
+ isCMSError,
4
+ isCMSErrorInNamespace
5
+ } from "./chunk-V6ML4QE5.js";
6
+ import {
7
+ mergeHooks,
8
+ mergeLoggers
9
+ } from "./chunk-4KGKWKKI.js";
10
+ import {
11
+ memoryCache,
12
+ memoryDocumentCache,
13
+ memoryImageCache
14
+ } from "./chunk-6LHROEPI.js";
15
+ import {
16
+ noopDocumentCache,
17
+ noopImageCache
18
+ } from "./chunk-6DG63XUF.js";
19
+
1
20
  // src/cache.ts
2
21
  async function sha256Hex(input) {
3
22
  const data = new TextEncoder().encode(input);
@@ -9,112 +28,6 @@ function isStale(cachedAt, ttlMs) {
9
28
  return Date.now() - cachedAt > ttlMs;
10
29
  }
11
30
 
12
- // src/cache/memory.ts
13
- var MemoryDocumentCache = class {
14
- name = "memory-document";
15
- list = null;
16
- items = /* @__PURE__ */ new Map();
17
- getList() {
18
- return Promise.resolve(this.list);
19
- }
20
- setList(data) {
21
- this.list = data;
22
- return Promise.resolve();
23
- }
24
- getItem(slug) {
25
- return Promise.resolve(this.items.get(slug) ?? null);
26
- }
27
- setItem(slug, data) {
28
- this.items.set(slug, data);
29
- return Promise.resolve();
30
- }
31
- async invalidate(scope) {
32
- if (scope === "all") {
33
- this.list = null;
34
- this.items.clear();
35
- } else if ("slug" in scope) {
36
- this.items.delete(scope.slug);
37
- }
38
- }
39
- };
40
- var MemoryImageCache = class {
41
- name = "memory-image";
42
- store = /* @__PURE__ */ new Map();
43
- get(hash) {
44
- return Promise.resolve(this.store.get(hash) ?? null);
45
- }
46
- set(hash, data, contentType) {
47
- this.store.set(hash, { data, contentType });
48
- return Promise.resolve();
49
- }
50
- };
51
- function memoryDocumentCache() {
52
- return new MemoryDocumentCache();
53
- }
54
- function memoryImageCache() {
55
- return new MemoryImageCache();
56
- }
57
- function memoryCache() {
58
- return new MemoryDocumentCache();
59
- }
60
-
61
- // src/cache/noop.ts
62
- var NoopDocumentCache = class {
63
- name = "noop-document";
64
- getList() {
65
- return Promise.resolve(null);
66
- }
67
- setList(_data) {
68
- return Promise.resolve();
69
- }
70
- getItem(_slug) {
71
- return Promise.resolve(null);
72
- }
73
- setItem(_slug, _data) {
74
- return Promise.resolve();
75
- }
76
- invalidate(_scope) {
77
- return Promise.resolve();
78
- }
79
- };
80
- var NoopImageCache = class {
81
- name = "noop-image";
82
- get(_hash) {
83
- return Promise.resolve(null);
84
- }
85
- set(_hash, _data, _contentType) {
86
- return Promise.resolve();
87
- }
88
- };
89
- var _noopDocument = new NoopDocumentCache();
90
- var _noopImage = new NoopImageCache();
91
- function noopDocumentCache() {
92
- return _noopDocument;
93
- }
94
- function noopImageCache() {
95
- return _noopImage;
96
- }
97
-
98
- // src/cms.ts
99
- import { renderMarkdown } from "@notion-headless-cms/renderer";
100
-
101
- // src/errors.ts
102
- var CMSError = class extends Error {
103
- code;
104
- cause;
105
- context;
106
- constructor(params) {
107
- super(params.message, { cause: params.cause });
108
- this.name = "CMSError";
109
- this.code = params.code;
110
- this.cause = params.cause;
111
- this.context = params.context;
112
- }
113
- };
114
- function isCMSError(error) {
115
- return error instanceof CMSError;
116
- }
117
-
118
31
  // src/image.ts
119
32
  function inferContentType(url, responseContentType) {
120
33
  if (responseContentType?.startsWith("image/")) {
@@ -134,7 +47,17 @@ async function fetchAndCacheImage(cache, notionUrl, imageProxyBase) {
134
47
  const response = await fetch(notionUrl, {
135
48
  signal: AbortSignal.timeout(1e4)
136
49
  });
137
- if (!response.ok) return proxyUrl;
50
+ if (!response.ok) {
51
+ throw new CMSError({
52
+ code: "cache/image_fetch_failed",
53
+ message: `Failed to fetch Notion image: HTTP ${response.status}`,
54
+ context: {
55
+ operation: "fetchAndCacheImage",
56
+ notionUrl,
57
+ httpStatus: response.status
58
+ }
59
+ });
60
+ }
138
61
  const data = await response.arrayBuffer();
139
62
  const contentType = inferContentType(
140
63
  notionUrl,
@@ -142,8 +65,9 @@ async function fetchAndCacheImage(cache, notionUrl, imageProxyBase) {
142
65
  );
143
66
  await cache.set(hash, data, contentType);
144
67
  } catch (err) {
68
+ if (isCMSError(err)) throw err;
145
69
  throw new CMSError({
146
- code: "IMAGE_CACHE_FAILED",
70
+ code: "cache/io_failed",
147
71
  message: "Failed to fetch or cache Notion image.",
148
72
  cause: err,
149
73
  context: { operation: "fetchAndCacheImage", notionUrl }
@@ -155,23 +79,181 @@ function buildCacheImageFn(cache, imageProxyBase) {
155
79
  return (notionUrl) => fetchAndCacheImage(cache, notionUrl, imageProxyBase);
156
80
  }
157
81
 
82
+ // src/query.ts
83
+ var QueryBuilder = class {
84
+ source;
85
+ defaultStatuses;
86
+ _statuses = [];
87
+ _tags = [];
88
+ _predicate;
89
+ _sortField;
90
+ _sortDir = "asc";
91
+ _page = 1;
92
+ _perPage = 20;
93
+ constructor(source, defaultStatuses = []) {
94
+ this.source = source;
95
+ this.defaultStatuses = defaultStatuses;
96
+ }
97
+ status(s) {
98
+ this._statuses = Array.isArray(s) ? s : [s];
99
+ return this;
100
+ }
101
+ tag(t) {
102
+ this._tags = Array.isArray(t) ? t : [t];
103
+ return this;
104
+ }
105
+ where(predicate) {
106
+ this._predicate = predicate;
107
+ return this;
108
+ }
109
+ sortBy(field, dir = "asc") {
110
+ this._sortField = field;
111
+ this._sortDir = dir;
112
+ return this;
113
+ }
114
+ paginate(opts) {
115
+ this._page = opts.page;
116
+ this._perPage = opts.perPage;
117
+ return this;
118
+ }
119
+ async execute() {
120
+ const statuses = this._statuses.length > 0 ? this._statuses : this.defaultStatuses.length > 0 ? this.defaultStatuses : void 0;
121
+ if (this.source.query && !this._predicate) {
122
+ const result = await this.source.query({
123
+ filter: {
124
+ statuses,
125
+ tags: this._tags.length > 0 ? this._tags : void 0
126
+ },
127
+ sort: this._sortField ? [{ property: this._sortField, direction: this._sortDir }] : void 0,
128
+ pageSize: this._perPage
129
+ });
130
+ const items2 = result.items;
131
+ return {
132
+ items: items2,
133
+ total: items2.length,
134
+ page: this._page,
135
+ perPage: this._perPage,
136
+ hasNext: result.hasMore,
137
+ hasPrev: this._page > 1
138
+ };
139
+ }
140
+ let items = await this.source.list({
141
+ publishedStatuses: statuses
142
+ });
143
+ if (this._tags.length > 0) {
144
+ items = items.filter((item) => {
145
+ const itemTags = item.tags;
146
+ if (!Array.isArray(itemTags)) return false;
147
+ return this._tags.some((tag) => itemTags.includes(tag));
148
+ });
149
+ }
150
+ if (this._predicate) {
151
+ items = items.filter(this._predicate);
152
+ }
153
+ if (this._sortField) {
154
+ const field = this._sortField;
155
+ const dir = this._sortDir;
156
+ items = [...items].sort((a, b) => {
157
+ const av = a[field];
158
+ const bv = b[field];
159
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
160
+ return dir === "asc" ? cmp : -cmp;
161
+ });
162
+ }
163
+ const total = items.length;
164
+ const start = (this._page - 1) * this._perPage;
165
+ const paged = items.slice(start, start + this._perPage);
166
+ return {
167
+ items: paged,
168
+ total,
169
+ page: this._page,
170
+ perPage: this._perPage,
171
+ hasNext: start + this._perPage < total,
172
+ hasPrev: this._page > 1
173
+ };
174
+ }
175
+ async executeOne() {
176
+ const result = await this.execute();
177
+ return result.items[0] ?? null;
178
+ }
179
+ /** 前後アイテムを返す。sortBy() で指定したソート順を適用する。 */
180
+ async adjacent(slug) {
181
+ const statuses = this._statuses.length > 0 ? this._statuses : this.defaultStatuses.length > 0 ? this.defaultStatuses : void 0;
182
+ let items = await this.source.list({ publishedStatuses: statuses });
183
+ if (this._sortField) {
184
+ const field = this._sortField;
185
+ const dir = this._sortDir;
186
+ items = [...items].sort((a, b) => {
187
+ const av = a[field];
188
+ const bv = b[field];
189
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
190
+ return dir === "asc" ? cmp : -cmp;
191
+ });
192
+ }
193
+ const idx = items.findIndex((item) => item.slug === slug);
194
+ if (idx === -1) return { prev: null, next: null };
195
+ return {
196
+ prev: idx > 0 ? items[idx - 1] : null,
197
+ next: idx < items.length - 1 ? items[idx + 1] : null
198
+ };
199
+ }
200
+ /** 最初の 1 件を返す。`.paginate({ page: 1, perPage: 1 }).executeOne()` の短縮形。 */
201
+ first() {
202
+ return this.paginate({ page: 1, perPage: 1 }).executeOne();
203
+ }
204
+ };
205
+
206
+ // src/retry.ts
207
+ var DEFAULT_RETRY_CONFIG = {
208
+ retryOn: [429, 502, 503],
209
+ maxRetries: 4,
210
+ baseDelayMs: 1e3,
211
+ jitter: true
212
+ };
213
+ async function withRetry(fn, config) {
214
+ let lastError;
215
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
216
+ try {
217
+ return await fn();
218
+ } catch (err) {
219
+ const status = err.status;
220
+ if (status === void 0 || !config.retryOn.includes(status)) {
221
+ throw err;
222
+ }
223
+ lastError = err;
224
+ if (attempt < config.maxRetries) {
225
+ config.onRetry?.(attempt + 1, status);
226
+ const jitterFactor = config.jitter !== false ? 0.5 + Math.random() * 0.5 : 1;
227
+ const delay = config.baseDelayMs * 2 ** attempt * jitterFactor;
228
+ await new Promise((resolve) => setTimeout(resolve, delay));
229
+ }
230
+ }
231
+ }
232
+ throw lastError;
233
+ }
234
+
158
235
  // src/cms.ts
159
236
  var DEFAULT_IMAGE_PROXY_BASE = "/api/images";
160
- function buildListVersion(items) {
161
- return items.map((item) => `${item.id}:${item.updatedAt}`).join("|");
162
- }
163
237
  function resolveDocumentCache(cache) {
164
- if (!cache || cache.document === false || cache.document === void 0) {
238
+ if (!cache || cache === "disabled" || !cache.document) {
165
239
  return noopDocumentCache();
166
240
  }
167
241
  return cache.document;
168
242
  }
169
243
  function resolveImageCache(cache) {
170
- if (!cache || cache.image === false || cache.image === void 0) {
244
+ if (!cache || cache === "disabled" || !cache.image) {
171
245
  return noopImageCache();
172
246
  }
173
247
  return cache.image;
174
248
  }
249
+ function resolveTtl(cache) {
250
+ if (!cache || cache === "disabled") return void 0;
251
+ return cache.ttlMs;
252
+ }
253
+ function hasImageCacheConfigured(cache) {
254
+ if (!cache || cache === "disabled") return false;
255
+ return !!cache.image;
256
+ }
175
257
  var CMS = class {
176
258
  source;
177
259
  docCache;
@@ -182,100 +264,154 @@ var CMS = class {
182
264
  accessibleStatuses;
183
265
  imageProxyBase;
184
266
  contentConfig;
267
+ rendererFn;
185
268
  waitUntil;
269
+ hooks;
270
+ logger;
271
+ retryConfig;
272
+ maxConcurrent;
273
+ cache;
186
274
  constructor(opts) {
187
275
  this.source = opts.source;
188
276
  this.docCache = resolveDocumentCache(opts.cache);
189
277
  this.imgCache = resolveImageCache(opts.cache);
190
- this.hasImageCache = !!opts.cache?.image;
191
- this.ttlMs = opts.cache?.ttlMs;
278
+ this.hasImageCache = hasImageCacheConfigured(opts.cache);
279
+ this.ttlMs = resolveTtl(opts.cache);
192
280
  this.publishedStatuses = opts.schema?.publishedStatuses ?? (opts.source.publishedStatuses ? [...opts.source.publishedStatuses] : []);
193
281
  this.accessibleStatuses = opts.schema?.accessibleStatuses ?? (opts.source.accessibleStatuses ? [...opts.source.accessibleStatuses] : []);
194
282
  this.imageProxyBase = opts.content?.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
195
283
  this.contentConfig = opts.content;
284
+ this.rendererFn = opts.renderer;
196
285
  this.waitUntil = opts.waitUntil;
286
+ this.logger = mergeLoggers(opts.plugins ?? [], opts.logger);
287
+ this.hooks = mergeHooks(opts.plugins ?? [], opts.hooks, this.logger);
288
+ this.maxConcurrent = opts.rateLimiter?.maxConcurrent ?? 3;
289
+ this.retryConfig = {
290
+ ...DEFAULT_RETRY_CONFIG,
291
+ ...opts.rateLimiter ?? {}
292
+ };
293
+ this.cache = {
294
+ getList: this.cachedList.bind(this),
295
+ get: this.cachedGet.bind(this),
296
+ prefetchAll: this.prefetchAll.bind(this),
297
+ revalidate: this.revalidate.bind(this),
298
+ sync: this.syncFromWebhook.bind(this),
299
+ checkList: this.checkListUpdate.bind(this),
300
+ checkItem: this.checkItemUpdate.bind(this)
301
+ };
197
302
  }
198
303
  // ── コンテンツ取得 ──────────────────────────────────────────────────────
199
304
  /** 公開済みコンテンツ一覧をソースから直接取得する。 */
200
305
  list() {
201
- return this.source.list({
202
- publishedStatuses: this.publishedStatuses.length > 0 ? this.publishedStatuses : void 0
203
- });
306
+ return withRetry(
307
+ () => this.source.list({
308
+ publishedStatuses: this.publishedStatuses.length > 0 ? this.publishedStatuses : void 0
309
+ }),
310
+ {
311
+ ...this.retryConfig,
312
+ onRetry: (attempt, status) => {
313
+ this.logger?.warn?.("list() \u30EA\u30C8\u30E9\u30A4\u4E2D", { attempt, status });
314
+ }
315
+ }
316
+ );
204
317
  }
205
318
  /** スラッグでコンテンツをソースから直接取得する。 */
206
- async findBySlug(slug) {
207
- const item = await this.source.findBySlug(slug);
319
+ async find(slug) {
320
+ const item = await withRetry(() => this.source.findBySlug(slug), {
321
+ ...this.retryConfig,
322
+ onRetry: (attempt, status) => {
323
+ this.logger?.warn?.("find() \u30EA\u30C8\u30E9\u30A4\u4E2D", { attempt, status, slug });
324
+ }
325
+ });
208
326
  if (!item) return null;
209
- if (this.accessibleStatuses.length > 0 && !this.accessibleStatuses.includes(item.status)) {
327
+ if (this.accessibleStatuses.length > 0 && (!item.status || !this.accessibleStatuses.includes(item.status))) {
210
328
  return null;
211
329
  }
212
330
  return item;
213
331
  }
332
+ /** 複数スラッグをまとめてソースから直接取得する。accessibleStatuses フィルタを適用する。 */
333
+ async findMany(slugs) {
334
+ const results = /* @__PURE__ */ new Map();
335
+ await Promise.all(
336
+ slugs.map(async (slug) => {
337
+ const item = await this.find(slug);
338
+ if (item) results.set(slug, item);
339
+ })
340
+ );
341
+ return results;
342
+ }
214
343
  /** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
215
344
  isPublished(item) {
216
345
  if (this.publishedStatuses.length === 0) return true;
217
- return this.publishedStatuses.includes(item.status);
346
+ return !!item.status && this.publishedStatuses.includes(item.status);
218
347
  }
219
- /** コンテンツを Markdown → HTML にレンダリングし、CachedItem として返す。 */
348
+ /** コンテンツを Markdown → HTML にレンダリングし、CachedItem として返す。キャッシュには保存しない。 */
220
349
  async render(item) {
221
350
  return this.buildCachedItem(item);
222
351
  }
223
- /** スラッグでコンテンツを取得して Markdown → HTML にレンダリングする。 */
224
- async renderBySlug(slug) {
225
- try {
226
- const item = await this.findBySlug(slug);
227
- if (!item) return null;
228
- return this.buildCachedItem(item);
229
- } catch (err) {
230
- if (isCMSError(err)) throw err;
231
- throw new CMSError({
232
- code: "NOTION_FETCH_ITEM_BY_SLUG_FAILED",
233
- message: "Failed to fetch item by slug from Notion data source.",
234
- cause: err,
235
- context: { operation: "renderBySlug", slug }
236
- });
237
- }
238
- }
239
- // ── 便利 API ───────────────────────────────────────────────────────────
240
- /** ステータスでフィルタリングした一覧を返す。 */
241
- async listByStatus(status) {
242
- const statuses = Array.isArray(status) ? status : [status];
243
- const all = await this.source.list();
244
- return all.filter((item) => statuses.includes(item.status));
352
+ /** QueryBuilder を返す。ステータス・タグ・ページネーションなどを連鎖で指定できる。 */
353
+ query() {
354
+ return new QueryBuilder(this.source, this.publishedStatuses);
245
355
  }
246
- /** 任意の条件でフィルタリングした一覧を返す。 */
247
- async where(predicate) {
356
+ /** 静的生成用のスラッグ一覧を返す。 */
357
+ async getStaticSlugs() {
248
358
  const items = await this.list();
249
- return items.filter(predicate);
250
- }
251
- /** ページネーション付き一覧を返す。 */
252
- async paginate(opts) {
253
- const all = await this.list();
254
- const total = all.length;
255
- const start = (opts.page - 1) * opts.perPage;
256
- const items = all.slice(start, start + opts.perPage);
257
- return {
258
- items,
259
- total,
260
- page: opts.page,
261
- perPage: opts.perPage,
262
- hasNext: start + opts.perPage < total
263
- };
359
+ return items.map((item) => item.slug);
360
+ }
361
+ // ── 画像配信 ──────────────────────────────────────────────────────────
362
+ /** ハッシュキーでキャッシュ画像を取得する。 */
363
+ getCachedImage(hash) {
364
+ return this.imgCache.get(hash);
365
+ }
366
+ /** ハッシュキーでキャッシュ画像を Response として返す。 */
367
+ async createCachedImageResponse(hash) {
368
+ const object = await this.imgCache.get(hash);
369
+ if (!object) return null;
370
+ const headers = new Headers();
371
+ if (object.contentType) headers.set("content-type", object.contentType);
372
+ headers.set("cache-control", "public, max-age=31536000, immutable");
373
+ return new Response(object.data, { headers });
264
374
  }
265
- /** 指定スラッグの前後コンテンツを返す。 */
266
- async getAdjacent(slug) {
375
+ // ── キャッシュ優先取得(Stale-While-Revalidate) ─────────────────────
376
+ async cachedList() {
377
+ const cached = await this.docCache.getList();
378
+ if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
379
+ this.hooks.onListCacheHit?.(cached.items, cached.cachedAt);
380
+ return { items: cached.items, isStale: false, cachedAt: cached.cachedAt };
381
+ }
382
+ this.hooks.onListCacheMiss?.();
267
383
  const items = await this.list();
268
- const idx = items.findIndex((item) => item.slug === slug);
269
- if (idx === -1) return { prev: null, next: null };
270
- return {
271
- prev: idx > 0 ? items[idx - 1] : null,
272
- next: idx < items.length - 1 ? items[idx + 1] : null
273
- };
384
+ const cachedAt = Date.now();
385
+ const save = this.docCache.setList({ items, cachedAt });
386
+ if (this.waitUntil) {
387
+ this.waitUntil(save);
388
+ } else {
389
+ await save;
390
+ }
391
+ return { items, isStale: !!cached, cachedAt };
274
392
  }
275
- /** 全コンテンツを事前レンダリングしてキャッシュに保存する。 */
393
+ async cachedGet(slug) {
394
+ const cached = await this.docCache.getItem(slug);
395
+ if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
396
+ this.hooks.onCacheHit?.(slug, cached);
397
+ return cached;
398
+ }
399
+ this.hooks.onCacheMiss?.(slug);
400
+ const item = await this.find(slug);
401
+ if (!item) return null;
402
+ const entry = await this.buildCachedItem(item);
403
+ const save = this.docCache.setItem(slug, entry);
404
+ if (this.waitUntil) {
405
+ this.waitUntil(save);
406
+ } else {
407
+ await save;
408
+ }
409
+ return entry;
410
+ }
411
+ // ── キャッシュ管理 ────────────────────────────────────────────────────
276
412
  async prefetchAll(opts) {
277
413
  const items = await this.list();
278
- const concurrency = opts?.concurrency ?? 3;
414
+ const concurrency = opts?.concurrency ?? this.maxConcurrent;
279
415
  let ok = 0;
280
416
  let failed = 0;
281
417
  for (let i = 0; i < items.length; i += concurrency) {
@@ -286,8 +422,16 @@ var CMS = class {
286
422
  const rendered = await this.buildCachedItem(item);
287
423
  await this.docCache.setItem(item.slug, rendered);
288
424
  ok++;
289
- } catch {
425
+ } catch (err) {
290
426
  failed++;
427
+ this.logger?.warn?.(
428
+ "prefetchAll: \u30A2\u30A4\u30C6\u30E0\u306E\u4E8B\u524D\u30EC\u30F3\u30C0\u30EA\u30F3\u30B0\u306B\u5931\u6557",
429
+ {
430
+ slug: item.slug,
431
+ pageId: item.id,
432
+ error: err instanceof Error ? err.message : String(err)
433
+ }
434
+ );
291
435
  }
292
436
  })
293
437
  );
@@ -296,21 +440,14 @@ var CMS = class {
296
440
  await this.docCache.setList({ items, cachedAt: Date.now() });
297
441
  return { ok, failed };
298
442
  }
299
- /** 静的生成用のスラッグ一覧を返す。 */
300
- async getStaticSlugs() {
301
- const items = await this.list();
302
- return items.map((item) => item.slug);
303
- }
304
- /** 指定スコープのキャッシュを無効化する。 */
305
443
  async revalidate(scope) {
306
444
  if (!this.docCache.invalidate) return;
307
445
  await this.docCache.invalidate(scope ?? "all");
308
446
  }
309
- /** Webhook ペイロードを元にキャッシュを同期する。 */
310
447
  async syncFromWebhook(payload) {
311
448
  const updated = [];
312
449
  if (payload?.slug) {
313
- const item = await this.findBySlug(payload.slug);
450
+ const item = await this.find(payload.slug);
314
451
  if (item) {
315
452
  const rendered = await this.buildCachedItem(item);
316
453
  await this.docCache.setItem(item.slug, rendered);
@@ -325,39 +462,6 @@ var CMS = class {
325
462
  }
326
463
  return { updated };
327
464
  }
328
- // ── キャッシュ優先取得(Stale-While-Revalidate) ─────────────────────
329
- /** キャッシュ優先でコンテンツ一覧を返す(SWR)。 */
330
- async getList() {
331
- const cached = await this.docCache.getList();
332
- if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
333
- return {
334
- items: cached.items,
335
- listVersion: buildListVersion(cached.items)
336
- };
337
- }
338
- const items = await this.list();
339
- const save = this.docCache.setList({ items, cachedAt: Date.now() });
340
- if (this.waitUntil) {
341
- this.waitUntil(save);
342
- } else {
343
- await save;
344
- }
345
- return { items, listVersion: buildListVersion(items) };
346
- }
347
- /** キャッシュ優先で単一コンテンツを返す(SWR)。 */
348
- async getItem(slug) {
349
- const cached = await this.docCache.getItem(slug);
350
- if (cached && !isStale(cached.cachedAt, this.ttlMs)) return cached;
351
- const entry = await this.renderBySlug(slug);
352
- if (!entry) return null;
353
- const save = this.docCache.setItem(slug, entry);
354
- if (this.waitUntil) {
355
- this.waitUntil(save);
356
- } else {
357
- await save;
358
- }
359
- return entry;
360
- }
361
465
  async checkListUpdate(version) {
362
466
  const items = await this.list();
363
467
  const serverVersion = buildListVersion(items);
@@ -366,12 +470,11 @@ var CMS = class {
366
470
  return { changed: true, items };
367
471
  }
368
472
  async checkItemUpdate(slug, lastEdited) {
369
- const item = await this.findBySlug(slug);
473
+ const item = await this.find(slug);
370
474
  if (!item) return { changed: false };
371
475
  if (!this.isPublished(item)) return { changed: false };
372
476
  if (item.updatedAt === lastEdited) return { changed: false };
373
- const entry = await this.renderBySlug(slug);
374
- if (!entry) return { changed: false };
477
+ const entry = await this.buildCachedItem(item);
375
478
  await this.docCache.setItem(slug, entry);
376
479
  return {
377
480
  changed: true,
@@ -380,29 +483,21 @@ var CMS = class {
380
483
  notionUpdatedAt: entry.notionUpdatedAt
381
484
  };
382
485
  }
383
- // ── 画像配信 ───────────────────────────────────────────────────────────
384
- /** ハッシュキーでキャッシュ画像を取得する。 */
385
- getCachedImage(hash) {
386
- return this.imgCache.get(hash);
387
- }
388
- /** ハッシュキーでキャッシュ画像を Response として返す。 */
389
- async createCachedImageResponse(hash) {
390
- const object = await this.imgCache.get(hash);
391
- if (!object) return null;
392
- const headers = new Headers();
393
- if (object.contentType) headers.set("content-type", object.contentType);
394
- headers.set("cache-control", "public, max-age=31536000, immutable");
395
- return new Response(object.data, { headers });
396
- }
397
486
  // ── プライベートヘルパー ────────────────────────────────────────────────
398
487
  async buildCachedItem(item) {
488
+ const start = Date.now();
489
+ this.logger?.info?.("\u30B3\u30F3\u30C6\u30F3\u30C4\u306E\u30EC\u30F3\u30C0\u30EA\u30F3\u30B0\u958B\u59CB", {
490
+ slug: item.slug,
491
+ pageId: item.id
492
+ });
493
+ this.hooks.onRenderStart?.(item.slug);
399
494
  let markdown;
400
495
  try {
401
496
  markdown = await this.source.loadMarkdown(item);
402
497
  } catch (err) {
403
498
  if (isCMSError(err)) throw err;
404
499
  throw new CMSError({
405
- code: "NOTION_MARKDOWN_FETCH_FAILED",
500
+ code: "source/load_markdown_failed",
406
501
  message: "Failed to load markdown from source.",
407
502
  cause: err,
408
503
  context: {
@@ -414,18 +509,18 @@ var CMS = class {
414
509
  }
415
510
  const cacheImage = this.hasImageCache ? buildCacheImageFn(this.imgCache, this.imageProxyBase) : void 0;
416
511
  let html;
512
+ const rendererFn = this.rendererFn ?? await loadDefaultRenderer();
417
513
  try {
418
- html = await renderMarkdown(markdown, {
514
+ html = await rendererFn(markdown, {
419
515
  imageProxyBase: this.imageProxyBase,
420
516
  cacheImage,
421
517
  remarkPlugins: this.contentConfig?.remarkPlugins,
422
- rehypePlugins: this.contentConfig?.rehypePlugins,
423
- render: this.contentConfig?.render
518
+ rehypePlugins: this.contentConfig?.rehypePlugins
424
519
  });
425
520
  } catch (err) {
426
521
  if (isCMSError(err)) throw err;
427
522
  throw new CMSError({
428
- code: "RENDERER_FAILED",
523
+ code: "renderer/failed",
429
524
  message: "Failed to render markdown.",
430
525
  cause: err,
431
526
  context: {
@@ -435,67 +530,69 @@ var CMS = class {
435
530
  }
436
531
  });
437
532
  }
438
- return {
533
+ if (this.hooks.afterRender) {
534
+ html = await this.hooks.afterRender(html, item);
535
+ }
536
+ let result = {
439
537
  html,
440
538
  item,
441
539
  notionUpdatedAt: item.updatedAt,
442
540
  cachedAt: Date.now()
443
541
  };
542
+ if (this.hooks.beforeCache) {
543
+ result = await this.hooks.beforeCache(result);
544
+ }
545
+ const durationMs = Date.now() - start;
546
+ this.logger?.info?.("\u30B3\u30F3\u30C6\u30F3\u30C4\u306E\u30EC\u30F3\u30C0\u30EA\u30F3\u30B0\u5B8C\u4E86", {
547
+ slug: item.slug,
548
+ durationMs
549
+ });
550
+ this.hooks.onRenderEnd?.(item.slug, durationMs);
551
+ return result;
444
552
  }
445
553
  };
554
+ async function loadDefaultRenderer() {
555
+ try {
556
+ const mod = await import("@notion-headless-cms/renderer");
557
+ return mod.renderMarkdown;
558
+ } catch (err) {
559
+ throw new CMSError({
560
+ code: "renderer/failed",
561
+ message: "renderer \u30AA\u30D7\u30B7\u30E7\u30F3\u304C\u672A\u6307\u5B9A\u3067 @notion-headless-cms/renderer \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002 createCMS({ renderer }) \u3067\u30EC\u30F3\u30C0\u30E9\u30FC\u3092\u6CE8\u5165\u3059\u308B\u304B\u3001@notion-headless-cms/renderer \u3092\u30A4\u30F3\u30B9\u30C8\u30FC\u30EB\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
562
+ cause: err,
563
+ context: { operation: "loadDefaultRenderer" }
564
+ });
565
+ }
566
+ }
567
+ function buildListVersion(items) {
568
+ return items.map((item) => `${item.id}:${item.updatedAt}`).join("|");
569
+ }
446
570
  function createCMS(opts) {
447
571
  return new CMS(opts);
448
572
  }
449
573
 
450
- // src/mapper.ts
451
- import { z } from "zod";
452
- var baseContentItemSchema = z.object({
453
- id: z.string().min(1),
454
- slug: z.string(),
455
- status: z.string(),
456
- publishedAt: z.string().min(1),
457
- updatedAt: z.string().min(1)
458
- });
459
- function getPlainText(items) {
460
- return items?.map((item) => item.plain_text).join("") ?? "";
461
- }
462
- function mapItem(page, props) {
463
- const statusProperty = page.properties[props.status];
464
- const dateProperty = page.properties[props.date];
465
- const parsed = baseContentItemSchema.safeParse({
466
- id: page.id,
467
- slug: getPlainText(
468
- page.properties[props.slug]?.rich_text
469
- ),
470
- status: statusProperty?.status?.name ?? statusProperty?.select?.name ?? "",
471
- publishedAt: dateProperty?.date?.start ?? page.created_time,
472
- updatedAt: page.last_edited_time
473
- });
474
- if (!parsed.success) {
475
- throw new CMSError({
476
- code: "NOTION_ITEM_SCHEMA_INVALID",
477
- message: "Failed to parse Notion page into BaseContentItem.",
478
- context: {
479
- operation: "mapItem",
480
- pageId: page.id,
481
- issues: JSON.stringify(parsed.error.issues)
482
- }
483
- });
484
- }
485
- return parsed.data;
574
+ // src/types/plugin.ts
575
+ function definePlugin(plugin) {
576
+ return plugin;
486
577
  }
487
578
  export {
488
579
  CMS,
489
580
  CMSError,
581
+ DEFAULT_RETRY_CONFIG,
582
+ QueryBuilder,
490
583
  createCMS,
491
- getPlainText,
584
+ definePlugin,
492
585
  isCMSError,
586
+ isCMSErrorInNamespace,
493
587
  isStale,
494
- mapItem,
495
588
  memoryCache,
496
589
  memoryDocumentCache,
497
590
  memoryImageCache,
591
+ mergeHooks,
592
+ mergeLoggers,
498
593
  noopDocumentCache,
499
594
  noopImageCache,
500
- sha256Hex
595
+ sha256Hex,
596
+ withRetry
501
597
  };
598
+ //# sourceMappingURL=index.js.map