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.
- package/build/actions/config/define.d.ts +6 -0
- package/build/content/article.js +4 -11
- package/build/content-collections.js +3 -5
- package/build/helpers/agent-skills.d.ts +22 -0
- package/build/helpers/agent-skills.js +46 -0
- package/build/helpers/agent-skills.unit.d.ts +1 -0
- package/build/helpers/agent-skills.unit.js +49 -0
- package/build/helpers/article.d.ts +13 -0
- package/build/helpers/article.js +17 -1
- package/build/helpers/head.d.ts +7 -2
- package/build/helpers/head.js +13 -2
- package/build/helpers/head.unit.js +4 -2
- package/build/helpers/llms.d.ts +33 -0
- package/build/helpers/llms.js +72 -0
- package/build/helpers/llms.unit.d.ts +1 -0
- package/build/helpers/llms.unit.js +98 -0
- package/build/helpers/resolve-includes.d.ts +1 -1
- package/build/helpers/resolve-includes.js +8 -8
- package/build/helpers/resolve-includes.unit.js +23 -23
- package/build/models/config.d.ts +20 -0
- package/build/models/config.js +8 -1
- package/build/plugins/vite-agents.d.ts +17 -0
- package/build/plugins/vite-agents.js +110 -0
- package/build/plugins/vite-agents.unit.d.ts +1 -0
- package/build/plugins/vite-agents.unit.js +20 -0
- package/build/plugins/vite-robots.d.ts +4 -2
- package/build/plugins/vite-robots.js +15 -2
- package/build/plugins/vite-robots.unit.js +5 -1
- package/build/vite.config.js +3 -1
- package/package.json +4 -2
- package/skills/livemark-configuration/SKILL.md +4 -0
- package/skills/livemark-customization/SKILL.md +1 -0
- package/source/content/article.ts +8 -13
- package/source/content-collections.ts +2 -5
- package/source/helpers/agent-skills.ts +59 -0
- package/source/helpers/agent-skills.unit.ts +56 -0
- package/source/helpers/article.ts +17 -0
- package/source/helpers/head.ts +12 -1
- package/source/helpers/head.unit.ts +3 -1
- package/source/helpers/llms.ts +109 -0
- package/source/helpers/llms.unit.ts +115 -0
- package/source/helpers/resolve-includes.ts +12 -12
- package/source/helpers/resolve-includes.unit.ts +46 -22
- package/source/models/config.ts +11 -0
- package/source/plugins/vite-agents.ts +137 -0
- package/source/plugins/vite-agents.unit.ts +26 -0
- package/source/plugins/vite-robots.ts +18 -3
- package/source/plugins/vite-robots.unit.ts +7 -0
- 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 {
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
})
|
package/source/models/config.ts
CHANGED
|
@@ -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(
|
|
39
|
-
|
|
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
|
})
|