livemark 0.26.0 → 0.27.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.
Files changed (49) hide show
  1. package/build/actions/config/define.d.ts +6 -0
  2. package/build/content/article.js +4 -11
  3. package/build/content-collections.js +3 -5
  4. package/build/helpers/agent-skills.d.ts +22 -0
  5. package/build/helpers/agent-skills.js +46 -0
  6. package/build/helpers/agent-skills.unit.d.ts +1 -0
  7. package/build/helpers/agent-skills.unit.js +49 -0
  8. package/build/helpers/article.d.ts +13 -0
  9. package/build/helpers/article.js +17 -1
  10. package/build/helpers/head.d.ts +7 -2
  11. package/build/helpers/head.js +13 -2
  12. package/build/helpers/head.unit.js +4 -2
  13. package/build/helpers/llms.d.ts +33 -0
  14. package/build/helpers/llms.js +72 -0
  15. package/build/helpers/llms.unit.d.ts +1 -0
  16. package/build/helpers/llms.unit.js +98 -0
  17. package/build/helpers/resolve-includes.d.ts +1 -1
  18. package/build/helpers/resolve-includes.js +8 -8
  19. package/build/helpers/resolve-includes.unit.js +23 -23
  20. package/build/models/config.d.ts +20 -0
  21. package/build/models/config.js +8 -1
  22. package/build/plugins/vite-agents.d.ts +17 -0
  23. package/build/plugins/vite-agents.js +110 -0
  24. package/build/plugins/vite-agents.unit.d.ts +1 -0
  25. package/build/plugins/vite-agents.unit.js +20 -0
  26. package/build/plugins/vite-robots.d.ts +4 -2
  27. package/build/plugins/vite-robots.js +15 -2
  28. package/build/plugins/vite-robots.unit.js +5 -1
  29. package/build/vite.config.js +3 -1
  30. package/package.json +4 -2
  31. package/skills/livemark-configuration/SKILL.md +4 -0
  32. package/skills/livemark-customization/SKILL.md +1 -0
  33. package/source/content/article.ts +8 -13
  34. package/source/content-collections.ts +2 -5
  35. package/source/helpers/agent-skills.ts +59 -0
  36. package/source/helpers/agent-skills.unit.ts +56 -0
  37. package/source/helpers/article.ts +17 -0
  38. package/source/helpers/head.ts +12 -1
  39. package/source/helpers/head.unit.ts +3 -1
  40. package/source/helpers/llms.ts +109 -0
  41. package/source/helpers/llms.unit.ts +115 -0
  42. package/source/helpers/resolve-includes.ts +12 -12
  43. package/source/helpers/resolve-includes.unit.ts +46 -22
  44. package/source/models/config.ts +11 -0
  45. package/source/plugins/vite-agents.ts +137 -0
  46. package/source/plugins/vite-agents.unit.ts +26 -0
  47. package/source/plugins/vite-robots.ts +18 -3
  48. package/source/plugins/vite-robots.unit.ts +7 -0
  49. package/source/vite.config.ts +2 -0
