livemark 0.28.2 → 0.28.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/build/actions/config/load.js +16 -15
  2. package/build/actions/config/load.unit.js +13 -1
  3. package/build/commands/start.js +31 -8
  4. package/build/content-collections.js +2 -2
  5. package/build/helpers/local-imports.d.ts +2 -0
  6. package/build/helpers/local-imports.js +20 -0
  7. package/build/helpers/local-imports.unit.d.ts +1 -0
  8. package/build/helpers/local-imports.unit.js +25 -0
  9. package/build/helpers/task-queue.d.ts +7 -0
  10. package/build/helpers/task-queue.js +28 -0
  11. package/build/helpers/task-queue.unit.d.ts +1 -0
  12. package/build/helpers/task-queue.unit.js +56 -0
  13. package/build/helpers/toc.d.ts +1 -1
  14. package/build/helpers/toc.js +29 -31
  15. package/build/helpers/toc.unit.js +23 -1
  16. package/build/models/config.d.ts +2 -0
  17. package/build/models/config.js +2 -1
  18. package/build/plugins/vite-content.d.ts +9 -0
  19. package/build/plugins/vite-content.js +60 -0
  20. package/build/plugins/vite-livemark.js +72 -6
  21. package/build/vite.config.js +3 -3
  22. package/package.json +2 -2
  23. package/skills/livemark-configuration/SKILL.md +1 -1
  24. package/source/actions/config/load.ts +15 -6
  25. package/source/actions/config/load.unit.ts +17 -0
  26. package/source/commands/start.ts +24 -0
  27. package/source/content-collections.ts +1 -1
  28. package/source/helpers/local-imports.ts +19 -0
  29. package/source/helpers/local-imports.unit.ts +36 -0
  30. package/source/helpers/task-queue.ts +28 -0
  31. package/source/helpers/task-queue.unit.ts +60 -0
  32. package/source/helpers/toc.ts +29 -29
  33. package/source/helpers/toc.unit.ts +27 -0
  34. package/source/models/config.ts +1 -0
  35. package/source/plugins/vite-content.ts +72 -0
  36. package/source/plugins/vite-livemark.ts +72 -6
  37. package/source/styles/markdown.css +5 -0
  38. package/source/vite.config.ts +2 -2
@@ -1,8 +1,10 @@
1
+ import { watch } from "node:fs"
1
2
  import { join } from "node:path"
2
3
  import { Command } from "commander"
3
4
  import { createServer } from "vite"
4
5
 
5
6
  const configFile = join(import.meta.dirname, "..", "..", "source", "vite.config.ts")
7
+ const IGNORED_DIRS = /(^|[/\\])(node_modules|\.git|\.livemark)([/\\]|$)/
6
8
 
