@notion-headless-cms/core 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +211 -29
  2. package/dist/index.js +342 -77
  3. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -83,6 +83,51 @@ interface CacheConfig<T extends BaseContentItem = BaseContentItem> {
83
83
  ttlMs?: number;
84
84
  }
85
85
 
86
+ type MaybePromise<T> = T | Promise<T>;
87
+ interface CMSHooks<T extends BaseContentItem = BaseContentItem> {
88
+ beforeCache?: (item: CachedItem<T>) => MaybePromise<CachedItem<T>>;
89
+ afterRender?: (html: string, item: T) => MaybePromise<string>;
90
+ onCacheHit?: (slug: string, item: CachedItem<T>) => void;
91
+ onCacheMiss?: (slug: string) => void;
92
+ onListCacheHit?: (items: T[], cachedAt: number) => void;
93
+ onListCacheMiss?: () => void;
94
+ onError?: (error: Error) => void;
95
+ }
96
+
97
+ interface Logger {
98
+ debug?: (message: string, context?: Record<string, unknown>) => void;
99
+ info?: (message: string, context?: Record<string, unknown>) => void;
100
+ warn?: (message: string, context?: Record<string, unknown>) => void;
101
+ error?: (message: string, context?: Record<string, unknown>) => void;
102
+ }
103
+
104
+ interface CMSPlugin<T extends BaseContentItem = BaseContentItem> {
105
+ name: string;
106
+ hooks?: CMSHooks<T>;
107
+ logger?: Partial<Logger>;
108
+ }
109
+ declare function definePlugin<T extends BaseContentItem>(plugin: CMSPlugin<T>): CMSPlugin<T>;
110
+
111
+ /** ソース側クエリのフィルタ・ソート条件。 */
112
+ interface SourceQueryOptions {
113
+ filter?: {
114
+ statuses?: string[];
115
+ tags?: string[];
116
+ [key: string]: unknown;
117
+ };
118
+ sort?: {
119
+ property: string;
120
+ direction: "asc" | "desc";
121
+ }[];
122
+ pageSize?: number;
123
+ cursor?: string;
124
+ }
125
+ /** ソース側クエリの結果。 */
126
+ interface SourceQueryResult<T> {
127
+ items: T[];
128
+ hasMore: boolean;
129
+ nextCursor?: string;
130
+ }
86
131
  /**
87
132
  * コンテンツソース(Notion など)を抽象化するインターフェース。
88
133
  * core は Notion の知識を持たず、DataSourceAdapter 経由でのみデータを取得する。
@@ -96,6 +141,8 @@ interface DataSourceAdapter<T extends BaseContentItem = BaseContentItem> {
96
141
  }): Promise<T[]>;
97
142
  findBySlug(slug: string): Promise<T | null>;
98
143
  loadMarkdown(item: T): Promise<string>;
144
+ /** Notion 側でフィルタ・ソートを行うクエリ(オプション)。 */
145
+ query?(opts: SourceQueryOptions): Promise<SourceQueryResult<T>>;
99
146
  }
100
147
 
101
148
  /** スキーマ設定。公開ステータスのフィルタやプロパティ名マッピングを制御する。 */
@@ -125,6 +172,17 @@ interface ContentConfig {
125
172
  /** カスタムブロックハンドラーのマップ。Notionブロックタイプをキーとする。 */
126
173
  blocks?: Record<string, BlockHandler>;
127
174
  }
175
+ /** レートリミット・リトライ設定。 */
176
+ interface RateLimiterConfig {
177
+ /** 同時実行数の上限。デフォルト: 3 */
178
+ maxConcurrent?: number;
179
+ /** リトライ対象の HTTP ステータスコード。デフォルト: [429, 502, 503] */
180
+ retryOn?: number[];
181
+ /** 最大リトライ回数。デフォルト: 4 */
182
+ maxRetries?: number;
183
+ /** リトライ時の基準待機時間(ミリ秒)。デフォルト: 1000 */
184
+ baseDelayMs?: number;
185
+ }
128
186
  /**
129
187
  * createCMS() に渡すオプション。
130
188
  * ジェネリクス型 T にカスタムコンテンツ型を指定できる(デフォルト: BaseContentItem)。
@@ -140,6 +198,14 @@ interface CreateCMSOptions<T extends BaseContentItem = BaseContentItem> {
140
198
  content?: ContentConfig;
141
199
  /** Cloudflare Workers の waitUntil に相当する非同期処理の登録関数。 */
142
200
  waitUntil?: (p: Promise<unknown>) => void;
201
+ /** ライフサイクルフック。 */
202
+ hooks?: CMSHooks<T>;
203
+ /** プラグイン配列。フックとロガーを組み合わせて提供できる。 */
204
+ plugins?: CMSPlugin<T>[];
205
+ /** ロガー。 */
206
+ logger?: Logger;
207
+ /** レートリミット・リトライ設定。 */
208
+ rateLimiter?: RateLimiterConfig;
143
209
  }
