@cosense-toolbox/astro 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,581 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { buildGraph, createIndex, readPage } from "@cosense-toolbox/cosense-x/graph";
3
+ import { Array, Effect, Match, Option, pipe } from "effect";
4
+ import { createHash } from "node:crypto";
5
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
+ import { basename, extname, join, relative, sep } from "node:path";
7
+ import { fetchAsset, isCosenseAssetUrl } from "@cosense-toolbox/cosense-x/fetch";
8
+ import { codeLanguageOf } from "@cosense-toolbox/parser/html";
9
+ import { bundledLanguages, createHighlighter } from "shiki";
10
+ import { checkSite } from "@cosense-toolbox/lsp/check";
11
+ import { createReadStream } from "node:fs";
12
+ import { compile } from "@cosense-toolbox/cosense-x";
13
+ //#region src/assets.ts
14
+ /**
15
+ * assets.ts — Cosense 上の画像やファイルを、ビルド時に取ってきてサイトの中に置く。
16
+ *
17
+ * Cosense のファイル (`/files/…`) とアイコン (`/api/pages/…/icon`) は
18
+ * `Cross-Origin-Resource-Policy: same-origin` を返すので、別のサイトの `<img>` からは読めない。
19
+ * リダイレクト先も期限付きの URL (`/files/` は 5 分で切れる) なので、URL を解決して埋め込むだけでは
20
+ * 公開する頃には表示されなくなる。そのため中身を取ってきて、サイトと同じ場所に置く。
21
+ */
22
+ /** `globalThis` に置き場を置くときのキー (`Symbol.for` に渡す)。 */
23
+ const ASSET_STORE_KEY = "@cosense-toolbox/astro/assets";
24
+ /** Content-Type から付ける拡張子。静的なホスティングは拡張子で Content-Type を決めるため。 */
25
+ const EXTENSIONS$1 = {
26
+ "image/png": ".png",
27
+ "image/jpeg": ".jpg",
28
+ "image/gif": ".gif",
29
+ "image/webp": ".webp",
30
+ "image/avif": ".avif",
31
+ "image/svg+xml": ".svg",
32
+ "image/x-icon": ".ico",
33
+ "application/pdf": ".pdf",
34
+ "video/mp4": ".mp4",
35
+ "audio/mpeg": ".mp3"
36
+ };
37
+ /** Content-Type で決まらなければ、元の URL の拡張子を使う。 */
38
+ const extensionOf = (url, contentType) => {
39
+ const known = EXTENSIONS$1[contentType.split(";")[0]?.trim().toLowerCase() ?? ""];
40
+ if (known !== void 0) return known;
41
+ return /\.[A-Za-z0-9]{1,5}$/.exec(new URL(url).pathname)?.[0]?.toLowerCase() ?? "";
42
+ };
43
+ /**
44
+ * 元の URL のハッシュ。同じ URL は何度ビルドしても同じ名前になり、ハッシュから元の URL
45
+ * (アップロードしたファイルの ID) は分からない。非公開プロジェクトのファイル ID を出さないため。
46
+ */
47
+ const hashOf = (url) => createHash("sha256").update(url).digest("hex").slice(0, 16);
48
+ const ICON_RE = /^\/api\/pages\/[^/]+\/(.+)\/icon$/;
49
+ const decode = (segment) => {
50
+ try {
51
+ return decodeURIComponent(segment);
52
+ } catch {
53
+ return segment;
54
+ }
55
+ };
56
+ /** ファイル名や URL に使えない文字を `_` にする。日本語はそのまま残す。 */
57
+ const sanitize = (label) => label.replace(/[\\/?#%:*"<>|\s\p{Cc}]+/gu, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
58
+ /**
59
+ * 人が見て何のファイルか分かるよう、ハッシュのあとに付ける名前。アイコンはユーザー名
60
+ * (ページには `<img alt>` としてもともと出ている)。アップロードしたファイルは ID を出さないので付けない。
61
+ */
62
+ const labelOf = (url) => {
63
+ const title = ICON_RE.exec(new URL(url).pathname)?.[1];
64
+ return title === void 0 ? "" : sanitize(title.split("/").map(decode).join("_"));
65
+ };
66
+ /** アップロードしたファイル (`/files/…`) は中身が変わらないので、前のビルドで取ったものを使い回せる。 */
67
+ const isImmutable = (url) => new URL(url).pathname.startsWith("/files/");
68
+ const unescapeAttribute = (value) => value.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
69
+ const ATTRIBUTE_RE = /(\s(src|href)=")([^"]*)(")/g;
70
+ const createAssetStore = (options) => {
71
+ const fetchOptions = options.fetchOptions ?? {};
72
+ const isAsset = (url) => isCosenseAssetUrl(url, fetchOptions);
73
+ const downloads = /* @__PURE__ */ new Map();
74
+ const used = /* @__PURE__ */ new Set();
75
+ const publicUrlOf = (file) => {
76
+ used.add(file);
77
+ return `${options.publicPath}${encodeURIComponent(file)}`;
78
+ };
79
+ /** 前のビルドで置いたファイル。拡張子は取ってくるまで分からないので、ハッシュで探す。 */
80
+ const cachedFileOf = async (base) => {
81
+ return (await readdir(options.cacheDir).catch(() => [])).find((file) => file === base || file.startsWith(`${base}.`));
82
+ };
83
+ const download = async (url) => {
84
+ const label = labelOf(url);
85
+ const base = label === "" ? hashOf(url) : `${hashOf(url)}_${label}`;
86
+ try {
87
+ const cached = isImmutable(url) ? await cachedFileOf(base) : void 0;
88
+ if (cached !== void 0) return publicUrlOf(cached);
89
+ const asset = await fetchAsset(url, fetchOptions);
90
+ const file = `${base}${extensionOf(url, asset.contentType)}`;
91
+ await mkdir(options.cacheDir, { recursive: true });
92
+ await writeFile(join(options.cacheDir, file), asset.data);
93
+ return publicUrlOf(file);
94
+ } catch (error) {
95
+ options.warn?.(`${url} を取得できないので、元の URL のまま出す: ${String(error)}`);
96
+ return url;
97
+ }
98
+ };
99
+ const resolve = (url) => {
100
+ if (!isAsset(url)) return Promise.resolve(url);
101
+ const cached = downloads.get(url) ?? download(url);
102
+ downloads.set(url, cached);
103
+ return cached;
104
+ };
105
+ const resolveLink = (url) => options.links === "download" ? resolve(url) : Promise.resolve(url);
106
+ const resolveAttribute = (name, url) => name === "href" ? resolveLink(url) : resolve(url);
107
+ const localizeHtml = async (html) => {
108
+ const targets = [...html.matchAll(ATTRIBUTE_RE)].map((match) => [match[2] ?? "", unescapeAttribute(match[3] ?? "")]).filter(([, url]) => isAsset(url));
109
+ const resolved = new Map(await Promise.all(targets.map(async ([name, url]) => [`${name} ${url}`, await resolveAttribute(name, url)])));
110
+ return html.replace(ATTRIBUTE_RE, (whole, open, name, value, close) => {
111
+ const local = resolved.get(`${name} ${unescapeAttribute(value)}`);
112
+ return local === void 0 ? whole : `${open}${local}${close}`;
113
+ });
114
+ };
115
+ const copyUsedTo = async (dir) => {
116
+ const target = dir instanceof URL ? fileURLToPath(dir) : dir;
117
+ await mkdir(target, { recursive: true });
118
+ await Promise.all([...used].map((file) => copyFile(join(options.cacheDir, file), join(target, file))));
119
+ };
120
+ return {
121
+ resolve,
122
+ resolveLink,
123
+ localizeHtml,
124
+ copyUsedTo,
125
+ cacheDir: options.cacheDir,
126
+ publicPath: options.publicPath
127
+ };
128
+ };
129
+ /** `img` の `src` と `a` の href。書き換えられるよう、要素とプロパティ名の組で返す。 */
130
+ const referencesIn = (node) => {
131
+ const own = node.type === "element" && node.properties !== void 0 ? node.tagName === "img" ? [[node.properties, "src"]] : node.tagName === "a" ? [[node.properties, "href"]] : [] : [];
132
+ const nested = [
133
+ ...node.children ?? [],
134
+ node.fallback,
135
+ node.fallbackEnd
136
+ ].filter((child) => child !== null && child !== void 0);
137
+ return [...own, ...nested.flatMap(referencesIn)];
138
+ };
139
+ /** `.csn` / `.csnx` の hast の中で、Cosense 上のファイルを指す URL を差し替える rehype プラグイン。 */
140
+ const rehypeCosenseAssets = (store) => () => async (tree) => {
141
+ await Promise.all(referencesIn(tree).map(async ([properties, key]) => {
142
+ const value = properties[key];
143
+ if (typeof value !== "string") return;
144
+ properties[key] = key === "href" ? await store.resolveLink(value) : await store.resolve(value);
145
+ }));
146
+ };
147
+ //#endregion
148
+ //#region src/highlight.ts
149
+ /** 行頭 (字下げの後) の `code:ファイル名` からコードブロックを見つける。 */
150
+ const CODE_BLOCK = /^[ \t]*code:(.+)$/gm;
151
+ /** ページに出てくるコードブロックの言語名。`highlight` に渡るのと同じ決め方。 */
152
+ const codeLanguagesIn = (source) => [...new Set([...source.matchAll(CODE_BLOCK)].map((match) => codeLanguageOf((match[1] ?? "").trim())))];
153
+ /** shiki を使う設定なら、色付けしない言語 (`excludeLangs`) の一覧。shiki を使わない設定なら None。 */
154
+ const shikiExclusionsOf = (setting) => Match.value(setting).pipe(Match.when("shiki", () => Option.some([])), Match.when({ type: "shiki" }, (config) => Option.some(config.excludeLangs ?? [])), Match.orElse(() => Option.none()));
155
+ /**
156
+ * Astro の Markdown の設定で色付けする。shiki を使わない設定 (`false` や `'prism'`) なら undefined。
157
+ * prism には相当するものが無いので、色付けしない。
158
+ */
159
+ const astroShikiHighlighter = (markdown) => pipe(shikiExclusionsOf(markdown.syntaxHighlight), Option.map((excluded) => shikiHighlighter(markdown.shikiConfig, new Set(excluded))), Option.getOrUndefined);
160
+ const shikiHighlighter = ({ langs, langAlias, theme, themes, defaultColor, transformers }, excluded) => {
161
+ const multiple = Object.keys(themes).length > 0;
162
+ const themed = multiple ? {
163
+ themes,
164
+ ...defaultColor === void 0 ? {} : { defaultColor }
165
+ } : { theme };
166
+ const shiki = Effect.runSync(Effect.cached(Effect.promise(() => createHighlighter({
167
+ themes: multiple ? Object.values(themes) : [theme],
168
+ langs
169
+ }))));
170
+ const resolve = (language) => Option.getOrElse(Option.fromNullable(langAlias[language]), () => language);
171
+ /** ページに出てくる言語のうち、読み込んでいないものを読み込む。shiki が知らない言語は飛ばす。 */
172
+ const loadLanguagesIn = (source) => (highlighter) => {
173
+ const loaded = new Set(highlighter.getLoadedLanguages());
174
+ const wanted = codeLanguagesIn(source).map(resolve).filter((language) => !excluded.has(language) && !loaded.has(language)).filter((language) => language in bundledLanguages);
175
+ return pipe(Effect.promise(() => highlighter.loadLanguage(...wanted)), Effect.when(() => wanted.length > 0));
176
+ };
177
+ /** 読み込めた言語だけを色付けする。知らない言語は null (色付けせず 1 行ずつのまま) にする。 */
178
+ const highlightWith = (highlighter) => {
179
+ const available = new Set(highlighter.getLoadedLanguages());
180
+ return (code, language) => pipe(Option.some(resolve(language)), Option.filter((lang) => !excluded.has(lang) && available.has(lang)), Option.map((lang) => highlighter.codeToHast(code, {
181
+ lang,
182
+ ...themed,
183
+ transformers
184
+ })), Option.getOrNull);
185
+ };
186
+ return (source) => pipe(shiki, Effect.tap(loadLanguagesIn(source)), Effect.map(highlightWith), Effect.runPromise);
187
+ };
188
+ /** 利用者が渡した色付けをそのまま使う。 */
189
+ const customHighlighter = (highlight) => async () => highlight;
190
+ /**
191
+ * 利用者の `renderOptions` に、統合が作った色付けを足す。色付けしない設定なら何も足さない。
192
+ */
193
+ const renderOptionsWith = (renderOptions, highlight) => pipe(highlight, Option.match({
194
+ onNone: () => ({ ...renderOptions }),
195
+ onSome: (value) => ({
196
+ ...renderOptions,
197
+ highlight: value
198
+ })
199
+ }));
200
+ //#endregion
201
+ //#region src/lint.ts
202
+ /**
203
+ * lint.ts — ビルドの前に、サイトのページのリンク切れを調べる。
204
+ *
205
+ * 判定は `@cosense-toolbox/lsp` の `checkSite` をそのまま使う。エディタの診断と
206
+ * `csn-lsp check` と同じ関数なので、エディタで警告されるものとビルドで止まるものが一致する。
207
+ */
208
+ /** `path:line:column message`。path はプロジェクトのルートから。 */
209
+ const messageOf = (root) => ({ path, line, column, message }) => `${relative(root, path).split(sep).join("/")}:${line}:${column} ${message}`;
210
+ /**
211
+ * `srcDir` の下の `.csn` / `.csnx` を読み、リンク切れを調べる。
212
+ * パースにはサイトの `parseOptions` を使うので、独自の装飾記法もビルドと同じに読む。
213
+ */
214
+ const lintSite = (root, srcDir, { unresolvedLinks = "warning", frontmatter = true }, parseOptions = {}) => Effect.map(checkSite({
215
+ roots: [srcDir],
216
+ unresolvedLinks,
217
+ parseOptions,
218
+ frontmatter
219
+ }), (reports) => {
220
+ const [warnings, errors] = Array.partition(reports, ({ level }) => level === "error");
221
+ return {
222
+ errors: Array.map(errors, messageOf(root)),
223
+ warnings: Array.map(warnings, messageOf(root))
224
+ };
225
+ });
226
+ //#endregion
227
+ //#region src/site.ts
228
+ /**
229
+ * site.ts — プロジェクトの `.csn` / `.csnx` を全部読み、索引とリンクグラフを作る。
230
+ *
231
+ * リンクの解決には全ページのタイトルが要るので、1 ファイルずつのコンパイルの前にまとめて読む。
232
+ * パースは軽いので、全ページを先読みしても問題にならない。
233
+ */
234
+ const EXTENSIONS = [".csn", ".csnx"];
235
+ const isCosenseFile = (path) => EXTENSIONS.some((extension) => path.endsWith(extension));
236
+ /** ルートからの相対パス (区切りは `/`)。Astro の content collection の `filePath` と同じ形。 */
237
+ const idOf = (root, absolute) => relative(root, absolute).split(sep).join("/");
238
+ const SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
239
+ "node_modules",
240
+ ".astro",
241
+ "dist"
242
+ ]);
243
+ /** `directory` の下の `.csn` / `.csnx`。読めないディレクトリ (まだ無い src/content など) は空とみなす。 */
244
+ const listFiles = async (directory) => {
245
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
246
+ return (await Promise.all(entries.map(async (entry) => {
247
+ const path = join(directory, entry.name);
248
+ if (entry.isDirectory()) return SKIP_DIRECTORIES.has(entry.name) ? [] : listFiles(path);
249
+ return entry.isFile() && isCosenseFile(entry.name) ? [path] : [];
250
+ }))).flat().sort();
251
+ };
252
+ /** `directory` の下を読んで、索引とグラフを作る。id は `root` からの相対パス。 */
253
+ const scanSite = async (root, directory, options = {}) => {
254
+ const files = await listFiles(directory);
255
+ const pages = (await Promise.all(files.map(async (file) => ({
256
+ id: idOf(root, file),
257
+ source: await readFile(file, "utf8")
258
+ })))).map(({ id, source }) => ({
259
+ id,
260
+ metadata: readPage(source, {
261
+ ...options,
262
+ filePath: id
263
+ }).metadata
264
+ }));
265
+ return {
266
+ index: createIndex(pages.map(({ id, metadata }) => ({
267
+ id,
268
+ title: metadata.title,
269
+ slug: metadata.slug,
270
+ draft: metadata.draft
271
+ }))),
272
+ graph: buildGraph(pages)
273
+ };
274
+ };
275
+ const createSiteCache = (root, directory, options = {}) => {
276
+ let site;
277
+ return {
278
+ get: () => {
279
+ site ??= scanSite(root, directory, options);
280
+ return site;
281
+ },
282
+ reset: () => {
283
+ site = void 0;
284
+ }
285
+ };
286
+ };
287
+ //#endregion
288
+ //#region src/vite-plugin.ts
289
+ /**
290
+ * vite-plugin.ts — `.csn` / `.csnx` を Astro のコンポーネントのモジュールにする。
291
+ */
292
+ const GRAPH_MODULE_ID = "virtual:cosense-x/graph";
293
+ const RESOLVED_GRAPH_MODULE_ID = `\0${GRAPH_MODULE_ID}`;
294
+ const ASSETS_MODULE_ID = "virtual:cosense-x/assets";
295
+ const RESOLVED_ASSETS_MODULE_ID = `\0${ASSETS_MODULE_ID}`;
296
+ /**
297
+ * `toHtml` などで自分で描画するページから、Cosense 上のファイルを置くための関数。
298
+ * 置き場は統合が作って `globalThis` に置いたものを使う。ページの描画はビルドでも dev でも
299
+ * 統合と同じプロセスで走るので、PAT をモジュールのコードに埋め込まずに済む。
300
+ * 置き場が無いとき (実行時に描画する SSR など) は、元の URL のまま返す。
301
+ */
302
+ const ASSETS_MODULE = `const store = () => globalThis[Symbol.for(${JSON.stringify(ASSET_STORE_KEY)})];
303
+ export const cosenseAsset = (url) => store()?.resolve(url) ?? Promise.resolve(url);
304
+ export const localizeCosenseAssets = (html) => store()?.localizeHtml(html) ?? Promise.resolve(html);
305
+ `;
306
+ /** dev サーバーで返す Content-Type。ビルドでは静的なホスティングが拡張子から決める。 */
307
+ const CONTENT_TYPES$1 = {
308
+ ".png": "image/png",
309
+ ".jpg": "image/jpeg",
310
+ ".gif": "image/gif",
311
+ ".webp": "image/webp",
312
+ ".avif": "image/avif",
313
+ ".svg": "image/svg+xml",
314
+ ".ico": "image/x-icon",
315
+ ".pdf": "application/pdf",
316
+ ".mp4": "video/mp4",
317
+ ".mp3": "audio/mpeg"
318
+ };
319
+ /** 統合の compile の設定を、`compile` が受け取る形にする。 */
320
+ const compileOptionsOf = ({ unresolvedLinks, ...rest }) => Option.match(Option.fromNullable(unresolvedLinks), {
321
+ onNone: () => rest,
322
+ onSome: (unresolved) => ({
323
+ ...rest,
324
+ unresolved
325
+ })
326
+ });
327
+ /**
328
+ * `@astrojs/mdx` と同じ形に整える。`Content` を default export にし、
329
+ * Astro がそれを `astro:jsx` のコンポーネントとして描画できるよう印を付ける。
330
+ */
331
+ const toAstroModule = (code, id, options) => {
332
+ const { components, layout, ssr } = options;
333
+ const content = layout === void 0 ? ["export const Content = (props = {}) => __cosenseBody(props);"] : [
334
+ `import __CosenseLayout from ${JSON.stringify(layout)};`,
335
+ "import { jsx as __cosenseJsx } from 'astro/jsx-runtime';",
336
+ "export const Content = (props = {}) => __cosenseJsx(__CosenseLayout, {",
337
+ " file, frontmatter, metadata,",
338
+ " children: __cosenseBody(props),",
339
+ "});"
340
+ ];
341
+ const tag = ssr ? ["import { __astro_tag_component__ } from 'astro/runtime/server/index.js';", "__astro_tag_component__(Content, 'astro:jsx');"] : [];
342
+ return [
343
+ code.replace("export default function CosenseContent", "function CosenseContent"),
344
+ components === void 0 ? "const __cosenseComponents = {};" : `import __cosenseComponents from ${JSON.stringify(components)};`,
345
+ `export const file = ${JSON.stringify(id)};`,
346
+ "const __cosenseBody = (props) => CosenseContent({",
347
+ " ...props,",
348
+ " components: { ...__cosenseComponents, ...props.components },",
349
+ "});",
350
+ ...content,
351
+ "export default Content;",
352
+ "Content[Symbol.for('mdx-component')] = true;",
353
+ `Content[Symbol.for('astro.needsHeadRendering')] = ${layout === void 0};`,
354
+ `Content.moduleId = ${JSON.stringify(id)};`,
355
+ ...tag
356
+ ].join("\n");
357
+ };
358
+ const vitePluginCosense = (options) => {
359
+ const root = fileURLToPath(options.root);
360
+ const loadSite = options.site.get;
361
+ return {
362
+ name: "@cosense-toolbox/astro",
363
+ enforce: "pre",
364
+ resolveId(id) {
365
+ if (id === "virtual:cosense-x/graph") return RESOLVED_GRAPH_MODULE_ID;
366
+ if (id === "virtual:cosense-x/assets") return RESOLVED_ASSETS_MODULE_ID;
367
+ },
368
+ async load(id) {
369
+ if (id === RESOLVED_ASSETS_MODULE_ID) return ASSETS_MODULE;
370
+ if (id !== RESOLVED_GRAPH_MODULE_ID) return void 0;
371
+ const { graph } = await loadSite();
372
+ return `export const graph = ${JSON.stringify(graph)};\nexport default graph;`;
373
+ },
374
+ transform: {
375
+ filter: { id: /\.csnx?$/ },
376
+ async handler(code, id) {
377
+ const { index } = await loadSite();
378
+ const highlight = Option.fromNullable(await options.highlighter?.(code));
379
+ const result = await compile(code, {
380
+ ...compileOptionsOf(options.compile),
381
+ renderOptions: renderOptionsWith(options.compile.renderOptions, highlight),
382
+ filePath: idOf(root, id),
383
+ index,
384
+ jsxImportSource: "astro"
385
+ });
386
+ for (const warning of result.warnings) this.warn(warning);
387
+ const ssr = this.environment.name === "ssr" || this.environment.name === "prerender";
388
+ const layout = result.frontmatter.layout;
389
+ return {
390
+ code: toAstroModule(result.code, id, {
391
+ components: options.components,
392
+ layout: typeof layout === "string" ? layout : void 0,
393
+ ssr
394
+ }),
395
+ map: null
396
+ };
397
+ }
398
+ },
399
+ configureServer(server) {
400
+ const assets = options.assets;
401
+ if (assets !== void 0) server.middlewares.use(assets.publicPath, async (request, response, next) => {
402
+ const name = (() => {
403
+ try {
404
+ return decodeURIComponent(basename(request.url?.split("?")[0] ?? ""));
405
+ } catch {
406
+ return "";
407
+ }
408
+ })();
409
+ const file = join(assets.cacheDir, basename(name));
410
+ if (!await stat(file).then((s) => s.isFile()).catch(() => false)) {
411
+ next();
412
+ return;
413
+ }
414
+ const type = CONTENT_TYPES$1[extname(file)];
415
+ if (type !== void 0) response.setHeader("content-type", type);
416
+ createReadStream(file).pipe(response);
417
+ });
418
+ const onChange = (file) => {
419
+ if (!isCosenseFile(file)) return;
420
+ options.site.reset();
421
+ for (const environment of Object.values(server.environments)) {
422
+ const graph = environment.moduleGraph;
423
+ for (const [id, module] of graph.idToModuleMap) if (id === RESOLVED_GRAPH_MODULE_ID || isCosenseFile(id.split("?")[0] ?? "")) graph.invalidateModule(module);
424
+ }
425
+ server.ws.send({ type: "full-reload" });
426
+ };
427
+ server.watcher.on("add", onChange);
428
+ server.watcher.on("change", onChange);
429
+ server.watcher.on("unlink", onChange);
430
+ }
431
+ };
432
+ };
433
+ //#endregion
434
+ //#region src/index.ts
435
+ /**
436
+ * `@cosense-toolbox/astro` — `.csn` / `.csnx` を Astro のページと content collection で使う。
437
+ *
438
+ * ```js
439
+ * // astro.config.mjs
440
+ * import cosense from '@cosense-toolbox/astro'
441
+ * export default defineConfig({
442
+ * integrations: [cosense({ pageUrl: (page) => `/posts/${page.slug}` })],
443
+ * })
444
+ * ```
445
+ */
446
+ /** サイトのリンク切れを調べ、見つかったものをログに出す。 */
447
+ const reportLint = (config, lint, compileOptions, logger) => pipe(lintSite(fileURLToPath(config.root), fileURLToPath(config.srcDir), lint, compileOptions.parseOptions), Effect.tap(({ errors, warnings }) => Effect.all([Effect.forEach(warnings, (warning) => Effect.sync(() => logger.warn(warning))), Effect.forEach(errors, (error) => Effect.sync(() => logger.error(error)))])));
448
+ /** ビルドでは、error があれば失敗してビルドを止める。 */
449
+ const stopOnErrors = ({ errors }) => Array.match(errors, {
450
+ onEmpty: () => Effect.void,
451
+ onNonEmpty: (found) => Effect.fail(/* @__PURE__ */ new Error(`リンク切れが ${found.length} 件あるので、ビルドを止めた`))
452
+ });
453
+ /**
454
+ * 開発中は止めずに知らせるだけにする。何も無いときも 1 行出して、調べたことが分かるようにする。
455
+ */
456
+ const noteWhenClean = (logger) => ({ errors, warnings }) => Array.isEmptyReadonlyArray([...errors, ...warnings]) ? Effect.sync(() => logger.info("リンク切れは見つからなかった")) : Effect.void;
457
+ /** `{base}/_cosense/`。base の末尾の `/` の有無を吸収する。 */
458
+ const assetsPathOf = (config) => `${config.base.replace(/\/$/, "")}/_cosense/`;
459
+ /** 静的なサイトでは出力先そのもの、サーバー出力ではクライアント向けの出力先に置く。 */
460
+ const assetsDirOf = (config, dir) => new URL("_cosense/", config.output === "static" ? dir : config.build.client);
461
+ const CONTENT_TYPES = `declare module 'astro:content' {
462
+ interface Render {
463
+ ${EXTENSIONS.map((extension) => ` '${extension}': Promise<{ Content: import('astro').MDXContent }>;`).join("\n")}
464
+ }
465
+ }
466
+ `;
467
+ const INJECTED_TYPES = `declare module '${GRAPH_MODULE_ID}' {
468
+ export const graph: import('@cosense-toolbox/cosense-x/graph').Graph;
469
+ export default graph;
470
+ }
471
+ declare module '${ASSETS_MODULE_ID}' {
472
+ /** Cosense 上のファイルなら、ビルド時に取ってきて置いたサイトの中の URL を返す。それ以外はそのまま返す */
473
+ export const cosenseAsset: (url: string) => Promise<string>;
474
+ /** HTML の src / href のうち、Cosense 上のファイルを指すものを差し替える (toHtml の出力に使う) */
475
+ export const localizeCosenseAssets: (html: string) => Promise<string>;
476
+ }
477
+ ${EXTENSIONS.map((extension) => `
478
+ declare module '*${extension}' {
479
+ export const frontmatter: Record<string, unknown>;
480
+ export const metadata: import('@cosense-toolbox/cosense-x').PageMetadata;
481
+ export const file: string;
482
+ export const Content: import('astro').MDXContent;
483
+ export default Content;
484
+ }`).join("\n")}
485
+ `;
486
+ function cosense(options = {}) {
487
+ const { components, assets: assetsOptions = {}, syntaxHighlight = "astro", lint, ...compileOptions } = options;
488
+ let assets;
489
+ let astroConfig;
490
+ return {
491
+ name: "@cosense-toolbox/astro",
492
+ hooks: {
493
+ "astro:config:setup": (params) => {
494
+ const { addPageExtension, addContentEntryType } = params;
495
+ const { config, addRenderer, updateConfig, logger } = params;
496
+ const root = fileURLToPath(config.root);
497
+ assets = assetsOptions === false ? void 0 : createAssetStore({
498
+ cacheDir: fileURLToPath(new URL("cosense-assets/", config.cacheDir)),
499
+ publicPath: assetsPathOf(config),
500
+ fetchOptions: {
501
+ ...assetsOptions.pat === void 0 ? {} : { pat: assetsOptions.pat },
502
+ ...assetsOptions.origin === void 0 ? {} : { origin: assetsOptions.origin }
503
+ },
504
+ ...assetsOptions.links === void 0 ? {} : { links: assetsOptions.links },
505
+ warn: (message) => logger.warn(message)
506
+ });
507
+ const highlighter = syntaxHighlight === false ? void 0 : syntaxHighlight === "astro" ? astroShikiHighlighter(config.markdown) : customHighlighter(syntaxHighlight);
508
+ Object.assign(globalThis, { [Symbol.for(ASSET_STORE_KEY)]: assets });
509
+ const site = createSiteCache(root, fileURLToPath(config.srcDir), options.parseOptions === void 0 ? {} : { parseOptions: options.parseOptions });
510
+ addRenderer({
511
+ name: "astro:jsx",
512
+ serverEntrypoint: new URL("./server.mjs", import.meta.url)
513
+ });
514
+ for (const extension of EXTENSIONS) addPageExtension(extension);
515
+ addContentEntryType({
516
+ extensions: [...EXTENSIONS],
517
+ async getEntryInfo({ fileUrl, contents }) {
518
+ const { frontmatter, metadata, body } = readPage(contents, {
519
+ filePath: idOf(root, fileURLToPath(fileUrl)),
520
+ ...options.parseOptions === void 0 ? {} : { parseOptions: options.parseOptions }
521
+ });
522
+ const { links: _links, ...fields } = metadata;
523
+ return {
524
+ data: {
525
+ ...frontmatter,
526
+ ...fields
527
+ },
528
+ body,
529
+ slug: metadata.slug,
530
+ rawData: ""
531
+ };
532
+ },
533
+ contentModuleTypes: CONTENT_TYPES,
534
+ handlePropagation: true
535
+ });
536
+ updateConfig({ vite: { plugins: [vitePluginCosense({
537
+ root: config.root,
538
+ site,
539
+ compile: assets === void 0 ? compileOptions : {
540
+ ...compileOptions,
541
+ rehypePlugins: [...compileOptions.rehypePlugins ?? [], rehypeCosenseAssets(assets)]
542
+ },
543
+ assets,
544
+ highlighter,
545
+ components: components === void 0 ? void 0 : fileURLToPath(new URL(components, config.root))
546
+ })] } });
547
+ },
548
+ "astro:config:done": ({ config, injectTypes }) => {
549
+ astroConfig = config;
550
+ injectTypes({
551
+ filename: "types.d.ts",
552
+ content: INJECTED_TYPES
553
+ });
554
+ },
555
+ "astro:build:start": async ({ logger }) => {
556
+ if (lint === void 0 || astroConfig === void 0) return;
557
+ await Effect.runPromise(Effect.flatMap(reportLint(astroConfig, lint, compileOptions, logger), stopOnErrors));
558
+ },
559
+ "astro:server:setup": ({ server, logger }) => {
560
+ if (lint === void 0 || astroConfig === void 0) return;
561
+ const config = astroConfig;
562
+ const check = () => Effect.runFork(Effect.flatMap(reportLint(config, lint, compileOptions, logger), noteWhenClean(logger)));
563
+ const onChange = (file) => {
564
+ if (isCosenseFile(file)) check();
565
+ };
566
+ server.watcher.on("add", onChange);
567
+ server.watcher.on("change", onChange);
568
+ server.watcher.on("unlink", onChange);
569
+ check();
570
+ },
571
+ "astro:build:done": async ({ dir }) => {
572
+ if (assets === void 0 || astroConfig === void 0) return;
573
+ await assets.copyUsedTo(assetsDirOf(astroConfig, dir));
574
+ }
575
+ }
576
+ };
577
+ }
578
+ //#endregion
579
+ export { cosense as default };
580
+
581
+ //# sourceMappingURL=index.mjs.map