@folio-agent/handler 0.3.0 → 0.4.0

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.
@@ -1,11 +1,24 @@
1
+ import { readFile, stat } from "node:fs/promises";
1
2
  import { createUrlMatcher } from "./glob.js";
2
3
  import { htmlToText } from "./html-to-text.js";
3
4
  import { readTextFile, scanDist, scanKnowledgeDir } from "./scan-dist.js";
4
5
  import { DEFAULT_TOKEN_WARNING_THRESHOLD, estimateTokens } from "./token-count.js";
5
6
  import { scanZennArticles } from "./zenn.js";
7
+ async function pathExists(path) {
8
+ try {
9
+ await stat(path);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return false;
15
+ throw error;
16
+ }
17
+ }
6
18
  export async function generateKnowledge(config) {
7
19
  const matchesUrl = createUrlMatcher(config);
8
20
  const pages = [];
21
+ const warnings = [];
9
22
  const distFiles = await scanDist(config.distDir);
10
23
  for (const file of distFiles.filter((f) => matchesUrl(f.urlPath))) {
11
24
  const html = await readTextFile(file.absolutePath);
@@ -20,12 +33,20 @@ export async function generateKnowledge(config) {
20
33
  }
21
34
  }
22
35
  if (config.zenn) {
23
- pages.push(...(await scanZennArticles(config.zenn)));
36
+ if (await pathExists(config.zenn.articlesDir)) {
37
+ pages.push(...(await scanZennArticles(config.zenn)));
38
+ }
39
+ else if (config.zennSnapshotPath) {
40
+ const snapshot = JSON.parse(await readFile(config.zennSnapshotPath, "utf-8"));
41
+ pages.push(...snapshot);
42
+ }
43
+ else {
44
+ warnings.push(`articlesDir not found: ${config.zenn.articlesDir} — skipping zenn ingest`);
45
+ }
24
46
  }
25
47
  pages.sort((a, b) => a.url.localeCompare(b.url));
26
48
  const estimatedTokens = estimateTokens(pages.map((p) => p.text).join("\n"));
27
49
  const threshold = config.tokenWarningThreshold ?? DEFAULT_TOKEN_WARNING_THRESHOLD;
28
- const warnings = [];
29
50
  if (estimatedTokens > threshold) {
30
51
  warnings.push(`estimated knowledge size (${estimatedTokens} tokens) exceeds the warning threshold (${threshold}). ` +
31
52
  "Consider narrowing include globs, or switching to a RAG-based approach.");
@@ -10,6 +10,13 @@ export interface IngestConfig {
10
10
  knowledgeDir?: string;
11
11
  /** Zenn CLI articles/ ingestion. Omit to skip Zenn articles entirely. */
12
12
  zenn?: ZennIngestConfig;
13
+ /**
14
+ * Path to a snapshot JSON produced by `folio-agent-sync-zenn`. Used as a
15
+ * fallback source of Zenn pages in environments where `zenn.articlesDir`
16
+ * cannot be read (e.g. CI, where the articles directory often comes from a
17
+ * separate/private repo that isn't checked out).
18
+ */
19
+ zennSnapshotPath?: string;
13
20
  /** Token threshold above which the CLI warns that CAG may no longer fit. */
14
21
  tokenWarningThreshold?: number;
15
22
  /** UI language captured by `folio-agent-init`. Ignored by ingest itself. */
@@ -1,2 +1,15 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export interface DevVarsAndGitignorePlan {
3
+ nextDevVars: string | undefined;
4
+ gitignoreResult: {
5
+ content: string;
6
+ changed: boolean;
7
+ };
8
+ }
9
+ /**
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.
14
+ */
15
+ export declare function planDevVarsAndGitignore(geminiApiKey: string | undefined, devVarsContent: string, gitignoreContent: string): DevVarsAndGitignorePlan;
package/dist/init/cli.js CHANGED
@@ -1,44 +1,61 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, statSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import * as clack from "@clack/prompts";
6
7
  import { DEFAULT_WIZARD_ANSWERS, runWizard } from "./questions.js";
7
- import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, upsertDevVar, } from "./writers.js";
8
+ import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, ensureGitignoreEntry, PUBLIC_DIR_NAME, resolveThemeCssPath, THEME_CSS_FILENAME, upsertDevVar, } from "./writers.js";
8
9
  const CONFIG_PATH = "folio-agent.config.json";
9
- const THEME_CSS_PATH = "folio-agent.theme.css";
10
10
  const PACKAGE_JSON_PATH = "package.json";
11
11
  const DEV_VARS_PATH = ".dev.vars";
12
+ const GITIGNORE_PATH = ".gitignore";
12
13
  const KNOWLEDGE_OUTPUT_PATH = "dist/knowledge.json";
13
14
  const CLI_TEXT = {
14
15
  ja: {
15
16
  summaryTitle: "以下の内容で書き込みます:",
16
17
  summaryConfigLine: `${CONFIG_PATH} を生成・更新`,
17
- summaryThemeLine: `${THEME_CSS_PATH} を生成・更新(再実行時はこのファイルの書き換えだけで dev サーバーの HMR に反映されます)`,
18
+ summaryThemeLine: (path) => `${path} を生成・更新(再実行時はこのファイルの書き換えだけで dev サーバーの HMR に反映されます)`,
18
19
  summaryApiRouteLine: (path) => `${path} を新規生成`,
19
20
  summaryApiRouteSkippedLine: (path) => `${path} は既に存在するためスキップします`,
21
+ summaryApiRouteNotGeneratedLine: "API ルート雛形は生成しません(既存の配線を利用する設定)",
20
22
  summaryBuildScriptLine: `${PACKAGE_JSON_PATH} の build スクリプトに ingest コマンドを追記`,
21
23
  summaryBuildScriptUnchangedLine: `${PACKAGE_JSON_PATH} の build スクリプトは変更なし(既に含まれています)`,
22
24
  summaryDevVarsLine: `${DEV_VARS_PATH} に GEMINI_API_KEY を書き込み`,
25
+ summaryGitignoreAppendedLine: `${GITIGNORE_PATH} に ${DEV_VARS_PATH} を追記`,
26
+ summaryGitignoreAlreadyPresentLine: `${GITIGNORE_PATH} には ${DEV_VARS_PATH} が既に含まれています(追記不要)`,
23
27
  confirmMessage: "この内容で書き込みますか?",
24
28
  writeCancelledMessage: "書き込みを中止しました。",
25
29
  outroTitle: "セットアップが完了しました。",
26
- snippetIntro: "レイアウトに以下を1回だけ貼り付けてください(再実行時は不要です):",
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} が既に含まれていました。`,
27
36
  d1Note: "D1 が未設定でも widget の表示とテーマ確認はローカルで動きます。チャット応答には D1 のセットアップと Gemini API キーが必要です。",
28
37
  },
29
38
  en: {
30
39
  summaryTitle: "The following will be written:",
31
40
  summaryConfigLine: `Generate/update ${CONFIG_PATH}`,
32
- summaryThemeLine: `Generate/update ${THEME_CSS_PATH} (re-running the wizard only rewrites this file, and your dev server's HMR picks it up)`,
41
+ summaryThemeLine: (path) => `Generate/update ${path} (re-running the wizard only rewrites this file, and your dev server's HMR picks it up)`,
33
42
  summaryApiRouteLine: (path) => `Generate ${path}`,
34
43
  summaryApiRouteSkippedLine: (path) => `Skip ${path} (already exists)`,
44
+ summaryApiRouteNotGeneratedLine: "Skip generating the API route scaffold (site already wires the handler up)",
35
45
  summaryBuildScriptLine: `Append the ingest command to ${PACKAGE_JSON_PATH}'s build script`,
36
46
  summaryBuildScriptUnchangedLine: `No change to ${PACKAGE_JSON_PATH}'s build script (already present)`,
37
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)`,
38
50
  confirmMessage: "Proceed with writing these files?",
39
51
  writeCancelledMessage: "Write cancelled.",
40
52
  outroTitle: "Setup complete.",
41
- snippetIntro: "Paste the following into your layout once (not needed again on re-runs):",
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}.`,
42
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.",
43
60
  },
44
61
  };
