@knitli/astro-docs-template 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/config.ts +0 -4
- package/src/remarkPlugin.test.ts +207 -0
- package/src/remarkPlugin.ts +52 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knitli/astro-docs-template",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Opinionated Astro + Starlight docs site template with Knitli branding",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"knitli",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"clean": "rm -rf dist",
|
|
39
39
|
"deploy": "bun publish --tag latest",
|
|
40
40
|
"prepack": "bun run build",
|
|
41
|
-
"test": "vitest run src/index.test.ts",
|
|
41
|
+
"test": "vitest run src/index.test.ts src/remarkPlugin.test.ts",
|
|
42
42
|
"test:integration": "vitest run src/integration.test.ts",
|
|
43
|
-
"test:unit": "vitest run src/index.test.ts",
|
|
43
|
+
"test:unit": "vitest run src/index.test.ts src/remarkPlugin.test.ts",
|
|
44
44
|
"test:watch": "vitest"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
package/src/config.ts
CHANGED
|
@@ -497,7 +497,6 @@ export default async function createConfig(options: DocsTemplateOptions) {
|
|
|
497
497
|
external: ["shiki", "shiki-esbuild"],
|
|
498
498
|
output: {
|
|
499
499
|
format: "es",
|
|
500
|
-
dir: `${rootDir}/dist/_astro/`,
|
|
501
500
|
compact: true,
|
|
502
501
|
interop: "esModule",
|
|
503
502
|
experimentalMinChunkSize: 10000,
|
|
@@ -514,9 +513,6 @@ export default async function createConfig(options: DocsTemplateOptions) {
|
|
|
514
513
|
objectShorthand: true,
|
|
515
514
|
symbols: true,
|
|
516
515
|
},
|
|
517
|
-
entryFileNames: "assets/[name]-[hash].js",
|
|
518
|
-
chunkFileNames: "assets/[name]-[hash].js",
|
|
519
|
-
assetFileNames: "assets/[name]-[hash][extname]",
|
|
520
516
|
},
|
|
521
517
|
treeshake: "smallest",
|
|
522
518
|
},
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Knitli Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unit tests for `remarkVersion`.
|
|
7
|
+
*
|
|
8
|
+
* The plugin's only side effect is mutating an mdast tree, so the tests
|
|
9
|
+
* construct synthetic trees by hand instead of pulling in `remark-parse`
|
|
10
|
+
* / `remark-mdx` (no extra deps, no parser-version drift). The exact
|
|
11
|
+
* version string returned by `getVersion()` depends on the test
|
|
12
|
+
* environment's `git describe` output, so we assert structurally —
|
|
13
|
+
* confirming that the token was replaced with *something* version-like,
|
|
14
|
+
* not pinning a specific value.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Root, Text } from "mdast";
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { remarkVersion } from "./remarkPlugin.js";
|
|
20
|
+
|
|
21
|
+
// A version string is something like `0.4.5`, `0.4.5-dev`, `0.4.5-beta.1`,
|
|
22
|
+
// a bare commit hash from `git describe --always`, or the `0.0.0-unknown`
|
|
23
|
+
// fallback. We just want it to be non-empty and free of the placeholder.
|
|
24
|
+
const VERSION_LIKE = /^[\w.+-]+$/;
|
|
25
|
+
|
|
26
|
+
function applyPlugin(tree: Root): Root {
|
|
27
|
+
remarkVersion()(tree);
|
|
28
|
+
return tree;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Synthetic node helpers. Cast through unknown so we can build mdast +
|
|
32
|
+
// MDX shapes without importing `mdast-util-mdx-expression` types.
|
|
33
|
+
function textNode(value: string): Text {
|
|
34
|
+
return { type: "text", value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mdxTextExpression(value: string): Text {
|
|
38
|
+
return {
|
|
39
|
+
type: "mdxTextExpression",
|
|
40
|
+
value,
|
|
41
|
+
data: { estree: { type: "Program", body: [] } },
|
|
42
|
+
} as unknown as Text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mdxFlowExpression(value: string): Text {
|
|
46
|
+
return {
|
|
47
|
+
type: "mdxFlowExpression",
|
|
48
|
+
value,
|
|
49
|
+
data: { estree: { type: "Program", body: [] } },
|
|
50
|
+
} as unknown as Text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("remarkVersion", () => {
|
|
54
|
+
describe("plain text nodes (.md path)", () => {
|
|
55
|
+
it("replaces a single {{VERSION}} occurrence", () => {
|
|
56
|
+
const tree: Root = {
|
|
57
|
+
type: "root",
|
|
58
|
+
children: [
|
|
59
|
+
{
|
|
60
|
+
type: "paragraph",
|
|
61
|
+
children: [textNode("Version is {{VERSION}} today")],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
applyPlugin(tree);
|
|
67
|
+
|
|
68
|
+
const text = (tree.children[0] as { children: Text[] }).children[0];
|
|
69
|
+
expect(text.type).toBe("text");
|
|
70
|
+
expect(text.value).not.toContain("{{VERSION}}");
|
|
71
|
+
expect(text.value.startsWith("Version is ")).toBe(true);
|
|
72
|
+
expect(text.value.endsWith(" today")).toBe(true);
|
|
73
|
+
const replaced = text.value.slice("Version is ".length, -" today".length);
|
|
74
|
+
expect(replaced).toMatch(VERSION_LIKE);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("replaces multiple occurrences in the same text node", () => {
|
|
78
|
+
const tree: Root = {
|
|
79
|
+
type: "root",
|
|
80
|
+
children: [
|
|
81
|
+
{
|
|
82
|
+
type: "paragraph",
|
|
83
|
+
children: [textNode("{{VERSION}} and {{VERSION}}")],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
applyPlugin(tree);
|
|
89
|
+
|
|
90
|
+
const text = (tree.children[0] as { children: Text[] }).children[0];
|
|
91
|
+
expect(text.value).not.toContain("{{VERSION}}");
|
|
92
|
+
const parts = text.value.split(" and ");
|
|
93
|
+
expect(parts).toHaveLength(2);
|
|
94
|
+
// Both replacements use the same cached version, so they must
|
|
95
|
+
// produce identical strings.
|
|
96
|
+
expect(parts[0]).toBe(parts[1]);
|
|
97
|
+
expect(parts[0]).toMatch(VERSION_LIKE);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("leaves text nodes without the token untouched", () => {
|
|
101
|
+
const original = "no token here";
|
|
102
|
+
const tree: Root = {
|
|
103
|
+
type: "root",
|
|
104
|
+
children: [{ type: "paragraph", children: [textNode(original)] }],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
applyPlugin(tree);
|
|
108
|
+
|
|
109
|
+
const text = (tree.children[0] as { children: Text[] }).children[0];
|
|
110
|
+
expect(text.value).toBe(original);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("MDX expression nodes (.mdx path)", () => {
|
|
115
|
+
it("mutates an mdxTextExpression with value '{VERSION}' into a text node", () => {
|
|
116
|
+
const tree: Root = {
|
|
117
|
+
type: "root",
|
|
118
|
+
children: [
|
|
119
|
+
{
|
|
120
|
+
type: "paragraph",
|
|
121
|
+
children: [textNode("v"), mdxTextExpression("{VERSION}")],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
applyPlugin(tree);
|
|
127
|
+
|
|
128
|
+
const para = tree.children[0] as {
|
|
129
|
+
children: Array<Text & { data?: unknown }>;
|
|
130
|
+
};
|
|
131
|
+
const node = para.children[1];
|
|
132
|
+
expect(node.type).toBe("text");
|
|
133
|
+
expect(node.value).toMatch(VERSION_LIKE);
|
|
134
|
+
// estree metadata must be cleared so the MDX serializer doesn't
|
|
135
|
+
// try to re-evaluate the (now-text) node as JavaScript.
|
|
136
|
+
expect(node.data).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("mutates an mdxFlowExpression with value '{VERSION}' into a text node", () => {
|
|
140
|
+
const tree: Root = {
|
|
141
|
+
type: "root",
|
|
142
|
+
children: [mdxFlowExpression("{VERSION}")],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
applyPlugin(tree);
|
|
146
|
+
|
|
147
|
+
const node = tree.children[0] as Text & { data?: unknown };
|
|
148
|
+
expect(node.type).toBe("text");
|
|
149
|
+
expect(node.value).toMatch(VERSION_LIKE);
|
|
150
|
+
expect(node.data).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("tolerates whitespace inside the expression ('{ VERSION }')", () => {
|
|
154
|
+
const tree: Root = {
|
|
155
|
+
type: "root",
|
|
156
|
+
children: [mdxTextExpression("{ VERSION }")],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
applyPlugin(tree);
|
|
160
|
+
|
|
161
|
+
const node = tree.children[0] as Text;
|
|
162
|
+
expect(node.type).toBe("text");
|
|
163
|
+
expect(node.value).toMatch(VERSION_LIKE);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("leaves unrelated MDX expressions alone", () => {
|
|
167
|
+
const tree: Root = {
|
|
168
|
+
type: "root",
|
|
169
|
+
children: [
|
|
170
|
+
mdxTextExpression("{otherVar}"),
|
|
171
|
+
mdxTextExpression("{1 + 2}"),
|
|
172
|
+
mdxFlowExpression("{someComponent}"),
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
applyPlugin(tree);
|
|
177
|
+
|
|
178
|
+
expect(tree.children[0].type).toBe("mdxTextExpression");
|
|
179
|
+
expect((tree.children[0] as { value: string }).value).toBe("{otherVar}");
|
|
180
|
+
expect(tree.children[1].type).toBe("mdxTextExpression");
|
|
181
|
+
expect((tree.children[1] as { value: string }).value).toBe("{1 + 2}");
|
|
182
|
+
expect(tree.children[2].type).toBe("mdxFlowExpression");
|
|
183
|
+
expect((tree.children[2] as { value: string }).value).toBe(
|
|
184
|
+
"{someComponent}",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("escaped form backwards-compat", () => {
|
|
190
|
+
it("still works when users escape the braces in .mdx", () => {
|
|
191
|
+
// After MDX parsing, `\{\{VERSION\}\}` becomes a plain text node
|
|
192
|
+
// with literal content `{{VERSION}}`. Pass 1 (the text-node visitor)
|
|
193
|
+
// should still substitute it, so any pre-fix workarounds keep
|
|
194
|
+
// rendering correctly after consumers upgrade.
|
|
195
|
+
const tree: Root = {
|
|
196
|
+
type: "root",
|
|
197
|
+
children: [{ type: "paragraph", children: [textNode("{{VERSION}}")] }],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
applyPlugin(tree);
|
|
201
|
+
|
|
202
|
+
const text = (tree.children[0] as { children: Text[] }).children[0];
|
|
203
|
+
expect(text.value).not.toContain("{{VERSION}}");
|
|
204
|
+
expect(text.value).toMatch(VERSION_LIKE);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/remarkPlugin.ts
CHANGED
|
@@ -3,16 +3,42 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Remark plugin that replaces {{VERSION}} tokens in markdown
|
|
7
|
-
* with the current version derived from `git describe`.
|
|
6
|
+
* Remark plugin that replaces `{{VERSION}}` tokens in markdown / MDX
|
|
7
|
+
* content with the current version derived from `git describe`.
|
|
8
8
|
*
|
|
9
9
|
* Runs at build time — no runtime cost.
|
|
10
|
+
*
|
|
11
|
+
* Works in two passes so it functions identically in `.md` and `.mdx`:
|
|
12
|
+
*
|
|
13
|
+
* 1. Plain markdown text nodes: `{{VERSION}}` is a literal substring
|
|
14
|
+
* in a `text` node and gets replaced directly. This covers all of
|
|
15
|
+
* `.md` and any text in `.mdx` that isn't wrapped in JSX expression
|
|
16
|
+
* delimiters.
|
|
17
|
+
*
|
|
18
|
+
* 2. MDX expression nodes: in `.mdx` files, MDX parses `{...}` as a
|
|
19
|
+
* JavaScript expression delimiter at parse time, so `{{VERSION}}`
|
|
20
|
+
* becomes an `mdxTextExpression` (inline) or `mdxFlowExpression`
|
|
21
|
+
* (block) node whose `value` is the inner `{VERSION}` — and the
|
|
22
|
+
* remark text visitor never sees the token. If left alone, MDX
|
|
23
|
+
* would try to evaluate `{ VERSION }` as JS at render time and
|
|
24
|
+
* throw `ReferenceError: VERSION is not defined`. We detect those
|
|
25
|
+
* nodes and mutate them into plain `text` nodes carrying the
|
|
26
|
+
* resolved version string, dropping the estree metadata so the
|
|
27
|
+
* downstream serializer doesn't re-attempt evaluation.
|
|
10
28
|
*/
|
|
11
29
|
|
|
12
30
|
import { execFileSync } from "node:child_process";
|
|
13
31
|
import type { Root, Text } from "mdast";
|
|
32
|
+
import type { Node } from "unist";
|
|
14
33
|
import { visit } from "unist-util-visit";
|
|
15
34
|
|
|
35
|
+
const VERSION_TOKEN = "{{VERSION}}";
|
|
36
|
+
|
|
37
|
+
// MDX strips the outer `{` `}` expression delimiters, so the value of an
|
|
38
|
+
// `mdxTextExpression` / `mdxFlowExpression` node holding `{{VERSION}}`
|
|
39
|
+
// is the inner `{VERSION}` (with optional surrounding whitespace).
|
|
40
|
+
const MDX_VERSION_EXPRESSION_RE = /^\s*\{\s*VERSION\s*\}\s*$/;
|
|
41
|
+
|
|
16
42
|
let cachedVersion: string | undefined;
|
|
17
43
|
|
|
18
44
|
function getVersion(): string {
|
|
@@ -50,9 +76,31 @@ export function remarkVersion(): (tree: Root) => void {
|
|
|
50
76
|
return (tree: Root) => {
|
|
51
77
|
const version = getVersion();
|
|
52
78
|
|
|
79
|
+
// Pass 1: plain markdown text nodes.
|
|
53
80
|
visit(tree, "text", (node: Text) => {
|
|
54
|
-
if (node.value.includes(
|
|
55
|
-
node.value = node.value.replaceAll(
|
|
81
|
+
if (node.value.includes(VERSION_TOKEN)) {
|
|
82
|
+
node.value = node.value.replaceAll(VERSION_TOKEN, version);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Pass 2: MDX expression nodes (only present in `.mdx` files).
|
|
87
|
+
// We use a structural cast because `mdxTextExpression` /
|
|
88
|
+
// `mdxFlowExpression` are defined in `mdast-util-mdx-expression`,
|
|
89
|
+
// not in `mdast`, and adding that as a direct dep just for type
|
|
90
|
+
// imports would bloat the dependency surface.
|
|
91
|
+
visit(tree, ["mdxTextExpression", "mdxFlowExpression"], (node: Node) => {
|
|
92
|
+
const expr = node as Node & {
|
|
93
|
+
value?: unknown;
|
|
94
|
+
type: string;
|
|
95
|
+
data?: unknown;
|
|
96
|
+
};
|
|
97
|
+
if (
|
|
98
|
+
typeof expr.value === "string" &&
|
|
99
|
+
MDX_VERSION_EXPRESSION_RE.test(expr.value)
|
|
100
|
+
) {
|
|
101
|
+
expr.type = "text";
|
|
102
|
+
(expr as { value: string }).value = version;
|
|
103
|
+
if ("data" in expr) delete expr.data;
|
|
56
104
|
}
|
|
57
105
|
});
|
|
58
106
|
};
|