7
9
  /**
8
10
  * Start a development server.
@@ -10,6 +12,13 @@ const configFile = join(import.meta.dirname, "..", "..", "source", "vite.config.
10
12
  export const start = new Command("start")
11
13
  .description("Start a live server")
12
14
  .action(async () => {
15
+ await startServer()
16
+ })
17
+
18
+ // A broken config fails `createServer` before Vite's watcher exists, so without this
19
+ // the process exits and the fix has nobody to pick it up.
20
+ async function startServer() {
21
+ try {
13
22
  const server = await createServer({
14
23
  configFile,
15
24
  server: { port: 8000, host: true },
@@ -17,4 +26,19 @@ export const start = new Command("start")
17
26
  await server.listen()
18
27
  server.printUrls()
19
28
  server.bindCLIShortcuts({ print: true })
29
+ } catch (error) {
30
+ console.error(error)
31
+ console.error("\nWaiting for a file change to retry…")
32
+ await waitForChange(process.cwd())
33
+ await startServer()
34
+ }
35
+ }
36
+
37
+ function waitForChange(dir: string) {
38
+ return new Promise<void>(resolve => {
39
+ const watcher = watch(dir, { recursive: true, ignore: IGNORED_DIRS }, () => {
40
+ watcher.close()
41
+ resolve()
42
+ })
20
43
  })
44
+ }
@@ -236,7 +236,7 @@ const articles = defineCollection({
236
236
  ],
237
237
  },
238
238
  )
239
- const tocItems = extractToc(content)
239
+ const tocItems = extractToc(bodyContent)
240
240
  const searchText = extractSearchText(content)
241
241
  const lastUpdated = lastUpdatedByFile.get(doc._meta.filePath)
242
242
  const image = doc.image
@@ -0,0 +1,19 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, resolve } from "node:path"
3
+
4
+ // Enough for this repo's own source, which always imports by full relative path.
5
+ const RELATIVE_IMPORT = /(?:from|import)\s*["'](\.{1,2}\/[^"']+)["']/g
6
+
7
+ /** The module and every file it reaches through relative imports, as absolute paths. */
8
+ export function collectLocalImports(entry: string) {
9
+ const seen = new Set<string>()
10
+ const queue = [resolve(entry)]
11
+ for (const file of queue) {
12
+ if (seen.has(file) || !existsSync(file)) continue
13
+ seen.add(file)
14
+ for (const [, specifier] of readFileSync(file, "utf8").matchAll(RELATIVE_IMPORT)) {
15
+ if (specifier) queue.push(resolve(dirname(file), specifier))
16
+ }
17
+ }
18
+ return [...seen]
19
+ }
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs"
2
+ import { join } from "node:path"
3
+ import { temporaryDirectoryTask } from "tempy"
4
+ import { describe, expect, it } from "vite-plus/test"
5
+ import { collectLocalImports } from "./local-imports.ts"
6
+
7
+ describe("collectLocalImports", () => {
8
+ it("should follow relative imports transitively and skip packages", () =>
9
+ temporaryDirectoryTask(tmpDir => {
10
+ fs.mkdirSync(join(tmpDir, "lib"))
11
+ fs.writeFileSync(
12
+ join(tmpDir, "entry.ts"),
13
+ 'import { a } from "./lib/a.ts"\nimport { z } from "zod"\n',
14
+ )
15
+ fs.writeFileSync(
16
+ join(tmpDir, "lib", "a.ts"),
17
+ 'import "../b.ts"\nexport const a = 1\n',
18
+ )
19
+ fs.writeFileSync(join(tmpDir, "b.ts"), 'export * from "./entry.ts"\n')
20
+ expect(collectLocalImports(join(tmpDir, "entry.ts")).sort()).toEqual(
21
+ [
22
+ join(tmpDir, "b.ts"),
23
+ join(tmpDir, "entry.ts"),
24
+ join(tmpDir, "lib", "a.ts"),
25
+ ].sort(),
26
+ )
27
+ }))
28
+
29
+ it("should skip imports that do not resolve to a file", () =>
30
+ temporaryDirectoryTask(tmpDir => {
31
+ fs.writeFileSync(join(tmpDir, "entry.ts"), 'import "./missing.ts"\n')
32
+ expect(collectLocalImports(join(tmpDir, "entry.ts"))).toEqual([
33
+ join(tmpDir, "entry.ts"),
34
+ ])
35
+ }))
36
+ })
@@ -0,0 +1,28 @@
1
+ /** Run keyed async tasks one at a time; a key enqueued again before its task
2
+ * starts replaces it, so a burst of events for one file costs one run. */
3
+ export function createTaskQueue(props: { onError: (error: unknown) => void }) {
4
+ const pending = new Map<string, () => Promise<unknown>>()
5
+ let draining: Promise<void> | undefined
6
+
7
+ return {
8
+ enqueue(key: string, task: () => Promise<unknown>) {
9
+ pending.set(key, task)
10
+ draining ??= drain()
11
+ return draining
12
+ },
13
+ }
14
+
15
+ async function drain() {
16
+ // A Map iterator also visits entries set during iteration, which is what
17
+ // picks up keys enqueued while a task runs.
18
+ for (const [key, task] of pending) {
19
+ pending.delete(key)
20
+ try {
21
+ await task()
22
+ } catch (error) {
23
+ props.onError(error)
24
+ }
25
+ }
26
+ draining = undefined
27
+ }
28
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vite-plus/test"
2
+ import { createTaskQueue } from "./task-queue.ts"
3
+
4
+ describe("createTaskQueue", () => {
5
+ it("runs one task at a time", async () => {
6
+ const queue = createTaskQueue({ onError: () => {} })
7
+ const log: string[] = []
8
+ const task = (name: string, ms: number) => async () => {
9
+ log.push(`start ${name}`)
10
+ await new Promise(resolve => setTimeout(resolve, ms))
11
+ log.push(`end ${name}`)
12
+ }
13
+ queue.enqueue("a", task("a", 30))
14
+ await queue.enqueue("b", task("b", 1))
15
+ expect(log).toEqual(["start a", "end a", "start b", "end b"])
16
+ })
17
+
18
+ it("collapses a key enqueued again before it starts", async () => {
19
+ const queue = createTaskQueue({ onError: () => {} })
20
+ const runs: string[] = []
21
+ queue.enqueue("first", async () => runs.push("first"))
22
+ queue.enqueue("file", async () => runs.push("stale"))
23
+ await queue.enqueue("file", async () => runs.push("latest"))
24
+ expect(runs).toEqual(["first", "latest"])
25
+ })
26
+
27
+ it("reruns a key enqueued while it is running", async () => {
28
+ const queue = createTaskQueue({ onError: () => {} })
29
+ const runs: number[] = []
30
+ let release = () => {}
31
+ queue.enqueue("file", async () => {
32
+ runs.push(1)
33
+ await new Promise<void>(resolve => (release = resolve))
34
+ })
35
+ const done = queue.enqueue("file", async () => runs.push(2))
36
+ release()
37
+ await done
38
+ expect(runs).toEqual([1, 2])
39
+ })
40
+
41
+ it("reports a failed task and keeps draining", async () => {
42
+ const errors: unknown[] = []
43
+ const queue = createTaskQueue({ onError: error => errors.push(error) })
44
+ const runs: string[] = []
45
+ queue.enqueue("a", async () => {
46
+ throw new Error("boom")
47
+ })
48
+ await queue.enqueue("b", async () => runs.push("b"))
49
+ expect(errors).toHaveLength(1)
50
+ expect(runs).toEqual(["b"])
51
+ })
52
+
53
+ it("starts a fresh drain after the previous one finished", async () => {
54
+ const queue = createTaskQueue({ onError: () => {} })
55
+ const runs: string[] = []
56
+ await queue.enqueue("a", async () => runs.push("a"))
57
+ await queue.enqueue("a", async () => runs.push("again"))
58
+ expect(runs).toEqual(["a", "again"])
59
+ })
60
+ })
@@ -1,4 +1,10 @@
1
- import GithubSlugger from "github-slugger"
1
+ import GithubSlugger, { slug as slugOnce } from "github-slugger"
2
+ import type { Root } from "mdast"
3
+ import { toString } from "mdast-util-to-string"
4
+ import remarkGfm from "remark-gfm"
5
+ import remarkParse from "remark-parse"
6
+ import { unified } from "unified"
7
+ import { visit } from "unist-util-visit"
2
8
  import type { TocItem } from "../models/toc.ts"
3
9
 
4
10
  const CUSTOM_ID_PATTERN = /\s*\[#([^\]]+)\]\s*$/
@@ -6,41 +12,35 @@ const TOC_HIDDEN_PATTERN = /\s*\[!toc\]\s*$/
6
12
  const TOC_ONLY_PATTERN = /\s*\[toc\]\s*$/
7
13
  const STEP_PATTERN = /\s*\[step\]\s*$/
8
14
 
9
- /** Extract TOC headings from raw markdown content */
15
+ /** Extract TOC headings from markdown content, with ids matching the rendered headings */
10
16
  export function extractToc(content: string): TocItem[] {
17
+ const tree: Root = unified().use(remarkParse).use(remarkGfm).parse(content)
11
18
  const slugger = new GithubSlugger()
12
19
  const items: TocItem[] = []
13
- const headingRegex = /^(#{2,4})\s+(.+)$/
14
- const lines = content.split("\n")
15
- let inCodeBlock = false
16
20
 
17
- for (const line of lines) {
18
- if (line.startsWith("```") || line.startsWith("````")) {
19
- inCodeBlock = !inCodeBlock
20
- continue
21
+ // rehype-slug numbers duplicates across every rendered heading, so hidden, step and
22
+ // out-of-range headings advance the slugger; `[toc]` ones are never rendered, so do not
23
+ visit(tree, "heading", node => {
24
+ const lastChild = node.children.at(-1)
25
+ const marker = lastChild?.type === "text" ? lastChild.value : ""
26
+ const isTocOnly = TOC_ONLY_PATTERN.test(marker)
27
+ const isHidden = TOC_HIDDEN_PATTERN.test(marker) || STEP_PATTERN.test(marker)
28
+ const customId = CUSTOM_ID_PATTERN.exec(marker)?.[1]
29
+
30
+ if (lastChild?.type === "text") {
31
+ lastChild.value = marker
32
+ .replace(CUSTOM_ID_PATTERN, "")
33
+ .replace(TOC_ONLY_PATTERN, "")
34
+ .replace(TOC_HIDDEN_PATTERN, "")
35
+ .replace(STEP_PATTERN, "")
21
36
  }
22
- if (inCodeBlock) continue
23
37
 
24
- const match = headingRegex.exec(line)
25
- if (!match) continue
38
+ const title = toString(node).trim()
39
+ const slug = customId ?? (isTocOnly ? slugOnce(title) : slugger.slug(title))
26
40
 
27
- const depth = match[1]!.length
28
- const rawTitle = match[2]!
29
-
30
- if (TOC_HIDDEN_PATTERN.test(rawTitle)) continue
31
- if (STEP_PATTERN.test(rawTitle)) continue
32
-
33
- const idMatch = CUSTOM_ID_PATTERN.exec(rawTitle)
34
- const title = rawTitle
35
- .replace(CUSTOM_ID_PATTERN, "")
36
- .replace(TOC_ONLY_PATTERN, "")
37
- .replace(STEP_PATTERN, "")
38
- .replace(/\*\*(.+?)\*\*/g, "$1")
39
- .replace(/`(.+?)`/g, "$1")
40
- const slug = idMatch?.[1] ?? slugger.slug(title)
41
-
42
- items.push({ url: `#${slug}`, title, depth })
43
- }
41
+ if (isHidden || node.depth < 2 || node.depth > 4) return
42
+ items.push({ url: `#${slug}`, title, depth: node.depth })
43
+ })
44
44
 
45
45
  return items
46
46
  }
@@ -60,6 +60,33 @@ describe("extractToc", () => {
60
60
  ])
61
61
  })
62
62
 
63
+ it("should render link and emphasis headings as plain text", () => {
64
+ const content =
65
+ "## [0.6.0](https://github.com/o/r/compare/v0.5.0...v0.6.0) (2026-10-20)\n\n" +
66
+ "### *Bug* ~~Fixes~~"
67
+ const toc = extractToc(content)
68
+ expect(toc).toEqual([
69
+ { url: "#060-2026-10-20", title: "0.6.0 (2026-10-20)", depth: 2 },
70
+ { url: "#bug-fixes", title: "Bug Fixes", depth: 3 },
71
+ ])
72
+ })
73
+
74
+ it("should number duplicate slugs counting hidden and h1 headings", () => {
75
+ const content =
76
+ "# Features\n\n## Features [!toc]\n\n## Features\n\n### Features [step]\n\n## Features"
77
+ const toc = extractToc(content)
78
+ expect(toc).toEqual([
79
+ { url: "#features-2", title: "Features", depth: 2 },
80
+ { url: "#features-4", title: "Features", depth: 2 },
81
+ ])
82
+ })
83
+
84
+ it("should ignore headings inside code fences", () => {
85
+ const content = "## Real\n\n```md\n## Fake\n```"
86
+ const toc = extractToc(content)
87
+ expect(toc).toEqual([{ url: "#real", title: "Real", depth: 2 }])
88
+ })
89
+
63
90
  it("should include [toc] headings with annotation stripped from title", () => {
64
91
  const content = "## Regular\n\n## TOC Only [toc]\n\n## Another"
65
92
  const toc = extractToc(content)
@@ -106,6 +106,7 @@ export type SystemConfig = z.infer<typeof SystemConfig>
106
106
  export const SystemConfig = z.object({
107
107
  root: z.string(),
108
108
  configPath: z.string(),
109
+ configDependencies: z.array(z.string()).default([]),
109
110
  })
110
111
 
111
112
  /**
@@ -0,0 +1,72 @@
1
+ import { existsSync, watch, type FSWatcher } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { createBuilder } from "@content-collections/core"
4
+ import { configureLogging } from "@content-collections/integrations"
5
+ import type { Plugin } from "vite"
6
+ import { createTaskQueue } from "../helpers/task-queue.ts"
7
+
8
+ export interface ContentOptions {
9
+ configPath: string
10
+ contentDir: string
11
+ }
12
+
13
+ type Builder = Awaited<ReturnType<typeof createBuilder>>
14
+
15
+ const IGNORED_PATHS = /(^|[/\\])(node_modules|\.[^/\\]+)([/\\]|$)/
16
+
17
+ /** Build the content collections and rebuild them on edits, one build at a time:
18
+ * `@content-collections/vite` builds per file event, a save fires two, and their
19
+ * overlapping writes tore `allArticles.js` so every request failed. */
20
+ export function content(opts: ContentOptions): Plugin {
21
+ const queue = createTaskQueue({ onError: error => console.error(error) })
22
+ let builder: Builder | undefined
23
+ let initialBuild: Promise<void> | undefined
24
+ let watcher: FSWatcher | undefined
25
+ return {
26
+ name: "livemark:content",
27
+ config(config) {
28
+ const configPath = resolve(config.root ?? process.cwd(), opts.configPath)
29
+ const generated = join(dirname(configPath), ".content-collections", "generated")
30
+ return {
31
+ optimizeDeps: { exclude: ["content-collections"] },
32
+ resolve: { alias: { "content-collections": generated } },
33
+ ...((config.server?.fs?.allow?.length ?? 0) > 0 && {
34
+ server: { fs: { allow: [generated] } },
35
+ }),
36
+ }
37
+ },
38
+ async configResolved(config) {
39
+ builder = await createBuilder(resolve(config.root, opts.configPath))
40
+ configureLogging(builder)
41
+ },
42
+ buildStart() {
43
+ const current = builder
44
+ if (!current) return
45
+ // Once, not per environment, and through the queue so an edit landing
46
+ // before it cannot overlap it.
47
+ initialBuild ??= new Promise((resolve, reject) => {
48
+ queue.enqueue("build", () => current.build().then(resolve, reject))
49
+ })
50
+ return initialBuild
51
+ },
52
+ configureServer() {
53
+ const current = builder
54
+ if (!current) return
55
+ watcher = watch(
56
+ opts.contentDir,
57
+ { recursive: true, ignore: IGNORED_PATHS },
58
+ (_, filename) => {
59
+ if (!filename) return
60
+ const file = join(opts.contentDir, filename)
61
+ queue.enqueue(file, () =>
62
+ current.sync(existsSync(file) ? "update" : "delete", file),
63
+ )
64
+ },
65
+ )
66
+ },
67
+ closeBundle() {
68
+ watcher?.close()
69
+ watcher = undefined
70
+ },
71
+ }
72
+ }
@@ -1,9 +1,10 @@
1
- import { cpSync, existsSync, rmSync, statSync } from "node:fs"
2
- import { join, sep } from "node:path"
3
- import type { ConfigEnv, Plugin, UserConfig as ViteUserConfig } from "vite"
1
+ import { cpSync, existsSync, readFileSync, rmSync, statSync } from "node:fs"
2
+ import { dirname, join, sep } from "node:path"
3
+ import type { ConfigEnv, Plugin, UserConfig as ViteUserConfig, ViteDevServer } from "vite"
4
4
  import { mergeConfig } from "vite"
5
5
  import packageJson from "../../package.json" with { type: "json" }
6
6
  import { buildTarget } from "../actions/target/build.ts"
7
+ import { collectLocalImports } from "../helpers/local-imports.ts"
7
8
  import { renderInlineMarkdown } from "../helpers/render-inline-markdown.ts"
8
9
  import { excludeTailwindSources } from "../helpers/tailwind-sources.ts"
9
10
  import { resolveTargetExport } from "../helpers/target-export.ts"
@@ -17,6 +18,13 @@ export interface LivemarkOptions {
17
18
 
18
19
  const SOURCE_DIR = join(import.meta.dirname, "..")
19
20
  const IS_INSTALLED = SOURCE_DIR.includes(`${sep}node_modules${sep}`)
21
+ const LOCKFILES = [
22
+ "pnpm-lock.yaml",
23
+ "package-lock.json",
24
+ "yarn.lock",
25
+ "bun.lock",
26
+ "bun.lockb",
27
+ ]
20
28
 
21
29
  /** Single virtual module exposing livemark's runtime values to consumer
22
30
  * override files via named exports:
@@ -102,18 +110,29 @@ export function livemark(opts: LivemarkOptions): Plugin {
102
110
  ].join("\n")
103
111
  },
104
112
  configureServer(server) {
113
+ // Vite restarts only for its own config's imports; the project config's
114
+ // imports and, in livemark's own dev, the content-collections config's
115
+ // imports need it too. A restart rebuilds the target, so no sync is lost.
116
+ const restartFiles = new Set([
117
+ opts.config.configPath,
118
+ ...opts.config.configDependencies,
119
+ ...(IS_INSTALLED
120
+ ? []
121
+ : collectLocalImports(join(SOURCE_DIR, "content-collections.ts"))),
122
+ ])
105
123
  // Vite watches only its root (the target); the files the target is
106
124
  // copied from join its watcher here. `.livemark/` always exists
107
125
  // because the target lives inside it.
108
- server.watcher.add([opts.config.configPath, overridesRoot])
109
- if (!IS_INSTALLED) server.watcher.add(SOURCE_DIR)
126
+ server.watcher.add([...restartFiles, overridesRoot])
127
+ if (IS_INSTALLED) watchInstalledVersion(server, opts.config.root)
128
+ else server.watcher.add(SOURCE_DIR)
110
129
  server.watcher.on("change", file => onChange(file, "change"))
111
130
  server.watcher.on("add", file => onChange(file, "add"))
112
131
  server.watcher.on("unlink", file => onChange(file, "unlink"))
113
132
  server.watcher.on("unlinkDir", file => onChange(file, "unlink"))
114
133
 
115
134
  function onChange(file: string, kind: "change" | "add" | "unlink") {
116
- if (file === opts.config.configPath) {
135
+ if (restartFiles.has(file)) {
117
136
  server.restart()
118
137
  return
119
138
  }
@@ -196,6 +215,53 @@ function syncSource(
196
215
  if (existsSync(source)) cpSync(source, dest, { recursive: true, force: true })
197
216
  }
198
217
 
218
+ // A running server keeps the livemark it started with: an in-process restart cannot
219
+ // reload the package's own modules, so an upgrade is reported instead.
220
+ function watchInstalledVersion(server: ViteDevServer, root: string) {
221
+ const packageDir = findInstalledPackageDir(root)
222
+ if (!packageDir) return
223
+ // Vite's watcher ignores node_modules, so the lockfile is what signals an install.
224
+ const projectDir = dirname(dirname(packageDir))
225
+ const manifest = join(packageDir, "package.json")
226
+ const watched = LOCKFILES.map(name => join(projectDir, name))
227
+ server.watcher.add(watched)
228
+ let timer: NodeJS.Timeout | undefined
229
+ server.watcher.on("all", (_, file) => {
230
+ if (!watched.includes(file)) return
231
+ clearTimeout(timer)
232
+ // Let the package manager finish relinking after it writes the lockfile.
233
+ timer = setTimeout(reportUpgrade, 1000)
234
+ })
235
+
236
+ function reportUpgrade() {
237
+ const version = readPackageVersion(manifest)
238
+ if (!version || version === packageJson.version) return
239
+ server.config.logger.warn(
240
+ `livemark ${packageJson.version} → ${version} installed; restart the dev server to use it`,
241
+ { timestamp: true },
242
+ )
243
+ }
244
+ }
245
+
246
+ function findInstalledPackageDir(root: string) {
247
+ for (let dir = root; ; dir = dirname(dir)) {
248
+ const packageDir = join(dir, "node_modules", packageJson.name)
249
+ if (existsSync(packageDir)) return packageDir
250
+ if (dirname(dir) === dir) return undefined
251
+ }
252
+ }
253
+
254
+ function readPackageVersion(manifest: string) {
255
+ try {
256
+ const parsed: unknown = JSON.parse(readFileSync(manifest, "utf8"))
257
+ return isObject(parsed) && typeof parsed.version === "string"
258
+ ? parsed.version
259
+ : undefined
260
+ } catch {
261
+ return undefined
262
+ }
263
+ }
264
+
199
265
  /** Resolve livemark.config.ts's `vite` field — either a config object
200
266
  * or a function `(env) => config` (matching Vite's own `defineConfig`).
201
267
  * Schema stores it as `unknown`; typing for the consumer's editor
@@ -44,6 +44,11 @@
44
44
  @apply px-4;
45
45
  }
46
46
 
47
+ /* `code` is a flex column, where an empty line span collapses to no height */
48
+ .prose pre code .line:empty::before {
49
+ content: " ";
50
+ }
51
+
47
52
  .prose pre code .line[data-highlighted] {
48
53
  @apply bg-primary/10;
49
54
  }
@@ -1,4 +1,3 @@
1
- import contentCollections from "@content-collections/vite"
2
1
  import tailwind from "@tailwindcss/vite"
3
2
  import { devtools } from "@tanstack/devtools-vite"
4
3
  import { tanstackStart } from "@tanstack/react-start/plugin/vite"
@@ -7,6 +6,7 @@ import { defineConfig } from "vite"
7
6
  import svgr from "vite-plugin-svgr"
8
7
  import { loadConfig } from "./actions/config/load.ts"
9
8
  import { agents } from "./plugins/vite-agents.ts"
9
+ import { content } from "./plugins/vite-content.ts"
10
10
  import { livemark } from "./plugins/vite-livemark.ts"
11
11
  import { robots } from "./plugins/vite-robots.ts"
12
12
 
@@ -34,7 +34,7 @@ export default defineConfig({
34
34
  // any browser error back and forth forever.
35
35
  devtools({ consolePiping: { enabled: false } }),
36
36
  tailwind(),
37
- contentCollections({ configPath: "content-collections.ts" }),
37
+ content({ configPath: "content-collections.ts", contentDir: config.root }),
38
38
  tanstackStart({
39
39
  srcDirectory: ".",
40
40
  // Seed path must match what the crawler produces (router-emitted