@@ -55,6 +72,17 @@ async function readJson(path) {
55
72
  async function readTextOrEmpty(path) {
56
73
  return existsSync(path) ? readFile(path, "utf-8") : "";
57
74
  }
75
+ /**
76
+ * 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.
80
+ */
81
+ export function planDevVarsAndGitignore(geminiApiKey, devVarsContent, gitignoreContent) {
82
+ const nextDevVars = geminiApiKey ? upsertDevVar(devVarsContent, "GEMINI_API_KEY", geminiApiKey) : undefined;
83
+ const gitignoreResult = ensureGitignoreEntry(gitignoreContent, DEV_VARS_PATH);
84
+ return { nextDevVars, gitignoreResult };
85
+ }
58
86
  function seedDefaults(previous) {
59
87
  if (!previous)
60
88
  return DEFAULT_WIZARD_ANSWERS;
@@ -65,6 +93,7 @@ function seedDefaults(previous) {
65
93
  include: previous.include ?? DEFAULT_WIZARD_ANSWERS.include,
66
94
  zenn: previous.zenn,
67
95
  theme: previous.theme ?? DEFAULT_WIZARD_ANSWERS.theme,
96
+ apiRoutePath: undefined,
68
97
  };
69
98
  }
70
99
  async function main() {
@@ -73,31 +102,38 @@ async function main() {
73
102
  const text = CLI_TEXT[answers.language];
74
103
  const configJson = buildConfigJson(answers, previousConfig);
75
104
  const themeCss = buildThemeCss(answers.theme);
76
- const apiRouteExists = existsSync(answers.apiRoutePath);
77
- const apiRouteContent = apiRouteExists
78
- ? undefined
79
- : buildApiRouteTemplate({
105
+ const hasPublicDir = existsSync(PUBLIC_DIR_NAME) && statSync(PUBLIC_DIR_NAME).isDirectory();
106
+ const publicThemeCssPath = `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}`;
107
+ const themeCssPath = resolveThemeCssPath(hasPublicDir, existsSync(publicThemeCssPath), existsSync(THEME_CSS_FILENAME));
108
+ const usesPublicDirForTheme = themeCssPath === publicThemeCssPath;
109
+ const apiRouteExists = answers.apiRoutePath !== undefined && existsSync(answers.apiRoutePath);
110
+ const apiRouteContent = answers.apiRoutePath !== undefined && !apiRouteExists
111
+ ? buildApiRouteTemplate({
80
112
  apiRoutePath: answers.apiRoutePath,
81
113
  distDir: answers.distDir,
82
114
  contactUrl: answers.contactUrl,
83
115
  language: answers.language,
84
- });
116
+ })
117
+ : undefined;
85
118
  const pkg = (await readJson(PACKAGE_JSON_PATH)) ?? {};
86
119
  const scripts = pkg.scripts ?? {};
87
120
  const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, KNOWLEDGE_OUTPUT_PATH);
88
121
  const buildScriptChanged = nextBuildScript !== (scripts.build ?? "");
89
- const nextDevVars = answers.geminiApiKey
90
- ? upsertDevVar(await readTextOrEmpty(DEV_VARS_PATH), "GEMINI_API_KEY", answers.geminiApiKey)
91
- : undefined;
122
+ const { nextDevVars, gitignoreResult } = planDevVarsAndGitignore(answers.geminiApiKey, await readTextOrEmpty(DEV_VARS_PATH), await readTextOrEmpty(GITIGNORE_PATH));
92
123
  const summaryLines = [
93
124
  text.summaryConfigLine,
94
- text.summaryThemeLine,
95
- apiRouteExists ? text.summaryApiRouteSkippedLine(answers.apiRoutePath) : text.summaryApiRouteLine(answers.apiRoutePath),
125
+ text.summaryThemeLine(themeCssPath),
126
+ answers.apiRoutePath !== undefined
127
+ ? apiRouteExists
128
+ ? text.summaryApiRouteSkippedLine(answers.apiRoutePath)
129
+ : text.summaryApiRouteLine(answers.apiRoutePath)
130
+ : text.summaryApiRouteNotGeneratedLine,
96
131
  buildScriptChanged ? text.summaryBuildScriptLine : text.summaryBuildScriptUnchangedLine,
97
132
  ];
98
133
  if (nextDevVars !== undefined) {
99
134
  summaryLines.push(text.summaryDevVarsLine);
100
135
  }
136
+ summaryLines.push(gitignoreResult.changed ? text.summaryGitignoreAppendedLine : text.summaryGitignoreAlreadyPresentLine);
101
137
  clack.note(summaryLines.join("\n"), text.summaryTitle);
102
138
  const shouldWrite = await clack.confirm({ message: text.confirmMessage, initialValue: true });
103
139
  if (clack.isCancel(shouldWrite) || !shouldWrite) {
@@ -105,8 +141,8 @@ async function main() {
105
141
  return;
106
142
  }
107
143
  await writeFile(CONFIG_PATH, `${JSON.stringify(configJson, null, 2)}\n`);
108
- await writeFile(THEME_CSS_PATH, themeCss);
109
- if (apiRouteContent) {
144
+ await writeFile(themeCssPath, themeCss);
145
+ if (apiRouteContent !== undefined && answers.apiRoutePath !== undefined) {
110
146
  await mkdir(dirname(answers.apiRoutePath), { recursive: true });
111
147
  await writeFile(answers.apiRoutePath, apiRouteContent);
112
148
  }
@@ -117,20 +153,40 @@ async function main() {
117
153
  if (nextDevVars !== undefined) {
118
154
  await writeFile(DEV_VARS_PATH, nextDevVars);
119
155
  }
120
- const endpoint = deriveEndpointPath(answers.apiRoutePath);
121
- const snippet = [
122
- `<link rel="stylesheet" href="/${THEME_CSS_PATH}">`,
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}">`] : []),
123
162
  `<folio-agent-widget endpoint="${endpoint}" policy-href="/data-policy" lang="${answers.language}"></folio-agent-widget>`,
124
163
  '<script type="module">',
125
164
  ' import { defineFolioAgentWidget } from "@folio-agent/widget";',
126
165
  " defineFolioAgentWidget();",
127
166
  "</script>",
128
- ].join("\n");
129
- clack.note(snippet, text.snippetIntro);
167
+ ];
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);
175
+ }
176
+ if (answers.apiRoutePath === undefined) {
177
+ introNotes.push(text.apiRouteNotGeneratedSnippetNote);
178
+ }
179
+ clack.note(introNotes.join("\n\n"));
180
+ console.log(`\n${snippet}\n`);
181
+ clack.note(gitignoreResult.changed ? text.gitignoreAppendedNote : text.gitignoreAlreadyPresentNote);
130
182
  clack.note(text.d1Note);
131
183
  clack.outro(text.outroTitle);
132
184
  }
