livemark 0.27.1 → 0.28.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/components/HeaderSearch.js +3 -2
- package/build/components/SearchDialog.js +3 -2
- package/build/components/SearchResultGroups.d.ts +6 -0
- package/build/components/SearchResultGroups.js +25 -0
- package/build/content-collections.js +33 -8
- package/build/helpers/article-search.d.ts +1 -7
- package/build/helpers/article-search.js +1 -24
- package/build/helpers/fence-languages.d.ts +2 -0
- package/build/helpers/fence-languages.js +14 -0
- package/build/helpers/fence-languages.unit.d.ts +1 -0
- package/build/helpers/fence-languages.unit.js +22 -0
- package/build/helpers/tailwind-sources.d.ts +10 -0
- package/build/helpers/tailwind-sources.js +18 -0
- package/build/helpers/tailwind-sources.unit.d.ts +1 -0
- package/build/helpers/tailwind-sources.unit.js +23 -0
- package/build/plugins/vite-livemark.js +10 -1
- package/build/router.js +6 -1
- package/build/routes/$.js +5 -1
- package/build/vite.config.js +4 -2
- package/package.json +1 -1
- package/skills/livemark-customization/SKILL.md +2 -1
- package/skills/livemark-deployment/SKILL.md +72 -0
- package/skills/livemark-landing/SKILL.md +84 -0
- package/source/components/HeaderSearch.tsx +2 -1
- package/source/components/SearchDialog.tsx +2 -1
- package/source/components/SearchResultGroups.tsx +54 -0
- package/source/content-collections.ts +37 -9
- package/source/helpers/{article-search.tsx → article-search.ts} +1 -53
- package/source/helpers/fence-languages.ts +21 -0
- package/source/helpers/fence-languages.unit.ts +28 -0
- package/source/helpers/tailwind-sources.ts +18 -0
- package/source/helpers/tailwind-sources.unit.ts +32 -0
- package/source/plugins/vite-livemark.ts +9 -0
- package/source/router.tsx +6 -0
- package/source/routes/$.tsx +3 -0
- package/source/vite.config.ts +3 -1
|
@@ -2,11 +2,9 @@ import { useState, useRef, useCallback, useMemo } from "react"
|
|
|
2
2
|
import { useNavigate } from "@tanstack/react-router"
|
|
3
3
|
import { create, insertMultiple, search } from "@orama/orama"
|
|
4
4
|
import { sortedArticles } from "../content/article.ts"
|
|
5
|
-
import { resolveArticleIcon } from "./article-icon.ts"
|
|
6
5
|
import { buildHighlightPattern, findTextMatches } from "./search-highlight.ts"
|
|
7
|
-
import { CommandGroup, CommandItem } from "../elements/command.tsx"
|
|
8
6
|
|
|
9
|
-
interface SearchResult {
|
|
7
|
+
export interface SearchResult {
|
|
10
8
|
title: string
|
|
11
9
|
path: string
|
|
12
10
|
icon?: string
|
|
@@ -103,38 +101,6 @@ export function useArticleSearch() {
|
|
|
103
101
|
return { query, grouped, searchArticles, selectArticle }
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
export function SearchResultGroups(props: {
|
|
107
|
-
query: string
|
|
108
|
-
grouped: Map<string, SearchResult[]>
|
|
109
|
-
onSelect: (path: string) => void
|
|
110
|
-
}) {
|
|
111
|
-
return [...props.grouped.entries()].map(([group, items]) => (
|
|
112
|
-
<CommandGroup key={group} heading={group}>
|
|
113
|
-
{items.map(result => {
|
|
114
|
-
const Icon = resolveArticleIcon(result.icon)
|
|
115
|
-
return (
|
|
116
|
-
<CommandItem
|
|
117
|
-
key={result.path}
|
|
118
|
-
value={result.path}
|
|
119
|
-
onSelect={() => props.onSelect(result.path)}
|
|
120
|
-
className="py-2.5"
|
|
121
|
-
>
|
|
122
|
-
{Icon && <Icon className="size-5 shrink-0 mt-0.5 self-start opacity-60" />}
|
|
123
|
-
<div className="flex flex-col gap-0.5 min-w-0">
|
|
124
|
-
<span className="text-base font-medium">{result.title}</span>
|
|
125
|
-
{result.snippet && (
|
|
126
|
-
<span className="text-sm text-muted-foreground line-clamp-1">
|
|
127
|
-
<HighlightedText text={result.snippet} term={props.query} />
|
|
128
|
-
</span>
|
|
129
|
-
)}
|
|
130
|
-
</div>
|
|
131
|
-
</CommandItem>
|
|
132
|
-
)
|
|
133
|
-
})}
|
|
134
|
-
</CommandGroup>
|
|
135
|
-
))
|
|
136
|
-
}
|
|
137
|
-
|
|
138
104
|
function toResult(article: (typeof sortedArticles)[number], term: string): SearchResult {
|
|
139
105
|
return {
|
|
140
106
|
title: article.title,
|
|
@@ -172,21 +138,3 @@ function extractSnippet(text: string, term: string) {
|
|
|
172
138
|
const suffix = end < text.length ? "…" : ""
|
|
173
139
|
return `${prefix}${text.slice(start, end)}${suffix}`
|
|
174
140
|
}
|
|
175
|
-
|
|
176
|
-
function HighlightedText(props: { text: string; term: string }) {
|
|
177
|
-
const pattern = buildHighlightPattern(props.term)
|
|
178
|
-
if (!pattern) return <>{props.text}</>
|
|
179
|
-
const parts: React.ReactNode[] = []
|
|
180
|
-
let cursor = 0
|
|
181
|
-
for (const [start, end] of findTextMatches(props.text, pattern)) {
|
|
182
|
-
parts.push(props.text.slice(cursor, start))
|
|
183
|
-
parts.push(
|
|
184
|
-
<span key={start} className="text-foreground font-medium">
|
|
185
|
-
{props.text.slice(start, end)}
|
|
186
|
-
</span>,
|
|
187
|
-
)
|
|
188
|
-
cursor = end
|
|
189
|
-
}
|
|
190
|
-
parts.push(props.text.slice(cursor))
|
|
191
|
-
return <>{parts}</>
|
|
192
|
-
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { bundledLanguages, bundledLanguagesInfo } from "shiki"
|
|
2
|
+
|
|
3
|
+
const FENCE_PATTERN = /^[ \t>]*(?:`{3,}|~{3,})[ \t]*([\w+#.-]+)/gm
|
|
4
|
+
|
|
5
|
+
/** The bundled Shiki languages a document's fences name, plus the ones their grammars embed lazily. */
|
|
6
|
+
export async function collectFenceLanguages(content: string) {
|
|
7
|
+
const names = new Set(
|
|
8
|
+
Array.from(content.matchAll(FENCE_PATTERN), m => (m[1] ?? "").toLowerCase()),
|
|
9
|
+
)
|
|
10
|
+
const infos = bundledLanguagesInfo.filter(
|
|
11
|
+
info => names.has(info.id) || info.aliases?.some(alias => names.has(alias)),
|
|
12
|
+
)
|
|
13
|
+
// Lazy embeds (the `js` fence inside a `md` fence) are not loaded with their host, so
|
|
14
|
+
// without them nested code renders plain or not depending on what earlier pages loaded.
|
|
15
|
+
const grammars = await Promise.all(infos.map(info => info.import()))
|
|
16
|
+
const embedded = grammars.flatMap(grammar =>
|
|
17
|
+
grammar.default.flatMap(lang => lang.embeddedLangsLazy ?? []),
|
|
18
|
+
)
|
|
19
|
+
const langs = new Set([...infos.map(info => info.id), ...embedded])
|
|
20
|
+
return [...langs].filter(lang => lang in bundledLanguages)
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { collectFenceLanguages } from "./fence-languages.ts"
|
|
3
|
+
|
|
4
|
+
describe("collectFenceLanguages", () => {
|
|
5
|
+
it("should collect the languages fences name", async () => {
|
|
6
|
+
const content =
|
|
7
|
+
"```ts\nconst a = 1\n```\n\n~~~bash\nls\n~~~\n\n- item\n\n ```yaml\n a: 1\n ```"
|
|
8
|
+
const langs = await collectFenceLanguages(content)
|
|
9
|
+
expect(langs).toEqual(expect.arrayContaining(["typescript", "shellscript", "yaml"]))
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("should resolve aliases and info-string metadata", async () => {
|
|
13
|
+
const langs = await collectFenceLanguages('```js title="a.js" {1}\nfoo()\n```')
|
|
14
|
+
expect(langs).toEqual(["javascript"])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("should add the languages a grammar embeds lazily", async () => {
|
|
18
|
+
const langs = await collectFenceLanguages("````md\n```js\nfoo()\n```\n````")
|
|
19
|
+
expect(langs).toEqual(expect.arrayContaining(["markdown", "javascript", "html"]))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("should skip languages Shiki does not bundle", async () => {
|
|
23
|
+
const langs = await collectFenceLanguages(
|
|
24
|
+
"```npm\ni livemark\n```\n\n```\nplain\n```",
|
|
25
|
+
)
|
|
26
|
+
expect(langs).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join, sep } from "node:path"
|
|
2
|
+
|
|
3
|
+
const TAILWIND_IMPORT = /@import\s+(["'])tailwindcss(\/utilities)?\1/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keep Tailwind's source scan out of the target's dependency link, which reaches the
|
|
7
|
+
* whole install (and, through a self-link, the whole project) from inside the Vite root,
|
|
8
|
+
* and out of the content-collections cache, whose rewrites Tailwind answers with a
|
|
9
|
+
* full page reload. The generated modules still carry every article's classes.
|
|
10
|
+
*/
|
|
11
|
+
export function excludeTailwindSources(props: { css: string; targetDir: string }) {
|
|
12
|
+
if (!TAILWIND_IMPORT.test(props.css)) return undefined
|
|
13
|
+
const exclusions = ["node_modules", ".content-collections/cache"].map(path => {
|
|
14
|
+
const absolute = join(props.targetDir, path).split(sep).join("/")
|
|
15
|
+
return `@source not "${absolute}";\n`
|
|
16
|
+
})
|
|
17
|
+
return `${props.css}\n${exclusions.join("")}`
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { excludeTailwindSources } from "./tailwind-sources.ts"
|
|
3
|
+
|
|
4
|
+
const targetDir = "/project/.livemark/.target"
|
|
5
|
+
|
|
6
|
+
describe("excludeTailwindSources", () => {
|
|
7
|
+
it("excludes the dependency link and the content cache from a Tailwind entry", () => {
|
|
8
|
+
const css = `@import "tailwindcss";\n@import "./shape.css";`
|
|
9
|
+
expect(excludeTailwindSources({ css, targetDir })).toBe(
|
|
10
|
+
`${css}\n@source not "/project/.livemark/.target/node_modules";\n` +
|
|
11
|
+
`@source not "/project/.livemark/.target/.content-collections/cache";\n`,
|
|
12
|
+
)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("accepts single quotes, import options and a bare utilities layer", () => {
|
|
16
|
+
for (const css of [
|
|
17
|
+
`@import 'tailwindcss' source(none);`,
|
|
18
|
+
`@import "tailwindcss/utilities" layer(utilities);`,
|
|
19
|
+
]) {
|
|
20
|
+
expect(excludeTailwindSources({ css, targetDir })).toContain("@source not")
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("leaves other stylesheets alone", () => {
|
|
25
|
+
expect(
|
|
26
|
+
excludeTailwindSources({ css: `@import "tw-animate-css";`, targetDir }),
|
|
27
|
+
).toBeUndefined()
|
|
28
|
+
expect(
|
|
29
|
+
excludeTailwindSources({ css: `@import "tailwindcss/theme";`, targetDir }),
|
|
30
|
+
).toBeUndefined()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -5,6 +5,7 @@ import { mergeConfig } from "vite"
|
|
|
5
5
|
import packageJson from "../../package.json" with { type: "json" }
|
|
6
6
|
import { buildTarget } from "../actions/target/build.ts"
|
|
7
7
|
import { renderInlineMarkdown } from "../helpers/render-inline-markdown.ts"
|
|
8
|
+
import { excludeTailwindSources } from "../helpers/tailwind-sources.ts"
|
|
8
9
|
import { resolveTargetExport } from "../helpers/target-export.ts"
|
|
9
10
|
import type { Config } from "../models/config.ts"
|
|
10
11
|
import { WebsiteConfig } from "../models/config.ts"
|
|
@@ -77,6 +78,14 @@ export function livemark(opts: LivemarkOptions): Plugin {
|
|
|
77
78
|
const override = await resolveViteOverride(opts.config.vite, env)
|
|
78
79
|
return override ? mergeConfig(base, override) : base
|
|
79
80
|
},
|
|
81
|
+
// Here, not in `styles/general.css`, so an escaped stylesheet keeps it. Works only
|
|
82
|
+
// while this plugin precedes `tailwind()`: both are `pre`, so array order decides.
|
|
83
|
+
transform: {
|
|
84
|
+
filter: { id: /\.css(\?|$)/ },
|
|
85
|
+
handler(css) {
|
|
86
|
+
return excludeTailwindSources({ css, targetDir })
|
|
87
|
+
},
|
|
88
|
+
},
|
|
80
89
|
resolveId(id) {
|
|
81
90
|
if (id === VIRTUAL_ID) return VIRTUAL_RESOLVED_ID
|
|
82
91
|
return resolveTargetExport({ id, targetDir, exports: packageJson.exports }) ?? null
|
package/source/router.tsx
CHANGED
|
@@ -26,6 +26,12 @@ export function getRouter() {
|
|
|
26
26
|
queryClient,
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
+
// Route HMR swaps a route's options but keeps its loader data, so an edited article
|
|
30
|
+
// would stay stale until a full page reload.
|
|
31
|
+
if (import.meta.hot && typeof window !== "undefined") {
|
|
32
|
+
import.meta.hot.on("vite:afterUpdate", () => router.invalidate())
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
return router
|
|
30
36
|
}
|
|
31
37
|
|
package/source/routes/$.tsx
CHANGED
|
@@ -83,6 +83,9 @@ export const Route = createFileRoute("/$")({
|
|
|
83
83
|
|
|
84
84
|
function Component() {
|
|
85
85
|
const data = Route.useLoaderData()
|
|
86
|
+
// Undefined for one render when HMR reloads a deleted article, before the root's
|
|
87
|
+
// not-found takes over.
|
|
88
|
+
if (!data) return null
|
|
86
89
|
return (
|
|
87
90
|
<Layout withSidebar={data.sidebar}>
|
|
88
91
|
<Page />
|
package/source/vite.config.ts
CHANGED
|
@@ -30,7 +30,9 @@ export default defineConfig({
|
|
|
30
30
|
livemark({ config }),
|
|
31
31
|
robots({ config }),
|
|
32
32
|
agents({ config }),
|
|
33
|
-
|
|
33
|
+
// Its server-to-browser console piping and Vite's browser-to-server forwarding echo
|
|
34
|
+
// any browser error back and forth forever.
|
|
35
|
+
devtools({ consolePiping: { enabled: false } }),
|
|
34
36
|
tailwind(),
|
|
35
37
|
contentCollections({ configPath: "content-collections.ts" }),
|
|
36
38
|
tanstackStart({
|