@flocon-ai/init 0.0.0-stage → 0.0.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 +33 -2
- package/package.json +22 -4
- package/src/cli.mjs +232 -0
- package/src/sitemap.mjs +149 -0
- package/src/templates.mjs +113 -0
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @flocon-ai/init
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One command to add Flocon AI pages to your website:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx @flocon-ai/init
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Supports Next.js (App Router), React Router v7 (framework mode) and Astro.
|
|
10
|
+
|
|
11
|
+
Project shapes:
|
|
12
|
+
|
|
13
|
+
- `src/app` (Next.js) and `src/pages` (Astro): detected.
|
|
14
|
+
- `[locale]` under the Next.js app folder: the route goes under it and the locale is passed to `<FloconPage />`.
|
|
15
|
+
- `src/pages/[locale]` in Astro: the route goes under it and the locale is passed to `getFloconPage`.
|
|
16
|
+
- Browser-only React (Vite, Lovable, Bolt, CRA): not supported (AI bots do not run JavaScript). The CLI stops and installs nothing.
|
|
17
|
+
- Next.js Pages Router: not supported yet. The CLI stops and installs nothing.
|
|
18
|
+
|
|
19
|
+
Options (no prompts, for AI agents):
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
npx @flocon-ai/init --path /blog --yes
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `--path <path>`: where pages live (`/` for the whole website, `/blog`…).
|
|
26
|
+
- `--yes`: no prompts (path defaults to `/`).
|
|
27
|
+
- `--skip-install`: do not install the package.
|
|
28
|
+
- `--no-sitemap`: do not add the sitemap and `llms.txt` files.
|
|
29
|
+
|
|
30
|
+
The sitemap and `llms.txt` files are never overwritten. If the website already has one, the CLI prints what to add and you merge it yourself.
|
|
31
|
+
|
|
32
|
+
The CLI never asks for or writes `FLOCON_TOKEN`. Copy it from https://flocon-ai.com/app/tokens, add it to your env file and to your host, then deploy.
|
|
33
|
+
|
|
34
|
+
Tests: `npm test`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flocon-ai/init",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "One command to add Flocon AI pages to your website: npx @flocon-ai/init",
|
|
5
|
+
"homepage": "https://flocon-ai.com/docs",
|
|
6
|
+
"keywords": ["flocon-ai", "seo", "geo", "mcp", "cli", "nextjs", "astro", "react-router"],
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"init": "./src/cli.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/init.test.mjs"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import readline from "node:readline/promises"
|
|
6
|
+
import { addSitemapAndLlms } from "./sitemap.mjs"
|
|
7
|
+
import { TEMPLATES } from "./templates.mjs"
|
|
8
|
+
|
|
9
|
+
const DOCS = "https://flocon-ai.com/docs"
|
|
10
|
+
const TOKENS_URL = "https://flocon-ai.com/app/tokens"
|
|
11
|
+
|
|
12
|
+
const FRAMEWORKS = {
|
|
13
|
+
nextjs: { label: "Next.js (App Router)", pkg: "@flocon-ai/next" },
|
|
14
|
+
react: { label: "React Router v7 (framework mode)", pkg: "@flocon-ai/react" },
|
|
15
|
+
astro: { label: "Astro", pkg: "@flocon-ai/astro" }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const log = (line = "") => console.log(line)
|
|
19
|
+
|
|
20
|
+
function fail(lines) {
|
|
21
|
+
for (const line of lines) console.error(line)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = { yes: false, skipInstall: false, sitemap: true, path: undefined }
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i]
|
|
29
|
+
if (a === "--yes") args.yes = true
|
|
30
|
+
else if (a === "--skip-install") args.skipInstall = true
|
|
31
|
+
else if (a === "--no-sitemap") args.sitemap = false
|
|
32
|
+
else if (a === "--path") args.path = argv[++i] ?? ""
|
|
33
|
+
else if (a.startsWith("--path=")) args.path = a.slice("--path=".length)
|
|
34
|
+
else fail([`Unknown option: ${a}`, "Options: --path <path> --yes --skip-install --no-sitemap"])
|
|
35
|
+
}
|
|
36
|
+
return args
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readPackageJson(cwd) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// "app" or "src/app" (Next.js uses src/app only when app/ is absent).
|
|
48
|
+
function nextAppDir(cwd) {
|
|
49
|
+
if (fs.existsSync(path.join(cwd, "app"))) return "app"
|
|
50
|
+
if (fs.existsSync(path.join(cwd, "src/app"))) return "src/app"
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function detectFramework(cwd) {
|
|
55
|
+
const pkg = readPackageJson(cwd)
|
|
56
|
+
if (!pkg) return undefined
|
|
57
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
58
|
+
if (deps.next && nextAppDir(cwd)) return "nextjs"
|
|
59
|
+
if (deps.astro && fs.existsSync(path.join(cwd, "src/pages"))) return "astro"
|
|
60
|
+
if (deps["@react-router/dev"] && fs.existsSync(path.join(cwd, "app/routes.ts"))) return "react"
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function detectPackageManager(cwd) {
|
|
65
|
+
const has = (f) => fs.existsSync(path.join(cwd, f))
|
|
66
|
+
if (has("pnpm-lock.yaml")) return "pnpm"
|
|
67
|
+
if (has("yarn.lock")) return "yarn"
|
|
68
|
+
if (has("bun.lock") || has("bun.lockb")) return "bun"
|
|
69
|
+
return "npm"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const INSTALL_ARGS = { npm: ["install"], pnpm: ["add"], yarn: ["add"], bun: ["add"] }
|
|
73
|
+
|
|
74
|
+
// "blog" -> "/blog", "/blog/" -> "/blog". Returns { error } when the path is not valid.
|
|
75
|
+
function normalizePath(input) {
|
|
76
|
+
let p = String(input ?? "").trim()
|
|
77
|
+
if (/\s/.test(p)) return { error: "The path must not contain spaces." }
|
|
78
|
+
if (p === "") p = "/"
|
|
79
|
+
if (!p.startsWith("/")) p = "/" + p
|
|
80
|
+
if (p.length > 1) p = p.replace(/\/+$/, "") || "/"
|
|
81
|
+
if (!/^(\/[A-Za-z0-9_-]+)*\/?$/.test(p)) {
|
|
82
|
+
return { error: "Use only letters, numbers, - and _ in the path (e.g. /blog or /guides/seo)." }
|
|
83
|
+
}
|
|
84
|
+
return { path: p }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Next.js (app/[locale]) or Astro (src/pages/[locale]) with a `[locale]` segment: the route goes under it.
|
|
88
|
+
function hasLocaleSegment(cwd, framework) {
|
|
89
|
+
if (framework === "nextjs") return fs.existsSync(path.join(cwd, nextAppDir(cwd), "[locale]"))
|
|
90
|
+
if (framework === "astro") return fs.existsSync(path.join(cwd, "src/pages/[locale]"))
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Projects we know and do not support: stop with the reason, install nothing.
|
|
95
|
+
function unsupportedReason(cwd) {
|
|
96
|
+
const pkg = readPackageJson(cwd)
|
|
97
|
+
if (!pkg) return undefined
|
|
98
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
99
|
+
if (deps.next && !nextAppDir(cwd) && (fs.existsSync(path.join(cwd, "pages")) || fs.existsSync(path.join(cwd, "src/pages")))) {
|
|
100
|
+
return [
|
|
101
|
+
"Flocon AI: this is a Next.js Pages Router project. It is not supported yet (@flocon-ai/next needs the App Router).",
|
|
102
|
+
"Nothing was installed or changed."
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
const ssrOnVite = deps.next || deps.astro || deps["@react-router/dev"] || deps["@tanstack/react-start"] || deps["@remix-run/dev"]
|
|
106
|
+
if ((deps.vite || deps["react-scripts"]) && deps.react && !ssrOnVite) {
|
|
107
|
+
return [
|
|
108
|
+
"Flocon AI: this is a browser-only React app (Vite: Lovable, Bolt, CRA…). It is not supported.",
|
|
109
|
+
"Why: AI bots and search crawlers do not run JavaScript. They would see an empty page.",
|
|
110
|
+
"Options: move to a server-rendered framework, like Next.js or React Router v7 (framework mode).",
|
|
111
|
+
"Nothing was installed or changed. Docs: " + DOCS
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function routeFile(cwd, framework, urlPath, hasLocale) {
|
|
118
|
+
const prefix = urlPath === "/" ? "" : urlPath
|
|
119
|
+
if (framework === "nextjs") {
|
|
120
|
+
const locale = hasLocale ? "/[locale]" : ""
|
|
121
|
+
return `${nextAppDir(cwd)}${locale}${prefix}/[...slug]/page.tsx`
|
|
122
|
+
}
|
|
123
|
+
if (framework === "astro") return `src/pages${hasLocale ? "/[locale]" : ""}${prefix}/[...slug].astro`
|
|
124
|
+
return "app/routes/flocon.tsx"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fileIsTaken(cwd, framework, file) {
|
|
128
|
+
if (fs.existsSync(path.join(cwd, file))) return true
|
|
129
|
+
if (framework === "nextjs") {
|
|
130
|
+
const stem = file.replace(/\.tsx$/, "")
|
|
131
|
+
return ["js", "jsx", "ts", "tsx"].some((ext) => fs.existsSync(path.join(cwd, `${stem}.${ext}`)))
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tokenAlreadySet(cwd) {
|
|
137
|
+
if (process.env.FLOCON_TOKEN) return true
|
|
138
|
+
const re = /^\s*(export\s+)?FLOCON_TOKEN\s*=\s*\S/m
|
|
139
|
+
for (const f of [".env.local", ".env", ".env.development.local", ".env.development"]) {
|
|
140
|
+
try {
|
|
141
|
+
if (re.test(fs.readFileSync(path.join(cwd, f), "utf8"))) return true
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
const args = parseArgs(process.argv.slice(2))
|
|
149
|
+
const cwd = process.cwd()
|
|
150
|
+
|
|
151
|
+
// 1. Framework
|
|
152
|
+
const framework = detectFramework(cwd)
|
|
153
|
+
if (!framework) {
|
|
154
|
+
const reason = unsupportedReason(cwd)
|
|
155
|
+
if (reason) fail(reason)
|
|
156
|
+
fail([
|
|
157
|
+
"Flocon AI: no supported framework found in this folder.",
|
|
158
|
+
"Supported: Next.js (App Router), React Router v7 (framework mode), Astro.",
|
|
159
|
+
"Run this command at the root of your website. Docs: " + DOCS
|
|
160
|
+
])
|
|
161
|
+
}
|
|
162
|
+
const { label, pkg } = FRAMEWORKS[framework]
|
|
163
|
+
log(`Found: ${label}`)
|
|
164
|
+
|
|
165
|
+
// 2. Path (asked before anything is changed)
|
|
166
|
+
let answer = args.path
|
|
167
|
+
if (answer === undefined) {
|
|
168
|
+
if (args.yes || !process.stdin.isTTY) {
|
|
169
|
+
answer = "/"
|
|
170
|
+
} else {
|
|
171
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
172
|
+
answer = await rl.question("Where should Flocon pages go? (e.g. / for the whole website, /blog, /guides) ")
|
|
173
|
+
rl.close()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const normalized = normalizePath(answer)
|
|
177
|
+
if (normalized.error) fail([`Invalid path "${answer}": ${normalized.error}`])
|
|
178
|
+
const urlPath = normalized.path
|
|
179
|
+
|
|
180
|
+
// 3. Install
|
|
181
|
+
const pm = detectPackageManager(cwd)
|
|
182
|
+
const installCmd = [pm, ...INSTALL_ARGS[pm], pkg]
|
|
183
|
+
if (args.skipInstall) {
|
|
184
|
+
log(`Skipped install. Package: ${pkg} (package manager: ${pm}).`)
|
|
185
|
+
} else {
|
|
186
|
+
log(`Installing ${pkg} with ${pm}...`)
|
|
187
|
+
const res = spawnSync(installCmd[0], installCmd.slice(1), { cwd, stdio: "inherit", shell: process.platform === "win32" })
|
|
188
|
+
if (res.status !== 0) fail([`Install failed. Run it yourself: ${installCmd.join(" ")}`])
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 4. Route file (never overwrite)
|
|
192
|
+
const hasLocale = hasLocaleSegment(cwd, framework)
|
|
193
|
+
const file = routeFile(cwd, framework, urlPath, hasLocale)
|
|
194
|
+
const template = hasLocale ? TEMPLATES[framework + "Locale"] : TEMPLATES[framework]
|
|
195
|
+
let exitCode = 0
|
|
196
|
+
if (fileIsTaken(cwd, framework, file)) {
|
|
197
|
+
exitCode = 1
|
|
198
|
+
log(`\n${file} already exists. I did not touch it. Add this file yourself (e.g. as ${file}):\n`)
|
|
199
|
+
log(template)
|
|
200
|
+
} else {
|
|
201
|
+
const full = path.join(cwd, file)
|
|
202
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
203
|
+
fs.writeFileSync(full, template, { flag: "wx" })
|
|
204
|
+
log(`Created ${file}`)
|
|
205
|
+
}
|
|
206
|
+
if (framework === "react") {
|
|
207
|
+
const routePath = urlPath === "/" ? "*" : `${urlPath.slice(1)}/*`
|
|
208
|
+
log(`\nRegister the route last in app/routes.ts:\n route("${routePath}", "routes/flocon.tsx")`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4b. Sitemap + llms.txt (never overwrite)
|
|
212
|
+
if (args.sitemap) {
|
|
213
|
+
const appDir = framework === "nextjs" ? nextAppDir(cwd) : undefined
|
|
214
|
+
if (addSitemapAndLlms({ cwd, framework, appDir, urlPath, log })) exitCode = 1
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 5. Token (no prompt, no file write)
|
|
218
|
+
log()
|
|
219
|
+
if (tokenAlreadySet(cwd)) {
|
|
220
|
+
log("FLOCON_TOKEN is already set. Good.")
|
|
221
|
+
} else {
|
|
222
|
+
log(
|
|
223
|
+
`Flocon needs FLOCON_TOKEN to work. Copy your website token from ${TOKENS_URL}, then add FLOCON_TOKEN=... to your env file (.env.local / .env) and to your host. Never commit it.`
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 6. Last step
|
|
228
|
+
log("\nNext step: add FLOCON_TOKEN to your host, then deploy.")
|
|
229
|
+
process.exit(exitCode)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await main()
|
package/src/sitemap.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Sitemap + llms.txt files written by the CLI (`--no-sitemap` skips them).
|
|
2
|
+
// Copy of the `get_setup` snippets in back/functions/mcp/setup.ts (a package never imports from back/).
|
|
3
|
+
// test/init.test.mjs fails if they drift.
|
|
4
|
+
import fs from "node:fs"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
const nextSitemap = `import type { MetadataRoute } from "next"
|
|
8
|
+
import { floconSitemap } from "@flocon-ai/next"
|
|
9
|
+
|
|
10
|
+
// Built on getAllPages(): published pages only, never drafts.
|
|
11
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
12
|
+
return floconSitemap({ url: ({ locale, path }) => \`https://example.com/\${locale}\${path}\` })
|
|
13
|
+
}
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
const nextLlms = `import { floconLlmsTxt } from "@flocon-ai/next"
|
|
17
|
+
|
|
18
|
+
export async function GET() {
|
|
19
|
+
return new Response(await floconLlmsTxt({ url: ({ locale, path }) => \`https://example.com/\${locale}\${path}\` }), {
|
|
20
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const astroSitemap = `import { getAllPages } from "@flocon-ai/astro"
|
|
26
|
+
|
|
27
|
+
// Published pages only, never drafts.
|
|
28
|
+
export async function GET() {
|
|
29
|
+
const pages = await getAllPages()
|
|
30
|
+
const urls = pages
|
|
31
|
+
.map((p) => \`<url><loc>https://example.com/\${p.locale}\${p.path}</loc><lastmod>\${p.updatedAt}</lastmod></url>\`)
|
|
32
|
+
.join("")
|
|
33
|
+
return new Response(
|
|
34
|
+
\`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\${urls}</urlset>\`,
|
|
35
|
+
{ headers: { "content-type": "application/xml" } }
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const astroLlms = `import { floconLlmsTxt } from "@flocon-ai/astro"
|
|
41
|
+
|
|
42
|
+
export async function GET() {
|
|
43
|
+
const text = await floconLlmsTxt({ url: ({ locale, path }) => \`https://example.com/\${locale}\${path}\` })
|
|
44
|
+
return new Response(text, { headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
const reactSitemap = `import { floconSitemap } from "@flocon-ai/react"
|
|
49
|
+
|
|
50
|
+
export async function loader() {
|
|
51
|
+
const xml = await floconSitemap({ url: ({ locale, path }) => \`https://example.com\${path}\` })
|
|
52
|
+
return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } })
|
|
53
|
+
}
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
const reactLlms = `import { floconLlmsTxt } from "@flocon-ai/react"
|
|
57
|
+
|
|
58
|
+
export async function loader() {
|
|
59
|
+
const text = await floconLlmsTxt({ url: ({ locale, path }) => \`https://example.com\${path}\` })
|
|
60
|
+
return new Response(text, { headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
61
|
+
}
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
const exts = (base, list) => list.map((e) => base + e)
|
|
65
|
+
|
|
66
|
+
// `existing` = files that mean the website already has one.
|
|
67
|
+
function spec(framework, appDir) {
|
|
68
|
+
if (framework === "nextjs") {
|
|
69
|
+
return {
|
|
70
|
+
sitemap: {
|
|
71
|
+
file: `${appDir}/sitemap.ts`,
|
|
72
|
+
code: nextSitemap,
|
|
73
|
+
existing: [...exts(`${appDir}/sitemap`, [".js", ".ts", ".xml"]), ...exts(`${appDir}/sitemap.xml/route`, [".js", ".ts"]), "public/sitemap.xml"]
|
|
74
|
+
},
|
|
75
|
+
llms: {
|
|
76
|
+
file: `${appDir}/llms.txt/route.ts`,
|
|
77
|
+
code: nextLlms,
|
|
78
|
+
existing: [...exts(`${appDir}/llms.txt/route`, [".js", ".ts"]), `${appDir}/llms.txt`, "public/llms.txt"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (framework === "astro") {
|
|
83
|
+
return {
|
|
84
|
+
sitemap: {
|
|
85
|
+
file: "src/pages/sitemap.xml.ts",
|
|
86
|
+
code: astroSitemap,
|
|
87
|
+
existing: [...exts("src/pages/sitemap.xml", [".ts", ".js", ".astro"]), "src/pages/sitemap-index.xml.ts", "public/sitemap.xml", "public/sitemap-index.xml"]
|
|
88
|
+
},
|
|
89
|
+
llms: {
|
|
90
|
+
file: "src/pages/llms.txt.ts",
|
|
91
|
+
code: astroLlms,
|
|
92
|
+
existing: [...exts("src/pages/llms.txt", [".ts", ".js", ".astro"]), "public/llms.txt"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
sitemap: {
|
|
98
|
+
file: "app/routes/sitemap[.]xml.ts",
|
|
99
|
+
code: reactSitemap,
|
|
100
|
+
existing: [...exts("app/routes/sitemap[.]xml", [".ts", ".tsx"]), ...exts("app/routes/sitemap.xml", [".ts", ".tsx"]), "public/sitemap.xml"]
|
|
101
|
+
},
|
|
102
|
+
llms: {
|
|
103
|
+
file: "app/routes/llms[.]txt.ts",
|
|
104
|
+
code: reactLlms,
|
|
105
|
+
existing: [...exts("app/routes/llms[.]txt", [".ts", ".tsx"]), ...exts("app/routes/llms.txt", [".ts", ".tsx"]), "public/llms.txt"]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Astro: @astrojs/sitemap builds the sitemap from the config, there is no file to detect.
|
|
111
|
+
function astroConfigHasSitemap(cwd) {
|
|
112
|
+
for (const f of ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"]) {
|
|
113
|
+
try {
|
|
114
|
+
if (/@astrojs\/sitemap/.test(fs.readFileSync(path.join(cwd, f), "utf8"))) return true
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Never overwrites. Returns true when something has to be done by hand.
|
|
121
|
+
export function addSitemapAndLlms({ cwd, framework, appDir, urlPath, log }) {
|
|
122
|
+
const files = spec(framework, appDir)
|
|
123
|
+
const exists = (f) => fs.existsSync(path.join(cwd, f))
|
|
124
|
+
let manual = false
|
|
125
|
+
log()
|
|
126
|
+
for (const [kind, name] of [
|
|
127
|
+
["sitemap", "sitemap"],
|
|
128
|
+
["llms", "llms.txt"]
|
|
129
|
+
]) {
|
|
130
|
+
const { file, code, existing } = files[kind]
|
|
131
|
+
const taken = existing.some(exists) || (kind === "sitemap" && framework === "astro" && astroConfigHasSitemap(cwd))
|
|
132
|
+
if (taken) {
|
|
133
|
+
manual = true
|
|
134
|
+
log(`The website already has a ${name}. I did not touch it. Add this yourself: merge the Flocon URLs (published pages, under ${urlPath}) into it.`)
|
|
135
|
+
log(`Example (${file}):\n`)
|
|
136
|
+
log(code)
|
|
137
|
+
} else {
|
|
138
|
+
const full = path.join(cwd, file)
|
|
139
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
140
|
+
fs.writeFileSync(full, code, { flag: "wx" })
|
|
141
|
+
log(`Created ${file}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
log(`In the sitemap and llms.txt files, replace https://example.com with your website URL (Flocon pages live under ${urlPath}).`)
|
|
145
|
+
if (framework === "react") {
|
|
146
|
+
log('Register both routes before the catch-all in app/routes.ts:\n route("sitemap.xml", "routes/sitemap[.]xml.ts")\n route("llms.txt", "routes/llms[.]txt.ts")')
|
|
147
|
+
}
|
|
148
|
+
return manual
|
|
149
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Route files written by the CLI.
|
|
2
|
+
// Copy of the `get_setup` snippets in back/functions/mcp/setup.ts (a package never imports from back/).
|
|
3
|
+
// test/init.test.mjs fails if they drift.
|
|
4
|
+
|
|
5
|
+
const nextjs = `import { FloconPage } from "@flocon-ai/next"
|
|
6
|
+
|
|
7
|
+
// One catch-all route serves every Flocon page. Rendered on each request.
|
|
8
|
+
export default async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
|
|
9
|
+
const { slug } = await params
|
|
10
|
+
return <FloconPage path={"/" + slug.join("/")} />
|
|
11
|
+
}
|
|
12
|
+
`
|
|
13
|
+
|
|
14
|
+
// Same as `nextjs`, for a route under `[locale]`: the locale goes to the lib.
|
|
15
|
+
const nextjsLocale = `import { FloconPage } from "@flocon-ai/next"
|
|
16
|
+
|
|
17
|
+
// One catch-all route serves every Flocon page, under the [locale] segment. Rendered on each request.
|
|
18
|
+
export default async function Page({ params }: { params: Promise<{ locale: string; slug: string[] }> }) {
|
|
19
|
+
const { locale, slug } = await params
|
|
20
|
+
return <FloconPage locale={locale} path={"/" + slug.join("/")} />
|
|
21
|
+
}
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const astro = `---
|
|
25
|
+
import FloconPage from "@flocon-ai/astro/FloconPage.astro"
|
|
26
|
+
import { getFloconPage, getFloconMetadata } from "@flocon-ai/astro"
|
|
27
|
+
|
|
28
|
+
// Your URL rule. Flocon AI adds no prefix of its own.
|
|
29
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com/\${locale}\${path}\`
|
|
30
|
+
|
|
31
|
+
const data = await getFloconPage({ path: "/" + (Astro.params.slug ?? "") })
|
|
32
|
+
if (!data) return new Response(null, { status: 404 })
|
|
33
|
+
const meta = getFloconMetadata({ data, url })
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<html lang={data.page.locale}>
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="utf-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width" />
|
|
40
|
+
<title>{meta.title}</title>
|
|
41
|
+
{meta.meta.map((m) => <meta {...m} />)}
|
|
42
|
+
{meta.links.map((l) => <link {...l} />)}
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<FloconPage data={data} url={url} />
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
// Same as `astro`, for a route under `src/pages/[locale]`.
|
|
51
|
+
const astroLocale = `---
|
|
52
|
+
import FloconPage from "@flocon-ai/astro/FloconPage.astro"
|
|
53
|
+
import { getFloconPage, getFloconMetadata } from "@flocon-ai/astro"
|
|
54
|
+
|
|
55
|
+
// Your URL rule. Flocon AI adds no prefix of its own.
|
|
56
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com/\${locale}\${path}\`
|
|
57
|
+
|
|
58
|
+
const data = await getFloconPage({ path: "/" + (Astro.params.slug ?? ""), locale: Astro.params.locale })
|
|
59
|
+
if (!data) return new Response(null, { status: 404 })
|
|
60
|
+
const meta = getFloconMetadata({ data, url })
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
<html lang={data.page.locale}>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8" />
|
|
66
|
+
<meta name="viewport" content="width=device-width" />
|
|
67
|
+
<title>{meta.title}</title>
|
|
68
|
+
{meta.meta.map((m) => <meta {...m} />)}
|
|
69
|
+
{meta.links.map((l) => <link {...l} />)}
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<FloconPage data={data} url={url} />
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
`
|
|
76
|
+
|
|
77
|
+
const react = `// Register it as a splat route in app/routes.ts: route("*", "routes/flocon.tsx") (or route("blog/*", ...) for a prefix)
|
|
78
|
+
import { FloconPage, getFloconMetadata, getPage } from "@flocon-ai/react"
|
|
79
|
+
import type { Route } from "./+types/flocon"
|
|
80
|
+
|
|
81
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com\${path}\`
|
|
82
|
+
|
|
83
|
+
export async function loader({ params }: Route.LoaderArgs) {
|
|
84
|
+
const page = await getPage({ path: "/" + params["*"] })
|
|
85
|
+
if (!page) throw new Response("Not found", { status: 404 })
|
|
86
|
+
return page
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function meta({ data }: Route.MetaArgs) {
|
|
90
|
+
if (!data) return [{ title: "Not found" }]
|
|
91
|
+
const m = getFloconMetadata({ page: data, url })
|
|
92
|
+
return [
|
|
93
|
+
{ title: m.title },
|
|
94
|
+
{ name: "description", content: m.description },
|
|
95
|
+
{ property: "og:type", content: m.openGraph.type },
|
|
96
|
+
{ property: "og:title", content: m.openGraph.title },
|
|
97
|
+
{ property: "og:description", content: m.openGraph.description },
|
|
98
|
+
{ property: "og:url", content: m.openGraph.url },
|
|
99
|
+
{ property: "og:locale", content: m.openGraph.locale },
|
|
100
|
+
{ name: "twitter:card", content: m.twitter.card },
|
|
101
|
+
{ name: "twitter:title", content: m.twitter.title },
|
|
102
|
+
{ name: "twitter:description", content: m.twitter.description },
|
|
103
|
+
{ tagName: "link", rel: "canonical", href: m.canonical },
|
|
104
|
+
...Object.entries(m.languages).map(([hrefLang, href]) => ({ tagName: "link", rel: "alternate", hrefLang, href }))
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default function Page({ loaderData }: Route.ComponentProps) {
|
|
109
|
+
return <FloconPage page={loaderData} url={url} />
|
|
110
|
+
}
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
export const TEMPLATES = { nextjs, nextjsLocale, astro, astroLocale, react }
|