133
- main().catch((error) => {
134
- console.error(error);
135
- process.exitCode = 1;
136
- });
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.
187
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
188
+ main().catch((error) => {
189
+ console.error(error);
190
+ process.exitCode = 1;
191
+ });
192
+ }
@@ -9,12 +9,24 @@ export interface WizardAnswers {
9
9
  theme: ThemeColors;
10
10
  /** Non-empty only when the visitor typed a new key this run; blank means "leave .dev.vars untouched". */
11
11
  geminiApiKey?: string;
12
- apiRoutePath: string;
12
+ /** `undefined` means "don't generate the API route scaffold" (site already wires the handler elsewhere). */
13
+ apiRoutePath?: string;
13
14
  }
14
15
  export declare const DEFAULT_WIZARD_ANSWERS: WizardAnswers;
15
16
  /** Pure hex color validation used both as a `clack.text` validator and in tests. */
16
17
  export declare function isValidHexColor(value: string): boolean;
17
18
  /** Splits a comma-separated glob list into a trimmed, non-empty array. */
18
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;
19
31
  /** Runs the interactive wizard (all-defaults-Enter-to-quick-mode) and returns the collected answers. */
20
32
  export declare function runWizard(defaults: WizardAnswers): Promise<WizardAnswers>;
@@ -1,11 +1,12 @@
1
1
  import * as clack from "@clack/prompts";
