@notion-headless-cms/core 0.0.6 → 0.0.7
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.d.ts +174 -115
- package/dist/index.js +307 -271
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { BlockHandler } from '@notion-headless-cms/transformer';
|
|
|
3
3
|
import { PageObjectResponse, RichTextItemResponse } from '@notionhq/client/build/src/api-endpoints';
|
|
4
4
|
import { PluggableList } from 'unified';
|
|
5
5
|
|
|
6
|
+
/** 文字列をSHA-256でハッシュ化し、16進数文字列として返す。画像キーの生成に使用。 */
|
|
7
|
+
declare function sha256Hex(input: string): Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* キャッシュが有効期限切れかどうかを判定する。
|
|
10
|
+
* ttlMs が未指定の場合は常に false(無期限有効)を返す。
|
|
11
|
+
*/
|
|
12
|
+
declare function isStale(cachedAt: number, ttlMs?: number): boolean;
|
|
13
|
+
|
|
6
14
|
/**
|
|
7
15
|
* ライブラリが動作するために必須なフィールド。
|
|
8
16
|
* 利用者はこのインターフェースを拡張して独自のコンテンツ型を定義する。
|
|
@@ -12,7 +20,7 @@ import { PluggableList } from 'unified';
|
|
|
12
20
|
* title: string;
|
|
13
21
|
* author: string;
|
|
14
22
|
* }
|
|
15
|
-
* createCMS<Post>({
|
|
23
|
+
* createCMS<Post>({ source: notionAdapter({ ... }) })
|
|
16
24
|
*/
|
|
17
25
|
interface BaseContentItem {
|
|
18
26
|
id: string;
|
|
@@ -38,20 +46,6 @@ interface StorageBinary {
|
|
|
38
46
|
data: ArrayBuffer;
|
|
39
47
|
contentType?: string;
|
|
40
48
|
}
|
|
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
49
|
/** Notionのプロパティ名マッピング(すべてオプション)。 */
|
|
56
50
|
interface CMSSchemaProperties {
|
|
57
51
|
/** Notionのスラッグプロパティ名。デフォルト: 'Slug' */
|
|
@@ -61,127 +55,188 @@ interface CMSSchemaProperties {
|
|
|
61
55
|
/** Notionの公開日プロパティ名。デフォルト: 'CreatedAt' */
|
|
62
56
|
date?: string;
|
|
63
57
|
}
|
|
58
|
+
|
|
59
|
+
/** ドキュメントキャッシュを抽象化するインターフェース。 */
|
|
60
|
+
interface DocumentCacheAdapter<T extends BaseContentItem = BaseContentItem> {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
getList(): Promise<CachedItemList<T> | null>;
|
|
63
|
+
setList(data: CachedItemList<T>): Promise<void>;
|
|
64
|
+
getItem(slug: string): Promise<CachedItem<T> | null>;
|
|
65
|
+
setItem(slug: string, data: CachedItem<T>): Promise<void>;
|
|
66
|
+
invalidate?(scope: "all" | {
|
|
67
|
+
slug: string;
|
|
68
|
+
} | {
|
|
69
|
+
tag: string;
|
|
70
|
+
}): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/** 画像キャッシュを抽象化するインターフェース。 */
|
|
73
|
+
interface ImageCacheAdapter {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
get(hash: string): Promise<StorageBinary | null>;
|
|
76
|
+
set(hash: string, data: ArrayBuffer, contentType: string): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/** キャッシュ設定オブジェクト。document / image それぞれ独立したアダプタを差し込める。 */
|
|
79
|
+
interface CacheConfig<T extends BaseContentItem = BaseContentItem> {
|
|
80
|
+
document?: DocumentCacheAdapter<T> | false;
|
|
81
|
+
image?: ImageCacheAdapter | false;
|
|
82
|
+
/** キャッシュの有効期間(ミリ秒)。未設定の場合はTTLなし。 */
|
|
83
|
+
ttlMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
87
|
+
* コンテンツソース(Notion など)を抽象化するインターフェース。
|
|
88
|
+
* core は Notion の知識を持たず、DataSourceAdapter 経由でのみデータを取得する。
|
|
67
89
|
*/
|
|
68
|
-
interface
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
};
|
|
90
|
+
interface DataSourceAdapter<T extends BaseContentItem = BaseContentItem> {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly publishedStatuses?: readonly string[];
|
|
93
|
+
readonly accessibleStatuses?: readonly string[];
|
|
94
|
+
list(opts?: {
|
|
95
|
+
publishedStatuses?: readonly string[];
|
|
96
|
+
}): Promise<T[]>;
|
|
97
|
+
findBySlug(slug: string): Promise<T | null>;
|
|
98
|
+
loadMarkdown(item: T): Promise<string>;
|
|
110
99
|
}
|
|
111
100
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
101
|
+
/** スキーマ設定。公開ステータスのフィルタやプロパティ名マッピングを制御する。 */
|
|
102
|
+
interface SchemaConfig<T extends BaseContentItem = BaseContentItem> {
|
|
103
|
+
/**
|
|
104
|
+
* Notionページをコンテンツ型 T にマッピングするカスタム関数。
|
|
105
|
+
* 指定した場合 properties の設定は無視される(slug プロパティ名のみ例外)。
|
|
106
|
+
*/
|
|
107
|
+
mapItem?: (page: PageObjectResponse) => T;
|
|
108
|
+
/** mapItem 未使用時のプロパティ名マッピング。 */
|
|
109
|
+
properties?: CMSSchemaProperties;
|
|
110
|
+
/** list() で返す「公開済み」ステータス値の配列。デフォルト: [] (全件返す) */
|
|
111
|
+
publishedStatuses?: string[];
|
|
112
|
+
/** findBySlug() でアクセス可能なステータス値の配列。デフォルト: [] (全件許可) */
|
|
113
|
+
accessibleStatuses?: string[];
|
|
114
|
+
}
|
|
115
|
+
/** レンダリング・コンテンツ処理設定。 */
|
|
116
|
+
interface ContentConfig {
|
|
117
|
+
/** 画像プロキシのベースURL。デフォルト: '/api/images' */
|
|
118
|
+
imageProxyBase?: string;
|
|
119
|
+
/** 追加する remark プラグイン。 */
|
|
120
|
+
remarkPlugins?: PluggableList;
|
|
121
|
+
/** 追加する rehype プラグイン。 */
|
|
122
|
+
rehypePlugins?: PluggableList;
|
|
123
|
+
/** デフォルトのパイプラインを置き換えるカスタムレンダラー。 */
|
|
124
|
+
render?: RendererFn;
|
|
125
|
+
/** カスタムブロックハンドラーのマップ。Notionブロックタイプをキーとする。 */
|
|
126
|
+
blocks?: Record<string, BlockHandler>;
|
|
127
|
+
}
|
|
114
128
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
129
|
+
* createCMS() に渡すオプション。
|
|
130
|
+
* ジェネリクス型 T にカスタムコンテンツ型を指定できる(デフォルト: BaseContentItem)。
|
|
117
131
|
*/
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
interface CreateCMSOptions<T extends BaseContentItem = BaseContentItem> {
|
|
133
|
+
/** データソースアダプタ(Notion など)。 */
|
|
134
|
+
source: DataSourceAdapter<T>;
|
|
135
|
+
/** キャッシュ設定。未設定時はキャッシュなし。 */
|
|
136
|
+
cache?: CacheConfig<T>;
|
|
137
|
+
/** スキーマ・ステータス設定。 */
|
|
138
|
+
schema?: SchemaConfig<T>;
|
|
139
|
+
/** レンダリング・コンテンツ処理設定。 */
|
|
140
|
+
content?: ContentConfig;
|
|
141
|
+
/** Cloudflare Workers の waitUntil に相当する非同期処理の登録関数。 */
|
|
142
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
132
143
|
}
|
|
133
144
|
|
|
145
|
+
/** インメモリキャッシュ(ドキュメント用)を生成する。 */
|
|
146
|
+
declare function memoryDocumentCache<T extends BaseContentItem = BaseContentItem>(): DocumentCacheAdapter<T>;
|
|
147
|
+
/** インメモリキャッシュ(画像用)を生成する。 */
|
|
148
|
+
declare function memoryImageCache(): ImageCacheAdapter;
|
|
134
149
|
/**
|
|
135
|
-
*
|
|
150
|
+
* ドキュメントと画像の両方にインメモリキャッシュを返す便利関数。
|
|
151
|
+
* memoryCache() はドキュメントキャッシュを返す(後方互換)。
|
|
152
|
+
*/
|
|
153
|
+
declare function memoryCache<T extends BaseContentItem = BaseContentItem>(): DocumentCacheAdapter<T>;
|
|
154
|
+
|
|
155
|
+
/** 何もしないドキュメントキャッシュを返す(シングルトン)。 */
|
|
156
|
+
declare function noopDocumentCache<T extends BaseContentItem = BaseContentItem>(): DocumentCacheAdapter<T>;
|
|
157
|
+
/** 何もしない画像キャッシュを返す(シングルトン)。 */
|
|
158
|
+
declare function noopImageCache(): ImageCacheAdapter;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Notion をバックエンドとして使う汎用ヘッドレス CMS クラス。
|
|
136
162
|
*
|
|
137
163
|
* @example
|
|
138
164
|
* const cms = createCMS({
|
|
139
|
-
*
|
|
140
|
-
* schema: { publishedStatuses: ['
|
|
165
|
+
* source: notionAdapter({ token: '...', dataSourceId: '...' }),
|
|
166
|
+
* schema: { publishedStatuses: ['公開'] },
|
|
141
167
|
* });
|
|
142
|
-
* const items = await cms.
|
|
168
|
+
* const items = await cms.list();
|
|
143
169
|
*/
|
|
144
170
|
declare class CMS<T extends BaseContentItem = BaseContentItem> {
|
|
145
|
-
private readonly
|
|
146
|
-
private readonly
|
|
171
|
+
private readonly source;
|
|
172
|
+
private readonly docCache;
|
|
173
|
+
private readonly imgCache;
|
|
174
|
+
private readonly hasImageCache;
|
|
175
|
+
private readonly ttlMs;
|
|
147
176
|
private readonly publishedStatuses;
|
|
148
177
|
private readonly accessibleStatuses;
|
|
149
178
|
private readonly imageProxyBase;
|
|
150
|
-
private readonly
|
|
151
|
-
private readonly
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
constructor(config?: CMSConfig<T>);
|
|
158
|
-
private requireClient;
|
|
159
|
-
/** 公開済みコンテンツ一覧を Notion から直接取得する。 */
|
|
160
|
-
getItems(): Promise<T[]>;
|
|
161
|
-
/** スラッグでコンテンツを Notion から直接取得する。 */
|
|
162
|
-
getItemBySlug(slug: string): Promise<T | null>;
|
|
179
|
+
private readonly contentConfig;
|
|
180
|
+
private readonly waitUntil;
|
|
181
|
+
constructor(opts: CreateCMSOptions<T>);
|
|
182
|
+
/** 公開済みコンテンツ一覧をソースから直接取得する。 */
|
|
183
|
+
list(): Promise<T[]>;
|
|
184
|
+
/** スラッグでコンテンツをソースから直接取得する。 */
|
|
185
|
+
findBySlug(slug: string): Promise<T | null>;
|
|
163
186
|
/** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
|
|
164
187
|
isPublished(item: T): boolean;
|
|
165
|
-
/** コンテンツをMarkdown→HTMLにレンダリングし、CachedItemとして返す。 */
|
|
166
|
-
|
|
167
|
-
/** スラッグでコンテンツを取得してMarkdown→HTMLにレンダリングする。 */
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
/** コンテンツを Markdown → HTML にレンダリングし、CachedItem として返す。 */
|
|
189
|
+
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
|
+
}>;
|
|
212
|
+
/** 全コンテンツを事前レンダリングしてキャッシュに保存する。 */
|
|
213
|
+
prefetchAll(opts?: {
|
|
214
|
+
concurrency?: number;
|
|
215
|
+
onProgress?: (done: number, total: number) => void;
|
|
216
|
+
}): Promise<{
|
|
217
|
+
ok: number;
|
|
218
|
+
failed: number;
|
|
219
|
+
}>;
|
|
220
|
+
/** 静的生成用のスラッグ一覧を返す。 */
|
|
221
|
+
getStaticSlugs(): Promise<string[]>;
|
|
222
|
+
/** 指定スコープのキャッシュを無効化する。 */
|
|
223
|
+
revalidate(scope?: "all" | {
|
|
224
|
+
slug: string;
|
|
225
|
+
}): Promise<void>;
|
|
226
|
+
/** Webhook ペイロードを元にキャッシュを同期する。 */
|
|
227
|
+
syncFromWebhook(payload?: {
|
|
228
|
+
slug?: string;
|
|
177
229
|
}): Promise<{
|
|
230
|
+
updated: string[];
|
|
231
|
+
}>;
|
|
232
|
+
/** キャッシュ優先でコンテンツ一覧を返す(SWR)。 */
|
|
233
|
+
getList(): Promise<{
|
|
178
234
|
items: T[];
|
|
179
235
|
listVersion: string;
|
|
180
236
|
}>;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
checkItemsUpdate(clientVersion: string): Promise<{
|
|
237
|
+
/** キャッシュ優先で単一コンテンツを返す(SWR)。 */
|
|
238
|
+
getItem(slug: string): Promise<CachedItem<T> | null>;
|
|
239
|
+
checkListUpdate(version: string): Promise<{
|
|
185
240
|
changed: false;
|
|
186
241
|
} | {
|
|
187
242
|
changed: true;
|
|
@@ -195,10 +250,14 @@ declare class CMS<T extends BaseContentItem = BaseContentItem> {
|
|
|
195
250
|
item: T;
|
|
196
251
|
notionUpdatedAt: string;
|
|
197
252
|
}>;
|
|
253
|
+
/** ハッシュキーでキャッシュ画像を取得する。 */
|
|
254
|
+
getCachedImage(hash: string): Promise<StorageBinary | null>;
|
|
255
|
+
/** ハッシュキーでキャッシュ画像を Response として返す。 */
|
|
256
|
+
createCachedImageResponse(hash: string): Promise<Response | null>;
|
|
198
257
|
private buildCachedItem;
|
|
199
258
|
}
|
|
200
|
-
/** 設定済みのCMSインスタンスを生成するファクトリ関数。 */
|
|
201
|
-
declare function createCMS<T extends BaseContentItem = BaseContentItem>(
|
|
259
|
+
/** 設定済みの CMS インスタンスを生成するファクトリ関数。 */
|
|
260
|
+
declare function createCMS<T extends BaseContentItem = BaseContentItem>(opts: CreateCMSOptions<T>): CMS<T>;
|
|
202
261
|
|
|
203
262
|
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
263
|
interface CMSErrorContext {
|
|
@@ -230,4 +289,4 @@ declare function getPlainText(items: RichTextItemResponse[] | undefined): string
|
|
|
230
289
|
*/
|
|
231
290
|
declare function mapItem(page: PageObjectResponse, props: Required<CMSSchemaProperties>): BaseContentItem;
|
|
232
291
|
|
|
233
|
-
export { type BaseContentItem, CMS, type
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -8,58 +8,95 @@ function isStale(cachedAt, ttlMs) {
|
|
|
8
8
|
if (ttlMs === void 0) return false;
|
|
9
9
|
return Date.now() - cachedAt > ttlMs;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
});
|
|
11
|
+
|
|
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();
|
|
32
23
|
}
|
|
33
24
|
getItem(slug) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
await this.storage.put(
|
|
40
|
-
`${this.itemPrefix}${slug}.json`,
|
|
41
|
-
JSON.stringify(data),
|
|
42
|
-
{ contentType: "application/json" }
|
|
43
|
-
);
|
|
25
|
+
return Promise.resolve(this.items.get(slug) ?? null);
|
|
26
|
+
}
|
|
27
|
+
setItem(slug, data) {
|
|
28
|
+
this.items.set(slug, data);
|
|
29
|
+
return Promise.resolve();
|
|
44
30
|
}
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
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);
|
|
48
84
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
await this.storage.put(`${this.imagePrefix}${hash}`, data, { contentType });
|
|
85
|
+
set(_hash, _data, _contentType) {
|
|
86
|
+
return Promise.resolve();
|
|
52
87
|
}
|
|
53
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
|
+
}
|
|
54
97
|
|
|
55
98
|
// src/cms.ts
|
|
56
|
-
import {
|
|
57
|
-
createClient,
|
|
58
|
-
queryAllPages,
|
|
59
|
-
queryPageBySlug
|
|
60
|
-
} from "@notion-headless-cms/fetcher";
|
|
61
99
|
import { renderMarkdown } from "@notion-headless-cms/renderer";
|
|
62
|
-
import { Transformer } from "@notion-headless-cms/transformer";
|
|
63
100
|
|
|
64
101
|
// src/errors.ts
|
|
65
102
|
var CMSError = class extends Error {
|
|
@@ -88,10 +125,10 @@ function inferContentType(url, responseContentType) {
|
|
|
88
125
|
if (url.includes(".webp")) return "image/webp";
|
|
89
126
|
return "image/jpeg";
|
|
90
127
|
}
|
|
91
|
-
async function fetchAndCacheImage(
|
|
128
|
+
async function fetchAndCacheImage(cache, notionUrl, imageProxyBase) {
|
|
92
129
|
const hash = await sha256Hex(notionUrl);
|
|
93
130
|
const proxyUrl = `${imageProxyBase}/${hash}`;
|
|
94
|
-
const existing = await
|
|
131
|
+
const existing = await cache.get(hash);
|
|
95
132
|
if (existing) return proxyUrl;
|
|
96
133
|
try {
|
|
97
134
|
const response = await fetch(notionUrl, {
|
|
@@ -103,7 +140,7 @@ async function fetchAndCacheImage(store, notionUrl, imageProxyBase) {
|
|
|
103
140
|
notionUrl,
|
|
104
141
|
response.headers.get("content-type")
|
|
105
142
|
);
|
|
106
|
-
await
|
|
143
|
+
await cache.set(hash, data, contentType);
|
|
107
144
|
} catch (err) {
|
|
108
145
|
throw new CMSError({
|
|
109
146
|
code: "IMAGE_CACHE_FAILED",
|
|
@@ -114,282 +151,228 @@ async function fetchAndCacheImage(store, notionUrl, imageProxyBase) {
|
|
|
114
151
|
}
|
|
115
152
|
return proxyUrl;
|
|
116
153
|
}
|
|
117
|
-
function buildCacheImageFn(
|
|
118
|
-
return (notionUrl) => fetchAndCacheImage(
|
|
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;
|
|
154
|
+
function buildCacheImageFn(cache, imageProxyBase) {
|
|
155
|
+
return (notionUrl) => fetchAndCacheImage(cache, notionUrl, imageProxyBase);
|
|
157
156
|
}
|
|
158
157
|
|
|
159
158
|
// src/cms.ts
|
|
160
|
-
var DEFAULT_PROPERTIES = {
|
|
161
|
-
slug: "Slug",
|
|
162
|
-
status: "Status",
|
|
163
|
-
date: "CreatedAt"
|
|
164
|
-
};
|
|
165
159
|
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
160
|
function buildListVersion(items) {
|
|
170
161
|
return items.map((item) => `${item.id}:${item.updatedAt}`).join("|");
|
|
171
162
|
}
|
|
172
|
-
function
|
|
173
|
-
if (!
|
|
174
|
-
|
|
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
|
-
});
|
|
163
|
+
function resolveDocumentCache(cache) {
|
|
164
|
+
if (!cache || cache.document === false || cache.document === void 0) {
|
|
165
|
+
return noopDocumentCache();
|
|
183
166
|
}
|
|
184
|
-
return
|
|
167
|
+
return cache.document;
|
|
168
|
+
}
|
|
169
|
+
function resolveImageCache(cache) {
|
|
170
|
+
if (!cache || cache.image === false || cache.image === void 0) {
|
|
171
|
+
return noopImageCache();
|
|
172
|
+
}
|
|
173
|
+
return cache.image;
|
|
185
174
|
}
|
|
186
175
|
var CMS = class {
|
|
187
|
-
|
|
188
|
-
|
|
176
|
+
source;
|
|
177
|
+
docCache;
|
|
178
|
+
imgCache;
|
|
179
|
+
hasImageCache;
|
|
180
|
+
ttlMs;
|
|
189
181
|
publishedStatuses;
|
|
190
182
|
accessibleStatuses;
|
|
191
183
|
imageProxyBase;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
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 };
|
|
184
|
+
contentConfig;
|
|
185
|
+
waitUntil;
|
|
186
|
+
constructor(opts) {
|
|
187
|
+
this.source = opts.source;
|
|
188
|
+
this.docCache = resolveDocumentCache(opts.cache);
|
|
189
|
+
this.imgCache = resolveImageCache(opts.cache);
|
|
190
|
+
this.hasImageCache = !!opts.cache?.image;
|
|
191
|
+
this.ttlMs = opts.cache?.ttlMs;
|
|
192
|
+
this.publishedStatuses = opts.schema?.publishedStatuses ?? (opts.source.publishedStatuses ? [...opts.source.publishedStatuses] : []);
|
|
193
|
+
this.accessibleStatuses = opts.schema?.accessibleStatuses ?? (opts.source.accessibleStatuses ? [...opts.source.accessibleStatuses] : []);
|
|
194
|
+
this.imageProxyBase = opts.content?.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
|
|
195
|
+
this.contentConfig = opts.content;
|
|
196
|
+
this.waitUntil = opts.waitUntil;
|
|
239
197
|
}
|
|
240
|
-
// ── コンテンツ取得
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
}
|
|
198
|
+
// ── コンテンツ取得 ──────────────────────────────────────────────────────
|
|
199
|
+
/** 公開済みコンテンツ一覧をソースから直接取得する。 */
|
|
200
|
+
list() {
|
|
201
|
+
return this.source.list({
|
|
202
|
+
publishedStatuses: this.publishedStatuses.length > 0 ? this.publishedStatuses : void 0
|
|
203
|
+
});
|
|
260
204
|
}
|
|
261
|
-
/**
|
|
262
|
-
async
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
});
|
|
205
|
+
/** スラッグでコンテンツをソースから直接取得する。 */
|
|
206
|
+
async findBySlug(slug) {
|
|
207
|
+
const item = await this.source.findBySlug(slug);
|
|
208
|
+
if (!item) return null;
|
|
209
|
+
if (this.accessibleStatuses.length > 0 && !this.accessibleStatuses.includes(item.status)) {
|
|
210
|
+
return null;
|
|
285
211
|
}
|
|
212
|
+
return item;
|
|
286
213
|
}
|
|
287
214
|
/** アイテムが publishedStatuses に含まれるステータスかどうかを返す。 */
|
|
288
215
|
isPublished(item) {
|
|
289
216
|
if (this.publishedStatuses.length === 0) return true;
|
|
290
217
|
return this.publishedStatuses.includes(item.status);
|
|
291
218
|
}
|
|
292
|
-
/** コンテンツをMarkdown→HTMLにレンダリングし、CachedItemとして返す。 */
|
|
293
|
-
async
|
|
294
|
-
|
|
295
|
-
return this.buildCachedItem(client, item);
|
|
219
|
+
/** コンテンツを Markdown → HTML にレンダリングし、CachedItem として返す。 */
|
|
220
|
+
async render(item) {
|
|
221
|
+
return this.buildCachedItem(item);
|
|
296
222
|
}
|
|
297
|
-
/** スラッグでコンテンツを取得してMarkdown→HTMLにレンダリングする。 */
|
|
298
|
-
async
|
|
299
|
-
const { client, dataSourceId } = this.requireClient();
|
|
223
|
+
/** スラッグでコンテンツを取得して Markdown → HTML にレンダリングする。 */
|
|
224
|
+
async renderBySlug(slug) {
|
|
300
225
|
try {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
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);
|
|
226
|
+
const item = await this.findBySlug(slug);
|
|
227
|
+
if (!item) return null;
|
|
228
|
+
return this.buildCachedItem(item);
|
|
313
229
|
} catch (err) {
|
|
314
230
|
if (isCMSError(err)) throw err;
|
|
315
231
|
throw new CMSError({
|
|
316
232
|
code: "NOTION_FETCH_ITEM_BY_SLUG_FAILED",
|
|
317
233
|
message: "Failed to fetch item by slug from Notion data source.",
|
|
318
234
|
cause: err,
|
|
319
|
-
context: { operation: "
|
|
235
|
+
context: { operation: "renderBySlug", slug }
|
|
320
236
|
});
|
|
321
237
|
}
|
|
322
238
|
}
|
|
323
|
-
// ──
|
|
324
|
-
|
|
325
|
-
|
|
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));
|
|
326
245
|
}
|
|
327
|
-
|
|
328
|
-
|
|
246
|
+
/** 任意の条件でフィルタリングした一覧を返す。 */
|
|
247
|
+
async where(predicate) {
|
|
248
|
+
const items = await this.list();
|
|
249
|
+
return items.filter(predicate);
|
|
329
250
|
}
|
|
330
|
-
|
|
331
|
-
|
|
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
|
+
};
|
|
332
264
|
}
|
|
333
|
-
|
|
334
|
-
|
|
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
|
+
};
|
|
335
274
|
}
|
|
336
|
-
|
|
337
|
-
|
|
275
|
+
/** 全コンテンツを事前レンダリングしてキャッシュに保存する。 */
|
|
276
|
+
async prefetchAll(opts) {
|
|
277
|
+
const items = await this.list();
|
|
278
|
+
const concurrency = opts?.concurrency ?? 3;
|
|
279
|
+
let ok = 0;
|
|
280
|
+
let failed = 0;
|
|
281
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
282
|
+
const chunk = items.slice(i, i + concurrency);
|
|
283
|
+
await Promise.all(
|
|
284
|
+
chunk.map(async (item) => {
|
|
285
|
+
try {
|
|
286
|
+
const rendered = await this.buildCachedItem(item);
|
|
287
|
+
await this.docCache.setItem(item.slug, rendered);
|
|
288
|
+
ok++;
|
|
289
|
+
} catch {
|
|
290
|
+
failed++;
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
opts?.onProgress?.(Math.min(i + concurrency, items.length), items.length);
|
|
295
|
+
}
|
|
296
|
+
await this.docCache.setList({ items, cachedAt: Date.now() });
|
|
297
|
+
return { ok, failed };
|
|
338
298
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
299
|
+
/** 静的生成用のスラッグ一覧を返す。 */
|
|
300
|
+
async getStaticSlugs() {
|
|
301
|
+
const items = await this.list();
|
|
302
|
+
return items.map((item) => item.slug);
|
|
303
|
+
}
|
|
304
|
+
/** 指定スコープのキャッシュを無効化する。 */
|
|
305
|
+
async revalidate(scope) {
|
|
306
|
+
if (!this.docCache.invalidate) return;
|
|
307
|
+
await this.docCache.invalidate(scope ?? "all");
|
|
308
|
+
}
|
|
309
|
+
/** Webhook ペイロードを元にキャッシュを同期する。 */
|
|
310
|
+
async syncFromWebhook(payload) {
|
|
311
|
+
const updated = [];
|
|
312
|
+
if (payload?.slug) {
|
|
313
|
+
const item = await this.findBySlug(payload.slug);
|
|
314
|
+
if (item) {
|
|
315
|
+
const rendered = await this.buildCachedItem(item);
|
|
316
|
+
await this.docCache.setItem(item.slug, rendered);
|
|
317
|
+
updated.push(item.slug);
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
const result = await this.prefetchAll();
|
|
321
|
+
if (result.ok > 0) {
|
|
322
|
+
const items = await this.list();
|
|
323
|
+
for (const item of items) updated.push(item.slug);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { updated };
|
|
346
327
|
}
|
|
347
328
|
// ── キャッシュ優先取得(Stale-While-Revalidate) ─────────────────────
|
|
348
|
-
|
|
349
|
-
|
|
329
|
+
/** キャッシュ優先でコンテンツ一覧を返す(SWR)。 */
|
|
330
|
+
async getList() {
|
|
331
|
+
const cached = await this.docCache.getList();
|
|
350
332
|
if (cached && !isStale(cached.cachedAt, this.ttlMs)) {
|
|
351
333
|
return {
|
|
352
334
|
items: cached.items,
|
|
353
335
|
listVersion: buildListVersion(cached.items)
|
|
354
336
|
};
|
|
355
337
|
}
|
|
356
|
-
const items = await this.
|
|
357
|
-
const save = this.
|
|
358
|
-
if (
|
|
359
|
-
|
|
338
|
+
const items = await this.list();
|
|
339
|
+
const save = this.docCache.setList({ items, cachedAt: Date.now() });
|
|
340
|
+
if (this.waitUntil) {
|
|
341
|
+
this.waitUntil(save);
|
|
360
342
|
} else {
|
|
361
343
|
await save;
|
|
362
344
|
}
|
|
363
345
|
return { items, listVersion: buildListVersion(items) };
|
|
364
346
|
}
|
|
365
|
-
|
|
366
|
-
|
|
347
|
+
/** キャッシュ優先で単一コンテンツを返す(SWR)。 */
|
|
348
|
+
async getItem(slug) {
|
|
349
|
+
const cached = await this.docCache.getItem(slug);
|
|
367
350
|
if (cached && !isStale(cached.cachedAt, this.ttlMs)) return cached;
|
|
368
|
-
const entry = await this.
|
|
351
|
+
const entry = await this.renderBySlug(slug);
|
|
369
352
|
if (!entry) return null;
|
|
370
|
-
const save = this.
|
|
371
|
-
if (
|
|
372
|
-
|
|
353
|
+
const save = this.docCache.setItem(slug, entry);
|
|
354
|
+
if (this.waitUntil) {
|
|
355
|
+
this.waitUntil(save);
|
|
373
356
|
} else {
|
|
374
357
|
await save;
|
|
375
358
|
}
|
|
376
359
|
return entry;
|
|
377
360
|
}
|
|
378
|
-
async
|
|
379
|
-
const items = await this.
|
|
361
|
+
async checkListUpdate(version) {
|
|
362
|
+
const items = await this.list();
|
|
380
363
|
const serverVersion = buildListVersion(items);
|
|
381
|
-
if (serverVersion ===
|
|
382
|
-
await this.
|
|
364
|
+
if (serverVersion === version) return { changed: false };
|
|
365
|
+
await this.docCache.setList({ items, cachedAt: Date.now() });
|
|
383
366
|
return { changed: true, items };
|
|
384
367
|
}
|
|
385
368
|
async checkItemUpdate(slug, lastEdited) {
|
|
386
|
-
const item = await this.
|
|
369
|
+
const item = await this.findBySlug(slug);
|
|
387
370
|
if (!item) return { changed: false };
|
|
388
371
|
if (!this.isPublished(item)) return { changed: false };
|
|
389
372
|
if (item.updatedAt === lastEdited) return { changed: false };
|
|
390
|
-
const entry = await this.
|
|
373
|
+
const entry = await this.renderBySlug(slug);
|
|
391
374
|
if (!entry) return { changed: false };
|
|
392
|
-
await this.
|
|
375
|
+
await this.docCache.setItem(slug, entry);
|
|
393
376
|
return {
|
|
394
377
|
changed: true,
|
|
395
378
|
html: entry.html,
|
|
@@ -397,36 +380,47 @@ var CMS = class {
|
|
|
397
380
|
notionUpdatedAt: entry.notionUpdatedAt
|
|
398
381
|
};
|
|
399
382
|
}
|
|
400
|
-
// ──
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
// ── プライベートヘルパー ────────────────────────────────────────────────
|
|
398
|
+
async buildCachedItem(item) {
|
|
405
399
|
let markdown;
|
|
406
400
|
try {
|
|
407
|
-
markdown = await
|
|
401
|
+
markdown = await this.source.loadMarkdown(item);
|
|
408
402
|
} catch (err) {
|
|
409
403
|
if (isCMSError(err)) throw err;
|
|
410
404
|
throw new CMSError({
|
|
411
405
|
code: "NOTION_MARKDOWN_FETCH_FAILED",
|
|
412
|
-
message: "Failed to load markdown from
|
|
406
|
+
message: "Failed to load markdown from source.",
|
|
413
407
|
cause: err,
|
|
414
408
|
context: {
|
|
415
|
-
operation: "buildCachedItem:
|
|
409
|
+
operation: "buildCachedItem:loadMarkdown",
|
|
416
410
|
pageId: item.id,
|
|
417
411
|
slug: item.slug
|
|
418
412
|
}
|
|
419
413
|
});
|
|
420
414
|
}
|
|
421
|
-
const cacheImage = this.
|
|
415
|
+
const cacheImage = this.hasImageCache ? buildCacheImageFn(this.imgCache, this.imageProxyBase) : void 0;
|
|
422
416
|
let html;
|
|
423
417
|
try {
|
|
424
418
|
html = await renderMarkdown(markdown, {
|
|
425
419
|
imageProxyBase: this.imageProxyBase,
|
|
426
420
|
cacheImage,
|
|
427
|
-
remarkPlugins: this.
|
|
428
|
-
rehypePlugins: this.
|
|
429
|
-
render: this.
|
|
421
|
+
remarkPlugins: this.contentConfig?.remarkPlugins,
|
|
422
|
+
rehypePlugins: this.contentConfig?.rehypePlugins,
|
|
423
|
+
render: this.contentConfig?.render
|
|
430
424
|
});
|
|
431
425
|
} catch (err) {
|
|
432
426
|
if (isCMSError(err)) throw err;
|
|
@@ -449,17 +443,59 @@ var CMS = class {
|
|
|
449
443
|
};
|
|
450
444
|
}
|
|
451
445
|
};
|
|
452
|
-
function createCMS(
|
|
453
|
-
return new CMS(
|
|
446
|
+
function createCMS(opts) {
|
|
447
|
+
return new CMS(opts);
|
|
448
|
+
}
|
|
449
|
+
|
|
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;
|
|
454
486
|
}
|
|
455
487
|
export {
|
|
456
488
|
CMS,
|
|
457
489
|
CMSError,
|
|
458
|
-
CacheStore,
|
|
459
490
|
createCMS,
|
|
460
491
|
getPlainText,
|
|
461
492
|
isCMSError,
|
|
462
493
|
isStale,
|
|
463
494
|
mapItem,
|
|
495
|
+
memoryCache,
|
|
496
|
+
memoryDocumentCache,
|
|
497
|
+
memoryImageCache,
|
|
498
|
+
noopDocumentCache,
|
|
499
|
+
noopImageCache,
|
|
464
500
|
sha256Hex
|
|
465
501
|
};
|