@@ -0,0 +1,109 @@
1
+ import type { Config } from "../models/config.ts"
2
+ import type { RoutedSection } from "../models/section.ts"
3
+ import {
4
+ buildArticleTree,
5
+ compareArticleDate,
6
+ compareArticleOrder,
7
+ flattenArticleTree,
8
+ } from "./article.ts"
9
+ import { partitionBySection } from "./section.ts"
10
+
11
+ /** The slice of a built article that agent-facing files are rendered from. */
12
+ export interface LlmsArticle {
13
+ path: string
14
+ title: string
15
+ description?: string
16
+ order?: number
17
+ date?: string
18
+ }
19
+
20
+ /** Render `llms.txt`: the site title and description, then one Markdown link
21
+ * per article twin, grouped under each routed section in sidebar order. */
22
+ export function renderLlmsText(props: {
23
+ config: Pick<Config, "title" | "description" | "site" | "base" | "sections">
24
+ articles: LlmsArticle[]
25
+ }) {
26
+ const lines = [`# ${props.config.title}`, "", `> ${props.config.description}`]
27
+ for (const group of groupArticles(props)) {
28
+ lines.push("", `## ${group.title}`, "", ...renderLinks(props.config, group.articles))
29
+ }
30
+ return `${lines.join("\n")}\n`
31
+ }
32
+
33
+ /** Render the Markdown twin of a section's index page (a blog or changelog
34
+ * listing), which has no article of its own: links to the section's articles. */
35
+ export function renderSectionIndex(props: {
36
+ config: Pick<Config, "site" | "base" | "sections">
37
+ section: RoutedSection
38
+ articles: LlmsArticle[]
39
+ }) {
40
+ const group = groupArticles(props).find(g => g.prefix === props.section.prefix)
41
+ const lines = [
42
+ `# ${props.section.title}`,
43
+ "",
44
+ ...renderLinks(props.config, group?.articles ?? []),
45
+ ]
46
+ return `${lines.join("\n")}\n`
47
+ }
48
+
49
+ /** Render the Markdown twin of an article: its title and description as the
50
+ * rendered page shows them, then the body without its own leading h1. */
51
+ export function renderMarkdownTwin(props: { article: LlmsArticle; content: string }) {
52
+ const article = props.article
53
+ const parts = [`# ${article.title}`]
54
+ if (article.description) parts.push(`> ${article.description}`)
55
+ parts.push(stripLeadingH1(props.content).trim())
56
+ return `${parts.join("\n\n")}\n`
57
+ }
58
+
59
+ /** Path of an article's Markdown twin inside the build output (no base). */
60
+ export function buildTwinFileName(path: string) {
61
+ return `${path.replace(/^\//, "")}index.md`
62
+ }
63
+
64
+ /** Drop a leading h1 so a title set by frontmatter or a patch is not shown twice */
65
+ export function stripLeadingH1(content: string) {
66
+ return content.replace(/^[ \t]*#\s+[^\n]*\n+/, "")
67
+ }
68
+
69
+ function buildTwinUrl(config: Pick<Config, "site" | "base">, path: string) {
70
+ const host = config.site?.replace(/\/+$/, "") ?? ""
71
+ return `${host}${config.base ?? ""}${path}index.md`
72
+ }
73
+
74
+ function groupArticles(props: {
75
+ config: Pick<Config, "sections">
76
+ articles: LlmsArticle[]
77
+ }) {
78
+ const sections =
79
+ props.config.sections?.filter((s): s is RoutedSection => s.type !== "custom") ?? []
80
+ const buckets = partitionBySection(
81
+ [...props.articles].sort(compareArticleOrder),
82
+ sections,
83
+ )
84
+ const groups = [
85
+ ...sections.map(s => ({
86
+ prefix: s.prefix,
87
+ title: s.title,
88
+ dated: s.type !== "article",
89
+ })),
90
+ { prefix: "__default__", title: sections.length ? "Other" : "Pages", dated: false },
91
+ ]
92
+ return groups.flatMap(group => {
93
+ const bucket = buckets.get(group.prefix)
94
+ if (!bucket?.length) return []
95
+ const articles = group.dated
96
+ ? [...bucket].sort(compareArticleDate)
97
+ : flattenArticleTree(buildArticleTree(bucket)).flatMap(
98
+ path => bucket.find(a => a.path === path) ?? [],
99
+ )
100
+ return [{ prefix: group.prefix, title: group.title, articles }]
101
+ })
102
+ }
103
+
104
+ function renderLinks(config: Pick<Config, "site" | "base">, articles: LlmsArticle[]) {
105
+ return articles.map(article => {
106
+ const link = `[${article.title}](${buildTwinUrl(config, article.path)})`
107
+ return article.description ? `- ${link}: ${article.description}` : `- ${link}`
108
+ })
109
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vite-plus/test"
2
+ import type { RoutedSection } from "../models/section.ts"
3
+ import {
4
+ buildTwinFileName,
5
+ renderLlmsText,
6
+ renderMarkdownTwin,
7
+ renderSectionIndex,
8
+ } from "./llms.ts"
9
+
10
+ const config = { title: "Docs", description: "Blurb" }
11
+
12
+ describe("renderLlmsText", () => {
13
+ it("lists articles in sidebar order under a single heading without sections", () => {
14
+ const text = renderLlmsText({
15
+ config,
16
+ articles: [
17
+ { path: "/guide/", title: "Guide", order: 2 },
18
+ { path: "/intro/", title: "Intro", description: "Start here", order: 1 },
19
+ { path: "/guide/deep/", title: "Deep" },
20
+ ],
21
+ })
22
+ expect(text).toBe(
23
+ [
24
+ "# Docs",
25
+ "",
26
+ "> Blurb",
27
+ "",
28
+ "## Pages",
29
+ "",
30
+ "- [Intro](/intro/index.md): Start here",
31
+ "- [Guide](/guide/index.md)",
32
+ "- [Deep](/guide/deep/index.md)",
33
+ "",
34
+ ].join("\n"),
35
+ )
36
+ })
37
+
38
+ it("groups by routed section, newest first in a blog, with absolute urls", () => {
39
+ const text = renderLlmsText({
40
+ config: {
41
+ ...config,
42
+ site: "https://example.com/",
43
+ base: "/repo",
44
+ sections: [
45
+ { title: "Docs", prefix: "/", type: "article", position: "header" },
46
+ { title: "Blog", prefix: "/blog/", type: "blog", position: "header" },
47
+ {
48
+ title: "GitHub",
49
+ url: "https://github.com",
50
+ type: "custom",
51
+ position: "header",
52
+ },
53
+ ],
54
+ },
55
+ articles: [
56
+ { path: "/blog/old/", title: "Old", date: "2026-01-01" },
57
+ { path: "/intro/", title: "Intro" },
58
+ { path: "/blog/new/", title: "New", date: "2026-02-01" },
59
+ ],
60
+ })
61
+ expect(text).toContain(
62
+ [
63
+ "## Docs",
64
+ "",
65
+ "- [Intro](https://example.com/repo/intro/index.md)",
66
+ "",
67
+ "## Blog",
68
+ "",
69
+ "- [New](https://example.com/repo/blog/new/index.md)",
70
+ "- [Old](https://example.com/repo/blog/old/index.md)",
71
+ ].join("\n"),
72
+ )
73
+ expect(text).not.toContain("GitHub")
74
+ })
75
+ })
76
+
77
+ describe("renderSectionIndex", () => {
78
+ it("lists only the section's articles, newest first", () => {
79
+ const blog: RoutedSection = {
80
+ title: "Blog",
81
+ prefix: "/blog/",
82
+ type: "blog",
83
+ position: "header",
84
+ }
85
+ const text = renderSectionIndex({
86
+ config: { sections: [blog] },
87
+ section: blog,
88
+ articles: [
89
+ { path: "/intro/", title: "Intro" },
90
+ { path: "/blog/old/", title: "Old", date: "2026-01-01" },
91
+ { path: "/blog/new/", title: "New", date: "2026-02-01", description: "Fresh" },
92
+ ],
93
+ })
94
+ expect(text).toBe(
95
+ "# Blog\n\n- [New](/blog/new/index.md): Fresh\n- [Old](/blog/old/index.md)\n",
96
+ )
97
+ })
98
+ })
99
+
100
+ describe("renderMarkdownTwin", () => {
101
+ it("leads with the title and description instead of the body's own h1", () => {
102
+ const twin = renderMarkdownTwin({
103
+ article: { path: "/intro/", title: "Introduction", description: "Start here" },
104
+ content: "# Livemark\n\nBody text\n",
105
+ })
106
+ expect(twin).toBe("# Introduction\n\n> Start here\n\nBody text\n")
107
+ })
108
+ })
109
+
110
+ describe("buildTwinFileName", () => {
111
+ it("places the twin beside the page's index.html", () => {
112
+ expect(buildTwinFileName("/")).toBe("index.md")
113
+ expect(buildTwinFileName("/guide/deep/")).toBe("guide/deep/index.md")
114
+ })
115
+ })
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs"
1
+ import { readFile } from "node:fs/promises"
2
2
  import { dirname, extname, resolve } from "node:path"
3
3
 
4
4
  const INCLUDE_RE = /^::include\{(.+)\}$/
@@ -56,18 +56,14 @@ function parseAttributes(raw: string): Record<string, string> {
56
56
  return attrs
57
57
  }
58
58
 
59
- function resolveDirective(
60
- rawAttrs: string,
61
- fileDir: string,
62
- depth: number,
63
- ): string | undefined {
59
+ async function resolveDirective(rawAttrs: string, fileDir: string, depth: number) {
64
60
  const attrs = parseAttributes(rawAttrs)
65
61
  const file = attrs.file
66
62
  if (!file) return undefined
67
63
 
68
64
  const absolutePath = resolve(fileDir, file)
69
65
  try {
70
- let included = readFileSync(absolutePath, "utf-8")
66
+ let included = await readFile(absolutePath, "utf-8")
71
67
 
72
68
  const ext = extname(file).toLowerCase()
73
69
  const lang = extToLang[ext]
@@ -87,12 +83,16 @@ function resolveDirective(
87
83
  }
88
84
 
89
85
  /** Resolves ::include{file="..."} directives by replacing them with referenced file contents */
90
- export function resolveIncludes(content: string, filePath: string, depth = 0): string {
86
+ export async function resolveIncludes(
87
+ content: string,
88
+ filePath: string,
89
+ depth = 0,
90
+ ): Promise<string> {
91
91
  if (depth > 5) return content
92
92
 
93
93
  const fileDir = dirname(filePath)
94
94
  const lines = content.split("\n")
95
- const result: string[] = []
95
+ const result: Array<string | Promise<string>> = []
96
96
  let fenceMarker: string | undefined
97
97
 
98
98
  for (const line of lines) {
@@ -114,12 +114,12 @@ export function resolveIncludes(content: string, filePath: string, depth = 0): s
114
114
 
115
115
  const includeMatch = INCLUDE_RE.exec(line)
116
116
  if (includeMatch) {
117
- const resolved = resolveDirective(includeMatch[1]!, fileDir, depth)
118
- result.push(resolved ?? line)
117
+ const directive = resolveDirective(includeMatch[1]!, fileDir, depth)
118
+ result.push(directive.then(resolved => resolved ?? line))
119
119
  } else {
120
120
  result.push(line)
121
121
  }
122
122
  }
123
123
 
124
- return result.join("\n")
124
+ return (await Promise.all(result)).join("\n")
125
125
  }
@@ -5,83 +5,104 @@ import { temporaryDirectory } from "tempy"
5
5
  import { resolveIncludes } from "./resolve-includes.ts"
6
6
 
7
7
  describe("resolveIncludes", () => {
8
- it("should replace include directive with file content", () => {
8
+ it("should replace include directive with file content", async () => {
9
9
  const dir = temporaryDirectory()
10
10
  writeFileSync(join(dir, "snippet.md"), "Hello from snippet")
11
- const result = resolveIncludes('::include{file="snippet.md"}', join(dir, "main.md"))
11
+ const result = await resolveIncludes(
12
+ '::include{file="snippet.md"}',
13
+ join(dir, "main.md"),
14
+ )
12
15
  expect(result).toBe("Hello from snippet")
13
16
  })
14
17
 
15
- it("should strip frontmatter from included files", () => {
18
+ it("should strip frontmatter from included files", async () => {
16
19
  const dir = temporaryDirectory()
17
20
  writeFileSync(join(dir, "snippet.md"), "---\ntitle: Test\n---\nContent here")
18
- const result = resolveIncludes('::include{file="snippet.md"}', join(dir, "main.md"))
21
+ const result = await resolveIncludes(
22
+ '::include{file="snippet.md"}',
23
+ join(dir, "main.md"),
24
+ )
19
25
  expect(result).toBe("Content here")
20
26
  })
21
27
 
22
- it("should preserve surrounding content", () => {
28
+ it("should preserve surrounding content", async () => {
23
29
  const dir = temporaryDirectory()
24
30
  writeFileSync(join(dir, "snippet.md"), "Included text")
25
- const result = resolveIncludes(
31
+ const result = await resolveIncludes(
26
32
  'Before\n\n::include{file="snippet.md"}\n\nAfter',
27
33
  join(dir, "main.md"),
28
34
  )
29
35
  expect(result).toBe("Before\n\nIncluded text\n\nAfter")
30
36
  })
31
37
 
32
- it("should keep directive when file is missing", () => {
33
- const result = resolveIncludes('::include{file="nonexistent.md"}', "/tmp/main.md")
38
+ it("should keep directive when file is missing", async () => {
39
+ const result = await resolveIncludes(
40
+ '::include{file="nonexistent.md"}',
41
+ "/tmp/main.md",
42
+ )
34
43
  expect(result).toBe('::include{file="nonexistent.md"}')
35
44
  })
36
45
 
37
- it("should resolve nested includes", () => {
46
+ it("should resolve nested includes", async () => {
38
47
  const dir = temporaryDirectory()
39
48
  writeFileSync(join(dir, "inner.md"), "Inner content")
40
49
  writeFileSync(join(dir, "outer.md"), '::include{file="inner.md"}')
41
- const result = resolveIncludes('::include{file="outer.md"}', join(dir, "main.md"))
50
+ const result = await resolveIncludes(
51
+ '::include{file="outer.md"}',
52
+ join(dir, "main.md"),
53
+ )
42
54
  expect(result).toBe("Inner content")
43
55
  })
44
56
 
45
- it("should limit recursion depth", () => {
57
+ it("should limit recursion depth", async () => {
46
58
  const dir = temporaryDirectory()
47
59
  writeFileSync(join(dir, "recursive.md"), '::include{file="recursive.md"}')
48
- const result = resolveIncludes('::include{file="recursive.md"}', join(dir, "main.md"))
60
+ const result = await resolveIncludes(
61
+ '::include{file="recursive.md"}',
62
+ join(dir, "main.md"),
63
+ )
49
64
  expect(result).toBe('::include{file="recursive.md"}')
50
65
  })
51
66
 
52
- it("should resolve relative paths from included file location", () => {
67
+ it("should resolve relative paths from included file location", async () => {
53
68
  const dir = temporaryDirectory()
54
69
  mkdirSync(join(dir, "sub"))
55
70
  writeFileSync(join(dir, "sub", "child.md"), "Child content")
56
71
  writeFileSync(join(dir, "parent.md"), '::include{file="sub/child.md"}')
57
- const result = resolveIncludes('::include{file="parent.md"}', join(dir, "main.md"))
72
+ const result = await resolveIncludes(
73
+ '::include{file="parent.md"}',
74
+ join(dir, "main.md"),
75
+ )
58
76
  expect(result).toBe("Child content")
59
77
  })
60
78
 
61
- it("should handle multiple includes in one file", () => {
79
+ it("should handle multiple includes in one file", async () => {
62
80
  const dir = temporaryDirectory()
63
81
  writeFileSync(join(dir, "a.md"), "Alpha")
64
82
  writeFileSync(join(dir, "b.md"), "Beta")
65
- const result = resolveIncludes(
83
+ const result = await resolveIncludes(
66
84
  '::include{file="a.md"}\n\n::include{file="b.md"}',
67
85
  join(dir, "main.md"),
68
86
  )
69
87
  expect(result).toBe("Alpha\n\nBeta")
70
88
  })
71
89
 
72
- it("should wrap code files in fenced blocks", () => {
90
+ it("should wrap code files in fenced blocks", async () => {
73
91
  const dir = temporaryDirectory()
74
92
  writeFileSync(join(dir, "example.ts"), 'const x = 1\nconst y = "hello"')
75
- const result = resolveIncludes('::include{file="example.ts"}', join(dir, "main.md"))
93
+ const result = await resolveIncludes(
94
+ '::include{file="example.ts"}',
95
+ join(dir, "main.md"),
96
+ )
76
97
  expect(result).toBe(
77
98
  '```typescript title="example.ts"\nconst x = 1\nconst y = "hello"\n```',
78
99
  )
79
100
  })
80
101
 
81
- it("should pass meta to code fence", () => {
102
+ it("should pass meta to code fence", async () => {
82
103
  const dir = temporaryDirectory()
83
104
  writeFileSync(join(dir, "app.tsx"), "export default function App() {}")
84
- const result = resolveIncludes(
105
+ const result = await resolveIncludes(
85
106
  '::include{file="app.tsx" meta="{1} lineNumbers"}',
86
107
  join(dir, "main.md"),
87
108
  )
@@ -90,10 +111,13 @@ describe("resolveIncludes", () => {
90
111
  )
91
112
  })
92
113
 
93
- it("should treat unknown extensions as markdown", () => {
114
+ it("should treat unknown extensions as markdown", async () => {
94
115
  const dir = temporaryDirectory()
95
116
  writeFileSync(join(dir, "notes.txt"), "Plain text")
96
- const result = resolveIncludes('::include{file="notes.txt"}', join(dir, "main.md"))
117
+ const result = await resolveIncludes(
118
+ '::include{file="notes.txt"}',
119
+ join(dir, "main.md"),
120
+ )
97
121
  expect(result).toBe("Plain text")
98
122
  })
99
123
  })
@@ -31,6 +31,15 @@ export const Head = z.object({
31
31
  scripts: z.array(z.record(z.string(), z.string())).optional(),
32
32
  })
33
33
 
34
+ /** How automated systems may use the site's content, published as a
35
+ * `Content-Signal` line in the generated `robots.txt`. */
36
+ export type ContentSignals = z.infer<typeof ContentSignals>
37
+ export const ContentSignals = z.object({
38
+ search: z.boolean().default(true),
39
+ aiInput: z.boolean().default(true),
40
+ aiTrain: z.boolean().default(true),
41
+ })
42
+
34
43
  /**
35
44
  * User-provided configuration for Livemark.
36
45
  *
@@ -64,6 +73,8 @@ export const UserConfig = z.object({
64
73
  sections: z.array(Section).optional(),
65
74
  patches: z.array(Patch).optional(),
66
75
  head: Head.optional(),
76
+ contentSignals: ContentSignals.prefault({}),
77
+ skills: z.union([z.string(), z.array(z.string())]).optional(),
67
78
  // Stored as `unknown` in the schema (zod can't type a Vite UserConfig
68
79
  // or a function returning one meaningfully); the public TypeScript
69
80
  // surface above lifts it back to ViteOverride for editor support.
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { join, resolve } from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+ import type { Plugin } from "vite"
5
+ import { z } from "zod"
6
+ import {
7
+ buildSkillFileName,
8
+ loadAgentSkills,
9
+ renderSkillsIndex,
10
+ } from "../helpers/agent-skills.ts"
11
+ import {
12
+ buildTwinFileName,
13
+ renderLlmsText,
14
+ renderMarkdownTwin,
15
+ renderSectionIndex,
16
+ } from "../helpers/llms.ts"
17
+ import { resolveIncludes } from "../helpers/resolve-includes.ts"
18
+ import type { Config } from "../models/config.ts"
19
+
20
+ export interface AgentsOptions {
21
+ config: Config
22
+ }
23
+
24
+ const GeneratedArticles = z.array(
25
+ z.object({
26
+ path: z.string(),
27
+ title: z.string(),
28
+ description: z.string().optional(),
29
+ order: z.number().optional(),
30
+ date: z.string().optional(),
31
+ file: z.string(),
32
+ content: z.string(),
33
+ }),
34
+ )
35
+
36
+ /** Emit what AI agents look for into the client build: a Markdown twin of
37
+ * every article and section listing at `<path>index.md`, `llms.txt`, the Agent Skills index for
38
+ * `config.skills`, and a `_headers` file whose `Link` points agents at them. */
39
+ export function agents(opts: AgentsOptions): Plugin {
40
+ const publicDir = join(opts.config.root, ".livemark", "public")
41
+ let root = ""
42
+ return {
43
+ name: "livemark:agents",
44
+ applyToEnvironment(env) {
45
+ return env.name === "client"
46
+ },
47
+ configResolved(resolved) {
48
+ root = resolved.root
49
+ },
50
+ async generateBundle() {
51
+ // Vite copies the publicDir before writeBundle, so emitting a file the
52
+ // project ships under `.livemark/public/` would silently replace it.
53
+ const emit = (fileName: string, source: string | Buffer) => {
54
+ if (existsSync(join(publicDir, fileName))) return
55
+ this.emitFile({ type: "asset", fileName, source })
56
+ }
57
+
58
+ const articles = await loadGeneratedArticles(root)
59
+ const contents = await Promise.all(
60
+ articles.map(article =>
61
+ resolveIncludes(article.content, resolve(opts.config.root, article.file)),
62
+ ),
63
+ )
64
+ articles.forEach((article, index) => {
65
+ const content = contents[index] ?? article.content
66
+ emit(buildTwinFileName(article.path), renderMarkdownTwin({ article, content }))
67
+ })
68
+
69
+ const llms = renderLlmsText({ config: opts.config, articles })
70
+ emit("llms.txt", llms)
71
+ const hasTwin = (path: string) => articles.some(a => a.path === path)
72
+ if (!hasTwin("/")) emit("index.md", llms)
73
+ // A rewrite to a missing twin is a 404, so every listing page needs one too.
74
+ for (const section of opts.config.sections ?? []) {
75
+ if (section.type === "custom" || section.prefix === "/") continue
76
+ if (hasTwin(section.prefix)) continue
77
+ emit(
78
+ buildTwinFileName(section.prefix),
79
+ renderSectionIndex({ config: opts.config, section, articles }),
80
+ )
81
+ }
82
+
83
+ const patterns = opts.config.skills ?? []
84
+ const skills = loadAgentSkills({
85
+ root: opts.config.root,
86
+ patterns: Array.isArray(patterns) ? patterns : [patterns],
87
+ })
88
+ for (const skill of skills) emit(buildSkillFileName(skill.name), skill.source)
89
+ if (skills.length) {
90
+ emit(
91
+ ".well-known/agent-skills/index.json",
92
+ renderSkillsIndex({ skills, base: opts.config.base }),
93
+ )
94
+ }
95
+
96
+ const headersFile = join(publicDir, "_headers")
97
+ this.emitFile({
98
+ type: "asset",
99
+ fileName: "_headers",
100
+ source: renderHeaders({
101
+ base: opts.config.base,
102
+ hasSkills: skills.length > 0,
103
+ existing: existsSync(headersFile) ? readFileSync(headersFile, "utf-8") : "",
104
+ }),
105
+ })
106
+ },
107
+ }
108
+ }
109
+
110
+ /** Render `_headers` (Cloudflare, Netlify): a `Link` on the home page naming
111
+ * `llms.txt`, its Markdown twin and the skills index, after any rules the
112
+ * project ships itself. */
113
+ export function renderHeaders(props: {
114
+ base?: string
115
+ hasSkills: boolean
116
+ existing?: string
117
+ }) {
118
+ const base = props.base ?? ""
119
+ const links = [
120
+ `<${base}/llms.txt>; rel="describedby"; type="text/plain"`,
121
+ `<${base}/index.md>; rel="alternate"; type="text/markdown"`,
122
+ ]
123
+ if (props.hasSkills) {
124
+ links.push(
125
+ `<${base}/.well-known/agent-skills/index.json>; rel="describedby"; type="application/json"`,
126
+ )
127
+ }
128
+ const rule = `${base}/\n Link: ${links.join(", ")}\n`
129
+ const existing = props.existing?.trimEnd()
130
+ return existing ? `${existing}\n\n${rule}` : rule
131
+ }
132
+
133
+ async function loadGeneratedArticles(root: string) {
134
+ const file = join(root, ".content-collections", "generated", "allArticles.js")
135
+ const module: { default: unknown } = await import(pathToFileURL(file).href)
136
+ return GeneratedArticles.parse(module.default)
137
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vite-plus/test"
2
+ import { renderHeaders } from "./vite-agents.ts"
3
+
4
+ describe("renderHeaders", () => {
5
+ it("links the home page to llms.txt and its markdown twin", () => {
6
+ expect(renderHeaders({ hasSkills: false })).toBe(
7
+ '/\n Link: </llms.txt>; rel="describedby"; type="text/plain", </index.md>; rel="alternate"; type="text/markdown"\n',
8
+ )
9
+ })
10
+
11
+ it("adds the skills index and mounts everything under base", () => {
12
+ const headers = renderHeaders({ base: "/repo", hasSkills: true })
13
+ expect(headers.startsWith("/repo/\n Link: </repo/llms.txt>")).toBe(true)
14
+ expect(headers).toContain(
15
+ '</repo/.well-known/agent-skills/index.json>; rel="describedby"; type="application/json"',
16
+ )
17
+ })
18
+
19
+ it("keeps the project's own rules ahead of the generated one", () => {
20
+ const headers = renderHeaders({
21
+ hasSkills: false,
22
+ existing: "/*\n X-Frame-Options: DENY\n\n",
23
+ })
24
+ expect(headers.startsWith("/*\n X-Frame-Options: DENY\n\n/\n Link: ")).toBe(true)
25
+ })
26
+ })
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import type { Plugin } from "vite"
4
- import type { Config } from "../models/config.ts"
4
+ import type { Config, ContentSignals } from "../models/config.ts"
5
5
 
6
6
  export interface RobotsOptions {
7
7
  config: Config
@@ -35,8 +35,13 @@ export function robots(opts: RobotsOptions): Plugin {
35
35
  * line is appended only when `site` is set, because the directive
36
36
  * requires a fully-qualified URL; `base` is included so the URL matches
37
37
  * where the build (and the `sitemap.xml` inside it) is actually mounted. */
38
- export function renderRobots(config: Pick<Config, "site" | "base">) {
39
- const lines = ["User-agent: *", "Allow: /"]
38
+ export function renderRobots(
39
+ config: Pick<Config, "site" | "base"> & { contentSignals?: ContentSignals },
40
+ ) {
41
+ const lines = ["User-agent: *"]
42
+ // Agents read the signal as part of a user-agent group, so it must sit inside one.
43
+ if (config.contentSignals) lines.push(renderContentSignal(config.contentSignals))
44
+ lines.push("Allow: /")
40
45
  if (config.site) {
41
46
  const host = config.site.replace(/\/+$/, "")
42
47
  const base = config.base ?? ""
@@ -44,3 +49,13 @@ export function renderRobots(config: Pick<Config, "site" | "base">) {
44
49
  }
45
50
  return `${lines.join("\n")}\n`
46
51
  }
52
+
53
+ function renderContentSignal(signals: ContentSignals) {
54
+ const flag = (value: boolean) => (value ? "yes" : "no")
55
+ const pairs = [
56
+ `search=${flag(signals.search)}`,
57
+ `ai-input=${flag(signals.aiInput)}`,
58
+ `ai-train=${flag(signals.aiTrain)}`,
59
+ ]
60
+ return `Content-Signal: ${pairs.join(", ")}`
61
+ }
@@ -27,4 +27,11 @@ describe("renderRobots", () => {
27
27
  it("ignores base when site is not set", () => {
28
28
  expect(renderRobots({ base: "/repo" })).toBe("User-agent: *\nAllow: /\n")
29
29
  })
30
+
31
+ it("puts the content signals inside the wildcard group", () => {
32
+ const contentSignals = { search: true, aiInput: true, aiTrain: false }
33
+ expect(renderRobots({ contentSignals })).toBe(
34
+ "User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=no\nAllow: /\n",
35
+ )
36
+ })
30
37
  })