2
2
  import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
+ const DEFAULT_API_ROUTE_PATH = "functions/api/chat.ts";
3
4
  export const DEFAULT_WIZARD_ANSWERS = {
4
5
  language: DEFAULT_LANGUAGE,
5
6
  distDir: "dist",
6
7
  include: ["/**"],
7
8
  theme: { accent: "#2563eb", surface: "#ffffff", text: "#111827" },
8
- apiRoutePath: "functions/api/chat.ts",
9
+ apiRoutePath: DEFAULT_API_ROUTE_PATH,
9
10
  };
10
11
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
11
12
  /** Pure hex color validation used both as a `clack.text` validator and in tests. */
@@ -19,6 +20,22 @@ export function parseIncludeList(value) {
19
20
  .map((item) => item.trim())
20
21
  .filter((item) => item.length > 0);
21
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
+ }
22
39
  const WIZARD_TEXT = {
23
40
  ja: {
24
41
  distDirMessage: "ビルド成果物のディレクトリ(distDir)",
@@ -26,7 +43,8 @@ const WIZARD_TEXT = {
26
43
  includePlaceholder: "/**",
27
44
  zennConfirmMessage: "Zenn 記事も知識に含めますか?",
28
45
  zennArticlesDirMessage: "Zenn CLI の articles/ ディレクトリへのパス",
29
- zennBaseUrlMessage: "記事の公開 URL のベース(例: https://zenn.dev/<username>/articles)",
46
+ zennBaseUrlMessage: "記事の公開 URL のベース(例: https://zenn.dev/<username>/articles、またはユーザー名のみでも可)",
47
+ zennBaseUrlNormalizedNote: (baseUrl) => `✅ ${baseUrl} として登録します。`,
30
48
  contactUrlMessage: "Contact ページの URL(任意。空 Enter でスキップ)",
31
49
  contactUrlPlaceholder: "https://example.com/contact",
32
50
  accentMessage: "テーマカラー: accent(#付き hex)",
@@ -35,7 +53,8 @@ const WIZARD_TEXT = {
35
53
  colorValidationError: "#fff や #ffffff のような hex カラーコードで入力してください。",
36
54
  geminiApiKeyMessage: "Gemini API キー(任意。空 Enter でスキップ)",
37
55
  geminiApiKeySkipNote: "スキップしました。キーは https://aistudio.google.com/apikey から取得できます。",
38
- apiRoutePathMessage: "API ルート雛形の出力先",
56
+ apiRoutePathMessage: "API ルート雛形の出力先(空 Enter で生成しない。既に別の経路で配線済みのサイト向け)",
57
+ apiRoutePathPlaceholder: "functions/api/chat.ts",
39
58
  cancelledMessage: "セットアップを中止しました。",
40
59
  },
41
60
  en: {
@@ -44,7 +63,8 @@ const WIZARD_TEXT = {
44
63
  includePlaceholder: "/**",
45
64
  zennConfirmMessage: "Include Zenn articles in the knowledge too?",
46
65
  zennArticlesDirMessage: "Path to the Zenn CLI articles/ directory",
47
- zennBaseUrlMessage: "Base URL for published articles (e.g. https://zenn.dev/<username>/articles)",
66
+ zennBaseUrlMessage: "Base URL for published articles (e.g. https://zenn.dev/<username>/articles, or just your username)",
67
+ zennBaseUrlNormalizedNote: (baseUrl) => `✅ Registering as ${baseUrl}.`,
48
68
  contactUrlMessage: "Contact page URL (optional, press Enter to skip)",
49
69
  contactUrlPlaceholder: "https://example.com/contact",
50
70
  accentMessage: "Theme color: accent (hex with #)",
@@ -53,7 +73,8 @@ const WIZARD_TEXT = {
53
73
  colorValidationError: "Enter a hex color code like #fff or #ffffff.",
54
74
  geminiApiKeyMessage: "Gemini API key (optional, press Enter to skip)",
55
75
  geminiApiKeySkipNote: "Skipped. Get a key at https://aistudio.google.com/apikey",
56
- apiRoutePathMessage: "Output path for the API route scaffold",
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",
57
78
  cancelledMessage: "Setup cancelled.",
58
79
  },
59
80
  };
@@ -90,10 +111,14 @@ export async function runWizard(defaults) {
90
111
  message: text.zennArticlesDirMessage,
91
112
  initialValue: defaults.zenn?.articlesDir ?? "../zenn-content/articles",
92
113
  }), text.cancelledMessage);
93
- const baseUrl = unwrap(await clack.text({
114
+ const baseUrlInput = unwrap(await clack.text({
94
115
  message: text.zennBaseUrlMessage,
95
116
  initialValue: defaults.zenn?.baseUrl ?? "",
96
117
  }), text.cancelledMessage);
118
+ const baseUrl = normalizeZennBaseUrl(baseUrlInput);
119
+ if (baseUrl !== baseUrlInput.trim()) {
120
+ clack.note(text.zennBaseUrlNormalizedNote(baseUrl));
121
+ }
97
122
  zenn = { articlesDir, baseUrl };
98
123
  }
99
124
  const contactUrlInput = unwrap(await clack.text({
@@ -111,7 +136,12 @@ export async function runWizard(defaults) {
111
136
  if (!geminiApiKey) {
112
137
  clack.note(text.geminiApiKeySkipNote);
113
138
  }
114
- const apiRoutePath = unwrap(await clack.text({ message: text.apiRoutePathMessage, initialValue: defaults.apiRoutePath }), text.cancelledMessage);
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;
115
145
  return {
116
146
  language,
117
147
  distDir,
@@ -1,10 +1,19 @@
1
1
  import type { Language } from "../chat/types.js";
2
2
  import type { IngestConfig, ThemeColors } from "../ingest/types.js";
3
3
  import type { WizardAnswers } from "./questions.js";
4
+ export declare const THEME_CSS_FILENAME = "folio-agent.theme.css";
5
+ export declare const PUBLIC_DIR_NAME = "public";
4
6
  /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
5
7
  export declare function buildConfigJson(answers: WizardAnswers, previous?: IngestConfig): IngestConfig;
6
8
  /** Builds `folio-agent.theme.css` content. Re-running the wizard only rewrites this file, which HMR picks up. */
7
9
  export declare function buildThemeCss(theme: ThemeColors): string;
10
+ /**
11
+ * Decides where `folio-agent.theme.css` should be written. A file outside `public/` (or its
12
+ * framework equivalent) isn't served by most static-site pipelines (Astro, Next, etc.), so a
13
+ * `public/` directory is preferred when present. Re-runs reuse whichever location already holds
14
+ * the file, so switching `public/` in and out of existence mid-project doesn't orphan a stale copy.
15
+ */
16
+ export declare function resolveThemeCssPath(hasPublicDir: boolean, existingPublicFile: boolean, existingRootFile: boolean): string;
8
17
  /**
9
18
  * Computes the relative import specifier from the API route file to `<distDir>/knowledge.json`,
10
19
  * independent of the OS path separator (config values are always POSIX-style project-relative paths).
@@ -24,3 +33,12 @@ export declare function buildApiRouteTemplate(answers: ApiRouteAnswers): string;
24
33
  export declare function appendIngestToBuildScript(buildScript: string | undefined, configPath: string, knowledgeOutputPath: string): string;
25
34
  /** Adds or replaces a single `KEY=value` line in `.dev.vars` content, leaving other lines untouched. */
26
35
  export declare function upsertDevVar(content: string, key: string, value: string): string;
36
+ /**
37
+ * Ensures `entry` appears as its own line in `.gitignore` content, appending it if no existing
38
+ * line starts with it. Intentionally doesn't interpret comments or wider patterns (e.g. `*.vars`);
39
+ * a duplicate append is harmless, so a plain line-start check is enough.
40
+ */
41
+ export declare function ensureGitignoreEntry(content: string, entry: string): {
42
+ content: string;
43
+ changed: boolean;
44
+ };
@@ -1,6 +1,8 @@
1
1
  import { posix } from "node:path";
2
2
  import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
3
  const INGEST_COMMAND_PREFIX = "folio-agent-ingest";
4
+ export const THEME_CSS_FILENAME = "folio-agent.theme.css";
5
+ export const PUBLIC_DIR_NAME = "public";
4
6
  /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
5
7
  export function buildConfigJson(answers, previous) {
6
8
  const config = {
@@ -34,6 +36,19 @@ export function buildThemeCss(theme) {
34
36
  "",
35
37
  ].join("\n");
36
38
  }
39
+ /**
40
+ * Decides where `folio-agent.theme.css` should be written. A file outside `public/` (or its
41
+ * framework equivalent) isn't served by most static-site pipelines (Astro, Next, etc.), so a
42
+ * `public/` directory is preferred when present. Re-runs reuse whichever location already holds
43
+ * the file, so switching `public/` in and out of existence mid-project doesn't orphan a stale copy.
44
+ */
45
+ export function resolveThemeCssPath(hasPublicDir, existingPublicFile, existingRootFile) {
46
+ if (existingPublicFile)
47
+ return `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}`;
48
+ if (existingRootFile)
49
+ return THEME_CSS_FILENAME;
50
+ return hasPublicDir ? `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}` : THEME_CSS_FILENAME;
51
+ }
37
52
  /**
38
53
  * Computes the relative import specifier from the API route file to `<distDir>/knowledge.json`,
39
54
  * independent of the OS path separator (config values are always POSIX-style project-relative paths).
@@ -103,3 +118,18 @@ export function upsertDevVar(content, key, value) {
103
118
  }
104
119
  return `${lines.join("\n")}\n`;
105
120
  }
121
+ /**
122
+ * Ensures `entry` appears as its own line in `.gitignore` content, appending it if no existing
123
+ * line starts with it. Intentionally doesn't interpret comments or wider patterns (e.g. `*.vars`);
124
+ * a duplicate append is harmless, so a plain line-start check is enough.
125
+ */
126
+ export function ensureGitignoreEntry(content, entry) {
127
+ const lines = content.length > 0 ? content.split("\n") : [];
128
+ const alreadyPresent = lines.some((line) => line.startsWith(entry));
129
+ if (alreadyPresent) {
130
+ return { content, changed: false };
131
+ }
132
+ const trimmed = content.replace(/\n+$/, "");
133
+ const nextContent = trimmed.length > 0 ? `${trimmed}\n${entry}\n` : `${entry}\n`;
134
+ return { content: nextContent, changed: true };
135
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import type { KnowledgePage } from "../ingest/types.js";
3
+ /**
4
+ * Reads `config.zenn` from `configPath`, scans the configured articles/
5
+ * directory, and writes the resulting `KnowledgePage[]` to `outputPath` as
6
+ * JSON. Throws if `config.zenn` is not set.
7
+ */
8
+ export declare function syncZennSnapshot(configPath: string, outputPath: string): Promise<KnowledgePage[]>;
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { scanZennArticles } from "../ingest/zenn.js";
4
+ /**
5
+ * Reads `config.zenn` from `configPath`, scans the configured articles/
6
+ * directory, and writes the resulting `KnowledgePage[]` to `outputPath` as
7
+ * JSON. Throws if `config.zenn` is not set.
8
+ */
9
+ export async function syncZennSnapshot(configPath, outputPath) {
10
+ const config = JSON.parse(await readFile(configPath, "utf-8"));
11
+ if (!config.zenn) {
12
+ throw new Error("config.zenn is not set");
13
+ }
14
+ const pages = await scanZennArticles(config.zenn);
15
+ await writeFile(outputPath, JSON.stringify(pages, null, 2));
16
+ return pages;
17
+ }
18
+ async function main() {
19
+ const configPath = process.argv[2];
20
+ const outputPath = process.argv[3];
21
+ if (!configPath || !outputPath) {
22
+ console.error("usage: folio-agent-sync-zenn <config.json> <output.json>");
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ const pages = await syncZennSnapshot(configPath, outputPath);
27
+ console.log(`wrote ${pages.length} zenn page(s) -> ${outputPath}`);
28
+ }
29
+ // Only run when executed directly (e.g. `folio-agent-sync-zenn ...`), not
30
+ // when imported by tests.
31
+ if (import.meta.url === `file://${process.argv[1]}`) {
32
+ main().catch((error) => {
33
+ console.error(error);
34
+ process.exitCode = 1;
35
+ });
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "bin": {
14
14
  "folio-agent-ingest": "dist/ingest/cli.js",
15
- "folio-agent-init": "dist/init/cli.js"
15
+ "folio-agent-init": "dist/init/cli.js",
16
+ "folio-agent-sync-zenn": "dist/sync/cli.js"
16
17
  },
17
18
  "repository": {
18
19
  "type": "git",