144
210
 
145
211
  /** インメモリキャッシュ(ドキュメント用)を生成する。 */
@@ -157,13 +223,88 @@ declare function noopDocumentCache<T extends BaseContentItem = BaseContentItem>(
157
223
  /** 何もしない画像キャッシュを返す(シングルトン)。 */
158
224
  declare function noopImageCache(): ImageCacheAdapter;
159
225
 
226
+ interface QueryResult<T> {
227
+ items: T[];
228
+ total: number;
229
+ page: number;
230
+ perPage: number;
231
+ hasNext: boolean;
232
+ hasPrev: boolean;
233
+ }
234
+ declare class QueryBuilder<T extends BaseContentItem> {
235
+ private readonly source;
236
+ private readonly defaultStatuses;
237
+ private _statuses;
238
+ private _tags;
239
+ private _predicate;
240
+ private _sortField;
241
+ private _sortDir;
242
+ private _page;
243
+ private _perPage;
244
+ constructor(source: DataSourceAdapter<T>, defaultStatuses?: string[]);
245
+ status(s: string | string[]): this;
246
+ tag(t: string | string[]): this;
247
+ where(predicate: (item: T) => boolean): this;
248
+ sortBy(field: keyof T & string, dir?: "asc" | "desc"): this;
249
+ paginate(opts: {
250
+ page: number;
251
+ perPage: number;
252
+ }): this;
253
+ execute(): Promise<QueryResult<T>>;
254
+ executeOne(): Promise<T | null>;
255
+ adjacent(slug: string): Promise<{
256
+ prev: T | null;
257
+ next: T | null;
258
+ }>;
259
+ }
260
+
261
+ /** キャッシュ優先アクセサ。 */
262
+ interface CachedAccessor<T extends BaseContentItem> {
263
+ list(): Promise<{
264
+ items: T[];
265
+ isStale: boolean;
266
+ cachedAt: number;
267
+ }>;
268
+ get(slug: string): Promise<CachedItem<T> | null>;
269
+ }
270
+ /** キャッシュ管理オペレーション。 */
271
+ interface CacheManager<T extends BaseContentItem> {
272
+ prefetchAll(opts?: {
273
+ concurrency?: number;
274
+ onProgress?: (done: number, total: number) => void;
275
+ }): Promise<{
276
+ ok: number;
277
+ failed: number;
278
+ }>;
279
+ revalidate(scope?: "all" | {
280
+ slug: string;
281
+ }): Promise<void>;
282
+ sync(payload?: {
283
+ slug?: string;
284
+ }): Promise<{
285
+ updated: string[];
286
+ }>;
287
+ checkList(version: string): Promise<{
288
+ changed: false;
289
+ } | {
290
+ changed: true;
291
+ items: T[];
292
+ }>;
293
+ checkItem(slug: string, lastEdited: string): Promise<{
294
+ changed: false;
295
+ } | {
296
+ changed: true;
297
+ html: string;
298
+ item: T;
299
+ notionUpdatedAt: string;
300
+ }>;
301
+ }
160
302
  /**
161
303
  * Notion をバックエンドとして使う汎用ヘッドレス CMS クラス。
162
304
  *
163
305
  * @example
164
306
  * const cms = createCMS({
165
307
  * source: notionAdapter({ token: '...', dataSourceId: '...' }),
166
- * schema: { publishedStatuses: ['公開'] },
167
308
  * });
168
309
  * const items = await cms.list();
169
310
  */
@@ -178,37 +319,24 @@ declare class CMS<T extends BaseContentItem = BaseContentItem> {
178
319
  private readonly imageProxyBase;
179
320
  private readonly contentConfig;
180
321
  private readonly waitUntil;
322
+ private readonly hooks;
323
+ private readonly logger;
324
+ private readonly retryConfig;
325
+ readonly cached: CachedAccessor<T>;
326
+ readonly cache: CacheManager<T>;
181
327
  constructor(opts: CreateCMSOptions<T>);
182
328
  /** 公開済みコンテンツ一覧をソースから直接取得する。 */
183
329
  list(): Promise<T[]>;
184
330
  /** スラッグでコンテンツをソースから直接取得する。 */
331
+ find(slug: string): Promise<T | null>;
332
+ /** @deprecated find() を使用してください。 */
185
333
  findBySlug(slug: string): Promise<T | null>;
186
334
  /** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
187
335
  isPublished(item: T): boolean;
188
336
  /** コンテンツを Markdown → HTML にレンダリングし、CachedItem として返す。 */
189
337
  render(item: T): Promise<CachedItem<T>>;
190
- /** スラッグでコンテンツを取得して Markdown → HTML にレンダリングする。 */
191
- renderBySlug(slug: string): Promise<CachedItem<T> | null>;
192
- /** ステータスでフィルタリングした一覧を返す。 */
193
- listByStatus(status: string | readonly string[]): Promise<T[]>;
194
- /** 任意の条件でフィルタリングした一覧を返す。 */
195
- where(predicate: (item: T) => boolean): Promise<T[]>;
196
- /** ページネーション付き一覧を返す。 */
197
- paginate(opts: {
198
- page: number;
199
- perPage: number;
200
- }): Promise<{
201
- items: T[];
202
- total: number;
203
- page: number;
204
- perPage: number;
205
- hasNext: boolean;
206
- }>;
207
- /** 指定スラッグの前後コンテンツを返す。 */
208
- getAdjacent(slug: string): Promise<{
209
- prev: T | null;
210
- next: T | null;
211
- }>;
338
+ /** QueryBuilder を返す。ステータス・タグ・ページネーションなどを連鎖で指定できる。 */
339
+ query(): QueryBuilder<T>;
212
340
  /** 全コンテンツを事前レンダリングしてキャッシュに保存する。 */
213
341
  prefetchAll(opts?: {
214
342
  concurrency?: number;
@@ -230,12 +358,9 @@ declare class CMS<T extends BaseContentItem = BaseContentItem> {
230
358
  updated: string[];
231
359
  }>;
232
360
  /** キャッシュ優先でコンテンツ一覧を返す(SWR)。 */
233
- getList(): Promise<{
234
- items: T[];
235
- listVersion: string;
236
- }>;
361
+ private cachedList;
237
362
  /** キャッシュ優先で単一コンテンツを返す(SWR)。 */
238
- getItem(slug: string): Promise<CachedItem<T> | null>;
363
+ private cachedGet;
239
364
  checkListUpdate(version: string): Promise<{
240
365
  changed: false;
241
366
  } | {
@@ -250,6 +375,41 @@ declare class CMS<T extends BaseContentItem = BaseContentItem> {
250
375
  item: T;
251
376
  notionUpdatedAt: string;
252
377
  }>;
378
+ /** @deprecated cached.list() を使用してください。 */
379
+ getList(): Promise<{
380
+ items: T[];
381
+ listVersion: string;
382
+ }>;
383
+ /** @deprecated cached.get() を使用してください。 */
384
+ getItem(slug: string): Promise<CachedItem<T> | null>;
385
+ /** @deprecated cache.prefetchAll() を使用してください。 */
386
+ prefetchAllLegacy(opts?: {
387
+ concurrency?: number;
388
+ onProgress?: (done: number, total: number) => void;
389
+ }): Promise<{
390
+ ok: number;
391
+ failed: number;
392
+ }>;
393
+ /** @deprecated query().status(s).execute() を使用してください。 */
394
+ listByStatus(status: string | readonly string[]): Promise<T[]>;
395
+ /** @deprecated query().where(pred).execute() を使用してください。 */
396
+ where(predicate: (item: T) => boolean): Promise<T[]>;
397
+ /** @deprecated query().paginate(opts).execute() を使用してください。 */
398
+ paginate(opts: {
399
+ page: number;
400
+ perPage: number;
401
+ }): Promise<{
402
+ items: T[];
403
+ total: number;
404
+ page: number;
405
+ perPage: number;
406
+ hasNext: boolean;
407
+ }>;
408
+ /** @deprecated query().adjacent(slug) を使用してください。 */
409
+ getAdjacent(slug: string): Promise<{
410
+ prev: T | null;
411
+ next: T | null;
412
+ }>;
253
413
  /** ハッシュキーでキャッシュ画像を取得する。 */
254
414
  getCachedImage(hash: string): Promise<StorageBinary | null>;
255
415
  /** ハッシュキーでキャッシュ画像を Response として返す。 */
@@ -280,6 +440,17 @@ declare class CMSError extends Error {
280
440
  }
281
441
  declare function isCMSError(error: unknown): error is CMSError;
282
442
 
443
+ /**
444
+ * プラグイン配列とダイレクトフックを合成して単一の CMSHooks を返す。
445
+ * beforeCache / afterRender はパイプライン(前の出力が次の入力)。
446
+ * onCacheHit などオブザーバー系は全員に同じ値を渡す。
447
+ */
448
+ declare function mergeHooks<T extends BaseContentItem>(plugins: CMSPlugin<T>[], directHooks?: CMSHooks<T>): CMSHooks<T>;
449
+ /** プラグイン配列とダイレクトロガーを合成して単一の Logger を返す。 */
450
+ declare function mergeLoggers(plugins: Array<{
451
+ logger?: Partial<Logger>;
452
+ }>, directLogger?: Logger): Logger | undefined;
453
+
283
454
  /** Notionリッチテキスト配列をプレーンテキストに結合する。 */
284
455
  declare function getPlainText(items: RichTextItemResponse[] | undefined): string;
285
456
  /**
@@ -289,4 +460,15 @@ declare function getPlainText(items: RichTextItemResponse[] | undefined): string
289
460
  */
290
461
  declare function mapItem(page: PageObjectResponse, props: Required<CMSSchemaProperties>): BaseContentItem;
291
462
 
292
- export { type BaseContentItem, CMS, CMSError, type CMSErrorCode, type CMSErrorContext, type CMSSchemaProperties, type CacheConfig, type CachedItem, type CachedItemList, type ContentConfig, type CreateCMSOptions, type DataSourceAdapter, type DocumentCacheAdapter, type ImageCacheAdapter, type SchemaConfig, type StorageBinary, createCMS, getPlainText, isCMSError, isStale, mapItem, memoryCache, memoryDocumentCache, memoryImageCache, noopDocumentCache, noopImageCache, sha256Hex };
463
+ interface RetryConfig {
464
+ maxConcurrent: number;
465
+ retryOn: number[];
466
+ maxRetries: number;
467
+ baseDelayMs: number;
468
+ onRetry?: (attempt: number, status: number) => void;
469
+ }
470
+ declare const DEFAULT_RETRY_CONFIG: RetryConfig;
471
+ /** 指数バックオフでリトライする。retryOn に含まれる HTTP エラーのみ対象。 */
472
+ declare function withRetry<T>(fn: () => Promise<T>, config: RetryConfig): Promise<T>;
473
+
474
+ export { type BaseContentItem, CMS, CMSError, type CMSErrorCode, type CMSErrorContext, type CMSHooks, type CMSPlugin, type CMSSchemaProperties, type CacheConfig, type CachedItem, type CachedItemList, type ContentConfig, type CreateCMSOptions, DEFAULT_RETRY_CONFIG, type DataSourceAdapter, type DocumentCacheAdapter, type ImageCacheAdapter, type Logger, type MaybePromise, QueryBuilder, type QueryResult, type RateLimiterConfig, type RetryConfig, type SchemaConfig, type SourceQueryOptions, type SourceQueryResult, type StorageBinary, createCMS, definePlugin, getPlainText, isCMSError, isStale, mapItem, memoryCache, memoryDocumentCache, memoryImageCache, mergeHooks, mergeLoggers, noopDocumentCache, noopImageCache, sha256Hex, withRetry };
package/dist/index.js CHANGED
@@ -115,6 +115,72 @@ function isCMSError(error) {
115
115
  return error instanceof CMSError;
116
116
  }
117
117
 
118
+ // src/hooks.ts
119
+ function mergeHooks(plugins, directHooks) {
120
+ const allHooks = [
121
+ ...plugins.map((p) => p.hooks ?? {}),
122
+ ...directHooks ? [directHooks] : []
123
+ ];
124
+ if (allHooks.length === 0) return {};
125
+ return {
126
+ beforeCache: buildPipeline(allHooks, "beforeCache"),
127
+ afterRender: buildRenderPipeline(allHooks),
128
+ onCacheHit: buildObserver(allHooks, "onCacheHit"),
129
+ onCacheMiss: buildObserver(allHooks, "onCacheMiss"),
130
+ onListCacheHit: buildObserver(allHooks, "onListCacheHit"),
131
+ onListCacheMiss: buildObserver(allHooks, "onListCacheMiss"),
132
+ onError: buildObserver(allHooks, "onError")
133
+ };
134
+ }
135
+ function buildPipeline(hooks, key) {
136
+ const fns = hooks.map((h) => h[key]).filter(Boolean);
137
+ if (fns.length === 0) return void 0;
138
+ return async (item) => {
139
+ let current = item;
140
+ for (const fn of fns) {
141
+ current = await fn(current);
142
+ }
143
+ return current;
144
+ };
145
+ }
146
+ function buildRenderPipeline(hooks) {
147
+ const fns = hooks.map((h) => h.afterRender).filter(Boolean);
148
+ if (fns.length === 0) return void 0;
149
+ return async (html, item) => {
150
+ let current = html;
151
+ for (const fn of fns) {
152
+ current = await fn(current, item);
153
+ }
154
+ return current;
155
+ };
156
+ }
157
+ function buildObserver(hooks, key) {
158
+ const fns = hooks.map((h) => h[key]).filter(Boolean);
159
+ if (fns.length === 0) return void 0;
160
+ return ((...args) => {
161
+ for (const fn of fns) {
162
+ fn(...args);
163
+ }
164
+ });
165
+ }
166
+ function mergeLoggers(plugins, directLogger) {
167
+ const loggers = [
168
+ ...plugins.map((p) => p.logger ?? {}),
169
+ ...directLogger ? [directLogger] : []
170
+ ];
171
+ if (loggers.length === 0) return void 0;
172
+ const merged = {};
173
+ for (const level of ["debug", "info", "warn", "error"]) {
174
+ const fns = loggers.map((l) => l[level]).filter(Boolean);
175
+ if (fns.length > 0) {
176
+ merged[level] = (message, context) => {
177
+ for (const fn of fns) fn(message, context);
178
+ };
179
+ }
180
+ }
181
+ return merged;
182
+ }
183
+
118
184
  // src/image.ts
119
185
  function inferContentType(url, responseContentType) {
120
186
  if (responseContentType?.startsWith("image/")) {
@@ -155,11 +221,146 @@ function buildCacheImageFn(cache, imageProxyBase) {
155
221
  return (notionUrl) => fetchAndCacheImage(cache, notionUrl, imageProxyBase);
156
222
  }
157
223
 
224
+ // src/query.ts
225
+ var QueryBuilder = class {
226
+ source;
227
+ defaultStatuses;
228
+ _statuses = [];
229
+ _tags = [];
230
+ _predicate;
231
+ _sortField;
232
+ _sortDir = "asc";
233
+ _page = 1;
234
+ _perPage = 20;
235
+ constructor(source, defaultStatuses = []) {
236
+ this.source = source;
237
+ this.defaultStatuses = defaultStatuses;
238
+ }
239
+ status(s) {
240
+ this._statuses = Array.isArray(s) ? s : [s];
241
+ return this;
242
+ }
243
+ tag(t) {
244
+ this._tags = Array.isArray(t) ? t : [t];
245
+ return this;
246
+ }
247
+ where(predicate) {
248
+ this._predicate = predicate;
249
+ return this;
250
+ }
251
+ sortBy(field, dir = "asc") {
252
+ this._sortField = field;
253
+ this._sortDir = dir;
254
+ return this;
255
+ }
256
+ paginate(opts) {
257
+ this._page = opts.page;
258
+ this._perPage = opts.perPage;
259
+ return this;
260
+ }
261
+ async execute() {
262
+ const statuses = this._statuses.length > 0 ? this._statuses : this.defaultStatuses.length > 0 ? this.defaultStatuses : void 0;
263
+ if (this.source.query && !this._predicate) {
264
+ const result = await this.source.query({
265
+ filter: {
266
+ statuses,
267
+ tags: this._tags.length > 0 ? this._tags : void 0
268
+ },
269
+ sort: this._sortField ? [{ property: this._sortField, direction: this._sortDir }] : void 0,
270
+ pageSize: this._perPage
271
+ });
272
+ const items2 = result.items;
273
+ return {
274
+ items: items2,
275
+ total: items2.length,
276
+ page: this._page,
277
+ perPage: this._perPage,
278
+ hasNext: result.hasMore,
279
+ hasPrev: this._page > 1
280
+ };
281
+ }
282
+ let items = await this.source.list({
283
+ publishedStatuses: statuses
284
+ });
285
+ if (this._tags.length > 0) {
286
+ items = items.filter((item) => {
287
+ const itemTags = item.tags;
288
+ if (!Array.isArray(itemTags)) return false;
289
+ return this._tags.some((tag) => itemTags.includes(tag));
290
+ });
291
+ }
292
+ if (this._predicate) {
293
+ items = items.filter(this._predicate);
294
+ }
295
+ if (this._sortField) {
296
+ const field = this._sortField;
297
+ const dir = this._sortDir;
298
+ items = [...items].sort((a, b) => {
299
+ const av = a[field];
300
+ const bv = b[field];
301
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
302
+ return dir === "asc" ? cmp : -cmp;
303
+ });
304
+ }
305
+ const total = items.length;
306
+ const start = (this._page - 1) * this._perPage;
307
+ const paged = items.slice(start, start + this._perPage);
308
+ return {
309
+ items: paged,
310
+ total,
311
+ page: this._page,
312
+ perPage: this._perPage,
313
+ hasNext: start + this._perPage < total,
314
+ hasPrev: this._page > 1
315
+ };
316
+ }
317
+ async executeOne() {
318
+ const result = await this.execute();
319
+ return result.items[0] ?? null;
320
+ }
321
+ async adjacent(slug) {
322
+ const statuses = this._statuses.length > 0 ? this._statuses : this.defaultStatuses.length > 0 ? this.defaultStatuses : void 0;
323
+ const items = await this.source.list({ publishedStatuses: statuses });
324
+ const idx = items.findIndex((item) => item.slug === slug);
325
+ if (idx === -1) return { prev: null, next: null };
326
+ return {
327
+ prev: idx > 0 ? items[idx - 1] : null,
328
+ next: idx < items.length - 1 ? items[idx + 1] : null
329
+ };
330
+ }
331
+ };
332
+
333
+ // src/retry.ts
334
+ var DEFAULT_RETRY_CONFIG = {
335
+ maxConcurrent: 3,
336
+ retryOn: [429, 502, 503],
337
+ maxRetries: 4,
338
+ baseDelayMs: 1e3
339
+ };
340
+ async function withRetry(fn, config) {
341
+ let lastError;
342
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
343
+ try {
344
+ return await fn();
345
+ } catch (err) {
346
+ const status = err.status;
347
+ if (status === void 0 || !config.retryOn.includes(status)) {
348
+ throw err;
349
+ }
350
+ lastError = err;
351
+ if (attempt < config.maxRetries) {
352
+ config.onRetry?.(attempt + 1, status);
353
+ await new Promise(
354
+ (resolve) => setTimeout(resolve, config.baseDelayMs * 2 ** attempt)
355
+ );
356
+ }
357
+ }
358
+ }
359
+ throw lastError;
360
+ }
361
+
158
362
  // src/cms.ts
159
363
  var DEFAULT_IMAGE_PROXY_BASE = "/api/images";
160
- function buildListVersion(items) {
161
- return items.map((item) => `${item.id}:${item.updatedAt}`).join("|");
162
- }
163
364
  function resolveDocumentCache(cache) {
164
365
  if (!cache || cache.document === false || cache.document === void 0) {
165
366
  return noopDocumentCache();
@@ -183,6 +384,11 @@ var CMS = class {
183
384
  imageProxyBase;
184
385
  contentConfig;
185
386
  waitUntil;
387
+ hooks;
388
+ logger;
389
+ retryConfig;
390
+ cached;
391
+ cache;
186
392
  constructor(opts) {
187
393
  this.source = opts.source;
188
394
  this.docCache = resolveDocumentCache(opts.cache);
@@ -194,23 +400,57 @@ var CMS = class {
194
400
  this.imageProxyBase = opts.content?.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
195
401
  this.contentConfig = opts.content;
196
402
  this.waitUntil = opts.waitUntil;
403
+ this.hooks = mergeHooks(opts.plugins ?? [], opts.hooks);
404
+ this.logger = mergeLoggers(opts.plugins ?? [], opts.logger);
405
+ this.retryConfig = {
406
+ ...DEFAULT_RETRY_CONFIG,
407
+ ...opts.rateLimiter
408
+ };
409
+ this.cached = {
410
+ list: this.cachedList.bind(this),
411
+ get: this.cachedGet.bind(this)
412
+ };
413
+ this.cache = {
414
+ prefetchAll: this.prefetchAll.bind(this),
415
+ revalidate: this.revalidate.bind(this),
416
+ sync: this.syncFromWebhook.bind(this),
417
+ checkList: this.checkListUpdate.bind(this),
418
+ checkItem: this.checkItemUpdate.bind(this)
419
+ };
197
420
  }
198
421
  // ── コンテンツ取得 ──────────────────────────────────────────────────────
199
422
  /** 公開済みコンテンツ一覧をソースから直接取得する。 */
200
423
  list() {
201
- return this.source.list({
202
- publishedStatuses: this.publishedStatuses.length > 0 ? this.publishedStatuses : void 0
203
- });
424
+ return withRetry(
425
+ () => this.source.list({
426
+ publishedStatuses: this.publishedStatuses.length > 0 ? this.publishedStatuses : void 0
427
+ }),
428
+ {
429
+ ...this.retryConfig,
430
+ onRetry: (attempt, status) => {
431
+ this.logger?.warn?.("list() \u30EA\u30C8\u30E9\u30A4\u4E2D", { attempt, status });
432
+ }
433
+ }
434
+ );
204
435
  }
205
436
  /** スラッグでコンテンツをソースから直接取得する。 */
206
- async findBySlug(slug) {
207
- const item = await this.source.findBySlug(slug);
437
+ async find(slug) {
438
+ const item = await withRetry(() => this.source.findBySlug(slug), {
439
+ ...this.retryConfig,
440
+ onRetry: (attempt, status) => {
441
+ this.logger?.warn?.("find() \u30EA\u30C8\u30E9\u30A4\u4E2D", { attempt, status, slug });
442
+ }
443
+ });
208
444
  if (!item) return null;
209
445
  if (this.accessibleStatuses.length > 0 && !this.accessibleStatuses.includes(item.status)) {
210
446
  return null;
211
447
  }
212
448
  return item;
213
449
  }
450
+ /** @deprecated find() を使用してください。 */
451
+ findBySlug(slug) {
452
+ return this.find(slug);
453
+ }
214
454
  /** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
215
455
  isPublished(item) {
216
456
  if (this.publishedStatuses.length === 0) return true;
@@ -220,57 +460,9 @@ var CMS = class {
220
460
  async render(item) {
221
461
  return this.buildCachedItem(item);
222
462
  }
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));
245
- }
246
- /** 任意の条件でフィルタリングした一覧を返す。 */
247
- async where(predicate) {
248
- 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
- };
264
- }
265
- /** 指定スラッグの前後コンテンツを返す。 */
266
- async getAdjacent(slug) {
267
- 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
- };
463
+ /** QueryBuilder を返す。ステータス・タグ・ページネーションなどを連鎖で指定できる。 */
464
+ query() {
465
+ return new QueryBuilder(this.source, this.publishedStatuses);
274
466
  }
275
467
  /** 全コンテンツを事前レンダリングしてキャッシュに保存する。 */
276
468
  async prefetchAll(opts) {
@@ -310,7 +502,7 @@ var CMS = class {
310
502
  async syncFromWebhook(payload) {
311
503
  const updated = [];
312
504
  if (payload?.slug) {
313
- const item = await this.findBySlug(payload.slug);
505
+ const item = await this.find(payload.slug);
314
506
  if (item) {
315
507
  const rendered = await this.buildCachedItem(item);
316
508
  await this.docCache.setItem(item.slug, rendered);
@@ -325,31 +517,36 @@ var CMS = class {
325
517
  }
326
518
  return { updated };
327
519
  }
328
- // ── キャッシュ優先取得(Stale-While-Revalidate) ─────────────────────
520
+ // ── キャッシュ優先取得(Stale-While-Revalidate) ──────────────────────
329
521
  /** キャッシュ優先でコンテンツ一覧を返す(SWR)。 */
330
- async getList() {
522
+ async cachedList() {
331
523
  const cached = await this.docCache.getList();
332
524
  if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
333
- return {
334
- items: cached.items,
335
- listVersion: buildListVersion(cached.items)
336
- };
525
+ this.hooks.onListCacheHit?.(cached.items, cached.cachedAt);
526
+ return { items: cached.items, isStale: false, cachedAt: cached.cachedAt };
337
527
  }
528
+ this.hooks.onListCacheMiss?.();
338
529
  const items = await this.list();
339
- const save = this.docCache.setList({ items, cachedAt: Date.now() });
530
+ const cachedAt = Date.now();
531
+ const save = this.docCache.setList({ items, cachedAt });
340
532
  if (this.waitUntil) {
341
533
  this.waitUntil(save);
342
534
  } else {
343
535
  await save;
344
536
  }
345
- return { items, listVersion: buildListVersion(items) };
537
+ return { items, isStale: !!cached, cachedAt };
346
538
  }
347
539
  /** キャッシュ優先で単一コンテンツを返す(SWR)。 */
348
- async getItem(slug) {
540
+ async cachedGet(slug) {
349
541
  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;
542
+ if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
543
+ this.hooks.onCacheHit?.(slug, cached);
544
+ return cached;
545
+ }
546
+ this.hooks.onCacheMiss?.(slug);
547
+ const item = await this.find(slug);
548
+ if (!item) return null;
549
+ const entry = await this.buildCachedItem(item);
353
550
  const save = this.docCache.setItem(slug, entry);
354
551
  if (this.waitUntil) {
355
552
  this.waitUntil(save);
@@ -366,12 +563,11 @@ var CMS = class {
366
563
  return { changed: true, items };
367
564
  }
368
565
  async checkItemUpdate(slug, lastEdited) {
369
- const item = await this.findBySlug(slug);
566
+ const item = await this.find(slug);
370
567
  if (!item) return { changed: false };
371
568
  if (!this.isPublished(item)) return { changed: false };
372
569
  if (item.updatedAt === lastEdited) return { changed: false };
373
- const entry = await this.renderBySlug(slug);
374
- if (!entry) return { changed: false };
570
+ const entry = await this.buildCachedItem(item);
375
571
  await this.docCache.setItem(slug, entry);
376
572
  return {
377
573
  changed: true,
@@ -380,6 +576,45 @@ var CMS = class {
380
576
  notionUpdatedAt: entry.notionUpdatedAt
381
577
  };
382
578
  }
579
+ // ── 後方互換 SWR ────────────────────────────────────────────────────────
580
+ /** @deprecated cached.list() を使用してください。 */
581
+ async getList() {
582
+ const result = await this.cachedList();
583
+ return { items: result.items, listVersion: buildListVersion(result.items) };
584
+ }
585
+ /** @deprecated cached.get() を使用してください。 */
586
+ getItem(slug) {
587
+ return this.cachedGet(slug);
588
+ }
589
+ /** @deprecated cache.prefetchAll() を使用してください。 */
590
+ async prefetchAllLegacy(opts) {
591
+ return this.prefetchAll(opts);
592
+ }
593
+ // ── 後方互換クエリ API ──────────────────────────────────────────────────
594
+ /** @deprecated query().status(s).execute() を使用してください。 */
595
+ async listByStatus(status) {
596
+ const statuses = Array.isArray(status) ? status : [status];
597
+ return this.query().status(statuses).execute().then((r) => r.items);
598
+ }
599
+ /** @deprecated query().where(pred).execute() を使用してください。 */
600
+ async where(predicate) {
601
+ return this.query().where(predicate).execute().then((r) => r.items);
602
+ }
603
+ /** @deprecated query().paginate(opts).execute() を使用してください。 */
604
+ async paginate(opts) {
605
+ const result = await this.query().paginate(opts).execute();
606
+ return {
607
+ items: result.items,
608
+ total: result.total,
609
+ page: result.page,
610
+ perPage: result.perPage,
611
+ hasNext: result.hasNext
612
+ };
613
+ }
614
+ /** @deprecated query().adjacent(slug) を使用してください。 */
615
+ getAdjacent(slug) {
616
+ return this.query().adjacent(slug);
617
+ }
383
618
  // ── 画像配信 ───────────────────────────────────────────────────────────
384
619
  /** ハッシュキーでキャッシュ画像を取得する。 */
385
620
  getCachedImage(hash) {
@@ -396,6 +631,11 @@ var CMS = class {
396
631
  }
397
632
  // ── プライベートヘルパー ────────────────────────────────────────────────
398
633
  async buildCachedItem(item) {
634
+ const start = Date.now();
635
+ this.logger?.info?.("\u30B3\u30F3\u30C6\u30F3\u30C4\u306E\u30EC\u30F3\u30C0\u30EA\u30F3\u30B0\u958B\u59CB", {
636
+ slug: item.slug,
637
+ pageId: item.id
638
+ });
399
639
  let markdown;
400
640
  try {
401
641
  markdown = await this.source.loadMarkdown(item);
@@ -435,14 +675,28 @@ var CMS = class {
435
675
  }
436
676
  });
437
677
  }
438
- return {
678
+ if (this.hooks.afterRender) {
679
+ html = await this.hooks.afterRender(html, item);
680
+ }
681
+ let result = {
439
682
  html,
440
683
  item,
441
684
  notionUpdatedAt: item.updatedAt,
442
685
  cachedAt: Date.now()
443
686
  };
687
+ if (this.hooks.beforeCache) {
688
+ result = await this.hooks.beforeCache(result);
689
+ }
690
+ this.logger?.info?.("\u30B3\u30F3\u30C6\u30F3\u30C4\u306E\u30EC\u30F3\u30C0\u30EA\u30F3\u30B0\u5B8C\u4E86", {
691
+ slug: item.slug,
692
+ durationMs: Date.now() - start
693
+ });
694
+ return result;
444
695
  }
445
696
  };
697
+ function buildListVersion(items) {
698
+ return items.map((item) => `${item.id}:${item.updatedAt}`).join("|");
699
+ }
446
700
  function createCMS(opts) {
447
701
  return new CMS(opts);
448
702
  }
@@ -484,10 +738,18 @@ function mapItem(page, props) {
484
738
  }
485
739
  return parsed.data;
486
740
  }
741
+
742
+ // src/types/plugin.ts
743
+ function definePlugin(plugin) {
744
+ return plugin;
745
+ }
487
746
  export {
488
747
  CMS,
489
748
  CMSError,
749
+ DEFAULT_RETRY_CONFIG,
750
+ QueryBuilder,
490
751
  createCMS,
752
+ definePlugin,
491
753
  getPlainText,
492
754
  isCMSError,
493
755
  isStale,
@@ -495,7 +757,10 @@ export {
495
757
  memoryCache,
496
758
  memoryDocumentCache,
497
759
  memoryImageCache,
760
+ mergeHooks,
761
+ mergeLoggers,
498
762
  noopDocumentCache,
499
763
  noopImageCache,
500
- sha256Hex
764
+ sha256Hex,
765
+ withRetry
501
766
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notion-headless-cms/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Core CMS engine for notion-headless-cms — fetch, transform, cache with stale-while-revalidate strategy",
5
5
  "keywords": [
6
6
  "notion",
@@ -43,7 +43,7 @@
43
43
  "unified": "^11.0.5",
44
44
  "zod": "^4.1.12",
45
45
  "@notion-headless-cms/fetcher": "0.1.0",
46
- "@notion-headless-cms/transformer": "0.1.0",
46
+ "@notion-headless-cms/transformer": "0.1.1",
47
47
  "@notion-headless-cms/renderer": "0.1.0"
48
48
  },
49
49
  "scripts": {