livemark 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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 +2 -4
  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/models/config.d.ts +20 -0
  18. package/build/models/config.js +8 -1
  19. package/build/plugins/vite-agents.d.ts +17 -0
  20. package/build/plugins/vite-agents.js +109 -0
  21. package/build/plugins/vite-agents.unit.d.ts +1 -0
  22. package/build/plugins/vite-agents.unit.js +20 -0
  23. package/build/plugins/vite-robots.d.ts +4 -2
  24. package/build/plugins/vite-robots.js +15 -2
  25. package/build/plugins/vite-robots.unit.js +5 -1
  26. package/build/vite.config.js +3 -1
  27. package/package.json +3 -2
  28. package/skills/livemark-configuration/SKILL.md +4 -0
  29. package/skills/livemark-customization/SKILL.md +1 -0
  30. package/source/content/article.ts +8 -13
  31. package/source/content-collections.ts +1 -4
  32. package/source/helpers/agent-skills.ts +59 -0
  33. package/source/helpers/agent-skills.unit.ts +56 -0
  34. package/source/helpers/article.ts +17 -0
  35. package/source/helpers/head.ts +12 -1
  36. package/source/helpers/head.unit.ts +3 -1
  37. package/source/helpers/llms.ts +109 -0
  38. package/source/helpers/llms.unit.ts +115 -0
  39. package/source/models/config.ts +11 -0
  40. package/source/plugins/vite-agents.ts +135 -0
  41. package/source/plugins/vite-agents.unit.ts +26 -0
  42. package/source/plugins/vite-robots.ts +18 -3
  43. package/source/plugins/vite-robots.unit.ts +7 -0
  44. package/source/vite.config.ts +2 -0
@@ -0,0 +1,135 @@
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
+ for (const article of articles) {
60
+ const content = resolveIncludes(
61
+ article.content,
62
+ resolve(opts.config.root, article.file),
63
+ )
64
+ emit(buildTwinFileName(article.path), renderMarkdownTwin({ article, content }))
65
+ }
66
+
67
+ const llms = renderLlmsText({ config: opts.config, articles })
68
+ emit("llms.txt", llms)
69
+ const hasTwin = (path: string) => articles.some(a => a.path === path)
70
+ if (!hasTwin("/")) emit("index.md", llms)
71
+ // A rewrite to a missing twin is a 404, so every listing page needs one too.
72
+ for (const section of opts.config.sections ?? []) {
73
+ if (section.type === "custom" || section.prefix === "/") continue
74
+ if (hasTwin(section.prefix)) continue
75
+ emit(
76
+ buildTwinFileName(section.prefix),
77
+ renderSectionIndex({ config: opts.config, section, articles }),
78
+ )
79
+ }
80
+
81
+ const patterns = opts.config.skills ?? []
82
+ const skills = loadAgentSkills({
83
+ root: opts.config.root,
84
+ patterns: Array.isArray(patterns) ? patterns : [patterns],
85
+ })
86
+ for (const skill of skills) emit(buildSkillFileName(skill.name), skill.source)
87
+ if (skills.length) {
88
+ emit(
89
+ ".well-known/agent-skills/index.json",
90
+ renderSkillsIndex({ skills, base: opts.config.base }),
91
+ )
92
+ }
93
+
94
+ const headersFile = join(publicDir, "_headers")
95
+ this.emitFile({
96
+ type: "asset",
97
+ fileName: "_headers",
98
+ source: renderHeaders({
99
+ base: opts.config.base,
100
+ hasSkills: skills.length > 0,
101
+ existing: existsSync(headersFile) ? readFileSync(headersFile, "utf-8") : "",
102
+ }),
103
+ })
104
+ },
105
+ }
106
+ }
107
+
108
+ /** Render `_headers` (Cloudflare, Netlify): a `Link` on the home page naming
109
+ * `llms.txt`, its Markdown twin and the skills index, after any rules the
110
+ * project ships itself. */
111
+ export function renderHeaders(props: {
112
+ base?: string
113
+ hasSkills: boolean
114
+ existing?: string
115
+ }) {
116
+ const base = props.base ?? ""
117
+ const links = [
118
+ `<${base}/llms.txt>; rel="describedby"; type="text/plain"`,
119
+ `<${base}/index.md>; rel="alternate"; type="text/markdown"`,
120
+ ]
121
+ if (props.hasSkills) {
122
+ links.push(
123
+ `<${base}/.well-known/agent-skills/index.json>; rel="describedby"; type="application/json"`,
124
+ )
125
+ }
126
+ const rule = `${base}/\n Link: ${links.join(", ")}\n`
127
+ const existing = props.existing?.trimEnd()
128
+ return existing ? `${existing}\n\n${rule}` : rule
129
+ }
130
+
131
+ async function loadGeneratedArticles(root: string) {
132
+ const file = join(root, ".content-collections", "generated", "allArticles.js")
133
+ const module: { default: unknown } = await import(pathToFileURL(file).href)
134
+ return GeneratedArticles.parse(module.default)
135
+ }
@@ -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
  })
@@ -6,6 +6,7 @@ import react from "@vitejs/plugin-react"
6
6
  import { defineConfig } from "vite"
7
7
  import svgr from "vite-plugin-svgr"
8
8
  import { loadConfig } from "./actions/config/load.ts"
9
+ import { agents } from "./plugins/vite-agents.ts"
9
10
  import { livemark } from "./plugins/vite-livemark.ts"
10
11
  import { robots } from "./plugins/vite-robots.ts"
11
12
 
@@ -28,6 +29,7 @@ export default defineConfig({
28
29
  plugins: [
29
30
  livemark({ config }),
30
31
  robots({ config }),
32
+ agents({ config }),
31
33
  devtools(),
32
34
  tailwind(),
33
35
  contentCollections({ configPath: "content-collections.ts" }),