@flocon-ai/init 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/package.json +16 -3
- package/src/cli.mjs +794 -72
- package/src/sitemap.mjs +50 -19
- package/src/templates.mjs +38 -11
- package/src/url.mjs +17 -0
package/src/cli.mjs
CHANGED
|
@@ -1,41 +1,92 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
3
|
import fs from "node:fs"
|
|
4
4
|
import path from "node:path"
|
|
5
|
-
import
|
|
6
|
-
import { addSitemapAndLlms } from "./sitemap.mjs"
|
|
5
|
+
import { addSitemapAndLlms, ownSitemapFiles } from "./sitemap.mjs"
|
|
7
6
|
import { TEMPLATES } from "./templates.mjs"
|
|
7
|
+
import { applyUrlRule } from "./url.mjs"
|
|
8
8
|
|
|
9
9
|
const DOCS = "https://flocon-ai.com/docs"
|
|
10
10
|
const TOKENS_URL = "https://flocon-ai.com/app/tokens"
|
|
11
11
|
|
|
12
12
|
const FRAMEWORKS = {
|
|
13
13
|
nextjs: { label: "Next.js (App Router)", pkg: "@flocon-ai/next" },
|
|
14
|
-
react: { label: "React Router
|
|
14
|
+
react: { label: "React Router (framework mode)", pkg: "@flocon-ai/react" },
|
|
15
15
|
astro: { label: "Astro", pkg: "@flocon-ai/astro" }
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// Interactive = a person in a terminal: step-by-step questions (like create-next-app).
|
|
19
|
+
// Not interactive (AI agents, CI, --yes, --path or --domain given): no questions, flags and defaults only, plain output.
|
|
20
|
+
let interactive = false
|
|
21
|
+
// @clack/prompts, loaded only for questions: it needs Node 20.12+ (util.styleText). Older Node crashed on start, even with --yes.
|
|
22
|
+
let p
|
|
23
|
+
|
|
24
|
+
// Node 20.12+ (the questions library needs it).
|
|
25
|
+
function canAsk() {
|
|
26
|
+
const [major, minor] = process.versions.node.split(".").map(Number)
|
|
27
|
+
return major > 20 || (major === 20 && minor >= 12)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function log(line = "") {
|
|
31
|
+
if (!interactive) return console.log(line)
|
|
32
|
+
const text = line.replace(/^\n+/, "")
|
|
33
|
+
if (text) p.log.message(text)
|
|
34
|
+
}
|
|
19
35
|
|
|
20
36
|
function fail(lines) {
|
|
21
|
-
|
|
37
|
+
if (interactive) p.log.error(lines.join("\n"))
|
|
38
|
+
else for (const line of lines) console.error(line)
|
|
22
39
|
process.exit(1)
|
|
23
40
|
}
|
|
24
41
|
|
|
42
|
+
// Ctrl+C or Esc at a question.
|
|
43
|
+
function cancelled() {
|
|
44
|
+
p.cancel("Cancelled. Nothing was installed or changed.")
|
|
45
|
+
process.exit(130)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function ask(prompt) {
|
|
49
|
+
const value = await prompt
|
|
50
|
+
if (p.isCancel(value)) cancelled()
|
|
51
|
+
return value
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
function parseArgs(argv) {
|
|
26
|
-
const args = { yes: false, skipInstall: false, sitemap: true, path: undefined }
|
|
55
|
+
const args = { yes: false, skipInstall: false, sitemap: true, path: undefined, domain: undefined }
|
|
27
56
|
for (let i = 0; i < argv.length; i++) {
|
|
28
57
|
const a = argv[i]
|
|
29
58
|
if (a === "--yes") args.yes = true
|
|
30
59
|
else if (a === "--skip-install") args.skipInstall = true
|
|
31
60
|
else if (a === "--no-sitemap") args.sitemap = false
|
|
32
|
-
else if (a === "--path")
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
else if (a === "--path") {
|
|
62
|
+
const value = argv[++i]
|
|
63
|
+
// `--path --yes`: the value is missing. Without this check the path would be "/--yes".
|
|
64
|
+
if (value === undefined || value.startsWith("-")) fail(["--path needs a value, e.g. --path /blog (or --path / for the whole website)."])
|
|
65
|
+
args.path = value
|
|
66
|
+
} else if (a.startsWith("--path=")) args.path = a.slice("--path=".length)
|
|
67
|
+
else if (a === "--domain") {
|
|
68
|
+
const value = argv[++i]
|
|
69
|
+
if (value === undefined || value.startsWith("-")) fail(["--domain needs a value, e.g. --domain https://acme.com"])
|
|
70
|
+
args.domain = value
|
|
71
|
+
} else if (a.startsWith("--domain=")) args.domain = a.slice("--domain=".length)
|
|
72
|
+
else fail([`Unknown option: ${a}`, "Options: --path <path> --domain <url> --yes --skip-install --no-sitemap"])
|
|
35
73
|
}
|
|
36
74
|
return args
|
|
37
75
|
}
|
|
38
76
|
|
|
77
|
+
// "acme.com", "https://acme.com/blog" → "https://acme.com". Undefined when it is not a domain.
|
|
78
|
+
function toOrigin(input) {
|
|
79
|
+
const t = String(input ?? "").trim()
|
|
80
|
+
if (!t) return undefined
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(t.includes("://") ? t : `https://${t}`)
|
|
83
|
+
if (!/^https?:$/.test(u.protocol) || !u.hostname.includes(".")) return undefined
|
|
84
|
+
return u.origin
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
39
90
|
function readPackageJson(cwd) {
|
|
40
91
|
try {
|
|
41
92
|
return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))
|
|
@@ -57,38 +108,92 @@ function detectFramework(cwd) {
|
|
|
57
108
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
58
109
|
if (deps.next && nextAppDir(cwd)) return "nextjs"
|
|
59
110
|
if (deps.astro && fs.existsSync(path.join(cwd, "src/pages"))) return "astro"
|
|
60
|
-
if (deps["@react-router/dev"] &&
|
|
111
|
+
if (deps["@react-router/dev"] && rrRoutesFile(cwd)) return "react"
|
|
61
112
|
return undefined
|
|
62
113
|
}
|
|
63
114
|
|
|
115
|
+
// React Router: the app folder (`appDirectory` in react-router.config, default "app").
|
|
116
|
+
function rrAppDir(cwd) {
|
|
117
|
+
const m = /appDirectory\s*:\s*["'`]([^"'`]+)["'`]/.exec(readFirst(cwd, RR_CONFIGS))
|
|
118
|
+
return (m?.[1] ?? "app").replace(/^\.\//, "").replace(/\/+$/, "")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// React Router: routes.ts, or routes.js in a JavaScript project. Undefined when there is none.
|
|
122
|
+
function rrRoutesFile(cwd) {
|
|
123
|
+
const app = rrAppDir(cwd)
|
|
124
|
+
return ["routes.ts", "routes.js", "routes.mts", "routes.mjs"].map((f) => `${app}/${f}`).find((f) => fs.existsSync(path.join(cwd, f)))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const LOCK_FILES = [
|
|
128
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
129
|
+
["yarn.lock", "yarn"],
|
|
130
|
+
["bun.lock", "bun"],
|
|
131
|
+
["bun.lockb", "bun"],
|
|
132
|
+
["package-lock.json", "npm"]
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
// 1. The lock file, in this folder or a parent one (monorepos keep it at the repo root).
|
|
136
|
+
// 2. No lock file: the tool that ran this command (pnpm dlx, yarn dlx, bunx, npx).
|
|
64
137
|
function detectPackageManager(cwd) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
138
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
139
|
+
for (const [file, pm] of LOCK_FILES) if (fs.existsSync(path.join(dir, file))) return pm
|
|
140
|
+
if (dir === path.dirname(dir)) break
|
|
141
|
+
}
|
|
142
|
+
const agent = process.env.npm_config_user_agent ?? ""
|
|
143
|
+
for (const pm of ["pnpm", "yarn", "bun"]) if (agent.startsWith(pm + "/")) return pm
|
|
69
144
|
return "npm"
|
|
70
145
|
}
|
|
71
146
|
|
|
72
147
|
const INSTALL_ARGS = { npm: ["install"], pnpm: ["add"], yarn: ["add"], bun: ["add"] }
|
|
73
148
|
|
|
74
149
|
// "blog" -> "/blog", "/blog/" -> "/blog". Returns { error } when the path is not valid.
|
|
150
|
+
// Next.js route groups are allowed: "/(site)/blog" -> path "/blog" (the URL), dir "/(site)/blog" (the folder).
|
|
75
151
|
function normalizePath(input) {
|
|
76
152
|
let p = String(input ?? "").trim()
|
|
153
|
+
// Git Bash on Windows turns "/blog" into "C:/Program Files/Git/blog" before the command sees it.
|
|
154
|
+
if (/^[A-Za-z]:[\\/]/.test(p)) {
|
|
155
|
+
return {
|
|
156
|
+
error:
|
|
157
|
+
'Git Bash changed the path into a Windows path. Type it without the first slash: --path blog (or --path "(site)/blog"). Whole website: --yes without --path.'
|
|
158
|
+
}
|
|
159
|
+
}
|
|
77
160
|
if (/\s/.test(p)) return { error: "The path must not contain spaces." }
|
|
78
161
|
if (p === "") p = "/"
|
|
79
162
|
if (!p.startsWith("/")) p = "/" + p
|
|
80
163
|
if (p.length > 1) p = p.replace(/\/+$/, "") || "/"
|
|
81
|
-
if (!/^(\/[A-Za-z0-9_-]
|
|
82
|
-
|
|
164
|
+
if (!/^(\/([A-Za-z0-9_-]+|\([A-Za-z0-9_-]+\)))*\/?$/.test(p)) {
|
|
165
|
+
// Accents (e.g. /actualités): suggest the plain letters.
|
|
166
|
+
const plain = p.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
|
167
|
+
const tip = plain !== p && /^(\/([A-Za-z0-9_-]+|\([A-Za-z0-9_-]+\)))*\/?$/.test(plain) ? ` Try ${plain}.` : ""
|
|
168
|
+
return { error: `Use only letters, numbers, - and _ in the path (e.g. /blog or /guides/seo). A Next.js route group is fine too: /(site)/blog.${tip}` }
|
|
83
169
|
}
|
|
84
|
-
|
|
170
|
+
const url = p.split("/").filter((s) => s && !s.startsWith("("))
|
|
171
|
+
return { path: "/" + url.join("/"), dir: p }
|
|
85
172
|
}
|
|
86
173
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
174
|
+
const LAYOUTS = ["layout.tsx", "layout.jsx", "layout.js", "layout.ts"]
|
|
175
|
+
const hasLayout = (dir) => LAYOUTS.some((f) => fs.existsSync(path.join(dir, f)))
|
|
176
|
+
|
|
177
|
+
// The language segment the route goes under: "locale" for app/[locale] or src/pages/[locale].
|
|
178
|
+
// Next.js also: no app/layout and the root layout in one top folder like app/[lang] (the Next.js i18n docs pattern).
|
|
179
|
+
// Returns the segment name, or undefined.
|
|
180
|
+
function localeSegment(cwd, framework) {
|
|
181
|
+
if (framework === "astro") return fs.existsSync(path.join(cwd, "src/pages/[locale]")) ? "locale" : undefined
|
|
182
|
+
if (framework !== "nextjs") return undefined
|
|
183
|
+
const appDir = path.join(cwd, nextAppDir(cwd))
|
|
184
|
+
if (fs.existsSync(path.join(appDir, "[locale]"))) return "locale"
|
|
185
|
+
if (hasLayout(appDir)) return undefined
|
|
186
|
+
const withLayout = fs
|
|
187
|
+
.readdirSync(appDir)
|
|
188
|
+
.filter((e) => /^\[[A-Za-z_]\w*\]$/.test(e) && hasLayout(path.join(appDir, e)))
|
|
189
|
+
return withLayout.length === 1 ? withLayout[0].slice(1, -1) : undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Next.js needs a root layout above every page. Route groups like (site) do not count: they cover their own folder only.
|
|
193
|
+
function missingRootLayout(cwd, file) {
|
|
194
|
+
const parts = path.dirname(file).split("/")
|
|
195
|
+
for (let i = 1; i <= parts.length; i++) if (hasLayout(path.join(cwd, ...parts.slice(0, i)))) return false
|
|
196
|
+
return true
|
|
92
197
|
}
|
|
93
198
|
|
|
94
199
|
// Projects we know and do not support: stop with the reason, install nothing.
|
|
@@ -114,29 +219,376 @@ function unsupportedReason(cwd) {
|
|
|
114
219
|
return undefined
|
|
115
220
|
}
|
|
116
221
|
|
|
117
|
-
|
|
118
|
-
|
|
222
|
+
// Removes /* */ and // comments, so a commented-out line (e.g. // output: "export") is not read.
|
|
223
|
+
// Keeps // after ":" or a quote (URLs like https://…).
|
|
224
|
+
function stripComments(code) {
|
|
225
|
+
return code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// First config or code file found, without its comments.
|
|
229
|
+
function readFirst(cwd, files) {
|
|
230
|
+
for (const f of files) {
|
|
231
|
+
try {
|
|
232
|
+
return stripComments(fs.readFileSync(path.join(cwd, f), "utf8"))
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
return ""
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const NEXT_CONFIGS = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs", "next.config.mts"]
|
|
239
|
+
const ASTRO_CONFIGS = ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"]
|
|
240
|
+
const RR_CONFIGS = ["react-router.config.ts", "react-router.config.js", "react-router.config.mjs"]
|
|
241
|
+
|
|
242
|
+
// Supported framework, but a setup Flocon cannot work with. Stop with the reason, install nothing.
|
|
243
|
+
function blockedReason(cwd, framework) {
|
|
244
|
+
const pkg = readPackageJson(cwd)
|
|
245
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
246
|
+
const nothing = "Nothing was installed or changed. Docs: " + DOCS
|
|
119
247
|
if (framework === "nextjs") {
|
|
120
|
-
const
|
|
121
|
-
|
|
248
|
+
const major = Number(/\d+/.exec(String(deps.next))?.[0])
|
|
249
|
+
if (major && major < 15) {
|
|
250
|
+
return [`Flocon AI: this project uses Next.js ${major}. @flocon-ai/next needs Next.js 15 or newer.`, "Upgrade Next.js first, then run this command again.", nothing]
|
|
251
|
+
}
|
|
252
|
+
if (/output\s*:\s*["'`]export["'`]/.test(readFirst(cwd, NEXT_CONFIGS))) {
|
|
253
|
+
return [
|
|
254
|
+
'Flocon AI: this project uses output: "export" (static export). Flocon pages are read on each request, so they need a server.',
|
|
255
|
+
'Remove output: "export" from next.config and host the website on a server (Vercel, Netlify, Node…).',
|
|
256
|
+
nothing
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (framework === "astro") {
|
|
261
|
+
const config = readFirst(cwd, ASTRO_CONFIGS)
|
|
262
|
+
if (!/output\s*:\s*["'`]server["'`]/.test(config) || !/adapter\s*:/.test(config)) {
|
|
263
|
+
return [
|
|
264
|
+
'Flocon AI: Astro must run in SSR (output: "server" + an adapter). Pages are read on each request.',
|
|
265
|
+
'Add an adapter (npx astro add node, or vercel, netlify, cloudflare), set output: "server" in astro.config, then run this command again.',
|
|
266
|
+
nothing
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
if (/\bi18n\s*:/.test(config) && !fs.existsSync(path.join(cwd, "src/pages/[locale]"))) {
|
|
270
|
+
return [
|
|
271
|
+
"Flocon AI: this Astro website uses i18n routing (i18n in astro.config). This command can't set it up yet: Flocon pages in other languages would not show.",
|
|
272
|
+
"Add the Flocon route yourself (see the Astro install page in the docs).",
|
|
273
|
+
nothing
|
|
274
|
+
]
|
|
275
|
+
}
|
|
122
276
|
}
|
|
277
|
+
if (framework === "react" && /ssr\s*:\s*false/.test(readFirst(cwd, RR_CONFIGS))) {
|
|
278
|
+
return [
|
|
279
|
+
"Flocon AI: this React Router app runs in SPA mode (ssr: false). It is not supported.",
|
|
280
|
+
"Why: AI bots and search crawlers do not run JavaScript. They would see an empty page.",
|
|
281
|
+
"Set ssr: true in react-router.config, then run this command again.",
|
|
282
|
+
nothing
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// React Router with flatRoutes(): the file name is the URL, nothing to add in app/routes.ts.
|
|
289
|
+
function usesFlatRoutes(cwd) {
|
|
290
|
+
return /flatRoutes\s*\(/.test(readFirst(cwd, [rrRoutesFile(cwd)]))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// React Router: the route files folder, inside the app folder. flatRoutes({ rootDirectory: "pages" }) reads app/pages.
|
|
294
|
+
function rrRoutesDir(cwd) {
|
|
295
|
+
const m = /flatRoutes\s*\(\s*\{[^}]*rootDirectory\s*:\s*["'`]([^"'`]+)["'`]/.exec(readFirst(cwd, [rrRoutesFile(cwd)]))
|
|
296
|
+
return (m?.[1] ?? "routes").replace(/^\.\//, "").replace(/\/+$/, "")
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// React Router: every route path in routes.ts, with its prefix() folders.
|
|
300
|
+
// e.g. ...prefix("blog", [route("*", …)]) -> "blog/*".
|
|
301
|
+
function rrRoutePaths(code) {
|
|
302
|
+
const out = []
|
|
303
|
+
const stack = []
|
|
304
|
+
const re = /prefix\(\s*["'`]([^"'`]*)["'`]\s*,\s*\[|route\(\s*["'`]([^"'`]*)["'`]|\[|\]/g
|
|
305
|
+
for (const m of code.matchAll(re)) {
|
|
306
|
+
if (m[0] === "[") stack.push(null)
|
|
307
|
+
else if (m[0] === "]") stack.pop()
|
|
308
|
+
else if (m[1] !== undefined) stack.push(m[1])
|
|
309
|
+
else out.push([...stack.filter((s) => s), m[2]].map((s) => s.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/"))
|
|
310
|
+
}
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Next.js 16 cacheComponents: `export const dynamic` breaks the build there.
|
|
315
|
+
// React Router 7.0 to 7.7: meta() gets `data` (`loaderData` came in 7.8, v8 only has `loaderData`).
|
|
316
|
+
// Installed version first, else the range in package.json. Unknown → new versions.
|
|
317
|
+
function rrOldMeta(cwd) {
|
|
318
|
+
let version
|
|
319
|
+
try {
|
|
320
|
+
version = JSON.parse(fs.readFileSync(path.join(cwd, "node_modules/react-router/package.json"), "utf8")).version
|
|
321
|
+
} catch {
|
|
322
|
+
const pkg = readPackageJson(cwd)
|
|
323
|
+
version = { ...pkg?.dependencies, ...pkg?.devDependencies }["react-router"]
|
|
324
|
+
}
|
|
325
|
+
const m = /(\d+)\.(\d+)/.exec(String(version ?? ""))
|
|
326
|
+
return Boolean(m) && Number(m[1]) === 7 && Number(m[2]) < 8
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function usesCacheComponents(cwd) {
|
|
330
|
+
return /cacheComponents\s*:\s*true/.test(readFirst(cwd, NEXT_CONFIGS))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// React Router: a splat route for the same URLs is already there (often a 404 page).
|
|
334
|
+
// Two splats for the same URLs: the first one always wins, so Flocon pages would never show.
|
|
335
|
+
// Returns the clashing route, or undefined.
|
|
336
|
+
function reactSplatClash(cwd, urlPath, flat, rr) {
|
|
337
|
+
const segments = urlPath === "/" ? [] : urlPath.slice(1).split("/")
|
|
338
|
+
if (!flat) {
|
|
339
|
+
const target = [...segments, "*"].join("/")
|
|
340
|
+
const hit = rrRoutePaths(readFirst(cwd, [rr.routes])).find((p) => p === target)
|
|
341
|
+
return hit ? `route("${hit}") in ${rr.routes}` : undefined
|
|
342
|
+
}
|
|
343
|
+
// flatRoutes: "$.tsx", "blog.$.tsx", "($locale).$.tsx" (optional and pathless "_x" segments add nothing to the URL).
|
|
344
|
+
let entries = []
|
|
345
|
+
try {
|
|
346
|
+
entries = fs.readdirSync(path.join(cwd, rr.app, rr.dir))
|
|
347
|
+
} catch {}
|
|
348
|
+
for (const e of entries) {
|
|
349
|
+
const parts = e.replace(/\.(tsx|ts|jsx|js)$/, "").split(".")
|
|
350
|
+
if (parts.at(-1) !== "$") continue
|
|
351
|
+
const rest = parts.slice(0, -1).filter((p) => !/^\(.+\)$/.test(p) && !p.startsWith("_"))
|
|
352
|
+
if (rest.join("/") === segments.join("/")) return `${rr.app}/${rr.dir}/${e}`
|
|
353
|
+
}
|
|
354
|
+
return undefined
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Next.js: optional catch-all [[...slug]] when nothing serves the folder's own URL: it also serves that URL
|
|
358
|
+
// (e.g. a Flocon page "/" = the home page). When the website has a page there (app/page.tsx, app/blog/page.tsx,
|
|
359
|
+
// app/(site)/page.tsx…), plain [...slug]: Next.js refuses a page and an optional catch-all for the same URL
|
|
360
|
+
// ("You cannot define a route with the same specificity as an optional catch-all route", tested with next build).
|
|
361
|
+
// `dirPath` = the path with its route groups, e.g. "/(site)/blog" (Next.js only).
|
|
362
|
+
function routeFile(cwd, framework, urlPath, dirPath, locale, flat, rr, optional) {
|
|
363
|
+
const prefix = urlPath === "/" ? "" : urlPath
|
|
364
|
+
const hasLocale = Boolean(locale)
|
|
365
|
+
if (framework === "nextjs") return `${nextAppDir(cwd)}${locale ? `/[${locale}]` : ""}${dirPath === "/" ? "" : dirPath}/${optional ? "[[...slug]]" : "[...slug]"}/page.tsx`
|
|
123
366
|
if (framework === "astro") return `src/pages${hasLocale ? "/[locale]" : ""}${prefix}/[...slug].astro`
|
|
124
|
-
|
|
367
|
+
// flatRoutes: "/" -> $.tsx, "/blog" -> blog.$.tsx, "/guides/seo" -> guides.seo.$.tsx
|
|
368
|
+
if (flat) return `${rr.app}/${rr.dir}/${prefix ? prefix.slice(1).replaceAll("/", ".") + "." : ""}$.tsx`
|
|
369
|
+
return `${rr.app}/routes/flocon.tsx`
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const PAGES = ["page.tsx", "page.jsx", "page.js", "page.ts"]
|
|
373
|
+
|
|
374
|
+
// Next.js: the website's own page for this URL (in the folder or in a route group with the same URL), or undefined.
|
|
375
|
+
// Pages Router files count too (a project moving to the App Router): pages/index.tsx, pages/blog.tsx, pages/blog/index.tsx.
|
|
376
|
+
function ownPage(cwd, base, urlPath) {
|
|
377
|
+
const segments = urlPath === "/" ? [] : urlPath.slice(1).split("/")
|
|
378
|
+
for (const dir of sameUrlDirs(cwd, base, segments)) {
|
|
379
|
+
const hit = PAGES.find((f) => fs.existsSync(path.join(cwd, dir, f)))
|
|
380
|
+
if (hit) return `${dir}/${hit}`
|
|
381
|
+
}
|
|
382
|
+
for (const pages of nextPagesDirs(cwd)) {
|
|
383
|
+
const stems = [[pages, ...segments, "index"].join("/"), ...(segments.length ? [[pages, ...segments].join("/")] : [])]
|
|
384
|
+
for (const stem of stems) {
|
|
385
|
+
const hit = ["tsx", "ts", "jsx", "js"].map((ext) => `${stem}.${ext}`).find((f) => fs.existsSync(path.join(cwd, f)))
|
|
386
|
+
if (hit) return hit
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Next.js Pages Router folders next to the App Router (a project moving to the App Router).
|
|
393
|
+
// A Pages Router catch-all and an App Router catch-all for the same URLs: next build passes, next start crashes.
|
|
394
|
+
function nextPagesDirs(cwd) {
|
|
395
|
+
return ["pages", "src/pages"].filter((d) => fs.existsSync(path.join(cwd, d)))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const isGroup = (name) => /^\(.+\)$/.test(name)
|
|
399
|
+
|
|
400
|
+
// Adds the route groups inside these folders, at any depth (they add no URL segment).
|
|
401
|
+
function withGroups(cwd, dirs) {
|
|
402
|
+
const out = [...dirs]
|
|
403
|
+
for (let i = 0; i < out.length; i++) {
|
|
404
|
+
let entries = []
|
|
405
|
+
try {
|
|
406
|
+
entries = fs.readdirSync(path.join(cwd, out[i]))
|
|
407
|
+
} catch {}
|
|
408
|
+
for (const e of entries) if (isGroup(e)) out.push(`${out[i]}/${e}`)
|
|
409
|
+
}
|
|
410
|
+
return out
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Next.js: every folder that maps to the same URL as `base` + `segments`, through route groups at any level.
|
|
414
|
+
// e.g. base "app", ["blog"] -> app/blog, app/(marketing)/blog, app/(a)/(b)/blog…
|
|
415
|
+
function sameUrlDirs(cwd, base, segments) {
|
|
416
|
+
let dirs = withGroups(cwd, [base])
|
|
417
|
+
for (const s of segments) dirs = withGroups(cwd, dirs.map((d) => `${d}/${s}`))
|
|
418
|
+
return dirs
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Next.js and Astro pick a [slug] or [lang] route before the [...slug] catch-all: Flocon pages would never show.
|
|
422
|
+
// Two catch-alls in one folder break the build. Returns the clashing route, or undefined.
|
|
423
|
+
function clashingRoute(cwd, framework, file, base, urlPath) {
|
|
424
|
+
if (framework === "react") return undefined
|
|
425
|
+
const ours = framework === "nextjs" ? path.basename(path.dirname(file)) : "[...slug].astro"
|
|
426
|
+
const segments = urlPath === "/" ? [] : urlPath.slice(1).split("/")
|
|
427
|
+
// Next.js: route groups like (marketing) add no URL segment, at any level: look inside them too.
|
|
428
|
+
// Pages Router folders too: pages/[...slug].tsx next to app/[[...slug]] makes next start crash.
|
|
429
|
+
const dirs = framework === "nextjs" ? [...sameUrlDirs(cwd, base, segments), ...nextPagesDirs(cwd).map((d) => [d, ...segments].join("/"))] : [path.dirname(file)]
|
|
430
|
+
for (const dir of dirs) {
|
|
431
|
+
let entries = []
|
|
432
|
+
try {
|
|
433
|
+
entries = fs.readdirSync(path.join(cwd, dir))
|
|
434
|
+
} catch {}
|
|
435
|
+
const hit = entries.find((e) => e.startsWith("[") && e !== ours)
|
|
436
|
+
if (hit) return path.join(dir, hit).split(path.sep).join("/")
|
|
437
|
+
}
|
|
438
|
+
return undefined
|
|
125
439
|
}
|
|
126
440
|
|
|
127
|
-
|
|
128
|
-
|
|
441
|
+
// The file already at the route's place (Next.js: page.js, page.jsx… count too), or undefined.
|
|
442
|
+
function takenFile(cwd, framework, file) {
|
|
443
|
+
if (fs.existsSync(path.join(cwd, file))) return file
|
|
129
444
|
if (framework === "nextjs") {
|
|
130
445
|
const stem = file.replace(/\.tsx$/, "")
|
|
131
|
-
return ["js", "jsx", "ts", "tsx"].
|
|
446
|
+
return ["js", "jsx", "ts", "tsx"].map((ext) => `${stem}.${ext}`).find((f) => fs.existsSync(path.join(cwd, f)))
|
|
132
447
|
}
|
|
133
|
-
return
|
|
448
|
+
return undefined
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The Flocon route already in the project, or undefined: a catch-all route file that imports the Flocon lib.
|
|
452
|
+
// Only catch-alls count: the website's own pages can use the lib too (e.g. a list of pages with getAllPages).
|
|
453
|
+
function findFloconRoute(cwd, framework, rr) {
|
|
454
|
+
const root = framework === "nextjs" ? nextAppDir(cwd) : framework === "astro" ? "src/pages" : `${rr.app}/${rr.dir}`
|
|
455
|
+
const lib = FRAMEWORKS[framework].pkg
|
|
456
|
+
const isRoute = (name, dir) =>
|
|
457
|
+
framework === "nextjs"
|
|
458
|
+
? /^page\.(tsx|ts|jsx|js)$/.test(name) && /^\[\[?\.\.\.[^\]]+\]\]?$/.test(path.basename(dir))
|
|
459
|
+
: framework === "astro"
|
|
460
|
+
? /^\[\.\.\.[^\]]+\]\.astro$/.test(name)
|
|
461
|
+
: /(^|\.)\$\.(tsx|ts|jsx|js)$/.test(name) || /^flocon\.(tsx|ts|jsx|js)$/.test(name)
|
|
462
|
+
const walk = (dir, depth) => {
|
|
463
|
+
let entries = []
|
|
464
|
+
try {
|
|
465
|
+
entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true })
|
|
466
|
+
} catch {}
|
|
467
|
+
for (const e of entries) {
|
|
468
|
+
const rel = `${dir}/${e.name}`
|
|
469
|
+
if (e.isDirectory() && depth < 8 && e.name !== "node_modules" && !e.name.startsWith(".")) {
|
|
470
|
+
const hit = walk(rel, depth + 1)
|
|
471
|
+
if (hit) return hit
|
|
472
|
+
} else if (e.isFile() && isRoute(e.name, dir) && fs.readFileSync(path.join(cwd, rel), "utf8").includes(lib)) return rel
|
|
473
|
+
}
|
|
474
|
+
return undefined
|
|
475
|
+
}
|
|
476
|
+
return walk(root, 0)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Same place = same folder (Next.js: [...slug] from an older version counts as [[...slug]]).
|
|
480
|
+
const routePlace = (file) => file.replace(/\/\[\[?\.\.\.slug\]\]?\/page\.\w+$/, "")
|
|
481
|
+
|
|
482
|
+
// Next.js: route groups that have a layout (often the header + footer), e.g. ["(site)"]. Only used in hints:
|
|
483
|
+
// the user picks the group by typing it in the path (/(site)/blog).
|
|
484
|
+
function groupLayouts(cwd, base) {
|
|
485
|
+
let entries = []
|
|
486
|
+
try {
|
|
487
|
+
entries = fs.readdirSync(path.join(cwd, base))
|
|
488
|
+
} catch {}
|
|
489
|
+
return entries.filter((e) => isGroup(e) && hasLayout(path.join(cwd, base, e)))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Folders that are often behind a login, or not for public content (admin, dashboard, (app)…).
|
|
493
|
+
// Bots would only see the login page: these go last in the suggestions, without the layout hint.
|
|
494
|
+
const PRIVATE_FOLDER =
|
|
495
|
+
/^\(?(app|auth|admin|dashboard|account|accounts|settings|login|signin|sign-in|signup|sign-up|register|logout|onboarding|billing|checkout|portal|platform|workspace|authenticated|protected|private|user|users|profile|me)\)?$/i
|
|
496
|
+
|
|
497
|
+
// Suggestions for the path question: "/", "/blog", "/guides" first, then the website's folders (Next.js: with route groups),
|
|
498
|
+
// private-looking folders last. Returns [{ value, hint }].
|
|
499
|
+
function pathSuggestions(cwd, framework, base) {
|
|
500
|
+
const out = [{ value: "/", hint: "whole website" }]
|
|
501
|
+
const later = []
|
|
502
|
+
const root = framework === "nextjs" ? base : framework === "astro" ? "src/pages" : undefined
|
|
503
|
+
const walk = (dir, rel, depth) => {
|
|
504
|
+
let entries = []
|
|
505
|
+
try {
|
|
506
|
+
entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true })
|
|
507
|
+
} catch {}
|
|
508
|
+
for (const e of entries) {
|
|
509
|
+
if (!e.isDirectory()) continue
|
|
510
|
+
const n = e.name
|
|
511
|
+
// Skip dynamic segments, private folders, parallel/intercepting routes and the API.
|
|
512
|
+
if (/^[[_@.]/.test(n) || /^\(\.+\)/.test(n) || n === "api" || n === "node_modules") continue
|
|
513
|
+
if (isGroup(n) && framework !== "nextjs") continue
|
|
514
|
+
const value = `${rel}/${n}`
|
|
515
|
+
const hidden = value.split("/").some((s) => PRIVATE_FOLDER.test(s))
|
|
516
|
+
const group = !hidden && isGroup(n) && hasLayout(path.join(cwd, dir, n))
|
|
517
|
+
const item = { value, hint: group ? `uses the layout of ${n}` : "existing folder" }
|
|
518
|
+
if (hidden) later.push(item)
|
|
519
|
+
else found.push(item)
|
|
520
|
+
if (depth < 2) walk(`${dir}/${n}`, value, depth + 1)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const found = []
|
|
524
|
+
if (root) walk(root, "", 0)
|
|
525
|
+
for (const value of ["/blog", "/guides"]) {
|
|
526
|
+
const existing = found.find((o) => o.value === value)
|
|
527
|
+
out.push(existing ?? { value, hint: "new folder" })
|
|
528
|
+
}
|
|
529
|
+
return [...out, ...found.filter((o) => !out.includes(o)), ...later]
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// The website's domain, when the project already has it. Returns { origin, from } or undefined.
|
|
533
|
+
function findDomain(cwd, framework) {
|
|
534
|
+
const valid = (raw, from) => {
|
|
535
|
+
// Only a plain URL. `https://${process.env.X}` is code, not a domain: written as-is it gives https://undefined/….
|
|
536
|
+
if (raw.includes("${")) return undefined
|
|
537
|
+
try {
|
|
538
|
+
const u = new URL(raw)
|
|
539
|
+
if (!/^https?:$/.test(u.protocol) || /^(localhost|127\.0\.0\.1|example\.com|www\.example\.com)$/.test(u.hostname)) return undefined
|
|
540
|
+
// Dev tunnels: never the real domain.
|
|
541
|
+
if (/(^|\.)(ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok\.dev|trycloudflare\.com|loca\.lt)$/.test(u.hostname)) return undefined
|
|
542
|
+
// Preview deploys: Vercel branch URLs (app-git-main-team.vercel.app), Netlify deploy previews, Cloudflare Pages previews (<hash>.app.pages.dev).
|
|
543
|
+
// A website's own *.vercel.app / *.pages.dev name is kept: for some websites it is the real domain.
|
|
544
|
+
if (/-git-[^.]+\.vercel\.app$|^deploy-preview-\d+--[^.]+\.netlify\.app$|^[0-9a-f]{8}\.[^.]+\.pages\.dev$/.test(u.hostname)) return undefined
|
|
545
|
+
// http:// → https:// (canonical URLs on http are a bad signal; every host serves https).
|
|
546
|
+
if (u.protocol === "http:") return { origin: `https://${u.host}`, from, upgraded: true }
|
|
547
|
+
return { origin: u.origin, from }
|
|
548
|
+
} catch {
|
|
549
|
+
return undefined
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (framework === "nextjs") {
|
|
553
|
+
const app = nextAppDir(cwd)
|
|
554
|
+
const dirs = [app, ...fs.readdirSync(path.join(cwd, app)).filter((e) => e.startsWith("[") || isGroup(e)).map((e) => `${app}/${e}`)]
|
|
555
|
+
for (const dir of dirs) {
|
|
556
|
+
for (const f of LAYOUTS) {
|
|
557
|
+
const code = readFirst(cwd, [`${dir}/${f}`])
|
|
558
|
+
// new URL("https://acme.com"), or the fallback in new URL(process.env.X ?? "https://acme.com").
|
|
559
|
+
const m =
|
|
560
|
+
/metadataBase\s*:\s*new URL\(\s*["'`](https?:\/\/[^"'`]+)["'`]/.exec(code) ??
|
|
561
|
+
/metadataBase\s*:\s*new URL\([^)"'`]*(?:\?\?|\|\|)\s*["'`](https?:\/\/[^"'`]+)["'`]/.exec(code)
|
|
562
|
+
const hit = m && valid(m[1], `${dir}/${f}`)
|
|
563
|
+
if (hit) return hit
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (framework === "astro") {
|
|
568
|
+
for (const f of ASTRO_CONFIGS) {
|
|
569
|
+
const m = /\bsite\s*:\s*["'`](https?:\/\/[^"'`]+)["'`]/.exec(readFirst(cwd, [f]))
|
|
570
|
+
const hit = m && valid(m[1], f)
|
|
571
|
+
if (hit) return hit
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const re =
|
|
575
|
+
/^\s*(?:export\s+)?(?:NEXT_PUBLIC_SITE_URL|NEXT_PUBLIC_BASE_URL|NEXT_PUBLIC_APP_URL|NEXT_PUBLIC_URL|PUBLIC_SITE_URL|SITE_URL|APP_URL|BASE_URL)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/m
|
|
576
|
+
// Production files first: .env and .env.local often hold a preview or dev URL.
|
|
577
|
+
for (const f of [".env.production.local", ".env.production", ".env", ".env.local"]) {
|
|
578
|
+
try {
|
|
579
|
+
const m = re.exec(fs.readFileSync(path.join(cwd, f), "utf8"))
|
|
580
|
+
const hit = m && valid(m[1], f)
|
|
581
|
+
if (hit) return hit
|
|
582
|
+
} catch {}
|
|
583
|
+
}
|
|
584
|
+
return undefined
|
|
134
585
|
}
|
|
135
586
|
|
|
136
587
|
function tokenAlreadySet(cwd) {
|
|
137
588
|
if (process.env.FLOCON_TOKEN) return true
|
|
138
589
|
const re = /^\s*(export\s+)?FLOCON_TOKEN\s*=\s*\S/m
|
|
139
|
-
|
|
590
|
+
// .dev.vars: local secrets on Cloudflare Workers.
|
|
591
|
+
for (const f of [".env.local", ".env", ".env.development.local", ".env.development", ".dev.vars"]) {
|
|
140
592
|
try {
|
|
141
593
|
if (re.test(fs.readFileSync(path.join(cwd, f), "utf8"))) return true
|
|
142
594
|
} catch {}
|
|
@@ -144,9 +596,69 @@ function tokenAlreadySet(cwd) {
|
|
|
144
596
|
return false
|
|
145
597
|
}
|
|
146
598
|
|
|
599
|
+
// Runs the install. Interactive: a spinner, the package manager output only if it fails.
|
|
600
|
+
async function install(cmd, cwd) {
|
|
601
|
+
const shell = process.platform === "win32"
|
|
602
|
+
if (!interactive) return spawnSync(cmd[0], cmd.slice(1), { cwd, stdio: "inherit", shell }).status === 0
|
|
603
|
+
const spin = p.spinner()
|
|
604
|
+
spin.start(`Installing ${cmd.at(-1)} with ${cmd[0]}`)
|
|
605
|
+
const { code, out } = await new Promise((resolve) => {
|
|
606
|
+
let out = ""
|
|
607
|
+
const child = spawn(cmd[0], cmd.slice(1), { cwd, shell })
|
|
608
|
+
child.stdout.on("data", (d) => (out += d))
|
|
609
|
+
child.stderr.on("data", (d) => (out += d))
|
|
610
|
+
child.on("error", (e) => resolve({ code: 1, out: String(e) }))
|
|
611
|
+
child.on("close", (code) => resolve({ code, out }))
|
|
612
|
+
})
|
|
613
|
+
if (code === 0) spin.stop(`Installed ${cmd.at(-1)}`)
|
|
614
|
+
else {
|
|
615
|
+
spin.stop("Install failed", 1)
|
|
616
|
+
console.error(out.trim())
|
|
617
|
+
}
|
|
618
|
+
return code === 0
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// The project's formatter, when it has one: { name, cmd } or undefined. The CLI writes its own style (no semicolons):
|
|
622
|
+
// a Prettier or Biome check in the user's CI would fail on the new files.
|
|
623
|
+
function formatter(cwd) {
|
|
624
|
+
const has = (files) => files.some((f) => fs.existsSync(path.join(cwd, f)))
|
|
625
|
+
const pkg = readPackageJson(cwd) ?? {}
|
|
626
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
627
|
+
if (has(["biome.json", "biome.jsonc"]) || deps["@biomejs/biome"]) return { name: "Biome", cmd: "npx biome format --write" }
|
|
628
|
+
const prettierFiles = [".prettierrc", ".prettierrc.json", ".prettierrc.yml", ".prettierrc.yaml", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.mjs", ".prettierrc.ts", "prettier.config.js", "prettier.config.cjs", "prettier.config.mjs", "prettier.config.ts"]
|
|
629
|
+
if (has(prettierFiles) || pkg.prettier || deps.prettier) return { name: "Prettier", cmd: "npx prettier --write" }
|
|
630
|
+
return undefined
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Cloudflare Workers / Pages: secrets are not in .env files there.
|
|
634
|
+
function onCloudflare(cwd) {
|
|
635
|
+
const pkg = readPackageJson(cwd) ?? {}
|
|
636
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
637
|
+
return ["wrangler.toml", "wrangler.json", "wrangler.jsonc"].some((f) => fs.existsSync(path.join(cwd, f))) || Boolean(deps["@opennextjs/cloudflare"] || deps["@astrojs/cloudflare"] || deps["@cloudflare/vite-plugin"])
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Shell-safe path for printed commands: ( ) must be quoted in bash, zsh and PowerShell.
|
|
641
|
+
const shellPath = (dir) => (/[()]/.test(dir) ? `"${dir}"` : dir)
|
|
642
|
+
|
|
147
643
|
async function main() {
|
|
148
644
|
const args = parseArgs(process.argv.slice(2))
|
|
149
645
|
const cwd = process.cwd()
|
|
646
|
+
// --path or --domain given = an AI agent or a script (Cursor and Windsurf run commands in a real terminal): no questions.
|
|
647
|
+
const flagsOnly = args.yes || args.path !== undefined || args.domain !== undefined
|
|
648
|
+
const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
649
|
+
if (!flagsOnly && tty && !canAsk()) {
|
|
650
|
+
// A person on old Node: no silent default path, stop with the one-line command instead.
|
|
651
|
+
fail([
|
|
652
|
+
`Flocon AI: the questions need Node.js 20.12 or newer (you have ${process.versions.node}).`,
|
|
653
|
+
"Upgrade Node.js, or run it with options (no questions), e.g.: npx @flocon-ai/init --path /blog --domain https://acme.com --yes",
|
|
654
|
+
"Nothing was installed or changed."
|
|
655
|
+
])
|
|
656
|
+
}
|
|
657
|
+
interactive = !flagsOnly && tty
|
|
658
|
+
if (interactive) {
|
|
659
|
+
p = await import("@clack/prompts")
|
|
660
|
+
p.intro("Flocon AI")
|
|
661
|
+
}
|
|
150
662
|
|
|
151
663
|
// 1. Framework
|
|
152
664
|
const framework = detectFramework(cwd)
|
|
@@ -155,77 +667,287 @@ async function main() {
|
|
|
155
667
|
if (reason) fail(reason)
|
|
156
668
|
fail([
|
|
157
669
|
"Flocon AI: no supported framework found in this folder.",
|
|
158
|
-
"Supported: Next.js (App Router), React Router v7 (framework mode), Astro.",
|
|
670
|
+
"Supported: Next.js (App Router), React Router v7+ (framework mode), Astro.",
|
|
159
671
|
"Run this command at the root of your website. Docs: " + DOCS
|
|
160
672
|
])
|
|
161
673
|
}
|
|
162
674
|
const { label, pkg } = FRAMEWORKS[framework]
|
|
163
|
-
log(`Found
|
|
675
|
+
if (interactive) p.log.success(`Found ${label}`)
|
|
676
|
+
else log(`Found: ${label}`)
|
|
677
|
+
const blocked = blockedReason(cwd, framework)
|
|
678
|
+
if (blocked) fail(blocked)
|
|
164
679
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
680
|
+
const rr = framework === "react" ? { app: rrAppDir(cwd), routes: rrRoutesFile(cwd), dir: rrRoutesDir(cwd) } : undefined
|
|
681
|
+
const locale = localeSegment(cwd, framework)
|
|
682
|
+
const hasLocale = Boolean(locale)
|
|
683
|
+
const flat = framework === "react" && usesFlatRoutes(cwd)
|
|
684
|
+
const base = framework === "nextjs" ? `${nextAppDir(cwd)}${locale ? `/[${locale}]` : ""}` : undefined
|
|
685
|
+
const nothing = "Nothing was installed or changed. Docs: " + DOCS
|
|
686
|
+
const ours = findFloconRoute(cwd, framework, rr)
|
|
687
|
+
|
|
688
|
+
// The route for a path: { urlPath, dirPath, optional, file }, or { error }.
|
|
689
|
+
// Next.js route groups: the user types them in the path (/(site)/blog) to use the group's layout.
|
|
690
|
+
const plan = (answer) => {
|
|
691
|
+
const n = normalizePath(answer)
|
|
692
|
+
if (n.error) return { error: n.error }
|
|
693
|
+
if (n.dir !== n.path && framework !== "nextjs") return { error: `Route groups like (site) are a Next.js thing. Use a path without them (e.g. ${n.path}).` }
|
|
694
|
+
// Next.js: a page of the website already serves this URL (e.g. app/page.tsx for "/") → [...slug], not [[...slug]].
|
|
695
|
+
const optional = framework === "nextjs" && !ownPage(cwd, base, n.path)
|
|
696
|
+
return { urlPath: n.path, dirPath: n.dir, optional, file: routeFile(cwd, framework, n.path, n.dir, locale, flat, rr, optional) }
|
|
697
|
+
}
|
|
698
|
+
// Why the route can't go there (lines), or undefined. Checked before anything is installed.
|
|
699
|
+
const problems = ({ file, urlPath }) => {
|
|
700
|
+
// Flocon's own route: handled by the "already set up" step.
|
|
701
|
+
if (ours && routePlace(ours) === routePlace(file)) return undefined
|
|
702
|
+
const other = framework === "nextjs" ? "Pick another path (e.g. /guides)" : "Pick another path (e.g. --path /guides)"
|
|
703
|
+
const taken = takenFile(cwd, framework, file)
|
|
704
|
+
if (taken) return [`Flocon AI: ${taken} is already there (your own route). Flocon needs this file for its pages.`, `${other}, or add the Flocon route yourself where it fits.`]
|
|
705
|
+
const clash = framework === "react" ? reactSplatClash(cwd, urlPath, flat, rr) : clashingRoute(cwd, framework, file, base, urlPath)
|
|
706
|
+
if (clash) return [`Flocon AI: ${clash} is already there. It would catch these URLs before Flocon's route, so Flocon pages would not show.`, `${other}, or add the Flocon route yourself where it fits.`]
|
|
707
|
+
if (framework === "nextjs" && missingRootLayout(cwd, file)) {
|
|
708
|
+
const groups = groupLayouts(cwd, base)
|
|
709
|
+
const example = `/${groups[0] ?? "(site)"}${urlPath === "/" ? "" : urlPath}`
|
|
710
|
+
return [
|
|
711
|
+
`Flocon AI: no root layout above ${file}. Next.js needs one (e.g. ${nextAppDir(cwd)}/layout.tsx), or the build fails.`,
|
|
712
|
+
// Quoted: ( ) unquoted is a syntax error in bash, zsh and PowerShell.
|
|
713
|
+
`Your layouts are inside route groups: put the route in one, e.g. ${interactive ? example : `--path "${example}"`}.`
|
|
714
|
+
]
|
|
174
715
|
}
|
|
716
|
+
return undefined
|
|
175
717
|
}
|
|
176
|
-
const normalized = normalizePath(answer)
|
|
177
|
-
if (normalized.error) fail([`Invalid path "${answer}": ${normalized.error}`])
|
|
178
|
-
const urlPath = normalized.path
|
|
179
718
|
|
|
180
|
-
//
|
|
719
|
+
// 2. Questions (a person in a terminal), one at a time. Nothing is changed before the last "Continue?".
|
|
720
|
+
let answer = args.path
|
|
721
|
+
if (answer === undefined && interactive) {
|
|
722
|
+
const suggestions = pathSuggestions(cwd, framework, base)
|
|
723
|
+
const groupTip = framework === "nextjs" ? "\n Add a route group to use its layout (header, footer), e.g. /(site)/blog." : ""
|
|
724
|
+
answer = await ask(
|
|
725
|
+
p.autocomplete({
|
|
726
|
+
message: `Where should Flocon pages go? Type a path or pick a folder.${groupTip}`,
|
|
727
|
+
placeholder: "/blog",
|
|
728
|
+
maxItems: 8,
|
|
729
|
+
options() {
|
|
730
|
+
const typed = (this.userInput ?? "").trim()
|
|
731
|
+
const typedDir = normalizePath(typed).dir
|
|
732
|
+
const list = suggestions.filter((o) => !typed || o.value.toLowerCase().includes(typed.toLowerCase()))
|
|
733
|
+
// What the user typed, as a choice (only a valid path, not "/(si" while typing a group).
|
|
734
|
+
const custom = typedDir && !list.some((o) => o.value === typedDir) ? [{ value: typedDir, label: typedDir, hint: "new folder" }] : []
|
|
735
|
+
return [...custom, ...list.map((o) => ({ value: o.value, label: o.value, hint: o.hint }))]
|
|
736
|
+
},
|
|
737
|
+
validate(value) {
|
|
738
|
+
if (typeof value !== "string") return "Type a path, e.g. /blog"
|
|
739
|
+
const r = plan(value)
|
|
740
|
+
return r.error ?? problems(r)?.join(" ")
|
|
741
|
+
}
|
|
742
|
+
})
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
const route = plan(answer ?? "/")
|
|
746
|
+
if (route.error) fail([`Invalid path "${answer}": ${route.error}`])
|
|
747
|
+
const { urlPath, dirPath, optional, file } = route
|
|
748
|
+
// URLs are case-sensitive: /Blog/x and /blog/x are two different pages.
|
|
749
|
+
if (/[A-Z]/.test(urlPath)) log(`Note: URLs are case-sensitive. Pages will be at ${urlPath}/…, not ${urlPath.toLowerCase()}/….`)
|
|
750
|
+
|
|
751
|
+
// --domain wins over what the project has, and skips the question.
|
|
752
|
+
let domain, domainFrom
|
|
753
|
+
if (args.domain !== undefined) {
|
|
754
|
+
domain = toOrigin(args.domain)
|
|
755
|
+
if (!domain) fail([`Invalid domain "${args.domain}": use your website's domain, e.g. --domain https://acme.com`])
|
|
756
|
+
domainFrom = "--domain"
|
|
757
|
+
} else {
|
|
758
|
+
const found = findDomain(cwd, framework)
|
|
759
|
+
domain = found?.origin
|
|
760
|
+
domainFrom = found?.upgraded ? `${found.from}, http changed to https` : found?.from
|
|
761
|
+
}
|
|
762
|
+
if (interactive && args.domain === undefined) {
|
|
763
|
+
const typed = await ask(
|
|
764
|
+
p.text({
|
|
765
|
+
message: "Your website's domain? (for canonical URLs and the sitemap)",
|
|
766
|
+
placeholder: "https://acme.com — empty = fill it in later",
|
|
767
|
+
initialValue: domain ?? "",
|
|
768
|
+
validate: (v) => (!(v ?? "").trim() || toOrigin(v) ? undefined : "Enter your domain, e.g. https://acme.com")
|
|
769
|
+
})
|
|
770
|
+
)
|
|
771
|
+
const origin = toOrigin(typed)
|
|
772
|
+
if (origin !== domain) domainFrom = "your answer"
|
|
773
|
+
domain = origin
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
let sitemap = args.sitemap
|
|
777
|
+
if (interactive && sitemap) {
|
|
778
|
+
sitemap = await ask(p.confirm({ message: "Add sitemap.xml and llms.txt? (they list your Flocon pages for Google and AI tools)", initialValue: true }))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Flocon is already set up (the command ran before).
|
|
782
|
+
if (ours) {
|
|
783
|
+
// React Router, not flat: the file is always routes/flocon.tsx, the place is the path in the routes file.
|
|
784
|
+
let samePlace = routePlace(ours) === routePlace(file)
|
|
785
|
+
if (samePlace && framework === "react" && !flat && rr.routes) {
|
|
786
|
+
const want = urlPath === "/" ? "*" : `${urlPath.slice(1)}/*`
|
|
787
|
+
const registered = [...readFirst(cwd, [rr.routes]).matchAll(/route\(\s*["'`]([^"'`]*)["'`]\s*,\s*["'`][^"'`]*flocon\.tsx["'`]/g)].map((m) => m[1].replace(/^\//, ""))
|
|
788
|
+
if (registered.length && !registered.includes(want)) samePlace = false
|
|
789
|
+
// Not registered yet: the folder is in the URLs written in the file (`<domain>/blog${path}`).
|
|
790
|
+
if (!registered.length) {
|
|
791
|
+
const m = fs.readFileSync(path.join(cwd, ours), "utf8").match(/https?:\/\/[^/\s`"'$]+(?:\/\$\{[\w.]+\})?((?:\/[\w.-]+)*)\$\{(?:p\.)?path\}/)
|
|
792
|
+
if (m && m[1] !== (urlPath === "/" ? "" : urlPath)) samePlace = false
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (!samePlace) {
|
|
796
|
+
fail([
|
|
797
|
+
`Flocon AI is already set up at ${ours}. A second route would show every page at two URLs (duplicate content, bad for SEO).`,
|
|
798
|
+
"To move it: delete that file, then run this command again with the new path.",
|
|
799
|
+
nothing
|
|
800
|
+
])
|
|
801
|
+
}
|
|
802
|
+
log(`Flocon AI is already set up here (${ours}). Nothing was installed.`)
|
|
803
|
+
const appDir = framework === "nextjs" ? nextAppDir(cwd) : undefined
|
|
804
|
+
// A first run left https://example.com (no domain then). Now the domain is known: put it in Flocon's own files only.
|
|
805
|
+
if (domain) {
|
|
806
|
+
const fixed = [ours, ...ownSitemapFiles({ cwd, framework, appDir, rrApp: rr?.app, rrDir: rr?.dir })].filter((f) => {
|
|
807
|
+
const full = path.join(cwd, f)
|
|
808
|
+
const code = fs.readFileSync(full, "utf8")
|
|
809
|
+
if (!code.includes("https://example.com")) return false
|
|
810
|
+
fs.writeFileSync(full, code.replaceAll("https://example.com", domain))
|
|
811
|
+
return true
|
|
812
|
+
})
|
|
813
|
+
if (fixed.length) log(`Domain set to ${domain} in: ${fixed.join(", ")}.`)
|
|
814
|
+
}
|
|
815
|
+
// Only adds what is missing (e.g. a first run with --no-sitemap).
|
|
816
|
+
let code = 0
|
|
817
|
+
if (sitemap) {
|
|
818
|
+
const res = addSitemapAndLlms({ cwd, framework, appDir, rrApp: rr?.app, rrDir: rr?.dir, routesFile: rr?.routes, urlPath, hasLocale, flat, domain, log })
|
|
819
|
+
if (res.manual) code = 1
|
|
820
|
+
}
|
|
821
|
+
if (interactive) p.outro(code ? "Do the step above, then deploy." : "Nothing else to do.")
|
|
822
|
+
process.exit(code)
|
|
823
|
+
}
|
|
824
|
+
const problem = problems(route)
|
|
825
|
+
if (problem) fail([...problem, nothing])
|
|
826
|
+
const cacheComponents = framework === "nextjs" && usesCacheComponents(cwd)
|
|
181
827
|
const pm = detectPackageManager(cwd)
|
|
182
828
|
const installCmd = [pm, ...INSTALL_ARGS[pm], pkg]
|
|
829
|
+
|
|
830
|
+
// Last question: the plan, then "Continue?".
|
|
831
|
+
if (interactive) {
|
|
832
|
+
const pages = `${urlPath === "/" ? "" : urlPath}/<page>`
|
|
833
|
+
p.note(
|
|
834
|
+
[
|
|
835
|
+
args.skipInstall ? undefined : `Install ${pkg} (${pm})`,
|
|
836
|
+
`Create ${file}`,
|
|
837
|
+
sitemap ? "Create sitemap.xml + llms.txt (if missing)" : undefined,
|
|
838
|
+
`Pages at ${domain ?? "https://example.com"}${pages}`
|
|
839
|
+
]
|
|
840
|
+
.filter(Boolean)
|
|
841
|
+
.join("\n"),
|
|
842
|
+
"Plan"
|
|
843
|
+
)
|
|
844
|
+
if (!(await ask(p.confirm({ message: "Continue?", initialValue: true })))) cancelled()
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// 3. Install
|
|
183
848
|
if (args.skipInstall) {
|
|
184
849
|
log(`Skipped install. Package: ${pkg} (package manager: ${pm}).`)
|
|
185
850
|
} else {
|
|
186
|
-
log(`Installing ${pkg} with ${pm}...`)
|
|
187
|
-
|
|
188
|
-
if (res.status !== 0) fail([`Install failed. Run it yourself: ${installCmd.join(" ")}`])
|
|
851
|
+
if (!interactive) log(`Installing ${pkg} with ${pm}...`)
|
|
852
|
+
if (!(await install(installCmd, cwd))) fail([`Install failed. Run it yourself: ${installCmd.join(" ")}`])
|
|
189
853
|
}
|
|
190
854
|
|
|
191
855
|
// 4. Route file (never overwrite)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
856
|
+
let template = applyUrlRule(hasLocale ? TEMPLATES[framework + "Locale"] : TEMPLATES[framework], urlPath, hasLocale, domain)
|
|
857
|
+
// Next.js language folder with another name, like app/[lang]: read it as the locale.
|
|
858
|
+
if (framework === "nextjs" && locale && locale !== "locale") {
|
|
859
|
+
template = template
|
|
860
|
+
.replace("params: Promise<{ locale: string;", `params: Promise<{ ${locale}: string;`)
|
|
861
|
+
.replaceAll("const { locale, slug } = await params", `const { ${locale}: locale, slug } = await params`)
|
|
862
|
+
.replace("under the [locale] segment", `under the [${locale}] segment`)
|
|
863
|
+
}
|
|
864
|
+
// Next.js optional catch-all: no slug on the folder's own URL.
|
|
865
|
+
if (optional) {
|
|
866
|
+
template = template
|
|
867
|
+
.replace("One catch-all route serves every Flocon page", "One optional catch-all route ([[...slug]]) serves every Flocon page")
|
|
868
|
+
.replace("slug: string[] }", "slug?: string[] }")
|
|
869
|
+
.replaceAll('"/" + slug.join("/")', '"/" + (slug ?? []).join("/")')
|
|
205
870
|
}
|
|
206
|
-
|
|
871
|
+
// cacheComponents: `dynamic` is not allowed, `instant = false` lets the page render on each request.
|
|
872
|
+
if (cacheComponents) {
|
|
873
|
+
template = template.replace('export const dynamic = "force-dynamic"', "export const instant = false")
|
|
874
|
+
log("cacheComponents is on: the route uses `export const instant = false` (not `dynamic`).")
|
|
875
|
+
}
|
|
876
|
+
if (framework === "react" && rrOldMeta(cwd)) {
|
|
877
|
+
template = template
|
|
878
|
+
.replace("meta({ loaderData }: Route.MetaArgs)", "meta({ data }: Route.MetaArgs)")
|
|
879
|
+
.replace("if (!loaderData) return", "if (!data) return")
|
|
880
|
+
.replace("page: loaderData, url })", "page: data, url })")
|
|
881
|
+
}
|
|
882
|
+
if (flat) {
|
|
883
|
+
const stem = path.basename(file, ".tsx")
|
|
884
|
+
template = template.replace(/^\/\/ Register it .*\n/, "").replace('"./+types/flocon"', `"./+types/${stem}"`)
|
|
885
|
+
}
|
|
886
|
+
let exitCode = 0
|
|
887
|
+
const full = path.join(cwd, file)
|
|
888
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
889
|
+
fs.writeFileSync(full, template, { flag: "wx" })
|
|
890
|
+
log(`Created ${file}`)
|
|
891
|
+
if (framework === "react" && !flat) {
|
|
207
892
|
const routePath = urlPath === "/" ? "*" : `${urlPath.slice(1)}/*`
|
|
208
|
-
log(`\nRegister the route last in
|
|
893
|
+
log(`\nRegister the route last in ${rr.routes}:\n route("${routePath}", "routes/flocon.tsx")`)
|
|
894
|
+
// routes.ts often imports only `index` (React Router template): `route` is needed too.
|
|
895
|
+
if (!/import\s*\{[^}]*\broute\b[^}]*\}\s*from\s*["']@react-router\/dev\/routes["']/.test(readFirst(cwd, [rr.routes]))) {
|
|
896
|
+
log(`And add route to the import: import { route } from "@react-router/dev/routes"`)
|
|
897
|
+
}
|
|
209
898
|
}
|
|
210
899
|
|
|
211
900
|
// 4b. Sitemap + llms.txt (never overwrite)
|
|
212
|
-
|
|
901
|
+
const urlFiles = template.includes(domain ?? "https://example.com") ? [file] : []
|
|
902
|
+
const created = [file]
|
|
903
|
+
if (sitemap) {
|
|
213
904
|
const appDir = framework === "nextjs" ? nextAppDir(cwd) : undefined
|
|
214
|
-
|
|
905
|
+
const res = addSitemapAndLlms({ cwd, framework, appDir, rrApp: rr?.app, rrDir: rr?.dir, routesFile: rr?.routes, urlPath, hasLocale, flat, domain, log })
|
|
906
|
+
if (res.manual) exitCode = 1
|
|
907
|
+
urlFiles.push(...res.files)
|
|
908
|
+
created.push(...res.created)
|
|
215
909
|
}
|
|
910
|
+
if (urlFiles.length) {
|
|
911
|
+
// Interactive: the user just confirmed the domain, no need to repeat it.
|
|
912
|
+
if (domain && !interactive) {
|
|
913
|
+
log(`\nDomain: ${domain} (${domainFrom === "--domain" ? "from --domain" : `found in ${domainFrom}`}). It is in: ${urlFiles.join(", ")}. Change it there if it is wrong.`)
|
|
914
|
+
} else if (!domain) {
|
|
915
|
+
log(`\nReplace https://example.com with your domain in: ${urlFiles.join(", ")}. The rest of the URL is already set (pages live under ${urlPath}).`)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 4c. Formatter: the new files use the CLI's style.
|
|
920
|
+
const fmt = formatter(cwd)
|
|
921
|
+
if (fmt) log(`\nYour project uses ${fmt.name}. Format the new files: ${fmt.cmd} ${created.map((f) => `"${f}"`).join(" ")}`)
|
|
216
922
|
|
|
217
923
|
// 5. Token (no prompt, no file write)
|
|
218
924
|
log()
|
|
219
|
-
|
|
925
|
+
const tokenSet = tokenAlreadySet(cwd)
|
|
926
|
+
const cloudflare = onCloudflare(cwd)
|
|
927
|
+
if (tokenSet) {
|
|
220
928
|
log("FLOCON_TOKEN is already set. Good.")
|
|
221
929
|
} else {
|
|
222
930
|
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.`
|
|
931
|
+
`Flocon needs FLOCON_TOKEN to work. Copy your website token from ${TOKENS_URL}, then add FLOCON_TOKEN=... to your env file (${cloudflare ? ".dev.vars" : ".env.local / .env"}) and to your host. Never commit it.`
|
|
224
932
|
)
|
|
225
933
|
}
|
|
934
|
+
if (cloudflare) {
|
|
935
|
+
log("On Cloudflare: add FLOCON_TOKEN as a Secret (wrangler secret put FLOCON_TOKEN, or type Secret in the dashboard). wrangler deploy removes plain variables.")
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const pkgJson = readPackageJson(cwd)
|
|
939
|
+
if (framework === "astro" && { ...pkgJson.dependencies, ...pkgJson.devDependencies }["@astrojs/cloudflare"]) {
|
|
940
|
+
log("On Cloudflare, secrets are not in process.env: pass the token option to each helper.")
|
|
941
|
+
}
|
|
226
942
|
|
|
227
|
-
// 6. Last step
|
|
228
|
-
|
|
943
|
+
// 6. Last step. Many hosts deploy on every push: without the token, Flocon's URLs return an error (500).
|
|
944
|
+
const extra = sitemap ? ", the sitemap and llms.txt" : ""
|
|
945
|
+
const errors = urlPath === "/" ? `every unknown URL of your website${extra} return an error (500)` : `${urlPath}/…${extra} return an error (500)`
|
|
946
|
+
const last = tokenSet
|
|
947
|
+
? "Next step: add FLOCON_TOKEN to your host too, then deploy."
|
|
948
|
+
: `Next step: add FLOCON_TOKEN to your host BEFORE you push or deploy. Without it, ${errors}.`
|
|
949
|
+
if (interactive) p.outro(last)
|
|
950
|
+
else log(`\n${last}`)
|
|
229
951
|
process.exit(exitCode)
|
|
230
952
|
}
|
|
231
953
|
|