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.
- package/build/actions/config/load.js +16 -15
- package/build/actions/config/load.unit.js +13 -1
- package/build/commands/start.js +31 -8
- package/build/content-collections.js +2 -2
- package/build/helpers/local-imports.d.ts +2 -0
- package/build/helpers/local-imports.js +20 -0
- package/build/helpers/local-imports.unit.d.ts +1 -0
- package/build/helpers/local-imports.unit.js +25 -0
- package/build/helpers/task-queue.d.ts +7 -0
- package/build/helpers/task-queue.js +28 -0
- package/build/helpers/task-queue.unit.d.ts +1 -0
- package/build/helpers/task-queue.unit.js +56 -0
- package/build/helpers/toc.d.ts +1 -1
- package/build/helpers/toc.js +29 -31
- package/build/helpers/toc.unit.js +23 -1
- package/build/models/config.d.ts +2 -0
- package/build/models/config.js +2 -1
- package/build/plugins/vite-content.d.ts +9 -0
- package/build/plugins/vite-content.js +60 -0
- package/build/plugins/vite-livemark.js +72 -6
- package/build/vite.config.js +3 -3
- package/package.json +2 -2
- package/skills/livemark-configuration/SKILL.md +1 -1
- package/source/actions/config/load.ts +15 -6
- package/source/actions/config/load.unit.ts +17 -0
- package/source/commands/start.ts +24 -0
- package/source/content-collections.ts +1 -1
- package/source/helpers/local-imports.ts +19 -0
- package/source/helpers/local-imports.unit.ts +36 -0
- package/source/helpers/task-queue.ts +28 -0
- package/source/helpers/task-queue.unit.ts +60 -0
- package/source/helpers/toc.ts +29 -29
- package/source/helpers/toc.unit.ts +27 -0
- package/source/models/config.ts +1 -0
- package/source/plugins/vite-content.ts +72 -0
- package/source/plugins/vite-livemark.ts +72 -6
- package/source/styles/markdown.css +5 -0
- package/source/vite.config.ts +2 -2
package/source/commands/start.ts
CHANGED
|
@@ -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(
|
|
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
|
+
})
|
package/source/helpers/toc.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
25
|
-
|
|
38
|
+
const title = toString(node).trim()
|
|
39
|
+
const slug = customId ?? (isTocOnly ? slugOnce(title) : slugger.slug(title))
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
package/source/models/config.ts
CHANGED
|
@@ -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([
|
|
109
|
-
if (
|
|
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
|
|
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
|
package/source/vite.config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|