@folio-agent/handler 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
  ```bash
10
10
  npm install @folio-agent/handler
11
11
 
12
- # 対話ウィザードで初期設定(config・テーマCSS・APIルート雛形などを生成)
13
- npx folio-agent-init
12
+ # 初期設定(config・APIルート雛形などを生成。引数は --help)
13
+ npx folio-agent-init --include "/,/works/**,/about"
14
14
 
15
- # ビルド時: 知識ファイルの生成(ウィザードが build スクリプトに追記する)
15
+ # ビルド時: 知識ファイルの生成(init が build スクリプトに追記する)
16
16
  npx folio-agent-ingest folio-agent.config.json knowledge.json
17
17
  ```
18
18
 
@@ -8,9 +8,9 @@ export interface DevVarsAndGitignorePlan {
8
8
  }
9
9
  /**
10
10
  * Computes what to write to `.dev.vars` and `.gitignore`. `.gitignore` protection for
11
- * `.dev.vars` is unconditional — independent of whether a Gemini API key was entered this run —
12
- * because a skipped key is commonly filled in by hand afterward per the wizard's own guidance
13
- * (see `geminiApiKeySkipNote`), and by then the file must already be gitignored.
11
+ * `.dev.vars` is unconditional — independent of whether `GEMINI_API_KEY` was set this run —
12
+ * because a missing key is commonly filled in by hand afterward, and by then the file must
13
+ * already be gitignored.
14
14
  */
15
15
  export declare function planDevVarsAndGitignore(geminiApiKey: string | undefined, devVarsContent: string, gitignoreContent: string): DevVarsAndGitignorePlan;
16
- export declare function main(): Promise<void>;
16
+ export declare function main(argv?: string[], env?: Record<string, string | undefined>): Promise<void>;
package/dist/init/cli.js CHANGED
@@ -1,64 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, realpathSync, statSync } from "node:fs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, posix } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import * as clack from "@clack/prompts";
7
- import { DEFAULT_WIZARD_ANSWERS, runWizard } from "./questions.js";
6
+ import { DEFAULT_API_ROUTE_PATH, InitArgsError, resolveInitRun, USAGE } from "./options.js";
8
7
  import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, ensureGitignoreEntry, PUBLIC_DIR_NAME, resolveThemeCssPath, THEME_CSS_FILENAME, upsertDevVar, } from "./writers.js";
9
8
  const CONFIG_PATH = "folio-agent.config.json";
10
9
  const PACKAGE_JSON_PATH = "package.json";
11
10
  const DEV_VARS_PATH = ".dev.vars";
12
11
  const GITIGNORE_PATH = ".gitignore";
