@knitli/astro-docs-template 0.2.7 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knitli/astro-docs-template",
3
- "version": "0.2.7",
3
+ "version": "0.4.1",
4
4
  "description": "Opinionated Astro + Starlight docs site template with Knitli branding",
5
5
  "keywords": [
6
6
  "knitli",
@@ -38,22 +38,33 @@
38
38
  "clean": "rm -rf dist",
39
39
  "deploy": "bun publish --tag latest",
40
40
  "prepack": "bun run build",
41
- "test": "vitest run",
41
+ "test": "vitest run src/index.test.ts",
42
+ "test:integration": "vitest run src/integration.test.ts",
43
+ "test:unit": "vitest run src/index.test.ts",
42
44
  "test:watch": "vitest"
43
45
  },
44
46
  "dependencies": {
45
- "@astrojs/cloudflare": "^13.1.1",
47
+ "@astrojs/check": "^0.9.6",
48
+ "@astrojs/cloudflare": "^13.1.7",
49
+ "@astrojs/compiler-rs": "^0.1.6",
46
50
  "@astrojs/markdoc": "^1.0.0",
47
- "@astrojs/mdx": "^5.0.0",
48
- "@astrojs/sitemap": "^3.6.0",
49
- "@astrojs/starlight": "^0.38.1",
51
+ "@astrojs/mdx": "^5.0.3",
52
+ "@astrojs/sitemap": "^3.7.2",
53
+ "@astrojs/starlight": "^0.38.2",
54
+ "@biomejs/biome": "^2.4.2",
50
55
  "@knitli/docs-components": "*",
51
- "@nuasite/llm-enhancements": "^0.18.0",
52
- "astro": "^6.0.4",
56
+ "@knitli/tsconfig": "*",
57
+ "@nuasite/llm-enhancements": "^0.19.1",
58
+ "@types/bun": "^1.3.4",
59
+ "@types/node": "^24.0.2",
60
+ "astro": "^6.1.4",
53
61
  "astro-cloudflare-pages-headers": "^1.7.7",
54
62
  "astro-d2": "^0.10.0",
55
63
  "astro-favicons": "3.1.6",
64
+ "bun": "^1.3.9",
65
+ "lightningcss": "^1.30.2",
56
66
  "rehype-external-links": "^3.0.0",
67
+ "sharp": "^0.34.5",
57
68
  "starlight-announcement": ">=0.1.1",
58
69
  "starlight-changelogs": " >=0.1.0",
59
70
  "starlight-heading-badges": ">=0.6.1",
@@ -64,18 +75,9 @@
64
75
  "starlight-scroll-to-top": ">=0.4.0",
65
76
  "starlight-sidebar-topics": ">=0.7.1",
66
77
  "starlight-tags": ">=0.4.0",
67
- "vite-tsconfig-paths": "6.1.1",
68
- "vite": "^7.0.0",
69
- "@astrojs/compiler-rs": "^0.1.4",
70
- "@biomejs/biome": "^2.4.2",
71
- "@knitli/tsconfig": "*",
72
- "@types/bun": "^1.3.4",
73
- "@types/node": "^24.0.2",
74
- "bun": "^1.3.9",
75
- "lightningcss": "^1.30.2",
76
- "sharp": "^0.34.5",
77
78
  "svgo": "^4.0.0",
78
- "@astrojs/check": "^0.9.6"
79
+ "vite": "^7.3.1",
80
+ "vite-tsconfig-paths": "6.1.1"
79
81
  },
80
82
  "devDependencies": {
81
83
  "typescript": "^5.9.3",
@@ -93,5 +95,8 @@
93
95
  "provenance": false,
94
96
  "registry": "https://registry.npmjs.org/",
95
97
  "tag": "latest"
96
- }
98
+ },
99
+ "trustedDependencies": [
100
+ "bun"
101
+ ]
97
102
  }
@@ -26,7 +26,7 @@ bun run deploy
26
26
  │ ├── styles/ # Site-specific CSS overrides
27
27
  │ └── content.config.ts
28
28
  ├── astro.config.ts # Uses @knitli/astro-docs-template createConfig
29
- ├── wrangler.jsonc # Cloudflare Workers configuration
29
+ ├── wrangler.json # Cloudflare Workers configuration
30
30
  └── package.json
