@notion-headless-cms/core 0.0.4 → 0.0.6

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.
@@ -0,0 +1,233 @@
1
+ import { RendererFn } from '@notion-headless-cms/renderer';
2
+ import { BlockHandler } from '@notion-headless-cms/transformer';
3
+ import { PageObjectResponse, RichTextItemResponse } from '@notionhq/client/build/src/api-endpoints';
4
+ import { PluggableList } from 'unified';
5
+
6
+ /**
7
+ * ライブラリが動作するために必須なフィールド。
8
+ * 利用者はこのインターフェースを拡張して独自のコンテンツ型を定義する。
9
+ *
10
+ * @example
11
+ * interface Post extends BaseContentItem {
12
+ * title: string;
13
+ * author: string;
14
+ * }
15
+ * createCMS<Post>({ schema: { mapItem: (page) => ... } })
16
+ */
17
+ interface BaseContentItem {
18
+ id: string;
19
+ slug: string;
20
+ status: string;
21
+ publishedAt: string;
22
+ updatedAt: string;
23
+ }
24
+ /** ストレージにキャッシュされたレンダリング済みコンテンツ。 */
25
+ interface CachedItem<T extends BaseContentItem = BaseContentItem> {
26
+ html: string;
27
+ item: T;
28
+ notionUpdatedAt: string;
29
+ cachedAt: number;
30
+ }
31
+ /** ストレージにキャッシュされたコンテンツ一覧。 */
32
+ interface CachedItemList<T extends BaseContentItem = BaseContentItem> {
33
+ items: T[];
34
+ cachedAt: number;
35
+ }
36
+ /** ストレージから取得したバイナリオブジェクト。 */
37
+ interface StorageBinary {
38
+ data: ArrayBuffer;
39
+ contentType?: string;
40
+ }
41
+ /** CMSコアが依存するストレージ抽象。 */
42
+ interface StorageAdapter {
43
+ get(key: string): Promise<ArrayBuffer | null>;
44
+ put(key: string, data: ArrayBuffer | ArrayBufferView | string, options?: {
45
+ contentType?: string;
46
+ }): Promise<void>;
47
+ json<T>(key: string): Promise<T | null>;
48
+ binary(key: string): Promise<StorageBinary | null>;
49
+ }
50
+ /** ライブラリが必要とする最小限の環境バインディング。 */
51
+ interface CMSEnv {
52
+ NOTION_TOKEN: string;
53
+ NOTION_DATA_SOURCE_ID: string;
54
+ }
55
+ /** Notionのプロパティ名マッピング(すべてオプション)。 */
56
+ interface CMSSchemaProperties {
57
+ /** Notionのスラッグプロパティ名。デフォルト: 'Slug' */
58
+ slug?: string;
59
+ /** Notionのステータスプロパティ名。デフォルト: 'Status' */
60
+ status?: string;
61
+ /** Notionの公開日プロパティ名。デフォルト: 'CreatedAt' */
62
+ date?: string;
63
+ }
64
+ /**
65
+ * CMSの設定オブジェクト。
66
+ * ジェネリクス型 T にカスタムコンテンツ型を指定できる(デフォルト: BaseContentItem)。
67
+ */
68
+ interface CMSConfig<T extends BaseContentItem = BaseContentItem> {
69
+ /** Notion API 認証情報。コンストラクタでクライアントを事前生成するために使用。 */
70
+ env?: CMSEnv;
71
+ /** キャッシュ/画像保存用ストレージ。未設定時はキャッシュ機能を無効化。 */
72
+ storage?: StorageAdapter;
73
+ schema?: {
74
+ /**
75
+ * Notionページをコンテンツ型 T にマッピングするカスタム関数。
76
+ * 指定した場合 properties の設定は無視される(slug プロパティ名のみ例外)。
77
+ */
78
+ mapItem?: (page: PageObjectResponse) => T;
79
+ /** mapItem 未使用時のプロパティ名マッピング。 */
80
+ properties?: CMSSchemaProperties;
81
+ /** getItems() で返す「公開済み」ステータス値の配列。デフォルト: [] (全件返す) */
82
+ publishedStatuses?: string[];
83
+ /** getItemBySlug() でアクセス可能なステータス値の配列。デフォルト: [] (全件許可) */
84
+ accessibleStatuses?: string[];
85
+ };
86
+ transformer?: {
87
+ /** カスタムブロックハンドラーのマップ。Notionブロックタイプをキーとする。 */
88
+ blocks?: Record<string, BlockHandler>;
89
+ };
90
+ renderer?: {
91
+ /** 画像プロキシのベースURL。デフォルト: '/api/images' */
92
+ imageProxyBase?: string;
93
+ /** 追加する remark プラグイン。 */
94
+ remarkPlugins?: PluggableList;
95
+ /** 追加する rehype プラグイン。 */
96
+ rehypePlugins?: PluggableList;
97
+ /** デフォルトのパイプラインを置き換えるカスタムレンダラー。 */
98
+ render?: RendererFn;
99
+ };
100
+ cache?: {
101
+ /** コンテンツ一覧のキャッシュキー。デフォルト: 'content.json' */
102
+ listKey?: string;
103
+ /** 個別コンテンツのキャッシュキープレフィックス。デフォルト: 'content/' */
104
+ itemPrefix?: string;
105
+ /** 画像キャッシュのキープレフィックス。デフォルト: 'images/' */
106
+ imagePrefix?: string;
107
+ /** キャッシュの有効期間(ミリ秒)。未設定の場合はTTLなし。 */
108
+ ttlMs?: number;
109
+ };
110
+ }
111
+
112
+ /** 文字列をSHA-256でハッシュ化し、16進数文字列として返す。画像キーの生成に使用。 */
113
+ declare function sha256Hex(input: string): Promise<string>;
114
+ /**
115
+ * キャッシュが有効期限切れかどうかを判定する。
116
+ * ttlMs が未指定の場合は常に false(無期限有効)を返す。
117
+ */
118
+ declare function isStale(cachedAt: number, ttlMs?: number): boolean;
119
+ /** ストレージキャッシュ操作をまとめたヘルパークラス。キープレフィックスは設定で変更可能。 */
120
+ declare class CacheStore<T extends BaseContentItem = BaseContentItem> {
121
+ private readonly storage?;
122
+ private readonly listKey;
123
+ private readonly itemPrefix;
124
+ private readonly imagePrefix;
125
+ constructor(storage: StorageAdapter | undefined, listKey: string, itemPrefix: string, imagePrefix: string);
126
+ getItemList(): Promise<CachedItemList<T> | null>;
127
+ setItemList(items: T[]): Promise<void>;
128
+ getItem(slug: string): Promise<CachedItem<T> | null>;
129
+ setItem(slug: string, data: CachedItem<T>): Promise<void>;
130
+ getImage(hash: string): Promise<StorageBinary | null>;
131
+ setImage(hash: string, data: ArrayBuffer, contentType: string): Promise<void>;
132
+ }
133
+
134
+ /**
135
+ * Notion をバックエンドとして使う汎用ヘッドレス CMSクラス。
136
+ *
137
+ * @example
138
+ * const cms = createCMS({
139
+ * env: { NOTION_TOKEN: '...', NOTION_DATA_SOURCE_ID: '...' },
140
+ * schema: { publishedStatuses: ['Published'] }
141
+ * });
142
+ * const items = await cms.getItems();
143
+ */
144
+ declare class CMS<T extends BaseContentItem = BaseContentItem> {
145
+ private readonly itemMapper;
146
+ private readonly slugPropName;
147
+ private readonly publishedStatuses;
148
+ private readonly accessibleStatuses;
149
+ private readonly imageProxyBase;
150
+ private readonly transformerConfig;
151
+ private readonly rendererConfig;
152
+ private readonly store;
153
+ private readonly hasStorage;
154
+ private readonly ttlMs;
155
+ private readonly client;
156
+ private readonly dataSourceId;
157
+ constructor(config?: CMSConfig<T>);
158
+ private requireClient;
159
+ /** 公開済みコンテンツ一覧を Notion から直接取得する。 */
160
+ getItems(): Promise<T[]>;
161
+ /** スラッグでコンテンツを Notion から直接取得する。 */
162
+ getItemBySlug(slug: string): Promise<T | null>;
163
+ /** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
164
+ isPublished(item: T): boolean;
165
+ /** コンテンツをMarkdown→HTMLにレンダリングし、CachedItemとして返す。 */
166
+ renderItem(item: T): Promise<CachedItem<T>>;
167
+ /** スラッグでコンテンツを取得してMarkdown→HTMLにレンダリングする。 */
168
+ renderItemBySlug(slug: string): Promise<CachedItem<T> | null>;
169
+ getCachedItemList(): Promise<CachedItemList<T> | null>;
170
+ setCachedItemList(items: T[]): Promise<void>;
171
+ getCachedItem(slug: string): Promise<CachedItem<T> | null>;
172
+ setCachedItem(slug: string, data: CachedItem<T>): Promise<void>;
173
+ getCachedImage(hash: string): Promise<StorageBinary | null>;
174
+ createCachedImageResponse(hash: string): Promise<Response | null>;
175
+ getItemsCachedFirst(options?: {
176
+ waitUntil?: (promise: Promise<void>) => void;
177
+ }): Promise<{
178
+ items: T[];
179
+ listVersion: string;
180
+ }>;
181
+ getItemCachedFirst(slug: string, options?: {
182
+ waitUntil?: (promise: Promise<void>) => void;
183
+ }): Promise<CachedItem<T> | null>;
184
+ checkItemsUpdate(clientVersion: string): Promise<{
185
+ changed: false;
186
+ } | {
187
+ changed: true;
188
+ items: T[];
189
+ }>;
190
+ checkItemUpdate(slug: string, lastEdited: string): Promise<{
191
+ changed: false;
192
+ } | {
193
+ changed: true;
194
+ html: string;
195
+ item: T;
196
+ notionUpdatedAt: string;
197
+ }>;
198
+ private buildCachedItem;
199
+ }
200
+ /** 設定済みのCMSインスタンスを生成するファクトリ関数。 */
201
+ declare function createCMS<T extends BaseContentItem = BaseContentItem>(config?: CMSConfig<T>): CMS<T>;
202
+
203
+ type CMSErrorCode = "CONFIG_INVALID" | "NOTION_ITEM_SCHEMA_INVALID" | "NOTION_FETCH_ITEMS_FAILED" | "NOTION_FETCH_ITEM_BY_SLUG_FAILED" | "NOTION_GET_BLOCKS_FAILED" | "NOTION_MARKDOWN_FETCH_FAILED" | "IMAGE_CACHE_FAILED" | "RENDERER_FAILED";
204
+ interface CMSErrorContext {
205
+ operation: string;
206
+ slug?: string;
207
+ dataSourceId?: string;
208
+ pageId?: string;
209
+ [key: string]: string | number | boolean | null | undefined;
210
+ }
211
+ declare class CMSError extends Error {
212
+ readonly code: CMSErrorCode;
213
+ readonly cause?: unknown;
214
+ readonly context: CMSErrorContext;
215
+ constructor(params: {
216
+ code: CMSErrorCode;
217
+ message: string;
218
+ cause?: unknown;
219
+ context: CMSErrorContext;
220
+ });
221
+ }
222
+ declare function isCMSError(error: unknown): error is CMSError;
223
+
224
+ /** Notionリッチテキスト配列をプレーンテキストに結合する。 */
225
+ declare function getPlainText(items: RichTextItemResponse[] | undefined): string;
226
+ /**
227
+ * NotionのPageObjectResponseをデフォルトの BaseContentItem に変換する。
228
+ * 独自の拡張型(title などを含む)が必要な場合は、本関数の戻り値に
229
+ * 追加フィールドを足してカスタム mapItem を実装する。
230
+ */
231
+ declare function mapItem(page: PageObjectResponse, props: Required<CMSSchemaProperties>): BaseContentItem;
232
+
233
+ export { type BaseContentItem, CMS, type CMSConfig, type CMSEnv, CMSError, type CMSErrorCode, type CMSErrorContext, type CMSSchemaProperties, CacheStore, type CachedItem, type CachedItemList, type StorageAdapter, type StorageBinary, createCMS, getPlainText, isCMSError, isStale, mapItem, sha256Hex };
package/dist/index.js ADDED
@@ -0,0 +1,465 @@
1
+ // src/cache.ts
2
+ async function sha256Hex(input) {
3
+ const data = new TextEncoder().encode(input);
4
+ const hash = await crypto.subtle.digest("SHA-256", data);
5
+ return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
6
+ }
7
+ function isStale(cachedAt, ttlMs) {
8
+ if (ttlMs === void 0) return false;
9
+ return Date.now() - cachedAt > ttlMs;
10
+ }
11
+ var CacheStore = class {
12
+ storage;
13
+ listKey;
14
+ itemPrefix;
15
+ imagePrefix;
16
+ constructor(storage, listKey, itemPrefix, imagePrefix) {
17
+ this.storage = storage;
18
+ this.listKey = listKey;
19
+ this.itemPrefix = itemPrefix;
20
+ this.imagePrefix = imagePrefix;
21
+ }
22
+ getItemList() {
23
+ if (!this.storage) return Promise.resolve(null);
24
+ return this.storage.json(this.listKey);
25
+ }
26
+ async setItemList(items) {
27
+ if (!this.storage) return;
28
+ const data = { items, cachedAt: Date.now() };
29
+ await this.storage.put(this.listKey, JSON.stringify(data), {
30
+ contentType: "application/json"
31
+ });
32
+ }
33
+ getItem(slug) {
34
+ if (!this.storage) return Promise.resolve(null);
35
+ return this.storage.json(`${this.itemPrefix}${slug}.json`);
36
+ }
37
+ async setItem(slug, data) {
38
+ if (!this.storage) return;
39
+ await this.storage.put(
40
+ `${this.itemPrefix}${slug}.json`,
41
+ JSON.stringify(data),
42
+ { contentType: "application/json" }
43
+ );
44
+ }
45
+ getImage(hash) {
46
+ if (!this.storage) return Promise.resolve(null);
47
+ return this.storage.binary(`${this.imagePrefix}${hash}`);
48
+ }
49
+ async setImage(hash, data, contentType) {
50
+ if (!this.storage) return;
51
+ await this.storage.put(`${this.imagePrefix}${hash}`, data, { contentType });
52
+ }
53
+ };
54
+
55
+ // src/cms.ts
56
+ import {
57
+ createClient,
58
+ queryAllPages,
59
+ queryPageBySlug
60
+ } from "@notion-headless-cms/fetcher";
61
+ import { renderMarkdown } from "@notion-headless-cms/renderer";
62
+ import { Transformer } from "@notion-headless-cms/transformer";
63
+
64
+ // src/errors.ts
65
+ var CMSError = class extends Error {
66
+ code;
67
+ cause;
68
+ context;
69
+ constructor(params) {
70
+ super(params.message, { cause: params.cause });
71
+ this.name = "CMSError";
72
+ this.code = params.code;
73
+ this.cause = params.cause;
74
+ this.context = params.context;
75
+ }
76
+ };
77
+ function isCMSError(error) {
78
+ return error instanceof CMSError;
79
+ }
80
+
81
+ // src/image.ts
82
+ function inferContentType(url, responseContentType) {
83
+ if (responseContentType?.startsWith("image/")) {
84
+ return responseContentType.split(";")[0].trim();
85
+ }
86
+ if (url.includes(".png")) return "image/png";
87
+ if (url.includes(".gif")) return "image/gif";
88
+ if (url.includes(".webp")) return "image/webp";
89
+ return "image/jpeg";
90
+ }
91
+ async function fetchAndCacheImage(store, notionUrl, imageProxyBase) {
92
+ const hash = await sha256Hex(notionUrl);
93
+ const proxyUrl = `${imageProxyBase}/${hash}`;
94
+ const existing = await store.getImage(hash);
95
+ if (existing) return proxyUrl;
96
+ try {
97
+ const response = await fetch(notionUrl, {
98
+ signal: AbortSignal.timeout(1e4)
99
+ });
100
+ if (!response.ok) return proxyUrl;
101
+ const data = await response.arrayBuffer();
102
+ const contentType = inferContentType(
103
+ notionUrl,
104
+ response.headers.get("content-type")
105
+ );
106
+ await store.setImage(hash, data, contentType);
107
+ } catch (err) {
108
+ throw new CMSError({
109
+ code: "IMAGE_CACHE_FAILED",
110
+ message: "Failed to fetch or cache Notion image.",
111
+ cause: err,
112
+ context: { operation: "fetchAndCacheImage", notionUrl }
113
+ });
114
+ }
115
+ return proxyUrl;
116
+ }
117
+ function buildCacheImageFn(store, imageProxyBase) {
118
+ return (notionUrl) => fetchAndCacheImage(store, notionUrl, imageProxyBase);
119
+ }
120
+
121
+ // src/mapper.ts
122
+ import { z } from "zod";
123
+ var baseContentItemSchema = z.object({
124
+ id: z.string().min(1),
125
+ slug: z.string(),
126
+ status: z.string(),
127
+ publishedAt: z.string().min(1),
128
+ updatedAt: z.string().min(1)
129
+ });
130
+ function getPlainText(items) {
131
+ return items?.map((item) => item.plain_text).join("") ?? "";
132
+ }
133
+ function mapItem(page, props) {
134
+ const statusProperty = page.properties[props.status];
135
+ const dateProperty = page.properties[props.date];
136
+ const parsed = baseContentItemSchema.safeParse({
137
+ id: page.id,
138
+ slug: getPlainText(
139
+ page.properties[props.slug]?.rich_text
140
+ ),
141
+ status: statusProperty?.status?.name ?? statusProperty?.select?.name ?? "",
142
+ publishedAt: dateProperty?.date?.start ?? page.created_time,
143
+ updatedAt: page.last_edited_time
144
+ });
145
+ if (!parsed.success) {
146
+ throw new CMSError({
147
+ code: "NOTION_ITEM_SCHEMA_INVALID",
148
+ message: "Failed to parse Notion page into BaseContentItem.",
149
+ context: {
150
+ operation: "mapItem",
151
+ pageId: page.id,
152
+ issues: JSON.stringify(parsed.error.issues)
153
+ }
154
+ });
155
+ }
156
+ return parsed.data;
157
+ }
158
+
159
+ // src/cms.ts
160
+ var DEFAULT_PROPERTIES = {
161
+ slug: "Slug",
162
+ status: "Status",
163
+ date: "CreatedAt"
164
+ };
165
+ var DEFAULT_IMAGE_PROXY_BASE = "/api/images";
166
+ var DEFAULT_LIST_KEY = "content.json";
167
+ var DEFAULT_ITEM_PREFIX = "content/";
168
+ var DEFAULT_IMAGE_PREFIX = "images/";
169
+ function buildListVersion(items) {
170
+ return items.map((item) => `${item.id}:${item.updatedAt}`).join("|");
171
+ }
172
+ function validateEnv(env) {
173
+ if (!env.NOTION_TOKEN || !env.NOTION_DATA_SOURCE_ID) {
174
+ throw new CMSError({
175
+ code: "CONFIG_INVALID",
176
+ message: "NOTION_TOKEN and NOTION_DATA_SOURCE_ID are required to use Notion CMS APIs.",
177
+ context: {
178
+ operation: "validateEnv",
179
+ hasNotionToken: !!env.NOTION_TOKEN,
180
+ hasNotionDataSourceId: !!env.NOTION_DATA_SOURCE_ID
181
+ }
182
+ });
183
+ }
184
+ return { dataSourceId: env.NOTION_DATA_SOURCE_ID };
185
+ }
186
+ var CMS = class {
187
+ itemMapper;
188
+ slugPropName;
189
+ publishedStatuses;
190
+ accessibleStatuses;
191
+ imageProxyBase;
192
+ transformerConfig;
193
+ rendererConfig;
194
+ store;
195
+ hasStorage;
196
+ ttlMs;
197
+ client;
198
+ dataSourceId;
199
+ constructor(config) {
200
+ const props = {
201
+ ...DEFAULT_PROPERTIES,
202
+ ...config?.schema?.properties
203
+ };
204
+ this.slugPropName = props.slug;
205
+ if (config?.schema?.mapItem) {
206
+ this.itemMapper = config.schema.mapItem;
207
+ } else {
208
+ this.itemMapper = (page) => mapItem(page, props);
209
+ }
210
+ this.publishedStatuses = config?.schema?.publishedStatuses ?? [];
211
+ this.accessibleStatuses = config?.schema?.accessibleStatuses ?? [];
212
+ this.imageProxyBase = config?.renderer?.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
213
+ this.transformerConfig = config?.transformer;
214
+ this.rendererConfig = config?.renderer;
215
+ this.hasStorage = !!config?.storage;
216
+ this.ttlMs = config?.cache?.ttlMs;
217
+ this.store = new CacheStore(
218
+ config?.storage,
219
+ config?.cache?.listKey ?? DEFAULT_LIST_KEY,
220
+ config?.cache?.itemPrefix ?? DEFAULT_ITEM_PREFIX,
221
+ config?.cache?.imagePrefix ?? DEFAULT_IMAGE_PREFIX
222
+ );
223
+ if (config?.env) {
224
+ const { dataSourceId } = validateEnv(config.env);
225
+ this.client = createClient(config.env);
226
+ this.dataSourceId = dataSourceId;
227
+ }
228
+ }
229
+ // ── プライベートヘルパー(認証) ──────────────────────────────────────
230
+ requireClient() {
231
+ if (!this.client || !this.dataSourceId) {
232
+ throw new CMSError({
233
+ code: "CONFIG_INVALID",
234
+ message: "NOTION_TOKEN \u3068 NOTION_DATA_SOURCE_ID \u306F CMS \u306E\u8A2D\u5B9A\u306B\u5FC5\u8981\u3067\u3059\u3002",
235
+ context: { operation: "requireClient" }
236
+ });
237
+ }
238
+ return { client: this.client, dataSourceId: this.dataSourceId };
239
+ }
240
+ // ── コンテンツ取得 ────────────────────────────────────────────────────
241
+ /** 公開済みコンテンツ一覧を Notion から直接取得する。 */
242
+ async getItems() {
243
+ const { client, dataSourceId } = this.requireClient();
244
+ try {
245
+ const pages = await queryAllPages(client, dataSourceId);
246
+ const items = pages.map(this.itemMapper);
247
+ const filtered = this.publishedStatuses.length > 0 ? items.filter((item) => this.publishedStatuses.includes(item.status)) : items;
248
+ return filtered.sort(
249
+ (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
250
+ );
251
+ } catch (err) {
252
+ if (isCMSError(err)) throw err;
253
+ throw new CMSError({
254
+ code: "NOTION_FETCH_ITEMS_FAILED",
255
+ message: "Failed to fetch items from Notion data source.",
256
+ cause: err,
257
+ context: { operation: "getItems", dataSourceId }
258
+ });
259
+ }
260
+ }
261
+ /** スラッグでコンテンツを Notion から直接取得する。 */
262
+ async getItemBySlug(slug) {
263
+ const { client, dataSourceId } = this.requireClient();
264
+ try {
265
+ const page = await queryPageBySlug(
266
+ client,
267
+ dataSourceId,
268
+ slug,
269
+ this.slugPropName
270
+ );
271
+ if (!page) return null;
272
+ const item = this.itemMapper(page);
273
+ if (this.accessibleStatuses.length > 0 && !this.accessibleStatuses.includes(item.status)) {
274
+ return null;
275
+ }
276
+ return item;
277
+ } catch (err) {
278
+ if (isCMSError(err)) throw err;
279
+ throw new CMSError({
280
+ code: "NOTION_FETCH_ITEM_BY_SLUG_FAILED",
281
+ message: "Failed to fetch item by slug from Notion data source.",
282
+ cause: err,
283
+ context: { operation: "getItemBySlug", dataSourceId, slug }
284
+ });
285
+ }
286
+ }
287
+ /** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
288
+ isPublished(item) {
289
+ if (this.publishedStatuses.length === 0) return true;
290
+ return this.publishedStatuses.includes(item.status);
291
+ }
292
+ /** コンテンツをMarkdown→HTMLにレンダリングし、CachedItemとして返す。 */
293
+ async renderItem(item) {
294
+ const { client } = this.requireClient();
295
+ return this.buildCachedItem(client, item);
296
+ }
297
+ /** スラッグでコンテンツを取得してMarkdown→HTMLにレンダリングする。 */
298
+ async renderItemBySlug(slug) {
299
+ const { client, dataSourceId } = this.requireClient();
300
+ try {
301
+ const page = await queryPageBySlug(
302
+ client,
303
+ dataSourceId,
304
+ slug,
305
+ this.slugPropName
306
+ );
307
+ if (!page) return null;
308
+ const item = this.itemMapper(page);
309
+ if (this.accessibleStatuses.length > 0 && !this.accessibleStatuses.includes(item.status)) {
310
+ return null;
311
+ }
312
+ return this.buildCachedItem(client, item);
313
+ } catch (err) {
314
+ if (isCMSError(err)) throw err;
315
+ throw new CMSError({
316
+ code: "NOTION_FETCH_ITEM_BY_SLUG_FAILED",
317
+ message: "Failed to fetch item by slug from Notion data source.",
318
+ cause: err,
319
+ context: { operation: "renderItemBySlug", dataSourceId, slug }
320
+ });
321
+ }
322
+ }
323
+ // ── キャッシュ操作 ─────────────────────────────────────────────────────
324
+ getCachedItemList() {
325
+ return this.store.getItemList();
326
+ }
327
+ setCachedItemList(items) {
328
+ return this.store.setItemList(items);
329
+ }
330
+ getCachedItem(slug) {
331
+ return this.store.getItem(slug);
332
+ }
333
+ setCachedItem(slug, data) {
334
+ return this.store.setItem(slug, data);
335
+ }
336
+ getCachedImage(hash) {
337
+ return this.store.getImage(hash);
338
+ }
339
+ async createCachedImageResponse(hash) {
340
+ const object = await this.store.getImage(hash);
341
+ if (!object) return null;
342
+ const headers = new Headers();
343
+ if (object.contentType) headers.set("content-type", object.contentType);
344
+ headers.set("cache-control", "public, max-age=31536000, immutable");
345
+ return new Response(object.data, { headers });
346
+ }
347
+ // ── キャッシュ優先取得(Stale-While-Revalidate) ─────────────────────
348
+ async getItemsCachedFirst(options) {
349
+ const cached = await this.store.getItemList();
350
+ if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
351
+ return {
352
+ items: cached.items,
353
+ listVersion: buildListVersion(cached.items)
354
+ };
355
+ }
356
+ const items = await this.getItems();
357
+ const save = this.store.setItemList(items);
358
+ if (options?.waitUntil) {
359
+ options.waitUntil(save);
360
+ } else {
361
+ await save;
362
+ }
363
+ return { items, listVersion: buildListVersion(items) };
364
+ }
365
+ async getItemCachedFirst(slug, options) {
366
+ const cached = await this.store.getItem(slug);
367
+ if (cached && !isStale(cached.cachedAt, this.ttlMs)) return cached;
368
+ const entry = await this.renderItemBySlug(slug);
369
+ if (!entry) return null;
370
+ const save = this.store.setItem(slug, entry);
371
+ if (options?.waitUntil) {
372
+ options.waitUntil(save);
373
+ } else {
374
+ await save;
375
+ }
376
+ return entry;
377
+ }
378
+ async checkItemsUpdate(clientVersion) {
379
+ const items = await this.getItems();
380
+ const serverVersion = buildListVersion(items);
381
+ if (serverVersion === clientVersion) return { changed: false };
382
+ await this.store.setItemList(items);
383
+ return { changed: true, items };
384
+ }
385
+ async checkItemUpdate(slug, lastEdited) {
386
+ const item = await this.getItemBySlug(slug);
387
+ if (!item) return { changed: false };
388
+ if (!this.isPublished(item)) return { changed: false };
389
+ if (item.updatedAt === lastEdited) return { changed: false };
390
+ const entry = await this.renderItemBySlug(slug);
391
+ if (!entry) return { changed: false };
392
+ await this.store.setItem(slug, entry);
393
+ return {
394
+ changed: true,
395
+ html: entry.html,
396
+ item: entry.item,
397
+ notionUpdatedAt: entry.notionUpdatedAt
398
+ };
399
+ }
400
+ // ── プライベートヘルパー ───────────────────────────────────────────────
401
+ async buildCachedItem(client, item) {
402
+ const transformer = new Transformer(
403
+ this.transformerConfig?.blocks ? { blocks: this.transformerConfig.blocks } : void 0
404
+ );
405
+ let markdown;
406
+ try {
407
+ markdown = await transformer.transform(client, item.id);
408
+ } catch (err) {
409
+ if (isCMSError(err)) throw err;
410
+ throw new CMSError({
411
+ code: "NOTION_MARKDOWN_FETCH_FAILED",
412
+ message: "Failed to load markdown from Notion.",
413
+ cause: err,
414
+ context: {
415
+ operation: "buildCachedItem:transform",
416
+ pageId: item.id,
417
+ slug: item.slug
418
+ }
419
+ });
420
+ }
421
+ const cacheImage = this.hasStorage ? buildCacheImageFn(this.store, this.imageProxyBase) : void 0;
422
+ let html;
423
+ try {
424
+ html = await renderMarkdown(markdown, {
425
+ imageProxyBase: this.imageProxyBase,
426
+ cacheImage,
427
+ remarkPlugins: this.rendererConfig?.remarkPlugins,
428
+ rehypePlugins: this.rendererConfig?.rehypePlugins,
429
+ render: this.rendererConfig?.render
430
+ });
431
+ } catch (err) {
432
+ if (isCMSError(err)) throw err;
433
+ throw new CMSError({
434
+ code: "RENDERER_FAILED",
435
+ message: "Failed to render markdown.",
436
+ cause: err,
437
+ context: {
438
+ operation: "buildCachedItem:renderMarkdown",
439
+ pageId: item.id,
440
+ slug: item.slug
441
+ }
442
+ });
443
+ }
444
+ return {
445
+ html,
446
+ item,
447
+ notionUpdatedAt: item.updatedAt,
448
+ cachedAt: Date.now()
449
+ };
450
+ }
451
+ };
452
+ function createCMS(config) {
453
+ return new CMS(config);
454
+ }
455
+ export {
456
+ CMS,
457
+ CMSError,
458
+ CacheStore,
459
+ createCMS,
460
+ getPlainText,
461
+ isCMSError,
462
+ isStale,
463
+ mapItem,
464
+ sha256Hex
465
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notion-headless-cms/core",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Core CMS engine for notion-headless-cms — fetch, transform, cache with stale-while-revalidate strategy",
5
5
  "keywords": [
6
6
  "notion",
@@ -39,12 +39,12 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@notion-headless-cms/fetcher": "latest",
43
- "@notion-headless-cms/transformer": "latest",
44
- "@notion-headless-cms/renderer": "latest",
45
42
  "@notionhq/client": "^5.18.0",
46
43
  "unified": "^11.0.5",
47
- "zod": "^4.1.12"
44
+ "zod": "^4.1.12",
45
+ "@notion-headless-cms/fetcher": "0.0.4",
46
+ "@notion-headless-cms/transformer": "0.0.4",
47
+ "@notion-headless-cms/renderer": "0.0.4"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsup src/index.ts --format esm --dts --out-dir dist",