@flocon-ai/init 0.0.0-stage → 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 +64 -2
- package/package.json +35 -4
- package/src/cli.mjs +954 -0
- package/src/sitemap.mjs +180 -0
- package/src/templates.mjs +140 -0
- package/src/url.mjs +17 -0
package/src/sitemap.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
import { applyUrlRule } from "./url.mjs"
|
|
7
|
+
|
|
8
|
+
const nextSitemap = `import type { MetadataRoute } from "next"
|
|
9
|
+
import { floconSitemap } from "@flocon-ai/next"
|
|
10
|
+
import { connection } from "next/server"
|
|
11
|
+
|
|
12
|
+
// Built on getAllPages(): published pages only, never drafts.
|
|
13
|
+
// connection(): rendered on each request, so new pages show up without a deploy and the build never needs FLOCON_TOKEN.
|
|
14
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
15
|
+
await connection()
|
|
16
|
+
return floconSitemap({ url: ({ locale, path }) => \`https://example.com\${path}\` })
|
|
17
|
+
}
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
const nextLlms = `import { floconLlmsTxt } from "@flocon-ai/next"
|
|
21
|
+
import { connection } from "next/server"
|
|
22
|
+
|
|
23
|
+
// connection(): rendered on each request (also with cacheComponents).
|
|
24
|
+
export async function GET() {
|
|
25
|
+
await connection()
|
|
26
|
+
return new Response(await floconLlmsTxt({ url: ({ locale, path }) => \`https://example.com\${path}\` }), {
|
|
27
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
const astroSitemap = `import { getAllPages } from "@flocon-ai/astro"
|
|
33
|
+
|
|
34
|
+
// Published pages only, never drafts.
|
|
35
|
+
export async function GET() {
|
|
36
|
+
const pages = await getAllPages()
|
|
37
|
+
const urls = pages
|
|
38
|
+
.map((p) => \`<url><loc>https://example.com\${p.path}</loc><lastmod>\${p.updatedAt}</lastmod></url>\`)
|
|
39
|
+
.join("")
|
|
40
|
+
return new Response(
|
|
41
|
+
\`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\${urls}</urlset>\`,
|
|
42
|
+
{ headers: { "content-type": "application/xml" } }
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
const astroLlms = `import { floconLlmsTxt } from "@flocon-ai/astro"
|
|
48
|
+
|
|
49
|
+
export async function GET() {
|
|
50
|
+
const text = await floconLlmsTxt({ url: ({ locale, path }) => \`https://example.com\${path}\` })
|
|
51
|
+
return new Response(text, { headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
const reactSitemap = `import { floconSitemap } from "@flocon-ai/react"
|
|
56
|
+
|
|
57
|
+
export async function loader() {
|
|
58
|
+
const xml = await floconSitemap({ url: ({ locale, path }) => \`https://example.com\${path}\` })
|
|
59
|
+
return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } })
|
|
60
|
+
}
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
const reactLlms = `import { floconLlmsTxt } from "@flocon-ai/react"
|
|
64
|
+
|
|
65
|
+
export async function loader() {
|
|
66
|
+
const text = await floconLlmsTxt({ url: ({ locale, path }) => \`https://example.com\${path}\` })
|
|
67
|
+
return new Response(text, { headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
68
|
+
}
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
const exts = (base, list) => list.map((e) => base + e)
|
|
72
|
+
|
|
73
|
+
// `existing` = files that mean the website already has one.
|
|
74
|
+
function spec(framework, appDir, rrApp = "app", rrDir = "routes") {
|
|
75
|
+
if (framework === "nextjs") {
|
|
76
|
+
return {
|
|
77
|
+
sitemap: {
|
|
78
|
+
file: `${appDir}/sitemap.ts`,
|
|
79
|
+
code: nextSitemap,
|
|
80
|
+
existing: [...exts(`${appDir}/sitemap`, [".js", ".ts", ".xml"]), ...exts(`${appDir}/sitemap.xml/route`, [".js", ".ts"]), "public/sitemap.xml"]
|
|
81
|
+
},
|
|
82
|
+
llms: {
|
|
83
|
+
file: `${appDir}/llms.txt/route.ts`,
|
|
84
|
+
code: nextLlms,
|
|
85
|
+
existing: [...exts(`${appDir}/llms.txt/route`, [".js", ".ts"]), `${appDir}/llms.txt`, "public/llms.txt"]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (framework === "astro") {
|
|
90
|
+
return {
|
|
91
|
+
sitemap: {
|
|
92
|
+
file: "src/pages/sitemap.xml.ts",
|
|
93
|
+
code: astroSitemap,
|
|
94
|
+
existing: [...exts("src/pages/sitemap.xml", [".ts", ".js", ".astro"]), "src/pages/sitemap-index.xml.ts", "public/sitemap.xml", "public/sitemap-index.xml"]
|
|
95
|
+
},
|
|
96
|
+
llms: {
|
|
97
|
+
file: "src/pages/llms.txt.ts",
|
|
98
|
+
code: astroLlms,
|
|
99
|
+
existing: [...exts("src/pages/llms.txt", [".ts", ".js", ".astro"]), "public/llms.txt"]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const routes = `${rrApp}/${rrDir}`
|
|
104
|
+
return {
|
|
105
|
+
sitemap: {
|
|
106
|
+
file: `${routes}/sitemap[.]xml.ts`,
|
|
107
|
+
code: reactSitemap,
|
|
108
|
+
existing: [...exts(`${routes}/sitemap[.]xml`, [".ts", ".tsx"]), ...exts(`${routes}/sitemap.xml`, [".ts", ".tsx"]), "public/sitemap.xml"]
|
|
109
|
+
},
|
|
110
|
+
llms: {
|
|
111
|
+
file: `${routes}/llms[.]txt.ts`,
|
|
112
|
+
code: reactLlms,
|
|
113
|
+
existing: [...exts(`${routes}/llms[.]txt`, [".ts", ".tsx"]), ...exts(`${routes}/llms.txt`, [".ts", ".tsx"]), "public/llms.txt"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Astro: @astrojs/sitemap builds the sitemap from the config, there is no file to detect.
|
|
119
|
+
function astroConfigHasSitemap(cwd) {
|
|
120
|
+
for (const f of ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"]) {
|
|
121
|
+
try {
|
|
122
|
+
if (/@astrojs\/sitemap/.test(fs.readFileSync(path.join(cwd, f), "utf8"))) return true
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Never overwrites. Returns { manual, files }: manual = something has to be done by hand,
|
|
129
|
+
// files = the files (written or printed) that still have https://example.com (or the domain the CLI found).
|
|
130
|
+
// A file that is already ours (imports @flocon-ai/) is skipped: the command ran here before.
|
|
131
|
+
export function addSitemapAndLlms({ cwd, framework, appDir, rrApp, rrDir, routesFile, urlPath, hasLocale, flat, domain, log }) {
|
|
132
|
+
const files = spec(framework, appDir, rrApp, rrDir)
|
|
133
|
+
const exists = (f) => fs.existsSync(path.join(cwd, f))
|
|
134
|
+
const ours = (f) => fs.statSync(path.join(cwd, f)).isFile() && fs.readFileSync(path.join(cwd, f), "utf8").includes("@flocon-ai/")
|
|
135
|
+
let manual = false
|
|
136
|
+
const created = []
|
|
137
|
+
const urlFiles = []
|
|
138
|
+
log()
|
|
139
|
+
for (const [kind, name] of [
|
|
140
|
+
["sitemap", "sitemap"],
|
|
141
|
+
["llms", "llms.txt"]
|
|
142
|
+
]) {
|
|
143
|
+
const { file, existing } = files[kind]
|
|
144
|
+
const code = applyUrlRule(files[kind].code, urlPath, hasLocale, domain)
|
|
145
|
+
const found = existing.filter(exists)
|
|
146
|
+
if (found.some(ours)) continue
|
|
147
|
+
urlFiles.push(file)
|
|
148
|
+
if (found.length || (kind === "sitemap" && framework === "astro" && astroConfigHasSitemap(cwd))) {
|
|
149
|
+
manual = true
|
|
150
|
+
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.`)
|
|
151
|
+
log(`Example (${file}):\n`)
|
|
152
|
+
log(code)
|
|
153
|
+
} else {
|
|
154
|
+
const full = path.join(cwd, file)
|
|
155
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
156
|
+
fs.writeFileSync(full, code, { flag: "wx" })
|
|
157
|
+
log(`Created ${file}`)
|
|
158
|
+
created.push(file)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// flatRoutes(): the file names are the URLs, nothing to register.
|
|
162
|
+
if (framework === "react" && !flat && created.length) {
|
|
163
|
+
const r = `${files.sitemap.file.split("/").slice(-2).join("/")}`
|
|
164
|
+
const l = `${files.llms.file.split("/").slice(-2).join("/")}`
|
|
165
|
+
log(`Register both routes before the catch-all in ${routesFile}:\n route("sitemap.xml", "${r}")\n route("llms.txt", "${l}")`)
|
|
166
|
+
}
|
|
167
|
+
return { manual, files: urlFiles, created }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The sitemap and llms.txt files that are Flocon's own (they import @flocon-ai/): a second run may fix their domain.
|
|
171
|
+
export function ownSitemapFiles({ cwd, framework, appDir, rrApp, rrDir }) {
|
|
172
|
+
const files = spec(framework, appDir, rrApp, rrDir)
|
|
173
|
+
return [files.sitemap.file, files.llms.file].filter((f) => {
|
|
174
|
+
try {
|
|
175
|
+
return fs.readFileSync(path.join(cwd, f), "utf8").includes("@flocon-ai/")
|
|
176
|
+
} catch {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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, getFloconMetadata } from "@flocon-ai/next"
|
|
6
|
+
|
|
7
|
+
// One catch-all route serves every Flocon page. Rendered on each request.
|
|
8
|
+
export const dynamic = "force-dynamic"
|
|
9
|
+
|
|
10
|
+
// Your URL rule. Flocon AI adds no prefix of its own.
|
|
11
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com\${path}\`
|
|
12
|
+
|
|
13
|
+
type Props = { params: Promise<{ slug: string[] }> }
|
|
14
|
+
|
|
15
|
+
// Title, description, canonical URL and hreflang.
|
|
16
|
+
export async function generateMetadata({ params }: Props) {
|
|
17
|
+
const { slug } = await params
|
|
18
|
+
return getFloconMetadata({ path: "/" + slug.join("/"), url })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function Page({ params }: Props) {
|
|
22
|
+
const { slug } = await params
|
|
23
|
+
return <FloconPage path={"/" + slug.join("/")} url={url} />
|
|
24
|
+
}
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
// Same as `nextjs`, for a route under `[locale]`: the locale goes to the lib.
|
|
28
|
+
const nextjsLocale = `import { FloconPage, getFloconMetadata } from "@flocon-ai/next"
|
|
29
|
+
|
|
30
|
+
// One catch-all route serves every Flocon page, under the [locale] segment. Rendered on each request.
|
|
31
|
+
export const dynamic = "force-dynamic"
|
|
32
|
+
|
|
33
|
+
// Your URL rule. Flocon AI adds no prefix of its own.
|
|
34
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com/\${locale}\${path}\`
|
|
35
|
+
|
|
36
|
+
type Props = { params: Promise<{ locale: string; slug: string[] }> }
|
|
37
|
+
|
|
38
|
+
// Title, description, canonical URL and hreflang.
|
|
39
|
+
export async function generateMetadata({ params }: Props) {
|
|
40
|
+
const { locale, slug } = await params
|
|
41
|
+
return getFloconMetadata({ locale, path: "/" + slug.join("/"), url })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default async function Page({ params }: Props) {
|
|
45
|
+
const { locale, slug } = await params
|
|
46
|
+
return <FloconPage locale={locale} path={"/" + slug.join("/")} url={url} />
|
|
47
|
+
}
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
const astro = `---
|
|
51
|
+
import FloconPage from "@flocon-ai/astro/FloconPage.astro"
|
|
52
|
+
import { getFloconPage, getFloconMetadata } from "@flocon-ai/astro"
|
|
53
|
+
|
|
54
|
+
// Your URL rule. Flocon AI adds no prefix of its own.
|
|
55
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com\${path}\`
|
|
56
|
+
|
|
57
|
+
const data = await getFloconPage({ path: "/" + (Astro.params.slug ?? "") })
|
|
58
|
+
if (!data) return new Response(null, { status: 404 })
|
|
59
|
+
const meta = getFloconMetadata({ data, url })
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<html lang={data.page.locale}>
|
|
63
|
+
<head>
|
|
64
|
+
<meta charset="utf-8" />
|
|
65
|
+
<meta name="viewport" content="width=device-width" />
|
|
66
|
+
<title>{meta.title}</title>
|
|
67
|
+
{meta.meta.map((m) => <meta {...m} />)}
|
|
68
|
+
{meta.links.map((l) => <link {...l} />)}
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
<FloconPage data={data} url={url} />
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
74
|
+
`
|
|
75
|
+
|
|
76
|
+
// Same as `astro`, for a route under `src/pages/[locale]`.
|
|
77
|
+
const astroLocale = `---
|
|
78
|
+
import FloconPage from "@flocon-ai/astro/FloconPage.astro"
|
|
79
|
+
import { getFloconPage, getFloconMetadata } from "@flocon-ai/astro"
|
|
80
|
+
|
|
81
|
+
// Your URL rule. Flocon AI adds no prefix of its own.
|
|
82
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com/\${locale}\${path}\`
|
|
83
|
+
|
|
84
|
+
// Always set: this file is under [locale].
|
|
85
|
+
const data = await getFloconPage({ path: "/" + (Astro.params.slug ?? ""), locale: Astro.params.locale as string })
|
|
86
|
+
if (!data) return new Response(null, { status: 404 })
|
|
87
|
+
const meta = getFloconMetadata({ data, url })
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
<html lang={data.page.locale}>
|
|
91
|
+
<head>
|
|
92
|
+
<meta charset="utf-8" />
|
|
93
|
+
<meta name="viewport" content="width=device-width" />
|
|
94
|
+
<title>{meta.title}</title>
|
|
95
|
+
{meta.meta.map((m) => <meta {...m} />)}
|
|
96
|
+
{meta.links.map((l) => <link {...l} />)}
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<FloconPage data={data} url={url} />
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
`
|
|
103
|
+
|
|
104
|
+
const react = `// Register it as a splat route in app/routes.ts: route("*", "routes/flocon.tsx") (or route("blog/*", ...) for a prefix)
|
|
105
|
+
import { FloconPage, getFloconMetadata, getPage } from "@flocon-ai/react"
|
|
106
|
+
import type { Route } from "./+types/flocon"
|
|
107
|
+
|
|
108
|
+
const url = ({ locale, path }: { locale: string; path: string }) => \`https://example.com\${path}\`
|
|
109
|
+
|
|
110
|
+
export async function loader({ params }: Route.LoaderArgs) {
|
|
111
|
+
const page = await getPage({ path: "/" + params["*"] })
|
|
112
|
+
if (!page) throw new Response("Not found", { status: 404 })
|
|
113
|
+
return page
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function meta({ loaderData }: Route.MetaArgs) {
|
|
117
|
+
if (!loaderData) return [{ title: "Not found" }]
|
|
118
|
+
const m = getFloconMetadata({ page: loaderData, url })
|
|
119
|
+
return [
|
|
120
|
+
{ title: m.title },
|
|
121
|
+
{ name: "description", content: m.description },
|
|
122
|
+
{ property: "og:type", content: m.openGraph.type },
|
|
123
|
+
{ property: "og:title", content: m.openGraph.title },
|
|
124
|
+
{ property: "og:description", content: m.openGraph.description },
|
|
125
|
+
{ property: "og:url", content: m.openGraph.url },
|
|
126
|
+
{ property: "og:locale", content: m.openGraph.locale },
|
|
127
|
+
{ name: "twitter:card", content: m.twitter.card },
|
|
128
|
+
{ name: "twitter:title", content: m.twitter.title },
|
|
129
|
+
{ name: "twitter:description", content: m.twitter.description },
|
|
130
|
+
{ tagName: "link", rel: "canonical", href: m.canonical },
|
|
131
|
+
...Object.entries(m.languages).map(([hrefLang, href]) => ({ tagName: "link", rel: "alternate", hrefLang, href }))
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default function Page({ loaderData }: Route.ComponentProps) {
|
|
136
|
+
return <FloconPage page={loaderData} url={url} />
|
|
137
|
+
}
|
|
138
|
+
`
|
|
139
|
+
|
|
140
|
+
export const TEMPLATES = { nextjs, nextjsLocale, astro, astroLocale, react }
|
package/src/url.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Fills the URL rule of a get_setup snippet for this website.
|
|
2
|
+
// get_setup snippets use https://example.com${path} (no locale, no folder).
|
|
3
|
+
// The CLI knows the folder (e.g. /blog) and if the website has a [locale] segment,
|
|
4
|
+
// so it writes the real rule. A website without a locale in its URLs never gets one.
|
|
5
|
+
// `domain`: the website's domain when the CLI found it (e.g. https://acme.com), else example.com stays.
|
|
6
|
+
export function applyUrlRule(code, urlPath, hasLocale, domain) {
|
|
7
|
+
const folder = urlPath === "/" ? "" : urlPath
|
|
8
|
+
const rule = (locale, p) => `https://example.com${hasLocale ? "/${" + locale + "}" : ""}${folder}\${${p}}`
|
|
9
|
+
let out = code
|
|
10
|
+
.replaceAll("https://example.com/${locale}${path}", rule("locale", "path"))
|
|
11
|
+
.replaceAll("https://example.com${path}", rule("locale", "path"))
|
|
12
|
+
.replaceAll("https://example.com${p.path}", rule("p.locale", "p.path"))
|
|
13
|
+
// No locale in the URLs: drop the unused `locale` param (strict TypeScript, noUnusedParameters, fails the build on it).
|
|
14
|
+
if (!hasLocale) out = out.replaceAll("({ locale, path })", "({ path })").replaceAll("({ locale, path }:", "({ path }:")
|
|
15
|
+
if (domain) out = out.replaceAll("https://example.com", domain)
|
|
16
|
+
return out
|
|
17
|
+
}
|