31
31
  ```
32
32
 
package/src/config.ts CHANGED
@@ -10,8 +10,10 @@ import markdoc from "@astrojs/markdoc";
10
10
  import mdx from "@astrojs/mdx";
11
11
  import sitemap from "@astrojs/sitemap";
12
12
  import starlight from "@astrojs/starlight";
13
+ import type { StarlightPlugin } from "@astrojs/starlight/types";
13
14
  import { DocsAssets } from "@knitli/docs-components";
14
15
  import llmEnhancements from "@nuasite/llm-enhancements";
16
+ import type { AstroIntegration } from "astro";
15
17
  import { defineConfig, fontProviders } from "astro/config";
16
18
  import cloudflarePagesHeaders from "astro-cloudflare-pages-headers";
17
19
  import astroD2 from "astro-d2";
@@ -39,6 +41,7 @@ type defaultIntegration =
39
41
  | "markdoc"
40
42
  | "mdx"
41
43
  | "favicons"
44
+ | "starlightIconsIntegration"
42
45
  | "cloudflare-pages-headers";
43
46
 
44
47
  function nonNullable<T>(value: T): value is NonNullable<T> {
@@ -194,6 +197,40 @@ const getIntegrations = (options: IntegrationOptions) => {
194
197
  integrationConfigs,
195
198
  unwantedIntegrations = [],
196
199
  } = options;
200
+ // Registry maps config keys (used in unwantedIntegrations and integrationConfigs)
201
+ // to actual runtime .name values and factory functions for overrides.
202
+ const integrationRegistry: Record<
203
+ string,
204
+ {
205
+ runtimeNames: string[];
206
+ // biome-ignore lint/suspicious/noExplicitAny: integration configs are opaque
207
+ create: (cfg: any) => AstroIntegration;
208
+ }
209
+ > = {
210
+ astroD2: { runtimeNames: ["astro-d2-integration"], create: astroD2 },
211
+ markdoc: { runtimeNames: ["@astrojs/markdoc"], create: markdoc },
212
+ mdx: { runtimeNames: ["@astrojs/mdx"], create: mdx },
213
+ favicons: { runtimeNames: ["astro-favicons"], create: favicons },
214
+ sitemap: { runtimeNames: ["@astrojs/sitemap"], create: sitemap },
215
+ starlightIconsIntegration: {
216
+ runtimeNames: ["starlight-plugin-icons"],
217
+ create: starlightIconsIntegration,
218
+ },
219
+ "cloudflare-pages-headers": {
220
+ runtimeNames: ["astroCloudflarePagesHeaders"],
221
+ create: cloudflarePagesHeaders,
222
+ },
223
+ };
224
+
225
+ // Resolve unwantedIntegrations config keys to runtime .name values
226
+ const unwantedRuntimeNames = new Set<string>();
227
+ for (const key of unwantedIntegrations) {
228
+ const entry = integrationRegistry[key];
229
+ if (entry) {
230
+ for (const n of entry.runtimeNames) unwantedRuntimeNames.add(n);
231
+ }
232
+ }
233
+
197
234
  const defaultIntegrations = [
198
235
  astroD2({ skipGeneration: true }),
199
236
  markdoc(),
@@ -220,33 +257,33 @@ const getIntegrations = (options: IntegrationOptions) => {
220
257
  }),
221
258
  ];
222
259
 
223
- const filtered = defaultIntegrations.filter((integration) => {
224
- const name = integration?.name;
225
- return !name || !unwantedIntegrations.includes(name as defaultIntegration);
226
- });
260
+ const filtered = defaultIntegrations.filter(
261
+ (integration) =>
262
+ !integration?.name || !unwantedRuntimeNames.has(integration.name),
263
+ );
227
264
 
228
265
  if (!integrationConfigs) return filtered;
229
266
 
267
+ // Collect runtime names that will be replaced by overrides
268
+ const replacedIntegrationNames = new Set<string>();
269
+ for (const key of Object.keys(integrationConfigs)) {
270
+ const entry = integrationRegistry[key];
271
+ if (entry) {
272
+ for (const n of entry.runtimeNames) replacedIntegrationNames.add(n);
273
+ }
274
+ }
275
+
276
+ // Remove defaults that will be replaced by overrides
277
+ const base = filtered.filter(
278
+ (integration) =>
279
+ !integration?.name || !replacedIntegrationNames.has(integration.name),
280
+ );
281
+
230
282
  const overrides = Object.entries(integrationConfigs)
231
- .map(([integrationName, config]) => {
232
- switch (integrationName) {
233
- case "astroD2":
234
- return astroD2(config);
235
- case "markdoc":
236
- return markdoc(config);
237
- case "mdx":
238
- return mdx(config);
239
- case "favicons":
240
- return favicons(config);
241
- case "sitemap":
242
- return sitemap(config);
243
- default:
244
- return null;
245
- }
246
- })
283
+ .map(([key, config]) => integrationRegistry[key]?.create(config) ?? null)
247
284
  .filter(nonNullable);
248
285
 
249
- return [...filtered, ...overrides];
286
+ return [...base, ...overrides];
250
287
  };
251
288
 
252
289
  export type PluginOptions = Pick<
@@ -293,30 +330,59 @@ const get_plugins = (options: PluginOptions) => {
293
330
 
294
331
  if (!pluginConfigs) return filtered;
295
332
 
333
+ // Map config keys to factory functions and the actual plugin .name values.
334
+ // Note: starlightIconsIntegration is an Astro *integration* (in getIntegrations),
335
+ // not a Starlight plugin — it was incorrectly in the old plugin override switch.
336
+ const pluginRegistry: Record<
337
+ string,
338
+ {
339
+ pluginNames: string[];
340
+ // biome-ignore lint/suspicious/noExplicitAny: plugin configs are opaque
341
+ create: (cfg: any) => StarlightPlugin;
342
+ }
343
+ > = {
344
+ starlightAnnouncement: {
345
+ pluginNames: ["starlight-announcement"],
346
+ create: starlightAnnouncement,
347
+ },
348
+ starlightIconsPlugin: {
349
+ pluginNames: ["starlight-plugin-icons"],
350
+ create: starlightIconsPlugin,
351
+ },
352
+ starlightLinksValidator: {
353
+ pluginNames: ["starlight-links-validator-plugin"],
354
+ create: starlightLinksValidator,
355
+ },
356
+ starlightPageActions: {
357
+ pluginNames: ["starlight-page-actions"],
358
+ create: starlightPageActions,
359
+ },
360
+ starlightTags: { pluginNames: ["starlight-tags"], create: starlightTags },
361
+ starlightScrollToTop: {
362
+ pluginNames: ["starlight-scroll-to-top-plugin"],
363
+ create: starlightScrollToTop,
364
+ },
365
+ };
366
+
367
+ // Collect the actual plugin .name values that will be replaced
368
+ const replacedPluginNames = new Set<string>();
369
+ for (const key of Object.keys(pluginConfigs)) {
370
+ const entry = pluginRegistry[key];
371
+ if (entry) {
372
+ for (const n of entry.pluginNames) replacedPluginNames.add(n);
373
+ }
374
+ }
375
+
376
+ // Remove defaults that will be replaced by overrides
377
+ const base = filtered.filter(
378
+ (plugin) => !replacedPluginNames.has(plugin.name),
379
+ );
380
+
296
381
  const overrides = Object.entries(pluginConfigs)
297
- .map(([pluginName, config]) => {
298
- switch (pluginName) {
299
- case "starlightAnnouncement":
300
- return starlightAnnouncement(config);
301
- case "starlightIconsIntegration":
302
- return starlightIconsIntegration(config);
303
- case "starlightIconsPlugin":
304
- return starlightIconsPlugin(config);
305
- case "starlightLinksValidator":
306
- return starlightLinksValidator(config);
307
- case "starlightPageActions":
308
- return starlightPageActions(config);
309
- case "starlightTags":
310
- return starlightTags(config);
311
- case "starlightScrollToTop":
312
- return starlightScrollToTop(config);
313
- default:
314
- return null;
315
- }
316
- })
382
+ .map(([key, config]) => pluginRegistry[key]?.create(config) ?? null)
317
383
  .filter(nonNullable);
318
384
 
319
- return [...filtered, ...overrides];
385
+ return [...base, ...overrides];
320
386
  };
321
387
 
322
388
  const defaultHeadersConfig: OutgoingHttpHeaders = {
@@ -354,11 +420,7 @@ export default function createConfig(options: DocsTemplateOptions) {
354
420
  site: "https://docs.knitli.com",
355
421
  base: `/${appName.toLowerCase()}/`,
356
422
  adapter: cloudflare({
357
- prerenderEnvironment: "workerd",
358
- experimental: {
359
- headersAndRedirectsDevModeSupport: true,
360
- },
361
- configPath: `${rootDir}/wrangler.jsonc`,
423
+ configPath: `${rootDir}/wrangler.json`,
362
424
  imageService: "compile",
363
425
  }),
364
426
  // Image optimization
@@ -383,10 +445,10 @@ export default function createConfig(options: DocsTemplateOptions) {
383
445
  Object.entries(shikiConfig).filter(([key]) => key !== "bundledLangs"),
384
446
  ),
385
447
  rehypePlugins: [
386
- rehypeExternalLinks({
387
- content: { type: "text", value: " ↗" },
388
- rel: ["nofollow"],
389
- }),
448
+ [
449
+ rehypeExternalLinks,
450
+ { content: { type: "text", value: " ↗" }, rel: ["nofollow"] },
451
+ ],
390
452
  ],
391
453
  },
392
454
  server: {
@@ -413,7 +475,7 @@ export default function createConfig(options: DocsTemplateOptions) {
413
475
  "import.meta.env.PUBLIC_DOCS_PRODUCT": JSON.stringify(appName),
414
476
  },
415
477
  build: {
416
- cssMinify: "lightningcss",
478
+ cssMinify: "esbuild",
417
479
  minify: "esbuild",
418
480
  cssCodeSplit: true,
419
481
  rollupOptions: {
@@ -445,9 +507,7 @@ export default function createConfig(options: DocsTemplateOptions) {
445
507
  },
446
508
  ssr: false,
447
509
  },
448
- css: {
449
- lightningcss: {},
450
- },
510
+ css: {},
451
511
  },
452
512
  prefetch: {
453
513
  defaultStrategy: "viewport",
@@ -479,12 +539,8 @@ export default function createConfig(options: DocsTemplateOptions) {
479
539
  // Static site generation for Cloudflare
480
540
  output: "static",
481
541
  integrations: [
482
- ...getIntegrations({
483
- appName,
484
- sitemapFilter,
485
- integrationConfigs,
486
- unwantedIntegrations,
487
- }),
542
+ // Starlight must come before mdx() because it injects astro-expressive-code
543
+ // which requires being ordered before mdx in the integration array.
488
544
  starlight({
489
545
  title: `${appName} Docs`,
490
546
  pagefind: true,
@@ -558,6 +614,12 @@ export default function createConfig(options: DocsTemplateOptions) {
558
614
  },
559
615
  ],
560
616
  }),
617
+ ...getIntegrations({
618
+ appName,
619
+ sitemapFilter,
620
+ integrationConfigs,
621
+ unwantedIntegrations,
622
+ }),
561
623
  ],
562
624
  });
563
625
  }
package/src/index.test.ts CHANGED
@@ -175,7 +175,7 @@ describe("initDocsTemplate", () => {
175
175
 
176
176
  it("substitutes {{appName}} in text files", () => {
177
177
  initDocsTemplate(tmpDir, BASE_OPTIONS);
178
- const wrangler = readFileSync(join(tmpDir, "wrangler.jsonc"), "utf-8");
178
+ const wrangler = readFileSync(join(tmpDir, "wrangler.json"), "utf-8");
179
179
  expect(wrangler).toContain(BASE_OPTIONS.workerName);
180
180
  expect(wrangler).not.toContain("{{workerName}}");
181
181
  });
@@ -195,7 +195,7 @@ describe("initDocsTemplate", () => {
195
195
  it("creates all expected piece files", () => {
196
196
  initDocsTemplate(tmpDir, BASE_OPTIONS);
197
197
  expect(existsSync(join(tmpDir, "astro.config.ts"))).toBe(true);
198
- expect(existsSync(join(tmpDir, "wrangler.jsonc"))).toBe(true);
198
+ expect(existsSync(join(tmpDir, "wrangler.json"))).toBe(true);
199
199
  expect(existsSync(join(tmpDir, "tsconfig.json"))).toBe(true);
200
200
  expect(existsSync(join(tmpDir, "package.json"))).toBe(true);
201
201
  expect(existsSync(join(tmpDir, "mise.toml"))).toBe(true);
@@ -258,7 +258,7 @@ describe("addPieces", () => {
258
258
 
259
259
  it("applies placeholder substitutions to added files", () => {
260
260
  addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["wrangler"] });
261
- const content = readFileSync(join(tmpDir, "wrangler.jsonc"), "utf-8");
261
+ const content = readFileSync(join(tmpDir, "wrangler.json"), "utf-8");
262
262
  expect(content).not.toContain("{{workerName}}");
263
263
  expect(content).toContain(BASE_OPTIONS.workerName);
264
264
  });
package/src/index.ts CHANGED
@@ -49,7 +49,7 @@ export const PIECES = {
49
49
  },
50
50
  wrangler: {
51
51
  description: "Cloudflare Worker deployment config",
52
- paths: ["wrangler.jsonc"],
52
+ paths: ["wrangler.json"],
53
53
  },
54
54
  tsconfig: {
55
55
  description: "TypeScript config extending shared base",
@@ -0,0 +1,95 @@
1
+ // SPDX-FileCopyrightText: 2026 Knitli Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ import { execFileSync, type SpawnSyncReturns } from "node:child_process";
6
+ import { existsSync, readdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ const FIXTURE_DIR = join(import.meta.dirname, "../fixture");
11
+ const DIST_DIR = join(FIXTURE_DIR, "dist");
12
+
13
+ const CONFIG_VARIANTS = [
14
+ "astro.config.default.ts",
15
+ "astro.config.codeweaver.ts",
16
+ "astro.config.stripped.ts",
17
+ "astro.config.overrides.ts",
18
+ "astro.config.custom.ts",
19
+ ];
20
+
21
+ /**
22
+ * Known non-fatal error from the Cloudflare adapter's post-build step.
23
+ * The adapter tries to read a wrangler.json from the prerender output
24
+ * directory, which doesn't exist in local builds. The Vite build phases
25
+ * all complete successfully — this error occurs after bundling is done.
26
+ */
27
+ const KNOWN_WRANGLER_PRERENDER_ERROR = "Could not read file:" as const;
28
+ const KNOWN_WRANGLER_PRERENDER_PATH =
29
+ "dist/server/.prerender/wrangler.json" as const;
30
+
31
+ /**
32
+ * Run `astro build` for a given config file. Returns stdout+stderr.
33
+ *
34
+ * Tolerates the known wrangler prerender error (see above) but re-throws
35
+ * any other build failure.
36
+ */
37
+ function astroBuild(configFile: string): { stdout: string; stderr: string } {
38
+ try {
39
+ const stdout = execFileSync(
40
+ "bunx",
41
+ ["astro", "build", "--config", configFile],
42
+ {
43
+ cwd: FIXTURE_DIR,
44
+ timeout: 120_000,
45
+ stdio: "pipe",
46
+ encoding: "utf-8",
47
+ },
48
+ );
49
+ return { stdout, stderr: "" };
50
+ } catch (err) {
51
+ const e = err as SpawnSyncReturns<string>;
52
+ const stderr = e.stderr ?? "";
53
+ const stdout = e.stdout ?? "";
54
+ const isKnownWranglerError =
55
+ stderr.includes(KNOWN_WRANGLER_PRERENDER_ERROR) &&
56
+ stderr.includes(KNOWN_WRANGLER_PRERENDER_PATH);
57
+ // Also check that Vite itself didn't report a build failure alongside
58
+ // the wrangler error — "Build failed" in stderr means a real problem.
59
+ const hasViteBuildFailure = stderr.includes("Build failed");
60
+ if (!isKnownWranglerError || hasViteBuildFailure) {
61
+ throw err;
62
+ }
63
+ console.warn(
64
+ `[${configFile}] tolerated known wrangler prerender error (non-fatal)`,
65
+ );
66
+ return { stdout, stderr };
67
+ }
68
+ }
69
+
70
+ describe("createConfig integration", { timeout: 180_000 }, () => {
71
+ afterEach(() => {
72
+ rmSync(DIST_DIR, { recursive: true, force: true });
73
+ // Clean up .astro cache between variants
74
+ rmSync(join(FIXTURE_DIR, ".astro"), { recursive: true, force: true });
75
+ });
76
+
77
+ for (const config of CONFIG_VARIANTS) {
78
+ const variant = config.replace("astro.config.", "").replace(".ts", "");
79
+
80
+ it(`builds successfully with ${variant} config`, () => {
81
+ astroBuild(config);
82
+
83
+ expect(existsSync(DIST_DIR)).toBe(true);
84
+ expect(existsSync(join(DIST_DIR, "_astro"))).toBe(true);
85
+
86
+ // The Cloudflare adapter places all output — including server entries
87
+ // and public/ files — inside dist/_astro/ (not dist/ root). This is
88
+ // specific to the @astrojs/cloudflare adapter's asset handling.
89
+ const astroDir = join(DIST_DIR, "_astro");
90
+ const files = readdirSync(astroDir);
91
+ expect(files).toContain("entry.mjs");
92
+ expect(files).toContain("robots.txt");
93
+ });
94
+ }
95
+ });
File without changes