13
- const KNOWLEDGE_OUTPUT_PATH = "dist/knowledge.json";
14
- const CLI_TEXT = {
15
- ja: {
16
- summaryTitle: "以下の内容で書き込みます:",
17
- summaryConfigLine: `${CONFIG_PATH} を生成・更新`,
18
- summaryThemeLine: (path) => `${path} を生成・更新(再実行時はこのファイルの書き換えだけで dev サーバーの HMR に反映されます)`,
19
- summaryApiRouteLine: (path) => `${path} を新規生成`,
20
- summaryApiRouteSkippedLine: (path) => `${path} は既に存在するためスキップします`,
21
- summaryApiRouteNotGeneratedLine: "API ルート雛形は生成しません(既存の配線を利用する設定)",
22
- summaryBuildScriptLine: `${PACKAGE_JSON_PATH} の build スクリプトに ingest コマンドを追記`,
23
- summaryBuildScriptUnchangedLine: `${PACKAGE_JSON_PATH} の build スクリプトは変更なし(既に含まれています)`,
24
- summaryDevVarsLine: `${DEV_VARS_PATH} に GEMINI_API_KEY を書き込み`,
25
- summaryGitignoreAppendedLine: `${GITIGNORE_PATH} に ${DEV_VARS_PATH} を追記`,
26
- summaryGitignoreAlreadyPresentLine: `${GITIGNORE_PATH} には ${DEV_VARS_PATH} が既に含まれています(追記不要)`,
27
- confirmMessage: "この内容で書き込みますか?",
28
- writeCancelledMessage: "書き込みを中止しました。",
29
- outroTitle: "セットアップが完了しました。",
30
- snippetIntro: "サイトの全ページで読み込まれる共通テンプレート(Astro なら `src/layouts/` のレイアウト、素の HTML なら `</body>` の直前)に、以下を1回だけ貼り付けてください(再実行時は不要です):",
31
- snippetIntroExistingConfigNote: "既に <folio-agent-widget> を導入済みの場合は、theme.css の読み込み(`<link>` または `import`)だけ追加すれば十分です。",
32
- themeImportGuidanceNote: 'バンドラを使っているサイトでは、レイアウトのスクリプトで `import "./folio-agent.theme.css";` を読み込んでください(相対パスは配置に合わせて調整してください)。',
33
- apiRouteNotGeneratedSnippetNote: "API ルート雛形は生成していません。endpoint は実際の配線パスに合わせて書き換えてください。",
34
- gitignoreAppendedNote: `${GITIGNORE_PATH} に ${DEV_VARS_PATH} を追記しました。`,
35
- gitignoreAlreadyPresentNote: `${GITIGNORE_PATH} には ${DEV_VARS_PATH} が既に含まれていました。`,
36
- d1Note: "D1 が未設定でも widget の表示とテーマ確認はローカルで動きます。チャット応答には D1 のセットアップと Gemini API キーが必要です。",
37
- },
38
- en: {
39
- summaryTitle: "The following will be written:",
40
- summaryConfigLine: `Generate/update ${CONFIG_PATH}`,
41
- summaryThemeLine: (path) => `Generate/update ${path} (re-running the wizard only rewrites this file, and your dev server's HMR picks it up)`,
42
- summaryApiRouteLine: (path) => `Generate ${path}`,
43
- summaryApiRouteSkippedLine: (path) => `Skip ${path} (already exists)`,
44
- summaryApiRouteNotGeneratedLine: "Skip generating the API route scaffold (site already wires the handler up)",
45
- summaryBuildScriptLine: `Append the ingest command to ${PACKAGE_JSON_PATH}'s build script`,
46
- summaryBuildScriptUnchangedLine: `No change to ${PACKAGE_JSON_PATH}'s build script (already present)`,
47
- summaryDevVarsLine: `Write GEMINI_API_KEY to ${DEV_VARS_PATH}`,
48
- summaryGitignoreAppendedLine: `Append ${DEV_VARS_PATH} to ${GITIGNORE_PATH}`,
49
- summaryGitignoreAlreadyPresentLine: `${GITIGNORE_PATH} already contains ${DEV_VARS_PATH} (no change needed)`,
50
- confirmMessage: "Proceed with writing these files?",
51
- writeCancelledMessage: "Write cancelled.",
52
- outroTitle: "Setup complete.",
53
- snippetIntro: "Paste the following once into the common template shared by every page (an Astro layout under `src/layouts/`, or right before `</body>` for plain HTML) — not needed again on re-runs:",
54
- snippetIntroExistingConfigNote: "If <folio-agent-widget> is already set up, you only need to add the theme.css load (`<link>` or `import`).",
55
- themeImportGuidanceNote: 'If your site uses a bundler, load it from your layout script with `import "./folio-agent.theme.css";` (adjust the relative path to your layout).',
56
- apiRouteNotGeneratedSnippetNote: "The API route scaffold wasn't generated. Update the endpoint to match your actual routing.",
57
- gitignoreAppendedNote: `Appended ${DEV_VARS_PATH} to ${GITIGNORE_PATH}.`,
58
- gitignoreAlreadyPresentNote: `${GITIGNORE_PATH} already contained ${DEV_VARS_PATH}.`,
59
- d1Note: "Without D1 set up, the widget still renders and the theme can be checked locally. Chat responses require D1 and a Gemini API key.",
60
- },
61
- };
62
12
  async function readJson(path) {
63
13
  if (!existsSync(path))
64
14
  return undefined;
@@ -74,37 +24,38 @@ async function readTextOrEmpty(path) {
74
24
  }
75
25
  /**
76
26
  * Computes what to write to `.dev.vars` and `.gitignore`. `.gitignore` protection for
77
- * `.dev.vars` is unconditional — independent of whether a Gemini API key was entered this run —
78
- * because a skipped key is commonly filled in by hand afterward per the wizard's own guidance
79
- * (see `geminiApiKeySkipNote`), and by then the file must already be gitignored.
27
+ * `.dev.vars` is unconditional — independent of whether `GEMINI_API_KEY` was set this run —
28
+ * because a missing key is commonly filled in by hand afterward, and by then the file must
29
+ * already be gitignored.
80
30
  */
81
31
  export function planDevVarsAndGitignore(geminiApiKey, devVarsContent, gitignoreContent) {
82
32
  const nextDevVars = geminiApiKey ? upsertDevVar(devVarsContent, "GEMINI_API_KEY", geminiApiKey) : undefined;
83
33
  const gitignoreResult = ensureGitignoreEntry(gitignoreContent, DEV_VARS_PATH);
84
34
  return { nextDevVars, gitignoreResult };
85
35
  }
86
- function seedDefaults(previous) {
87
- if (!previous)
88
- return DEFAULT_WIZARD_ANSWERS;
89
- return {
90
- ...DEFAULT_WIZARD_ANSWERS,
91
- language: previous.language ?? DEFAULT_WIZARD_ANSWERS.language,
92
- distDir: previous.distDir ?? DEFAULT_WIZARD_ANSWERS.distDir,
93
- include: previous.include ?? DEFAULT_WIZARD_ANSWERS.include,
94
- zenn: previous.zenn,
95
- theme: previous.theme ?? DEFAULT_WIZARD_ANSWERS.theme,
96
- apiRoutePath: undefined,
97
- };
98
- }
99
- export async function main() {
36
+ export async function main(argv = process.argv.slice(2), env = process.env) {
100
37
  const previousConfig = await readJson(CONFIG_PATH);
101
- const answers = await runWizard(seedDefaults(previousConfig));
102
- const text = CLI_TEXT[answers.language];
38
+ let run;
39
+ try {
40
+ run = resolveInitRun(argv, previousConfig, env);
41
+ }
42
+ catch (error) {
43
+ if (!(error instanceof InitArgsError))
44
+ throw error;
45
+ console.error(`folio-agent-init: ${error.message}\n\n${USAGE}`);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ if (!run) {
50
+ console.log(USAGE);
51
+ return;
52
+ }
53
+ const { answers, dryRun } = run;
103
54
  const configJson = buildConfigJson(answers, previousConfig);
104
- const themeCss = buildThemeCss(answers.theme);
105
55
  const hasPublicDir = existsSync(PUBLIC_DIR_NAME) && statSync(PUBLIC_DIR_NAME).isDirectory();
106
56
  const publicThemeCssPath = `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}`;
107
57
  const themeCssPath = resolveThemeCssPath(hasPublicDir, existsSync(publicThemeCssPath), existsSync(THEME_CSS_FILENAME));
58
+ const themeCssExists = existsSync(themeCssPath);
108
59
  const usesPublicDirForTheme = themeCssPath === publicThemeCssPath;
109
60
  const apiRouteExists = answers.apiRoutePath !== undefined && existsSync(answers.apiRoutePath);
110
61
  const apiRouteContent = answers.apiRoutePath !== undefined && !apiRouteExists
@@ -117,73 +68,78 @@ export async function main() {
117
68
  : undefined;
118
69
  const pkg = (await readJson(PACKAGE_JSON_PATH)) ?? {};
119
70
  const scripts = pkg.scripts ?? {};
120
- const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, KNOWLEDGE_OUTPUT_PATH);
71
+ // API ルート雛形は <distDir>/knowledge.json を import するので、出力先もそこに揃える
72
+ const knowledgeOutputPath = posix.join(answers.distDir, "knowledge.json");
73
+ const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, knowledgeOutputPath);
121
74
  const buildScriptChanged = nextBuildScript !== (scripts.build ?? "");
122
75
  const { nextDevVars, gitignoreResult } = planDevVarsAndGitignore(answers.geminiApiKey, await readTextOrEmpty(DEV_VARS_PATH), await readTextOrEmpty(GITIGNORE_PATH));
123
- const summaryLines = [
124
- text.summaryConfigLine,
125
- text.summaryThemeLine(themeCssPath),
126
- answers.apiRoutePath !== undefined
127
- ? apiRouteExists
128
- ? text.summaryApiRouteSkippedLine(answers.apiRoutePath)
129
- : text.summaryApiRouteLine(answers.apiRoutePath)
130
- : text.summaryApiRouteNotGeneratedLine,
131
- buildScriptChanged ? text.summaryBuildScriptLine : text.summaryBuildScriptUnchangedLine,
76
+ const plan = [
77
+ `write ${CONFIG_PATH}`,
78
+ answers.theme
79
+ ? `write ${themeCssPath}`
80
+ : themeCssExists
81
+ ? `remove ${themeCssPath} (theme: auto)`
82
+ : "skip theme CSS (theme: auto, the widget follows the site's colors)",
83
+ answers.apiRoutePath === undefined
84
+ ? "skip API route scaffold"
85
+ : apiRouteExists
86
+ ? `skip ${answers.apiRoutePath} (already exists)`
87
+ : `create ${answers.apiRoutePath}`,
88
+ buildScriptChanged ? `update ${PACKAGE_JSON_PATH} (append ingest to the build script)` : `keep ${PACKAGE_JSON_PATH}`,
89
+ nextDevVars !== undefined ? `write ${DEV_VARS_PATH} (GEMINI_API_KEY)` : `skip ${DEV_VARS_PATH} (GEMINI_API_KEY is not set)`,
90
+ gitignoreResult.changed ? `update ${GITIGNORE_PATH} (add ${DEV_VARS_PATH})` : `keep ${GITIGNORE_PATH}`,
132
91
  ];
133
- if (nextDevVars !== undefined) {
134
- summaryLines.push(text.summaryDevVarsLine);
92
+ console.log(`folio-agent-init${dryRun ? " (dry run)" : ""}\n${plan.map((line) => ` ${line}`).join("\n")}\n`);
93
+ if (!dryRun) {
94
+ await writeFile(CONFIG_PATH, `${JSON.stringify(configJson, null, 2)}\n`);
95
+ if (answers.theme) {
96
+ await writeFile(themeCssPath, buildThemeCss(answers.theme));
97
+ }
98
+ else if (themeCssExists) {
99
+ await rm(themeCssPath);
100
+ }
101
+ if (apiRouteContent !== undefined && answers.apiRoutePath !== undefined) {
102
+ await mkdir(dirname(answers.apiRoutePath), { recursive: true });
103
+ await writeFile(answers.apiRoutePath, apiRouteContent);
104
+ }
105
+ if (buildScriptChanged) {
106
+ const nextPkg = { ...pkg, scripts: { ...scripts, build: nextBuildScript } };
107
+ await writeFile(PACKAGE_JSON_PATH, `${JSON.stringify(nextPkg, null, 2)}\n`);
108
+ }
109
+ if (nextDevVars !== undefined) {
110
+ await writeFile(DEV_VARS_PATH, nextDevVars);
111
+ }
112
+ if (gitignoreResult.changed) {
113
+ await writeFile(GITIGNORE_PATH, gitignoreResult.content);
114
+ }
135
115
  }
136
- summaryLines.push(gitignoreResult.changed ? text.summaryGitignoreAppendedLine : text.summaryGitignoreAlreadyPresentLine);
137
- clack.note(summaryLines.join("\n"), text.summaryTitle);
138
- const shouldWrite = await clack.confirm({ message: text.confirmMessage, initialValue: true });
139
- if (clack.isCancel(shouldWrite) || !shouldWrite) {
140
- clack.cancel(text.writeCancelledMessage);
141
- return;
142
- }
143
- await writeFile(CONFIG_PATH, `${JSON.stringify(configJson, null, 2)}\n`);
144
- await writeFile(themeCssPath, themeCss);
145
- if (apiRouteContent !== undefined && answers.apiRoutePath !== undefined) {
146
- await mkdir(dirname(answers.apiRoutePath), { recursive: true });
147
- await writeFile(answers.apiRoutePath, apiRouteContent);
148
- }
149
- if (buildScriptChanged) {
150
- const nextPkg = { ...pkg, scripts: { ...scripts, build: nextBuildScript } };
151
- await writeFile(PACKAGE_JSON_PATH, `${JSON.stringify(nextPkg, null, 2)}\n`);
152
- }
153
- if (nextDevVars !== undefined) {
154
- await writeFile(DEV_VARS_PATH, nextDevVars);
155
- }
156
- if (gitignoreResult.changed) {
157
- await writeFile(GITIGNORE_PATH, gitignoreResult.content);
158
- }
159
- const endpoint = deriveEndpointPath(answers.apiRoutePath ?? DEFAULT_WIZARD_ANSWERS.apiRoutePath ?? "functions/api/chat.ts");
160
- const snippetLines = [
161
- ...(usesPublicDirForTheme ? [`<link rel="stylesheet" href="/${THEME_CSS_FILENAME}">`] : []),
116
+ const endpoint = deriveEndpointPath(answers.apiRoutePath ?? DEFAULT_API_ROUTE_PATH);
117
+ const snippet = [
118
+ ...(answers.theme && usesPublicDirForTheme ? [`<link rel="stylesheet" href="/${THEME_CSS_FILENAME}">`] : []),
162
119
  `<folio-agent-widget endpoint="${endpoint}" policy-href="/data-policy" lang="${answers.language}"></folio-agent-widget>`,
163
120
  '<script type="module">',
164
121
  ' import { defineFolioAgentWidget } from "@folio-agent/widget";',
165
122
  " defineFolioAgentWidget();",
166
123
  "</script>",
124
+ ].join("\n");
125
+ const notes = [
126
+ "Add this once to the layout shared by every page (an Astro layout, or just before </body>). Not needed again on re-runs:",
127
+ "",
128
+ snippet,
167
129
  ];
168
- const snippet = snippetLines.join("\n");
169
- const introNotes = [text.snippetIntro];
170
- if (previousConfig) {
171
- introNotes.push(text.snippetIntroExistingConfigNote);
172
- }
173
- if (!usesPublicDirForTheme) {
174
- introNotes.push(text.themeImportGuidanceNote);
130
+ if (answers.theme && !usesPublicDirForTheme) {
131
+ notes.push("", `With a bundler, load the theme from the layout script: import "./${THEME_CSS_FILENAME}"; (adjust the path).`);
175
132
  }
176
133
  if (answers.apiRoutePath === undefined) {
177
- introNotes.push(text.apiRouteNotGeneratedSnippetNote);
134
+ notes.push("", "No API route scaffold was generated. Point endpoint at your actual route.");
135
+ }
136
+ if (nextDevVars === undefined) {
137
+ notes.push("", `Chat answers need GEMINI_API_KEY in ${DEV_VARS_PATH} (re-run with the variable set, or add it by hand) and a D1 database.`);
178
138
  }
179
- clack.note(introNotes.join("\n\n"));
180
- console.log(`\n${snippet}\n`);
181
- clack.note(gitignoreResult.changed ? text.gitignoreAppendedNote : text.gitignoreAlreadyPresentNote);
182
- clack.note(text.d1Note);
183
- clack.outro(text.outroTitle);
139
+ console.log(notes.join("\n"));
184
140
  }
185
- // Only run the wizard when executed directly (the bin entry point); importing this module for
186
- // unit tests (e.g. `planDevVarsAndGitignore`) must not trigger the interactive CLI as a side effect.
141
+ // Only run when executed directly (the bin entry point); importing this module for unit tests
142
+ // (e.g. `planDevVarsAndGitignore`) must not run the CLI as a side effect.
187
143
  // Compares realpaths, not raw paths: npm's node_modules/.bin/ entries are symlinks, so
188
144
  // process.argv[1] stays the symlink path while import.meta.url is already the resolved target
189
145
  // (see sync/cli.ts for the fuller rationale).
@@ -0,0 +1,35 @@
1
+ import type { Language } from "../chat/types.js";
2
+ import type { IngestConfig, ThemeColors, ZennIngestConfig } from "../ingest/types.js";
3
+ export interface InitAnswers {
4
+ language: Language;
5
+ distDir: string;
6
+ include: string[];
7
+ zenn?: ZennIngestConfig;
8
+ contactUrl?: string;
9
+ /** `undefined` means "auto": no theme CSS, the widget follows the host's colors. */
10
+ theme?: ThemeColors;
11
+ /** Taken from the `GEMINI_API_KEY` environment variable; `undefined` leaves `.dev.vars` untouched. */
12
+ geminiApiKey?: string;
13
+ /** `undefined` means "don't generate the API route scaffold". */
14
+ apiRoutePath?: string;
15
+ }
16
+ export interface InitRun {
17
+ answers: InitAnswers;
18
+ dryRun: boolean;
19
+ }
20
+ export type ThemePreset = "auto" | "poimandres" | "custom";
21
+ export declare const DEFAULT_API_ROUTE_PATH = "functions/api/chat.ts";
22
+ export declare const THEME_PRESETS: Record<Exclude<ThemePreset, "auto" | "custom">, ThemeColors>;
23
+ export declare const USAGE = "Usage: folio-agent-init [options]\n\nWrites folio-agent.config.json, the theme CSS, an API route scaffold, the ingest step in\npackage.json's build script, and .dev.vars. Re-running keeps every value you don't pass.\n\nOptions:\n --lang <ja|en> UI and prompt language (default: ja)\n --dist <dir> Built site directory (default: dist)\n --include <globs> Comma-separated URL globs to ingest (default: /**)\n --zenn-dir <path> Zenn CLI articles/ directory (with --zenn-user)\n --zenn-user <name|url> Zenn username or articles base URL (with --zenn-dir)\n --no-zenn Stop ingesting Zenn articles\n --contact-url <url> Contact page URL used in the API route scaffold\n --theme <preset> auto | poimandres | custom (default: auto)\n --accent <hex> Theme accent color (implies --theme custom)\n --surface <hex> Theme surface color (implies --theme custom)\n --text <hex> Theme text color (implies --theme custom)\n --api-route <path> Generate the API route scaffold at this path\n (default on a fresh setup: functions/api/chat.ts)\n --no-api-route Don't generate the API route scaffold\n --dry-run Print what would be written, write nothing\n -h, --help Show this help\n\nEnvironment:\n GEMINI_API_KEY Written to .dev.vars when set. Never pass the key as an argument.\n";
24
+ export declare class InitArgsError extends Error {
25
+ }
26
+ export declare function isValidHexColor(value: string): boolean;
27
+ /** Splits a comma-separated glob list into a trimmed, non-empty array. */
28
+ export declare function parseIncludeList(value: string): string[];
29
+ /**
30
+ * Normalizes a Zenn `baseUrl` answer. A bare username (no `http(s)://` prefix) is expanded to the
31
+ * canonical Zenn articles URL; an already-fully-qualified URL passes through unchanged.
32
+ */
33
+ export declare function normalizeZennBaseUrl(input: string): string;
34
+ /** Returns `undefined` when help was requested; throws `InitArgsError` on invalid input. */
35
+ export declare function resolveInitRun(argv: string[], previous: IngestConfig | undefined, env: Record<string, string | undefined>): InitRun | undefined;
@@ -0,0 +1,160 @@
1
+ import { parseArgs } from "node:util";
2
+ import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
+ export const DEFAULT_API_ROUTE_PATH = "functions/api/chat.ts";
4
+ const DEFAULT_DIST_DIR = "dist";
5
+ const DEFAULT_INCLUDE = ["/**"];
6
+ export const THEME_PRESETS = {
7
+ poimandres: { surface: "#1b1e28", text: "#e4f0fb", accent: "#5de4c7" },
8
+ };
9
+ export const USAGE = `Usage: folio-agent-init [options]
10
+
11
+ Writes folio-agent.config.json, the theme CSS, an API route scaffold, the ingest step in
12
+ package.json's build script, and .dev.vars. Re-running keeps every value you don't pass.
13
+
14
+ Options:
15
+ --lang <ja|en> UI and prompt language (default: ja)
16
+ --dist <dir> Built site directory (default: dist)
17
+ --include <globs> Comma-separated URL globs to ingest (default: /**)
18
+ --zenn-dir <path> Zenn CLI articles/ directory (with --zenn-user)
19
+ --zenn-user <name|url> Zenn username or articles base URL (with --zenn-dir)
20
+ --no-zenn Stop ingesting Zenn articles
21
+ --contact-url <url> Contact page URL used in the API route scaffold
22
+ --theme <preset> auto | poimandres | custom (default: auto)
23
+ --accent <hex> Theme accent color (implies --theme custom)
24
+ --surface <hex> Theme surface color (implies --theme custom)
25
+ --text <hex> Theme text color (implies --theme custom)
26
+ --api-route <path> Generate the API route scaffold at this path
27
+ (default on a fresh setup: ${DEFAULT_API_ROUTE_PATH})
28
+ --no-api-route Don't generate the API route scaffold
29
+ --dry-run Print what would be written, write nothing
30
+ -h, --help Show this help
31
+
32
+ Environment:
33
+ GEMINI_API_KEY Written to .dev.vars when set. Never pass the key as an argument.
34
+ `;
35
+ export class InitArgsError extends Error {
36
+ }
37
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
38
+ export function isValidHexColor(value) {
39
+ return HEX_COLOR_PATTERN.test(value.trim());
40
+ }
41
+ /** Splits a comma-separated glob list into a trimmed, non-empty array. */
42
+ export function parseIncludeList(value) {
43
+ return value
44
+ .split(",")
45
+ .map((item) => item.trim())
46
+ .filter((item) => item.length > 0);
47
+ }
48
+ /**
49
+ * Normalizes a Zenn `baseUrl` answer. A bare username (no `http(s)://` prefix) is expanded to the
50
+ * canonical Zenn articles URL; an already-fully-qualified URL passes through unchanged.
51
+ */
52
+ export function normalizeZennBaseUrl(input) {
53
+ const trimmed = input.trim();
54
+ return /^https?:\/\//i.test(trimmed) ? trimmed : `https://zenn.dev/${trimmed}/articles`;
55
+ }
56
+ const OPTIONS = {
57
+ lang: { type: "string" },
58
+ dist: { type: "string" },
59
+ include: { type: "string" },
60
+ "zenn-dir": { type: "string" },
61
+ "zenn-user": { type: "string" },
62
+ "no-zenn": { type: "boolean" },
63
+ "contact-url": { type: "string" },
64
+ theme: { type: "string" },
65
+ accent: { type: "string" },
66
+ surface: { type: "string" },
67
+ text: { type: "string" },
68
+ "api-route": { type: "string" },
69
+ "no-api-route": { type: "boolean" },
70
+ "dry-run": { type: "boolean" },
71
+ help: { type: "boolean", short: "h" },
72
+ };
73
+ function parseInitArgs(argv) {
74
+ try {
75
+ return parseArgs({ args: argv, options: OPTIONS, strict: true, allowPositionals: false }).values;
76
+ }
77
+ catch (error) {
78
+ throw new InitArgsError(error instanceof Error ? error.message : String(error));
79
+ }
80
+ }
81
+ /** Returns `undefined` when help was requested; throws `InitArgsError` on invalid input. */
82
+ export function resolveInitRun(argv, previous, env) {
83
+ const values = parseInitArgs(argv);
84
+ if (values.help)
85
+ return undefined;
86
+ const language = values.lang ?? previous?.language ?? DEFAULT_LANGUAGE;
87
+ if (language !== "ja" && language !== "en") {
88
+ throw new InitArgsError(`--lang must be "ja" or "en" (got "${language}")`);
89
+ }
90
+ const include = values.include !== undefined ? parseIncludeList(values.include) : (previous?.include ?? DEFAULT_INCLUDE);
91
+ if (include.length === 0)
92
+ throw new InitArgsError("--include needs at least one URL glob");
93
+ const apiRoutePath = resolveApiRoutePath(values["api-route"], values["no-api-route"], previous !== undefined);
94
+ const geminiApiKey = env.GEMINI_API_KEY?.trim() || undefined;
95
+ return {
96
+ dryRun: values["dry-run"] ?? false,
97
+ answers: {
98
+ language,
99
+ distDir: values.dist ?? previous?.distDir ?? DEFAULT_DIST_DIR,
100
+ include,
101
+ zenn: resolveZenn(values["zenn-dir"], values["zenn-user"], values["no-zenn"], previous?.zenn),
102
+ contactUrl: values["contact-url"]?.trim() || undefined,
103
+ theme: resolveTheme(values, previous?.theme),
104
+ geminiApiKey,
105
+ apiRoutePath,
106
+ },
107
+ };
108
+ }
109
+ function resolveApiRoutePath(path, skip, hasPreviousConfig) {
110
+ if (path !== undefined && skip)
111
+ throw new InitArgsError("--api-route and --no-api-route can't be combined");
112
+ if (skip)
113
+ return undefined;
114
+ if (path !== undefined)
115
+ return path;
116
+ // 既存の設定がある再実行では、サイトが別の経路で handler を配線済みのことが多い
117
+ return hasPreviousConfig ? undefined : DEFAULT_API_ROUTE_PATH;
118
+ }
119
+ function resolveZenn(dir, user, remove, previous) {
120
+ if (remove) {
121
+ if (dir !== undefined || user !== undefined)
122
+ throw new InitArgsError("--no-zenn can't be combined with --zenn-dir / --zenn-user");
123
+ return undefined;
124
+ }
125
+ const articlesDir = dir ?? previous?.articlesDir;
126
+ const baseUrl = user !== undefined ? normalizeZennBaseUrl(user) : previous?.baseUrl;
127
+ if (articlesDir === undefined && baseUrl === undefined)
128
+ return undefined;
129
+ if (articlesDir === undefined || baseUrl === undefined) {
130
+ throw new InitArgsError("Zenn ingest needs both --zenn-dir and --zenn-user");
131
+ }
132
+ return { articlesDir, baseUrl };
133
+ }
134
+ function resolveTheme(values, previous) {
135
+ const hasColor = values.accent !== undefined || values.surface !== undefined || values.text !== undefined;
136
+ const preset = values.theme ?? (hasColor ? "custom" : undefined);
137
+ if (preset === undefined)
138
+ return previous;
139
+ if (preset !== "custom" && hasColor) {
140
+ throw new InitArgsError(`--accent / --surface / --text only apply to --theme custom (got --theme ${preset})`);
141
+ }
142
+ if (preset === "auto")
143
+ return undefined;
144
+ if (preset === "poimandres")
145
+ return THEME_PRESETS.poimandres;
146
+ if (preset !== "custom")
147
+ throw new InitArgsError(`--theme must be auto, poimandres or custom (got "${preset}")`);
148
+ const theme = {
149
+ accent: values.accent ?? previous?.accent,
150
+ surface: values.surface ?? previous?.surface,
151
+ text: values.text ?? previous?.text,
152
+ };
153
+ for (const [key, value] of Object.entries(theme)) {
154
+ if (value === undefined)
155
+ throw new InitArgsError(`--theme custom needs --${key}`);
156
+ if (!isValidHexColor(value))
157
+ throw new InitArgsError(`--${key} must be a hex color like #fff or #ffffff (got "${value}")`);
158
+ }
159
+ return { accent: theme.accent.trim(), surface: theme.surface.trim(), text: theme.text.trim() };
160
+ }
@@ -1,11 +1,11 @@
1
1
  import type { Language } from "../chat/types.js";
2
2
  import type { IngestConfig, ThemeColors } from "../ingest/types.js";
3
- import type { WizardAnswers } from "./questions.js";
3
+ import type { InitAnswers } from "./options.js";
4
4
  export declare const THEME_CSS_FILENAME = "folio-agent.theme.css";
5
5
  export declare const PUBLIC_DIR_NAME = "public";
6
- /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
7
- export declare function buildConfigJson(answers: WizardAnswers, previous?: IngestConfig): IngestConfig;
8
- /** Builds `folio-agent.theme.css` content. Re-running the wizard only rewrites this file, which HMR picks up. */
6
+ /** Builds `folio-agent.config.json` content, keeping every previous field that init doesn't manage. */
7
+ export declare function buildConfigJson(answers: InitAnswers, previous?: IngestConfig): IngestConfig;
8
+ /** Builds `folio-agent.theme.css` content. Re-running init only rewrites this file, which HMR picks up. */
9
9
  export declare function buildThemeCss(theme: ThemeColors): string;
10
10
  /**
11
11
  * Decides where `folio-agent.theme.css` should be written. A file outside `public/` (or its
@@ -29,7 +29,11 @@ export interface ApiRouteAnswers {
29
29
  }
30
30
  /** Builds a minimal Cloudflare Pages Function that wires up `createChatHandler`. */
31
31
  export declare function buildApiRouteTemplate(answers: ApiRouteAnswers): string;
32
- /** Appends the ingest command to a `build` script if it isn't already present; otherwise returns it unchanged. */
32
+ /**
33
+ * Appends the ingest command to a `build` script unless it already runs ingest. Any existing
34
+ * `folio-agent-ingest` counts, whatever its arguments: sites often write to a different output
35
+ * path than the default, and appending a second run would ingest twice.
36
+ */
33
37
  export declare function appendIngestToBuildScript(buildScript: string | undefined, configPath: string, knowledgeOutputPath: string): string;
34
38
  /** Adds or replaces a single `KEY=value` line in `.dev.vars` content, leaving other lines untouched. */
35
39
  export declare function upsertDevVar(content: string, key: string, value: string): string;
@@ -3,29 +3,25 @@ import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
3
  const INGEST_COMMAND_PREFIX = "folio-agent-ingest";
4
4
  export const THEME_CSS_FILENAME = "folio-agent.theme.css";
5
5
  export const PUBLIC_DIR_NAME = "public";
6
- /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
6
+ /** Builds `folio-agent.config.json` content, keeping every previous field that init doesn't manage. */
7
7
  export function buildConfigJson(answers, previous) {
8
8
  const config = {
9
+ ...previous,
9
10
  distDir: answers.distDir,
10
11
  include: answers.include,
11
12
  language: answers.language,
12
- theme: answers.theme,
13
13
  };
14
- if (previous?.exclude) {
15
- config.exclude = previous.exclude;
16
- }
17
- if (previous?.knowledgeDir) {
18
- config.knowledgeDir = previous.knowledgeDir;
19
- }
20
- if (previous?.tokenWarningThreshold !== undefined) {
21
- config.tokenWarningThreshold = previous.tokenWarningThreshold;
14
+ delete config.theme;
15
+ delete config.zenn;
16
+ if (answers.theme) {
17
+ config.theme = answers.theme;
22
18
  }
23
19
  if (answers.zenn) {
24
20
  config.zenn = answers.zenn;
25
21
  }
26
22
  return config;
27
23
  }
28
- /** Builds `folio-agent.theme.css` content. Re-running the wizard only rewrites this file, which HMR picks up. */
24
+ /** Builds `folio-agent.theme.css` content. Re-running init only rewrites this file, which HMR picks up. */
29
25
  export function buildThemeCss(theme) {
30
26
  return [
31
27
  "folio-agent-widget {",
@@ -94,11 +90,15 @@ export function buildApiRouteTemplate(answers) {
94
90
  "",
95
91
  ].join("\n");
96
92
  }
97
- /** Appends the ingest command to a `build` script if it isn't already present; otherwise returns it unchanged. */
93
+ /**
94
+ * Appends the ingest command to a `build` script unless it already runs ingest. Any existing
95
+ * `folio-agent-ingest` counts, whatever its arguments: sites often write to a different output
96
+ * path than the default, and appending a second run would ingest twice.
97
+ */
98
98
  export function appendIngestToBuildScript(buildScript, configPath, knowledgeOutputPath) {
99
99
  const ingestCommand = `${INGEST_COMMAND_PREFIX} ${configPath} ${knowledgeOutputPath}`;
100
100
  const base = (buildScript ?? "").trim();
101
- if (base.includes(ingestCommand)) {
101
+ if (base.includes(INGEST_COMMAND_PREFIX)) {
102
102
  return base;
103
103
  }
104
104
  return base.length > 0 ? `${base} && ${ingestCommand}` : ingestCommand;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,15 +39,14 @@
39
39
  "prepublishOnly": "tsc -b --force"
40
40
  },
41
41
  "dependencies": {
42
- "@clack/prompts": "^1.7.0",
43
- "@google/genai": "^2.21.0",
44
- "@langchain/langgraph": "^1.4.14",
45
- "node-html-parser": "^9.0.3",
42
+ "@google/genai": "^2.23.0",
43
+ "@langchain/langgraph": "^1.4.16",
44
+ "node-html-parser": "^9.0.4",
46
45
  "picomatch": "^4.0.7"
47
46
  },
48
47
  "devDependencies": {
49
- "@cloudflare/workers-types": "^5.20260906.1",
48
+ "@cloudflare/workers-types": "^5.20260920.1",
50
49
  "@types/picomatch": "^4.0.2",
51
- "wrangler": "^4.129.0"
50
+ "wrangler": "^4.135.0"
52
51
  }
53
52
  }
@@ -1,32 +0,0 @@
1
- import type { Language } from "../chat/types.js";
2
- import type { ThemeColors, ZennIngestConfig } from "../ingest/types.js";
3
- export interface WizardAnswers {
4
- language: Language;
5
- distDir: string;
6
- include: string[];
7
- zenn?: ZennIngestConfig;
8
- contactUrl?: string;
9
- theme: ThemeColors;
10
- /** Non-empty only when the visitor typed a new key this run; blank means "leave .dev.vars untouched". */
11
- geminiApiKey?: string;
12
- /** `undefined` means "don't generate the API route scaffold" (site already wires the handler elsewhere). */
13
- apiRoutePath?: string;
14
- }
15
- export declare const DEFAULT_WIZARD_ANSWERS: WizardAnswers;
16
- /** Pure hex color validation used both as a `clack.text` validator and in tests. */
17
- export declare function isValidHexColor(value: string): boolean;
18
- /** Splits a comma-separated glob list into a trimmed, non-empty array. */
19
- export declare function parseIncludeList(value: string): string[];
20
- /**
21
- * Normalizes a Zenn `baseUrl` answer. A bare username (no `http(s)://` prefix) is expanded to the
22
- * canonical Zenn articles URL; an already-fully-qualified URL passes through unchanged.
23
- */
24
- export declare function normalizeZennBaseUrl(input: string): string;
25
- /**
26
- * Default answer for the "API route scaffold output path" question: the scaffold path for a
27
- * fresh setup (no previous config), or `undefined` ("don't generate") when re-running against an
28
- * existing config, since the site likely already wires the handler up some other way.
29
- */
30
- export declare function defaultApiRoutePath(hasPreviousConfig: boolean): string | undefined;
31
- /** Runs the interactive wizard (all-defaults-Enter-to-quick-mode) and returns the collected answers. */
32
- export declare function runWizard(defaults: WizardAnswers): Promise<WizardAnswers>;
@@ -1,155 +0,0 @@
1
- import * as clack from "@clack/prompts";
2
- import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
- const DEFAULT_API_ROUTE_PATH = "functions/api/chat.ts";
4
- export const DEFAULT_WIZARD_ANSWERS = {
5
- language: DEFAULT_LANGUAGE,
6
- distDir: "dist",
7
- include: ["/**"],
8
- theme: { accent: "#2563eb", surface: "#ffffff", text: "#111827" },
9
- apiRoutePath: DEFAULT_API_ROUTE_PATH,
10
- };
11
- const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
12
- /** Pure hex color validation used both as a `clack.text` validator and in tests. */
13
- export function isValidHexColor(value) {
14
- return HEX_COLOR_PATTERN.test(value.trim());
15
- }
16
- /** Splits a comma-separated glob list into a trimmed, non-empty array. */
17
- export function parseIncludeList(value) {
18
- return value
19
- .split(",")
20
- .map((item) => item.trim())
21
- .filter((item) => item.length > 0);
22
- }
23
- /**
24
- * Normalizes a Zenn `baseUrl` answer. A bare username (no `http(s)://` prefix) is expanded to the
25
- * canonical Zenn articles URL; an already-fully-qualified URL passes through unchanged.
26
- */
27
- export function normalizeZennBaseUrl(input) {
28
- const trimmed = input.trim();
29
- return /^https?:\/\//i.test(trimmed) ? trimmed : `https://zenn.dev/${trimmed}/articles`;
30
- }
31
- /**
32
- * Default answer for the "API route scaffold output path" question: the scaffold path for a
33
- * fresh setup (no previous config), or `undefined` ("don't generate") when re-running against an
34
- * existing config, since the site likely already wires the handler up some other way.
35
- */
36
- export function defaultApiRoutePath(hasPreviousConfig) {
37
- return hasPreviousConfig ? undefined : DEFAULT_API_ROUTE_PATH;
38
- }
39
- const WIZARD_TEXT = {
40
- ja: {
41
- distDirMessage: "ビルド成果物のディレクトリ(distDir)",
42
- includeMessage: "知識に含める URL パスのグロブ(カンマ区切りで複数可)",
43
- includePlaceholder: "/**",
44
- zennConfirmMessage: "Zenn 記事も知識に含めますか?",
45
- zennArticlesDirMessage: "Zenn CLI の articles/ ディレクトリへのパス",
46
- zennBaseUrlMessage: "記事の公開 URL のベース(例: https://zenn.dev/<username>/articles、またはユーザー名のみでも可)",
47
- zennBaseUrlNormalizedNote: (baseUrl) => `✅ ${baseUrl} として登録します。`,
48
- contactUrlMessage: "Contact ページの URL(任意。空 Enter でスキップ)",
49
- contactUrlPlaceholder: "https://example.com/contact",
50
- accentMessage: "テーマカラー: accent(#付き hex)",
51
- surfaceMessage: "テーマカラー: surface(#付き hex)",
52
- textColorMessage: "テーマカラー: text(#付き hex)",
53
- colorValidationError: "#fff や #ffffff のような hex カラーコードで入力してください。",
54
- geminiApiKeyMessage: "Gemini API キー(任意。空 Enter でスキップ)",
55
- geminiApiKeySkipNote: "スキップしました。キーは https://aistudio.google.com/apikey から取得できます。",
56
- apiRoutePathMessage: "API ルート雛形の出力先(空 Enter で生成しない。既に別の経路で配線済みのサイト向け)",
57
- apiRoutePathPlaceholder: "functions/api/chat.ts",
58
- cancelledMessage: "セットアップを中止しました。",
59
- },
60
- en: {
61
- distDirMessage: "Build output directory (distDir)",
62
- includeMessage: "URL path globs to include in the knowledge (comma-separated for multiple)",
63
- includePlaceholder: "/**",
64
- zennConfirmMessage: "Include Zenn articles in the knowledge too?",
65
- zennArticlesDirMessage: "Path to the Zenn CLI articles/ directory",
66
- zennBaseUrlMessage: "Base URL for published articles (e.g. https://zenn.dev/<username>/articles, or just your username)",
67
- zennBaseUrlNormalizedNote: (baseUrl) => `✅ Registering as ${baseUrl}.`,
68
- contactUrlMessage: "Contact page URL (optional, press Enter to skip)",
69
- contactUrlPlaceholder: "https://example.com/contact",
70
- accentMessage: "Theme color: accent (hex with #)",
71
- surfaceMessage: "Theme color: surface (hex with #)",
72
- textColorMessage: "Theme color: text (hex with #)",
73
- colorValidationError: "Enter a hex color code like #fff or #ffffff.",
74
- geminiApiKeyMessage: "Gemini API key (optional, press Enter to skip)",
75
- geminiApiKeySkipNote: "Skipped. Get a key at https://aistudio.google.com/apikey",
76
- apiRoutePathMessage: "Output path for the API route scaffold (blank to skip generating it, e.g. if already wired up elsewhere)",
77
- apiRoutePathPlaceholder: "functions/api/chat.ts",
78
- cancelledMessage: "Setup cancelled.",
79
- },
80
- };
81
- function unwrap(value, cancelledMessage) {
82
- if (clack.isCancel(value)) {
83
- clack.cancel(cancelledMessage);
84
- process.exit(0);
85
- }
86
- return value;
87
- }
88
- /** Runs the interactive wizard (all-defaults-Enter-to-quick-mode) and returns the collected answers. */
89
- export async function runWizard(defaults) {
90
- clack.intro("folio-agent-init");
91
- const language = unwrap(await clack.select({
92
- message: "Language / 言語",
93
- options: [
94
- { value: "ja", label: "日本語" },
95
- { value: "en", label: "English" },
96
- ],
97
- initialValue: defaults.language,
98
- }), WIZARD_TEXT[defaults.language].cancelledMessage);
99
- const text = WIZARD_TEXT[language];
100
- const distDir = unwrap(await clack.text({ message: text.distDirMessage, initialValue: defaults.distDir }), text.cancelledMessage);
101
- const includeInput = unwrap(await clack.text({
102
- message: text.includeMessage,
103
- placeholder: text.includePlaceholder,
104
- initialValue: defaults.include.join(", "),
105
- }), text.cancelledMessage);
106
- const include = parseIncludeList(includeInput);
107
- const wantsZenn = unwrap(await clack.confirm({ message: text.zennConfirmMessage, initialValue: Boolean(defaults.zenn) }), text.cancelledMessage);
108
- let zenn;
109
- if (wantsZenn) {
110
- const articlesDir = unwrap(await clack.text({
111
- message: text.zennArticlesDirMessage,
112
- initialValue: defaults.zenn?.articlesDir ?? "../zenn-content/articles",
113
- }), text.cancelledMessage);
114
- const baseUrlInput = unwrap(await clack.text({
115
- message: text.zennBaseUrlMessage,
116
- initialValue: defaults.zenn?.baseUrl ?? "",
117
- }), text.cancelledMessage);
118
- const baseUrl = normalizeZennBaseUrl(baseUrlInput);
119
- if (baseUrl !== baseUrlInput.trim()) {
120
- clack.note(text.zennBaseUrlNormalizedNote(baseUrl));
121
- }
122
- zenn = { articlesDir, baseUrl };
123
- }
124
- const contactUrlInput = unwrap(await clack.text({
125
- message: text.contactUrlMessage,
126
- placeholder: text.contactUrlPlaceholder,
127
- initialValue: defaults.contactUrl ?? "",
128
- }), text.cancelledMessage);
129
- const contactUrl = contactUrlInput.trim().length > 0 ? contactUrlInput.trim() : undefined;
130
- const colorValidate = (value) => isValidHexColor(value ?? "") ? undefined : text.colorValidationError;
131
- const accent = unwrap(await clack.text({ message: text.accentMessage, initialValue: defaults.theme.accent, validate: colorValidate }), text.cancelledMessage);
132
- const surface = unwrap(await clack.text({ message: text.surfaceMessage, initialValue: defaults.theme.surface, validate: colorValidate }), text.cancelledMessage);
133
- const textColor = unwrap(await clack.text({ message: text.textColorMessage, initialValue: defaults.theme.text, validate: colorValidate }), text.cancelledMessage);
134
- const geminiApiKeyInput = unwrap(await clack.password({ message: text.geminiApiKeyMessage }), text.cancelledMessage);
135
- const geminiApiKey = geminiApiKeyInput.trim().length > 0 ? geminiApiKeyInput.trim() : undefined;
136
- if (!geminiApiKey) {
137
- clack.note(text.geminiApiKeySkipNote);
138
- }
139
- const apiRoutePathInput = unwrap(await clack.text({
140
- message: text.apiRoutePathMessage,
141
- placeholder: text.apiRoutePathPlaceholder,
142
- initialValue: defaults.apiRoutePath ?? "",
143
- }), text.cancelledMessage);
144
- const apiRoutePath = apiRoutePathInput.trim().length > 0 ? apiRoutePathInput.trim() : undefined;
145
- return {
146
- language,
147
- distDir,
148
- include,
149
- zenn,
150
- contactUrl,
151
- theme: { accent, surface, text: textColor },
152
- geminiApiKey,
153
- apiRoutePath,
154
- };
155
- }