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.
- package/build/actions/config/define.d.ts +6 -0
- package/build/content/article.js +4 -11
- package/build/content-collections.js +2 -4
- 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/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 +109 -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 +3 -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 +1 -4
- 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/models/config.ts +11 -0
- package/source/plugins/vite-agents.ts +135 -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
package/build/vite.config.js
CHANGED
|
@@ -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.js";
|
|
9
|
+
import { agents } from "./plugins/vite-agents.js";
|
|
9
10
|
import { livemark } from "./plugins/vite-livemark.js";
|
|
10
11
|
import { robots } from "./plugins/vite-robots.js";
|
|
11
12
|
const config = await loadConfig();
|
|
@@ -25,6 +26,7 @@ export default defineConfig({
|
|
|
25
26
|
plugins: [
|
|
26
27
|
livemark({ config }),
|
|
27
28
|
robots({ config }),
|
|
29
|
+
agents({ config }),
|
|
28
30
|
devtools(),
|
|
29
31
|
tailwind(),
|
|
30
32
|
contentCollections({ configPath: "content-collections.ts" }),
|
|
@@ -53,4 +55,4 @@ export default defineConfig({
|
|
|
53
55
|
svgr(),
|
|
54
56
|
],
|
|
55
57
|
});
|
|
56
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
58
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZS5jb25maWcuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zb3VyY2Uvdml0ZS5jb25maWcudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxrQkFBa0IsTUFBTSwyQkFBMkIsQ0FBQTtBQUMxRCxPQUFPLFFBQVEsTUFBTSxtQkFBbUIsQ0FBQTtBQUN4QyxPQUFPLEVBQUUsUUFBUSxFQUFFLE1BQU0seUJBQXlCLENBQUE7QUFDbEQsT0FBTyxFQUFFLGFBQWEsRUFBRSxNQUFNLG1DQUFtQyxDQUFBO0FBQ2pFLE9BQU8sS0FBSyxNQUFNLHNCQUFzQixDQUFBO0FBQ3hDLE9BQU8sRUFBRSxZQUFZLEVBQUUsTUFBTSxNQUFNLENBQUE7QUFDbkMsT0FBTyxJQUFJLE1BQU0sa0JBQWtCLENBQUE7QUFDbkMsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLDBCQUEwQixDQUFBO0FBQ3JELE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQTtBQUNqRCxPQUFPLEVBQUUsUUFBUSxFQUFFLE1BQU0sNEJBQTRCLENBQUE7QUFDckQsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLDBCQUEwQixDQUFBO0FBRWpELE1BQU0sTUFBTSxHQUFHLE1BQU0sVUFBVSxFQUFFLENBQUE7QUFFakMsdUVBQXVFO0FBQ3ZFLHdFQUF3RTtBQUN4RSx5RUFBeUU7QUFDekUsbURBQW1EO0FBQ25ELE1BQU0sYUFBYSxHQUFHLE1BQU0sQ0FBQyxJQUFJO0lBQy9CLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxRQUFRLElBQUksRUFBRSxDQUFDO1NBQ3BCLE1BQU0sQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxJQUFJLEtBQUssTUFBTSxDQUFDO1NBQzlCLEdBQUcsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUM7UUFDVCxJQUFJLEVBQUUsR0FBRyxNQUFNLENBQUMsSUFBSSxJQUFJLEVBQUUsR0FBRyxDQUFDLENBQUMsTUFBTSxTQUFTO1FBQzlDLE9BQU8sRUFBRSxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUU7S0FDM0IsQ0FBQyxDQUFDO0lBQ1AsQ0FBQyxDQUFDLEVBQUUsQ0FBQTtBQUVOLGVBQWUsWUFBWSxDQUFDO0lBQzFCLE9BQU8sRUFBRTtRQUNQLFFBQVEsQ0FBQyxFQUFFLE1BQU0sRUFBRSxDQUFDO1FBQ3BCLE1BQU0sQ0FBQyxFQUFFLE1BQU0sRUFBRSxDQUFDO1FBQ2xCLE1BQU0sQ0FBQyxFQUFFLE1BQU0sRUFBRSxDQUFDO1FBQ2xCLFFBQVEsRUFBRTtRQUNWLFFBQVEsRUFBRTtRQUNWLGtCQUFrQixDQUFDLEVBQUUsVUFBVSxFQUFFLHdCQUF3QixFQUFFLENBQUM7UUFDNUQsYUFBYSxDQUFDO1lBQ1osWUFBWSxFQUFFLEdBQUc7WUFDakIsaUVBQWlFO1lBQ2pFLGdFQUFnRTtZQUNoRSx5REFBeUQ7WUFDekQsS0FBSyxFQUFFLENBQUMsRUFBRSxJQUFJLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsR0FBRyxNQUFNLENBQUMsSUFBSSxHQUFHLENBQUMsQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLEdBQUcsYUFBYSxDQUFDO1lBQzFFLGdFQUFnRTtZQUNoRSxvRUFBb0U7WUFDcEUsa0VBQWtFO1lBQ2xFLDZCQUE2QjtZQUM3QixTQUFTLEVBQUU7Z0JBQ1QsT0FBTyxFQUFFLElBQUk7Z0JBQ2IsVUFBVSxFQUFFLElBQUk7Z0JBQ2hCLHdCQUF3QixFQUFFLEtBQUs7YUFDaEM7WUFDRCxPQUFPLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxNQUFNLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsT0FBTyxFQUFFLEtBQUssRUFBRTtZQUNoRixNQUFNLEVBQUU7Z0JBQ04sa0JBQWtCLEVBQUUsa0JBQWtCO2dCQUN0QyxRQUFRLEVBQUUsTUFBTSxDQUFDLElBQUk7YUFDdEI7U0FDRixDQUFDO1FBQ0YsS0FBSyxFQUFFO1FBQ1AsSUFBSSxFQUFFO0tBQ1A7Q0FDRixDQUFDLENBQUEiLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgY29udGVudENvbGxlY3Rpb25zIGZyb20gXCJAY29udGVudC1jb2xsZWN0aW9ucy92aXRlXCJcbmltcG9ydCB0YWlsd2luZCBmcm9tIFwiQHRhaWx3aW5kY3NzL3ZpdGVcIlxuaW1wb3J0IHsgZGV2dG9vbHMgfSBmcm9tIFwiQHRhbnN0YWNrL2RldnRvb2xzLXZpdGVcIlxuaW1wb3J0IHsgdGFuc3RhY2tTdGFydCB9IGZyb20gXCJAdGFuc3RhY2svcmVhY3Qtc3RhcnQvcGx1Z2luL3ZpdGVcIlxuaW1wb3J0IHJlYWN0IGZyb20gXCJAdml0ZWpzL3BsdWdpbi1yZWFjdFwiXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiXG5pbXBvcnQgc3ZnciBmcm9tIFwidml0ZS1wbHVnaW4tc3ZnclwiXG5pbXBvcnQgeyBsb2FkQ29uZmlnIH0gZnJvbSBcIi4vYWN0aW9ucy9jb25maWcvbG9hZC50c1wiXG5pbXBvcnQgeyBhZ2VudHMgfSBmcm9tIFwiLi9wbHVnaW5zL3ZpdGUtYWdlbnRzLnRzXCJcbmltcG9ydCB7IGxpdmVtYXJrIH0gZnJvbSBcIi4vcGx1Z2lucy92aXRlLWxpdmVtYXJrLnRzXCJcbmltcG9ydCB7IHJvYm90cyB9IGZyb20gXCIuL3BsdWdpbnMvdml0ZS1yb2JvdHMudHNcIlxuXG5jb25zdCBjb25maWcgPSBhd2FpdCBsb2FkQ29uZmlnKClcblxuLy8gUGVyLWJsb2ctc2VjdGlvbiBSU1MgZmVlZCBwYXRocyB0byBwcmVyZW5kZXIuIEdhdGVkIG9uIGBjb25maWcuc2l0ZWBcbi8vIGJlY2F1c2UgZmVlZCBVUkxzIGFyZSBhYnNvbHV0ZSAobWF0Y2hpbmcgdGhlIHNpdGVtYXAvcm9ib3RzLnR4dCBnYXRlKVxuLy8gYW5kIHRoZSByb3V0ZSByZXR1cm5zIDQwNCB3aXRob3V0IGl0IGFueXdheS4gRXhjbHVkZWQgZnJvbSB0aGUgc2l0ZW1hcFxuLy8gc2luY2UgYC54bWxgIHBheWxvYWRzIGRvbid0IGJlbG9uZyBpbiBhIHNpdGVtYXAuXG5jb25zdCBibG9nRmVlZFBhZ2VzID0gY29uZmlnLnNpdGVcbiAgPyAoY29uZmlnLnNlY3Rpb25zID8/IFtdKVxuICAgICAgLmZpbHRlcihzID0+IHMudHlwZSA9PT0gXCJibG9nXCIpXG4gICAgICAubWFwKHMgPT4gKHtcbiAgICAgICAgcGF0aDogYCR7Y29uZmlnLmJhc2UgPz8gXCJcIn0ke3MucHJlZml4fXJzcy54bWxgLFxuICAgICAgICBzaXRlbWFwOiB7IGV4Y2x1ZGU6IHRydWUgfSxcbiAgICAgIH0pKVxuICA6IFtdXG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gIHBsdWdpbnM6IFtcbiAgICBsaXZlbWFyayh7IGNvbmZpZyB9KSxcbiAgICByb2JvdHMoeyBjb25maWcgfSksXG4gICAgYWdlbnRzKHsgY29uZmlnIH0pLFxuICAgIGRldnRvb2xzKCksXG4gICAgdGFpbHdpbmQoKSxcbiAgICBjb250ZW50Q29sbGVjdGlvbnMoeyBjb25maWdQYXRoOiBcImNvbnRlbnQtY29sbGVjdGlvbnMudHNcIiB9KSxcbiAgICB0YW5zdGFja1N0YXJ0KHtcbiAgICAgIHNyY0RpcmVjdG9yeTogXCIuXCIsXG4gICAgICAvLyBTZWVkIHBhdGggbXVzdCBtYXRjaCB3aGF0IHRoZSBjcmF3bGVyIHByb2R1Y2VzIChyb3V0ZXItZW1pdHRlZFxuICAgICAgLy8gaHJlZnMgaW5jbHVkZSB0aGUgYmFzZXBhdGgpLCBvdGhlcndpc2UgcHJlcmVuZGVyIHJlY29yZHMgYm90aFxuICAgICAgLy8gc2hhcGVzIGFuZCB0aGUgc2l0ZW1hcCBlbmRzIHVwIHdpdGggZHVwbGljYXRlIGVudHJpZXMuXG4gICAgICBwYWdlczogW3sgcGF0aDogY29uZmlnLmJhc2UgPyBgJHtjb25maWcuYmFzZX0vYCA6IFwiL1wiIH0sIC4uLmJsb2dGZWVkUGFnZXNdLFxuICAgICAgLy8gRGlzYWJsZSByb3V0ZS10cmVlIGF1dG8tZGlzY292ZXJ5OiBpdCBwcm9kdWNlcyBiYXNlcGF0aC1uYWl2ZVxuICAgICAgLy8gcGF0aHMgKGUuZy4gYC9gKSB0aGF0IGR1cGxpY2F0ZSB0aGUgY3Jhd2wtZGVyaXZlZCBwcmVmaXhlZCBwYXRocyxcbiAgICAgIC8vIGJsb2F0aW5nIHBhZ2VzLmpzb24gYW5kIHNpdGVtYXAuIENyYXdsaW5nIGZyb20gdGhlIHNlZWQgYWxyZWFkeVxuICAgICAgLy8gcmVhY2hlcyBldmVyeSBsaW5rZWQgcGFnZS5cbiAgICAgIHByZXJlbmRlcjoge1xuICAgICAgICBlbmFibGVkOiB0cnVlLFxuICAgICAgICBjcmF3bExpbmtzOiB0cnVlLFxuICAgICAgICBhdXRvU3RhdGljUGF0aHNEaXNjb3Zlcnk6IGZhbHNlLFxuICAgICAgfSxcbiAgICAgIHNpdGVtYXA6IGNvbmZpZy5zaXRlID8geyBlbmFibGVkOiB0cnVlLCBob3N0OiBjb25maWcuc2l0ZSB9IDogeyBlbmFibGVkOiBmYWxzZSB9LFxuICAgICAgcm91dGVyOiB7XG4gICAgICAgIGdlbmVyYXRlZFJvdXRlVHJlZTogXCJyb3V0ZVRyZWUuZ2VuLnRzXCIsXG4gICAgICAgIGJhc2VwYXRoOiBjb25maWcuYmFzZSxcbiAgICAgIH0sXG4gICAgfSksXG4gICAgcmVhY3QoKSxcbiAgICBzdmdyKCksXG4gIF0sXG59KVxuIl19
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livemark",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Livemark description",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Evgeny Karev",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"unist-util-visit": "^5.1.0",
|
|
92
92
|
"vite": "8.3.4",
|
|
93
93
|
"vite-plugin-svgr": "5.2.0",
|
|
94
|
+
"yaml": "^2.9.1",
|
|
94
95
|
"zod": "4.6.6"
|
|
95
96
|
},
|
|
96
97
|
"devDependencies": {
|
|
@@ -127,7 +128,7 @@
|
|
|
127
128
|
"scan": "pnpm scan:leaks && pnpm scan:vulns",
|
|
128
129
|
"scan:leaks": "gitleaks git --config=.gitleaks.toml --verbose --redact",
|
|
129
130
|
"scan:vulns": "semgrep --config=p/typescript --config=p/nodejs --config=p/owasp-top-ten --config=p/secrets --exclude=\"**/fixtures/**\" --metrics=off",
|
|
130
|
-
"squash": "node
|
|
131
|
+
"squash": "node scripts/squash-branch.ts",
|
|
131
132
|
"start": "node source/entrypoints/main.ts start",
|
|
132
133
|
"test": "pnpm check && pnpm test:unit",
|
|
133
134
|
"test:unit": "vp test",
|
|
@@ -42,6 +42,10 @@ export default defineConfig({
|
|
|
42
42
|
scripts: [{ src: "https://example.com/analytics.js", defer: "" }],
|
|
43
43
|
},
|
|
44
44
|
|
|
45
|
+
// Optional: AI agents (llms.txt and Markdown twins are always emitted)
|
|
46
|
+
contentSignals: { search: true, aiInput: true, aiTrain: true }, // robots.txt Content-Signal; all default true
|
|
47
|
+
skills: "skills/*/SKILL.md", // publish skills at /.well-known/agent-skills/
|
|
48
|
+
|
|
45
49
|
// Optional: content selection
|
|
46
50
|
exclude: "docs/drafts/**", // string | string[]
|
|
47
51
|
|
|
@@ -25,6 +25,7 @@ Invoke when the user asks to:
|
|
|
25
25
|
| Replace a default component/style | Drop a file at `.livemark/components/<Name>.tsx` etc. |
|
|
26
26
|
| Add a new page at an exact URL | Create `.livemark/routes/<path>.tsx` |
|
|
27
27
|
| Serve a static asset at `/<path>` | Put the file in `.livemark/public/<path>` |
|
|
28
|
+
| Extend the generated `_headers` | `.livemark/public/_headers` — kept, `Link` appended |
|
|
28
29
|
| Rewrite site-wide `<head>` tags | `.livemark/head.ts` exporting `customizeHead(head)` |
|
|
29
30
|
|
|
30
31
|
None require config changes. The Vite plugin (`source/plugins/vite-livemark.ts`) watches `.livemark/**` and hot-reloads.
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { config } from "livemark:virtual"
|
|
2
2
|
import { allArticles } from "content-collections"
|
|
3
3
|
import type { ArticleNode } from "../models/article.ts"
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildArticleTree,
|
|
6
|
+
compareArticleDate,
|
|
7
|
+
compareArticleOrder,
|
|
8
|
+
flattenArticleTree,
|
|
9
|
+
} from "../helpers/article.ts"
|
|
5
10
|
import { matchSection, partitionBySection } from "../helpers/section.ts"
|
|
6
11
|
|
|
7
12
|
/**
|
|
@@ -9,9 +14,7 @@ import { matchSection, partitionBySection } from "../helpers/section.ts"
|
|
|
9
14
|
* positive values first (ascending), then unordered, then negative values
|
|
10
15
|
* (so `order: -1` lands at the very end, `-2` just before it, and so on).
|
|
11
16
|
*/
|
|
12
|
-
export const sortedArticles = [...allArticles].sort(
|
|
13
|
-
(a, b) => orderKey(a.order) - orderKey(b.order),
|
|
14
|
-
)
|
|
17
|
+
export const sortedArticles = [...allArticles].sort(compareArticleOrder)
|
|
15
18
|
|
|
16
19
|
/** The article whose path is `/`, if any (rendered in place at the root) */
|
|
17
20
|
export const homeArticle = sortedArticles.find(a => a.path === "/")
|
|
@@ -25,12 +28,6 @@ export const flatArticles = flattenArticleTree(articleTree)
|
|
|
25
28
|
/** The first article in sidebar order, used as the default landing page */
|
|
26
29
|
export const firstArticle = sortedArticles.find(a => a.path === flatArticles[0])
|
|
27
30
|
|
|
28
|
-
function orderKey(order?: number) {
|
|
29
|
-
if (order === undefined) return Number.MAX_SAFE_INTEGER / 2
|
|
30
|
-
if (order < 0) return Number.MAX_SAFE_INTEGER + order
|
|
31
|
-
return order
|
|
32
|
-
}
|
|
33
|
-
|
|
34
31
|
const configSections = config.sections
|
|
35
32
|
|
|
36
33
|
/** Route-bound sections (everything except `custom`). Custom sections
|
|
@@ -53,9 +50,7 @@ if (routedSections.length) {
|
|
|
53
50
|
const section = sectionByPrefix.get(key)
|
|
54
51
|
const sorted =
|
|
55
52
|
section?.type === "blog" || section?.type === "changelog"
|
|
56
|
-
? [...bucket].sort(
|
|
57
|
-
(a, b) => new Date(b.date ?? 0).getTime() - new Date(a.date ?? 0).getTime(),
|
|
58
|
-
)
|
|
53
|
+
? [...bucket].sort(compareArticleDate)
|
|
59
54
|
: bucket
|
|
60
55
|
const tree = buildArticleTree(sorted)
|
|
61
56
|
const flat = flattenArticleTree(tree)
|
|
@@ -49,6 +49,7 @@ import { transformerIcon } from "./plugins/shiki-icon.ts"
|
|
|
49
49
|
import { resolveAssetPath } from "./helpers/resolve-asset-path.ts"
|
|
50
50
|
import { resolveIncludes } from "./helpers/resolve-includes.ts"
|
|
51
51
|
import { extractSearchText } from "./helpers/search-text.ts"
|
|
52
|
+
import { stripLeadingH1 } from "./helpers/llms.ts"
|
|
52
53
|
import { extractToc } from "./helpers/toc.ts"
|
|
53
54
|
|
|
54
55
|
const config = await loadConfig()
|
|
@@ -98,10 +99,6 @@ function getLastUpdated(filePath: string) {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function stripLeadingH1(content: string) {
|
|
102
|
-
return content.replace(/^[ \t]*#\s+[^\n]*\n+/, "")
|
|
103
|
-
}
|
|
104
|
-
|
|
105
102
|
function extractTitle(content: string, path: string) {
|
|
106
103
|
const match = /^#\s+(.+)$/m.exec(content)
|
|
107
104
|
if (match) return match[1]!
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { globSync, readFileSync } from "node:fs"
|
|
3
|
+
import { resolve, sep } from "node:path"
|
|
4
|
+
import { parse } from "yaml"
|
|
5
|
+
import { z } from "zod"
|
|
6
|
+
|
|
7
|
+
const SKILLS_SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
|
|
8
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
9
|
+
|
|
10
|
+
const SkillFrontmatter = z.object({
|
|
11
|
+
name: z
|
|
12
|
+
.string()
|
|
13
|
+
.max(64)
|
|
14
|
+
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
|
|
15
|
+
description: z.string().min(1).max(1024),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** A `SKILL.md` published for agents, with the raw bytes its digest covers. */
|
|
19
|
+
export interface AgentSkill {
|
|
20
|
+
name: string
|
|
21
|
+
description: string
|
|
22
|
+
source: Buffer
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read every `SKILL.md` the `skills` globs match, in a stable order. */
|
|
26
|
+
export function loadAgentSkills(props: { root: string; patterns: string[] }) {
|
|
27
|
+
// Windows globs with `\`, which would leak into messages and change the sort.
|
|
28
|
+
return globSync(props.patterns, { cwd: props.root })
|
|
29
|
+
.map(file => file.split(sep).join("/"))
|
|
30
|
+
.sort()
|
|
31
|
+
.map(file => {
|
|
32
|
+
const source = readFileSync(resolve(props.root, file))
|
|
33
|
+
const frontmatter = FRONTMATTER_RE.exec(source.toString("utf-8"))?.[1] ?? ""
|
|
34
|
+
const parsed = SkillFrontmatter.safeParse(parse(frontmatter))
|
|
35
|
+
if (!parsed.success) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${file}: a published skill needs a frontmatter \`name\` (lowercase, hyphenated) and \`description\``,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
return { ...parsed.data, source }
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a skill is published inside the build output (no base). */
|
|
45
|
+
export function buildSkillFileName(name: string) {
|
|
46
|
+
return `.well-known/agent-skills/${name}/SKILL.md`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render the Agent Skills discovery index (v0.2.0) for the published skills. */
|
|
50
|
+
export function renderSkillsIndex(props: { skills: AgentSkill[]; base?: string }) {
|
|
51
|
+
const skills = props.skills.map(skill => ({
|
|
52
|
+
name: skill.name,
|
|
53
|
+
type: "skill-md",
|
|
54
|
+
description: skill.description,
|
|
55
|
+
url: `${props.base ?? ""}/${buildSkillFileName(skill.name)}`,
|
|
56
|
+
digest: `sha256:${createHash("sha256").update(skill.source).digest("hex")}`,
|
|
57
|
+
}))
|
|
58
|
+
return `${JSON.stringify({ $schema: SKILLS_SCHEMA, skills }, null, 2)}\n`
|
|
59
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
5
|
+
import { temporaryDirectory } from "tempy"
|
|
6
|
+
import { loadAgentSkills, renderSkillsIndex } from "./agent-skills.ts"
|
|
7
|
+
|
|
8
|
+
function writeSkill(root: string, dir: string, source: string) {
|
|
9
|
+
mkdirSync(join(root, "skills", dir), { recursive: true })
|
|
10
|
+
writeFileSync(join(root, "skills", dir, "SKILL.md"), source)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("loadAgentSkills", () => {
|
|
14
|
+
it("reads name and description from each matched SKILL.md", () => {
|
|
15
|
+
const root = temporaryDirectory()
|
|
16
|
+
writeSkill(root, "b", "---\nname: beta\ndescription: >\n Folded\n text\n---\n# B\n")
|
|
17
|
+
writeSkill(root, "a", "---\nname: alpha\ndescription: First\n---\n# A\n")
|
|
18
|
+
const skills = loadAgentSkills({ root, patterns: ["skills/*/SKILL.md"] })
|
|
19
|
+
expect(skills.map(s => [s.name, s.description])).toEqual([
|
|
20
|
+
["alpha", "First"],
|
|
21
|
+
["beta", "Folded text\n"],
|
|
22
|
+
])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("rejects a skill whose name agents cannot address", () => {
|
|
26
|
+
const root = temporaryDirectory()
|
|
27
|
+
writeSkill(root, "a", "---\nname: Not Valid\ndescription: First\n---\n")
|
|
28
|
+
expect(() => loadAgentSkills({ root, patterns: ["skills/*/SKILL.md"] })).toThrow(
|
|
29
|
+
"skills/a/SKILL.md",
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("renderSkillsIndex", () => {
|
|
35
|
+
it("publishes each skill with a digest of its exact bytes", () => {
|
|
36
|
+
const source = Buffer.from("---\nname: alpha\ndescription: First\n---\n")
|
|
37
|
+
const index = JSON.parse(
|
|
38
|
+
renderSkillsIndex({
|
|
39
|
+
skills: [{ name: "alpha", description: "First", source }],
|
|
40
|
+
base: "/repo",
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
expect(index).toEqual({
|
|
44
|
+
$schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
|
45
|
+
skills: [
|
|
46
|
+
{
|
|
47
|
+
name: "alpha",
|
|
48
|
+
type: "skill-md",
|
|
49
|
+
description: "First",
|
|
50
|
+
url: "/repo/.well-known/agent-skills/alpha/SKILL.md",
|
|
51
|
+
digest: `sha256:${createHash("sha256").update(source).digest("hex")}`,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -50,3 +50,20 @@ export function flattenArticleTree(nodes: ArticleNode[]) {
|
|
|
50
50
|
}
|
|
51
51
|
return result
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/** Sort comparator on frontmatter `order`: positive ascending, then unordered,
|
|
55
|
+
* then negative (so `order: -1` lands last) */
|
|
56
|
+
export function compareArticleOrder(a: { order?: number }, b: { order?: number }) {
|
|
57
|
+
return orderKey(a.order) - orderKey(b.order)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sort comparator putting the newest `date` first, as blog and changelog list */
|
|
61
|
+
export function compareArticleDate(a: { date?: string }, b: { date?: string }) {
|
|
62
|
+
return new Date(b.date ?? 0).getTime() - new Date(a.date ?? 0).getTime()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function orderKey(order?: number) {
|
|
66
|
+
if (order === undefined) return Number.MAX_SAFE_INTEGER / 2
|
|
67
|
+
if (order < 0) return Number.MAX_SAFE_INTEGER + order
|
|
68
|
+
return order
|
|
69
|
+
}
|
package/source/helpers/head.ts
CHANGED
|
@@ -83,7 +83,18 @@ export function buildPageHead(props: {
|
|
|
83
83
|
...(url ? [{ property: "og:url", content: url }] : []),
|
|
84
84
|
...(image ? [{ property: "og:image", content: image }] : []),
|
|
85
85
|
],
|
|
86
|
-
links:
|
|
86
|
+
links: [
|
|
87
|
+
...(url ? [{ rel: "canonical", href: url }] : []),
|
|
88
|
+
...(props.isArticle
|
|
89
|
+
? [
|
|
90
|
+
{
|
|
91
|
+
rel: "alternate",
|
|
92
|
+
type: "text/markdown",
|
|
93
|
+
href: prefixUrl(`${props.path}index.md`, props.config.base),
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
: []),
|
|
97
|
+
],
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
100
|
|
|
@@ -79,7 +79,9 @@ describe("buildPageHead", () => {
|
|
|
79
79
|
{ property: "og:description", content: "How to" },
|
|
80
80
|
{ property: "og:type", content: "article" },
|
|
81
81
|
])
|
|
82
|
-
expect(head.links).toEqual([
|
|
82
|
+
expect(head.links).toEqual([
|
|
83
|
+
{ rel: "alternate", type: "text/markdown", href: "/guide/index.md" },
|
|
84
|
+
])
|
|
83
85
|
})
|
|
84
86
|
|
|
85
87
|
it("adds absolute url, canonical and image when site is set", () => {
|
|
@@ -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
|
+
})
|
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.
|