@notion-headless-cms/core 0.1.2 → 0.1.3
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/cache/memory.d.mts +52 -0
- package/dist/cache/memory.mjs +112 -0
- package/dist/cache/memory.mjs.map +1 -0
- package/dist/cache/{noop.d.ts → noop.d.mts} +5 -3
- package/dist/cache/noop.mjs +44 -0
- package/dist/cache/noop.mjs.map +1 -0
- package/dist/cache-B-MG4yyg.d.mts +45 -0
- package/dist/content-BrwEY2_p.d.mts +53 -0
- package/dist/{errors.d.ts → errors.d.mts} +18 -16
- package/dist/errors.mjs +24 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/hooks-DCSAQkST.d.mts +60 -0
- package/dist/hooks.d.mts +2 -0
- package/dist/hooks.mjs +75 -0
- package/dist/hooks.mjs.map +1 -0
- package/dist/index.d.mts +449 -0
- package/dist/index.mjs +616 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +17 -16
- package/dist/cache/memory.d.ts +0 -54
- package/dist/cache/memory.js +0 -15
- package/dist/cache/memory.js.map +0 -1
- package/dist/cache/noop.js +0 -9
- package/dist/cache/noop.js.map +0 -1
- package/dist/cache-DvbyemBK.d.ts +0 -33
- package/dist/chunk-4KGKWKKI.js +0 -80
- package/dist/chunk-4KGKWKKI.js.map +0 -1
- package/dist/chunk-6DG63XUF.js +0 -42
- package/dist/chunk-6DG63XUF.js.map +0 -1
- package/dist/chunk-6LHROEPI.js +0 -104
- package/dist/chunk-6LHROEPI.js.map +0 -1
- package/dist/chunk-V6ML4QE5.js +0 -26
- package/dist/chunk-V6ML4QE5.js.map +0 -1
- package/dist/content-Biqf0l_o.d.ts +0 -51
- package/dist/errors.js +0 -11
- package/dist/errors.js.map +0 -1
- package/dist/hooks-B83RUclt.d.ts +0 -41
- package/dist/hooks.d.ts +0 -2
- package/dist/hooks.js +0 -9
- package/dist/hooks.js.map +0 -1
- package/dist/index.d.ts +0 -278
- package/dist/index.js +0 -598
- package/dist/index.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { memoryCache, memoryDocumentCache, memoryImageCache } from "./cache/memory.mjs";
|
|
2
|
+
import { noopDocumentCache, noopImageCache } from "./cache/noop.mjs";
|
|
3
|
+
import { CMSError, isCMSError, isCMSErrorInNamespace } from "./errors.mjs";
|
|
4
|
+
import { mergeHooks, mergeLoggers } from "./hooks.mjs";
|
|
5
|
+
//#region src/cache.ts
|
|
6
|
+
/** 文字列をSHA-256でハッシュ化し、16進数文字列として返す。画像キーの生成に使用。 */
|
|
7
|
+
async function sha256Hex(input) {
|
|
8
|
+
const data = new TextEncoder().encode(input);
|
|
9
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
10
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* キャッシュが有効期限切れかどうかを判定する。
|
|
14
|
+
* ttlMs が未指定の場合は常に false(無期限有効)を返す。
|
|
15
|
+
*/
|
|
16
|
+
function isStale(cachedAt, ttlMs) {
|
|
17
|
+
if (ttlMs === void 0) return false;
|
|
18
|
+
return Date.now() - cachedAt > ttlMs;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/image.ts
|
|
22
|
+
/** レスポンスヘッダまたはURLの拡張子からContent-Typeを推測する。 */
|
|
23
|
+
function inferContentType(url, responseContentType) {
|
|
24
|
+
if (responseContentType?.startsWith("image/")) return responseContentType.split(";")[0].trim();
|
|
25
|
+
if (url.includes(".png")) return "image/png";
|
|
26
|
+
if (url.includes(".gif")) return "image/gif";
|
|
27
|
+
if (url.includes(".webp")) return "image/webp";
|
|
28
|
+
return "image/jpeg";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Notion画像URLをfetchしてImageCacheAdapterにキャッシュし、プロキシURL を返す。
|
|
32
|
+
* 既存キャッシュがあれば再fetchしない。
|
|
33
|
+
*/
|
|
34
|
+
async function fetchAndCacheImage(cache, notionUrl, imageProxyBase) {
|
|
35
|
+
const hash = await sha256Hex(notionUrl);
|
|
36
|
+
const proxyUrl = `${imageProxyBase}/${hash}`;
|
|
37
|
+
if (await cache.get(hash)) return proxyUrl;
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(notionUrl, { signal: AbortSignal.timeout(1e4) });
|
|
40
|
+
if (!response.ok) throw new CMSError({
|
|
41
|
+
code: "cache/image_fetch_failed",
|
|
42
|
+
message: `Failed to fetch Notion image: HTTP ${response.status}`,
|
|
43
|
+
context: {
|
|
44
|
+
operation: "fetchAndCacheImage",
|
|
45
|
+
notionUrl,
|
|
46
|
+
httpStatus: response.status
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const data = await response.arrayBuffer();
|
|
50
|
+
const contentType = inferContentType(notionUrl, response.headers.get("content-type"));
|
|
51
|
+
await cache.set(hash, data, contentType);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (isCMSError(err)) throw err;
|
|
54
|
+
throw new CMSError({
|
|
55
|
+
code: "cache/io_failed",
|
|
56
|
+
message: "Failed to fetch or cache Notion image.",
|
|
57
|
+
cause: err,
|
|
58
|
+
context: {
|
|
59
|
+
operation: "fetchAndCacheImage",
|
|
60
|
+
notionUrl
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return proxyUrl;
|
|
65
|
+
}
|
|
66
|
+
/** ImageCacheAdapter と imageProxyBase から cacheImage 関数を構築するファクトリ。 */
|
|
67
|
+
function buildCacheImageFn(cache, imageProxyBase) {
|
|
68
|
+
return (notionUrl) => fetchAndCacheImage(cache, notionUrl, imageProxyBase);
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/rendering.ts
|
|
72
|
+
/**
|
|
73
|
+
* コンテンツアイテムをソースから Markdown ロード → blocks 生成 → HTML レンダリング
|
|
74
|
+
* → フック適用まで実行し、キャッシュ保存用の `CachedItem` を返す。
|
|
75
|
+
*/
|
|
76
|
+
async function buildCachedItem(item, ctx) {
|
|
77
|
+
const start = Date.now();
|
|
78
|
+
ctx.logger?.info?.("コンテンツのレンダリング開始", {
|
|
79
|
+
slug: item.slug,
|
|
80
|
+
pageId: item.id
|
|
81
|
+
});
|
|
82
|
+
ctx.hooks.onRenderStart?.(item.slug);
|
|
83
|
+
let markdown;
|
|
84
|
+
try {
|
|
85
|
+
markdown = await ctx.source.loadMarkdown(item);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (isCMSError(err)) throw err;
|
|
88
|
+
throw new CMSError({
|
|
89
|
+
code: "source/load_markdown_failed",
|
|
90
|
+
message: "Failed to load markdown from source.",
|
|
91
|
+
cause: err,
|
|
92
|
+
context: {
|
|
93
|
+
operation: "buildCachedItem:loadMarkdown",
|
|
94
|
+
pageId: item.id,
|
|
95
|
+
slug: item.slug
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
let blocks = [];
|
|
100
|
+
try {
|
|
101
|
+
blocks = await ctx.source.loadBlocks(item);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
ctx.logger?.warn?.("loadBlocks に失敗したため raw フォールバック", {
|
|
104
|
+
slug: item.slug,
|
|
105
|
+
error: err instanceof Error ? err.message : String(err)
|
|
106
|
+
});
|
|
107
|
+
blocks = [];
|
|
108
|
+
}
|
|
109
|
+
const cacheImage = ctx.hasImageCache ? buildCacheImageFn(ctx.imgCache, ctx.imageProxyBase) : void 0;
|
|
110
|
+
let html;
|
|
111
|
+
const rendererFn = ctx.rendererFn ?? await loadDefaultRenderer();
|
|
112
|
+
try {
|
|
113
|
+
html = await rendererFn(markdown, {
|
|
114
|
+
imageProxyBase: ctx.imageProxyBase,
|
|
115
|
+
cacheImage,
|
|
116
|
+
remarkPlugins: ctx.contentConfig?.remarkPlugins,
|
|
117
|
+
rehypePlugins: ctx.contentConfig?.rehypePlugins
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (isCMSError(err)) throw err;
|
|
121
|
+
throw new CMSError({
|
|
122
|
+
code: "renderer/failed",
|
|
123
|
+
message: "Failed to render markdown.",
|
|
124
|
+
cause: err,
|
|
125
|
+
context: {
|
|
126
|
+
operation: "buildCachedItem:renderMarkdown",
|
|
127
|
+
pageId: item.id,
|
|
128
|
+
slug: item.slug
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (ctx.hooks.afterRender) html = await ctx.hooks.afterRender(html, item);
|
|
133
|
+
let result = {
|
|
134
|
+
html,
|
|
135
|
+
blocks,
|
|
136
|
+
markdown,
|
|
137
|
+
item,
|
|
138
|
+
notionUpdatedAt: ctx.source.getLastModified(item),
|
|
139
|
+
cachedAt: Date.now()
|
|
140
|
+
};
|
|
141
|
+
if (ctx.hooks.beforeCache) result = await ctx.hooks.beforeCache(result);
|
|
142
|
+
const durationMs = Date.now() - start;
|
|
143
|
+
ctx.logger?.info?.("コンテンツのレンダリング完了", {
|
|
144
|
+
slug: item.slug,
|
|
145
|
+
durationMs
|
|
146
|
+
});
|
|
147
|
+
ctx.hooks.onRenderEnd?.(item.slug, durationMs);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* renderer オプション未指定時のフォールバック。
|
|
152
|
+
* @notion-headless-cms/renderer を動的 import する。
|
|
153
|
+
* adapter-cloudflare / adapter-node は renderer を明示注入するためこのパスは通らない。
|
|
154
|
+
*/
|
|
155
|
+
async function loadDefaultRenderer() {
|
|
156
|
+
try {
|
|
157
|
+
return (await import("@notion-headless-cms/renderer")).renderMarkdown;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new CMSError({
|
|
160
|
+
code: "renderer/failed",
|
|
161
|
+
message: "renderer オプションが未指定で @notion-headless-cms/renderer が見つかりません。 createCMS({ renderer }) でレンダラーを注入するか、@notion-headless-cms/renderer をインストールしてください。",
|
|
162
|
+
cause: err,
|
|
163
|
+
context: { operation: "loadDefaultRenderer" }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/retry.ts
|
|
169
|
+
const DEFAULT_RETRY_CONFIG = {
|
|
170
|
+
retryOn: [
|
|
171
|
+
429,
|
|
172
|
+
502,
|
|
173
|
+
503
|
|
174
|
+
],
|
|
175
|
+
maxRetries: 4,
|
|
176
|
+
baseDelayMs: 1e3,
|
|
177
|
+
jitter: true
|
|
178
|
+
};
|
|
179
|
+
/** 指数バックオフ(オプションでジッター付き)でリトライする。retryOn に含まれる HTTP エラーのみ対象。 */
|
|
180
|
+
async function withRetry(fn, config) {
|
|
181
|
+
let lastError;
|
|
182
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) try {
|
|
183
|
+
return await fn();
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const status = err.status;
|
|
186
|
+
if (status === void 0 || !config.retryOn.includes(status)) throw err;
|
|
187
|
+
lastError = err;
|
|
188
|
+
if (attempt < config.maxRetries) {
|
|
189
|
+
config.onRetry?.(attempt + 1, status);
|
|
190
|
+
const jitterFactor = config.jitter !== false ? .5 + Math.random() * .5 : 1;
|
|
191
|
+
const delay = config.baseDelayMs * 2 ** attempt * jitterFactor;
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
throw lastError;
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/collection.ts
|
|
199
|
+
/**
|
|
200
|
+
* コレクション別キャッシュキーを生成する。
|
|
201
|
+
* item: `{collection}:{slug}` / list: `{collection}`
|
|
202
|
+
*/
|
|
203
|
+
function collectionKey(collection, slug) {
|
|
204
|
+
return slug ? `${collection}:${slug}` : collection;
|
|
205
|
+
}
|
|
206
|
+
/** CollectionClient の実装。ユーザーは `createCMS` 経由でインスタンスを受け取る。 */
|
|
207
|
+
var CollectionClientImpl = class {
|
|
208
|
+
constructor(ctx) {
|
|
209
|
+
this.ctx = ctx;
|
|
210
|
+
}
|
|
211
|
+
async getItem(slug) {
|
|
212
|
+
const cached = await this.ctx.docCache.getItem(slug);
|
|
213
|
+
if (cached && !isStale(cached.cachedAt, this.ctx.ttlMs)) {
|
|
214
|
+
this.ctx.hooks.onCacheHit?.(slug, cached);
|
|
215
|
+
return this.attachContent(cached.item, cached);
|
|
216
|
+
}
|
|
217
|
+
this.ctx.hooks.onCacheMiss?.(slug);
|
|
218
|
+
const item = await this.findRaw(slug);
|
|
219
|
+
if (!item) return null;
|
|
220
|
+
const entry = await buildCachedItem(item, this.ctx.render);
|
|
221
|
+
const save = this.ctx.docCache.setItem(slug, entry);
|
|
222
|
+
if (this.ctx.waitUntil) this.ctx.waitUntil(save);
|
|
223
|
+
else await save;
|
|
224
|
+
return this.attachContent(entry.item, entry);
|
|
225
|
+
}
|
|
226
|
+
async getList(opts) {
|
|
227
|
+
return applyGetListOptions(await this.fetchList(), opts);
|
|
228
|
+
}
|
|
229
|
+
async getStaticParams() {
|
|
230
|
+
return (await this.fetchList()).map((item) => ({ slug: item.slug }));
|
|
231
|
+
}
|
|
232
|
+
async getStaticPaths() {
|
|
233
|
+
return (await this.fetchList()).map((item) => item.slug);
|
|
234
|
+
}
|
|
235
|
+
async adjacent(slug, opts) {
|
|
236
|
+
const items = applyGetListOptions(await this.fetchList(), { sort: opts?.sort });
|
|
237
|
+
const index = items.findIndex((it) => it.slug === slug);
|
|
238
|
+
if (index === -1) return {
|
|
239
|
+
prev: null,
|
|
240
|
+
next: null
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
prev: index > 0 ? items[index - 1] ?? null : null,
|
|
244
|
+
next: index < items.length - 1 ? items[index + 1] ?? null : null
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async revalidate(scope) {
|
|
248
|
+
if (!this.ctx.docCache.invalidate) return;
|
|
249
|
+
if (scope === void 0 || scope === "all") await this.ctx.docCache.invalidate({ collection: this.ctx.collection });
|
|
250
|
+
else await this.ctx.docCache.invalidate({
|
|
251
|
+
collection: this.ctx.collection,
|
|
252
|
+
slug: scope.slug
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async prefetch(opts) {
|
|
256
|
+
const items = await this.fetchListRaw();
|
|
257
|
+
const concurrency = opts?.concurrency ?? this.ctx.maxConcurrent;
|
|
258
|
+
let ok = 0;
|
|
259
|
+
let failed = 0;
|
|
260
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
261
|
+
const chunk = items.slice(i, i + concurrency);
|
|
262
|
+
await Promise.all(chunk.map(async (item) => {
|
|
263
|
+
try {
|
|
264
|
+
const rendered = await buildCachedItem(item, this.ctx.render);
|
|
265
|
+
await this.ctx.docCache.setItem(item.slug, rendered);
|
|
266
|
+
ok++;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
failed++;
|
|
269
|
+
this.ctx.logger?.warn?.("prefetch: アイテムの事前レンダリングに失敗", {
|
|
270
|
+
slug: item.slug,
|
|
271
|
+
pageId: item.id,
|
|
272
|
+
error: err instanceof Error ? err.message : String(err)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}));
|
|
276
|
+
opts?.onProgress?.(Math.min(i + concurrency, items.length), items.length);
|
|
277
|
+
}
|
|
278
|
+
await this.ctx.docCache.setList({
|
|
279
|
+
items,
|
|
280
|
+
cachedAt: Date.now()
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
ok,
|
|
284
|
+
failed
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
attachContent(item, cached) {
|
|
288
|
+
const ctx = this.ctx;
|
|
289
|
+
let blocksCache;
|
|
290
|
+
let htmlCache = cached.html;
|
|
291
|
+
let markdownCache;
|
|
292
|
+
const content = {
|
|
293
|
+
get blocks() {
|
|
294
|
+
if (!blocksCache) blocksCache = [{
|
|
295
|
+
type: "raw",
|
|
296
|
+
html: cached.html
|
|
297
|
+
}];
|
|
298
|
+
return blocksCache;
|
|
299
|
+
},
|
|
300
|
+
async html() {
|
|
301
|
+
if (htmlCache !== void 0) return htmlCache;
|
|
302
|
+
htmlCache = cached.html;
|
|
303
|
+
return htmlCache;
|
|
304
|
+
},
|
|
305
|
+
async markdown() {
|
|
306
|
+
if (markdownCache !== void 0) return markdownCache;
|
|
307
|
+
markdownCache = await ctx.source.loadMarkdown(item);
|
|
308
|
+
return markdownCache;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const maybeBlocks = cached.blocks;
|
|
312
|
+
if (maybeBlocks) blocksCache = maybeBlocks;
|
|
313
|
+
return Object.assign(Object.create(null), item, { content });
|
|
314
|
+
}
|
|
315
|
+
async fetchList() {
|
|
316
|
+
const cached = await this.ctx.docCache.getList();
|
|
317
|
+
if (cached && !isStale(cached.cachedAt, this.ctx.ttlMs)) {
|
|
318
|
+
this.ctx.hooks.onListCacheHit?.(cached.items, cached.cachedAt);
|
|
319
|
+
return cached.items;
|
|
320
|
+
}
|
|
321
|
+
this.ctx.hooks.onListCacheMiss?.();
|
|
322
|
+
const items = await this.fetchListRaw();
|
|
323
|
+
const cachedAt = Date.now();
|
|
324
|
+
const save = this.ctx.docCache.setList({
|
|
325
|
+
items,
|
|
326
|
+
cachedAt
|
|
327
|
+
});
|
|
328
|
+
if (this.ctx.waitUntil) this.ctx.waitUntil(save);
|
|
329
|
+
else await save;
|
|
330
|
+
return items;
|
|
331
|
+
}
|
|
332
|
+
fetchListRaw() {
|
|
333
|
+
return withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
|
|
334
|
+
...this.ctx.retryConfig,
|
|
335
|
+
onRetry: (attempt, status) => {
|
|
336
|
+
this.ctx.logger?.warn?.("getList() リトライ中", {
|
|
337
|
+
attempt,
|
|
338
|
+
status
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async findRaw(slug) {
|
|
344
|
+
const item = await withRetry(() => this.ctx.source.findBySlug(slug), {
|
|
345
|
+
...this.ctx.retryConfig,
|
|
346
|
+
onRetry: (attempt, status) => {
|
|
347
|
+
this.ctx.logger?.warn?.("getItem() リトライ中", {
|
|
348
|
+
attempt,
|
|
349
|
+
status,
|
|
350
|
+
slug
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
if (!item) return null;
|
|
355
|
+
if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return null;
|
|
356
|
+
return item;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
function applyGetListOptions(items, opts) {
|
|
360
|
+
if (!opts) return items;
|
|
361
|
+
let result = items;
|
|
362
|
+
if (opts.statuses && opts.statuses.length > 0) {
|
|
363
|
+
const allow = new Set(opts.statuses);
|
|
364
|
+
result = result.filter((it) => it.status !== void 0 && allow.has(it.status));
|
|
365
|
+
}
|
|
366
|
+
if (opts.tag) {
|
|
367
|
+
const tag = opts.tag;
|
|
368
|
+
result = result.filter((it) => {
|
|
369
|
+
const tags = it.tags;
|
|
370
|
+
return Array.isArray(tags) && tags.includes(tag);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (opts.where) {
|
|
374
|
+
const where = opts.where;
|
|
375
|
+
result = result.filter((it) => Object.entries(where).every(([key, value]) => it[key] === value));
|
|
376
|
+
}
|
|
377
|
+
if (opts.sort) result = [...result].sort(makeComparator(opts.sort));
|
|
378
|
+
const skip = opts.skip ?? 0;
|
|
379
|
+
const limit = opts.limit;
|
|
380
|
+
if (skip > 0 || limit !== void 0) result = result.slice(skip, limit !== void 0 ? skip + limit : void 0);
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
function makeComparator(sort) {
|
|
384
|
+
const by = sort.by;
|
|
385
|
+
const dir = sort.direction === "asc" ? 1 : -1;
|
|
386
|
+
return (a, b) => {
|
|
387
|
+
const av = a[by];
|
|
388
|
+
const bv = b[by];
|
|
389
|
+
if (av === bv) return 0;
|
|
390
|
+
if (av === void 0) return 1;
|
|
391
|
+
if (bv === void 0) return -1;
|
|
392
|
+
return av > bv ? dir : -dir;
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/handler.ts
|
|
397
|
+
const DEFAULT_OPTS = {
|
|
398
|
+
basePath: "/api/cms",
|
|
399
|
+
imagesPath: "/images",
|
|
400
|
+
revalidatePath: "/revalidate"
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Web Standard な Request → Response ルーター。
|
|
404
|
+
* Next.js / React Router / Hono / Cloudflare Workers いずれでも使える。
|
|
405
|
+
*
|
|
406
|
+
* ルート:
|
|
407
|
+
* - GET `{basePath}/images/:hash` — 画像プロキシ
|
|
408
|
+
* - POST `{basePath}/revalidate` — Webhook 受信 + $revalidate()
|
|
409
|
+
*/
|
|
410
|
+
function createHandler(adapter, opts = {}) {
|
|
411
|
+
const basePath = trimTrailingSlash(opts.basePath ?? DEFAULT_OPTS.basePath);
|
|
412
|
+
const imagesPath = opts.imagesPath ?? DEFAULT_OPTS.imagesPath;
|
|
413
|
+
const revalidatePath = opts.revalidatePath ?? DEFAULT_OPTS.revalidatePath;
|
|
414
|
+
return async (req) => {
|
|
415
|
+
const path = new URL(req.url).pathname;
|
|
416
|
+
if (!path.startsWith(basePath)) return new Response("Not Found", { status: 404 });
|
|
417
|
+
const rel = path.slice(basePath.length) || "/";
|
|
418
|
+
if (req.method === "GET" && rel.startsWith(`${imagesPath}/`)) {
|
|
419
|
+
const hash = rel.slice(imagesPath.length + 1);
|
|
420
|
+
if (!hash) return new Response("Bad Request", { status: 400 });
|
|
421
|
+
const object = await adapter.imageCache.get(hash);
|
|
422
|
+
if (!object) return new Response("Not Found", { status: 404 });
|
|
423
|
+
const headers = new Headers();
|
|
424
|
+
if (object.contentType) headers.set("content-type", object.contentType);
|
|
425
|
+
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
426
|
+
return new Response(object.data, { headers });
|
|
427
|
+
}
|
|
428
|
+
if (req.method === "POST" && rel === revalidatePath) {
|
|
429
|
+
const scope = await adapter.parseWebhook(req, opts.webhookSecret);
|
|
430
|
+
if (!scope) return new Response(JSON.stringify({
|
|
431
|
+
ok: false,
|
|
432
|
+
reason: "invalid"
|
|
433
|
+
}), {
|
|
434
|
+
status: 400,
|
|
435
|
+
headers: { "content-type": "application/json" }
|
|
436
|
+
});
|
|
437
|
+
await adapter.revalidate(scope);
|
|
438
|
+
return new Response(JSON.stringify({
|
|
439
|
+
ok: true,
|
|
440
|
+
scope
|
|
441
|
+
}), {
|
|
442
|
+
status: 200,
|
|
443
|
+
headers: { "content-type": "application/json" }
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return new Response("Not Found", { status: 404 });
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function trimTrailingSlash(s) {
|
|
450
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/cms.ts
|
|
454
|
+
const DEFAULT_IMAGE_PROXY_BASE = "/api/images";
|
|
455
|
+
function resolveDocumentCache(cache) {
|
|
456
|
+
if (!cache || cache === "disabled" || !cache.document) return noopDocumentCache();
|
|
457
|
+
return cache.document;
|
|
458
|
+
}
|
|
459
|
+
function resolveImageCache(cache) {
|
|
460
|
+
if (!cache || cache === "disabled" || !cache.image) return noopImageCache();
|
|
461
|
+
return cache.image;
|
|
462
|
+
}
|
|
463
|
+
function resolveTtl(cache) {
|
|
464
|
+
if (!cache || cache === "disabled") return void 0;
|
|
465
|
+
return cache.ttlMs;
|
|
466
|
+
}
|
|
467
|
+
function hasImageCacheConfigured(cache) {
|
|
468
|
+
if (!cache || cache === "disabled") return false;
|
|
469
|
+
return !!cache.image;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* `{collection}:{slug}` キー空間で動作するコレクション別キャッシュビューを生成する。
|
|
473
|
+
* 単一の `DocumentCacheAdapter` に複数コレクションを同居させるためのアダプタ。
|
|
474
|
+
*/
|
|
475
|
+
function scopeDocumentCache(base, collection) {
|
|
476
|
+
const itemKey = (slug) => `${collection}:${slug}`;
|
|
477
|
+
const listKey = collection;
|
|
478
|
+
return {
|
|
479
|
+
name: `${base.name}@${collection}`,
|
|
480
|
+
async getList() {
|
|
481
|
+
const anyBase = base;
|
|
482
|
+
if (typeof anyBase.getListByKey === "function") return anyBase.getListByKey(listKey);
|
|
483
|
+
return base.getList();
|
|
484
|
+
},
|
|
485
|
+
async setList(data) {
|
|
486
|
+
const anyBase = base;
|
|
487
|
+
if (typeof anyBase.setListByKey === "function") return anyBase.setListByKey(listKey, data);
|
|
488
|
+
return base.setList(data);
|
|
489
|
+
},
|
|
490
|
+
async getItem(slug) {
|
|
491
|
+
const anyBase = base;
|
|
492
|
+
if (typeof anyBase.getItemByKey === "function") return anyBase.getItemByKey(itemKey(slug));
|
|
493
|
+
return base.getItem(slug);
|
|
494
|
+
},
|
|
495
|
+
async setItem(slug, data) {
|
|
496
|
+
const anyBase = base;
|
|
497
|
+
if (typeof anyBase.setItemByKey === "function") return anyBase.setItemByKey(itemKey(slug), data);
|
|
498
|
+
return base.setItem(slug, data);
|
|
499
|
+
},
|
|
500
|
+
async invalidate(scope) {
|
|
501
|
+
if (!base.invalidate) return;
|
|
502
|
+
if (scope === "all") return base.invalidate({ collection });
|
|
503
|
+
if ("slug" in scope && !("collection" in scope)) return base.invalidate({
|
|
504
|
+
collection,
|
|
505
|
+
slug: scope.slug
|
|
506
|
+
});
|
|
507
|
+
return base.invalidate(scope);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* 複数の DataSource を束ねた CMS クライアントを生成する。
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* const cms = createCMS({
|
|
516
|
+
* dataSources: {
|
|
517
|
+
* posts: createNotionCollection({ token, databaseId, schema }),
|
|
518
|
+
* },
|
|
519
|
+
* cache: { document, image, ttlMs: 60_000 },
|
|
520
|
+
* });
|
|
521
|
+
* const post = await cms.posts.getItem("my-slug");
|
|
522
|
+
*/
|
|
523
|
+
function createCMS(opts) {
|
|
524
|
+
if (!opts.dataSources || Object.keys(opts.dataSources).length === 0) throw new Error("createCMS: dataSources に少なくとも1つのコレクションを指定してください。");
|
|
525
|
+
const baseDocCache = resolveDocumentCache(opts.cache);
|
|
526
|
+
const imgCache = resolveImageCache(opts.cache);
|
|
527
|
+
const hasImageCache = hasImageCacheConfigured(opts.cache);
|
|
528
|
+
const ttlMs = resolveTtl(opts.cache);
|
|
529
|
+
const imageProxyBase = opts.content?.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
|
|
530
|
+
const contentConfig = opts.content;
|
|
531
|
+
const rendererFn = opts.renderer;
|
|
532
|
+
const waitUntil = opts.waitUntil;
|
|
533
|
+
const logger = mergeLoggers(opts.plugins ?? [], opts.logger);
|
|
534
|
+
const hooks = mergeHooks(opts.plugins ?? [], opts.hooks, logger);
|
|
535
|
+
const maxConcurrent = opts.rateLimiter?.maxConcurrent ?? 3;
|
|
536
|
+
const retryConfig = {
|
|
537
|
+
...DEFAULT_RETRY_CONFIG,
|
|
538
|
+
...opts.rateLimiter ?? {}
|
|
539
|
+
};
|
|
540
|
+
const collectionNames = Object.keys(opts.dataSources);
|
|
541
|
+
const collections = {};
|
|
542
|
+
for (const name of collectionNames) {
|
|
543
|
+
const source = opts.dataSources[name];
|
|
544
|
+
collections[name] = new CollectionClientImpl({
|
|
545
|
+
collection: name,
|
|
546
|
+
source,
|
|
547
|
+
docCache: scopeDocumentCache(baseDocCache, name),
|
|
548
|
+
render: {
|
|
549
|
+
source,
|
|
550
|
+
rendererFn,
|
|
551
|
+
imgCache,
|
|
552
|
+
hasImageCache,
|
|
553
|
+
imageProxyBase,
|
|
554
|
+
contentConfig,
|
|
555
|
+
hooks,
|
|
556
|
+
logger
|
|
557
|
+
},
|
|
558
|
+
hooks,
|
|
559
|
+
logger,
|
|
560
|
+
ttlMs,
|
|
561
|
+
publishedStatuses: source.publishedStatuses ? [...source.publishedStatuses] : [],
|
|
562
|
+
accessibleStatuses: source.accessibleStatuses ? [...source.accessibleStatuses] : [],
|
|
563
|
+
retryConfig,
|
|
564
|
+
maxConcurrent,
|
|
565
|
+
waitUntil
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
const globalOps = {
|
|
569
|
+
$collections: collectionNames,
|
|
570
|
+
async $revalidate(scope) {
|
|
571
|
+
if (!baseDocCache.invalidate) return;
|
|
572
|
+
await baseDocCache.invalidate(scope ?? "all");
|
|
573
|
+
},
|
|
574
|
+
$handler(handlerOpts) {
|
|
575
|
+
return createHandler({
|
|
576
|
+
imageCache: imgCache,
|
|
577
|
+
parseWebhook: async (req, webhookSecret) => {
|
|
578
|
+
for (const name of collectionNames) {
|
|
579
|
+
const ds = opts.dataSources[name];
|
|
580
|
+
if (ds.parseWebhook) try {
|
|
581
|
+
return await ds.parseWebhook(req.clone(), { secret: webhookSecret });
|
|
582
|
+
} catch (err) {
|
|
583
|
+
logger?.warn?.("parseWebhook 失敗", {
|
|
584
|
+
collection: name,
|
|
585
|
+
error: err instanceof Error ? err.message : String(err)
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const body = await req.json();
|
|
591
|
+
if (body.slug && body.collection) return {
|
|
592
|
+
collection: body.collection,
|
|
593
|
+
slug: body.slug
|
|
594
|
+
};
|
|
595
|
+
if (body.collection) return { collection: body.collection };
|
|
596
|
+
} catch {}
|
|
597
|
+
return null;
|
|
598
|
+
},
|
|
599
|
+
revalidate: (scope) => globalOps.$revalidate(scope)
|
|
600
|
+
}, handlerOpts);
|
|
601
|
+
},
|
|
602
|
+
$getCachedImage(hash) {
|
|
603
|
+
return imgCache.get(hash);
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
return Object.assign(Object.create(null), collections, globalOps);
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/types/plugin.ts
|
|
610
|
+
function definePlugin(plugin) {
|
|
611
|
+
return plugin;
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, memoryCache, memoryDocumentCache, memoryImageCache, mergeHooks, mergeLoggers, noopDocumentCache, noopImageCache, sha256Hex, withRetry };
|
|
615
|
+
|
|
616
|
+
//# sourceMappingURL=index.mjs.map
|