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.
Files changed (36) hide show
  1. package/build/components/HeaderSearch.js +3 -2
  2. package/build/components/SearchDialog.js +3 -2
  3. package/build/components/SearchResultGroups.d.ts +6 -0
  4. package/build/components/SearchResultGroups.js +25 -0
  5. package/build/content-collections.js +33 -8
  6. package/build/helpers/article-search.d.ts +1 -7
  7. package/build/helpers/article-search.js +1 -24
  8. package/build/helpers/fence-languages.d.ts +2 -0
  9. package/build/helpers/fence-languages.js +14 -0
  10. package/build/helpers/fence-languages.unit.d.ts +1 -0
  11. package/build/helpers/fence-languages.unit.js +22 -0
  12. package/build/helpers/tailwind-sources.d.ts +10 -0
  13. package/build/helpers/tailwind-sources.js +18 -0
  14. package/build/helpers/tailwind-sources.unit.d.ts +1 -0
  15. package/build/helpers/tailwind-sources.unit.js +23 -0
  16. package/build/plugins/vite-livemark.js +10 -1
  17. package/build/router.js +6 -1
  18. package/build/routes/$.js +5 -1
  19. package/build/vite.config.js +4 -2
  20. package/package.json +1 -1
  21. package/skills/livemark-customization/SKILL.md +2 -1
  22. package/skills/livemark-deployment/SKILL.md +72 -0
  23. package/skills/livemark-landing/SKILL.md +84 -0
  24. package/source/components/HeaderSearch.tsx +2 -1
  25. package/source/components/SearchDialog.tsx +2 -1
  26. package/source/components/SearchResultGroups.tsx +54 -0
  27. package/source/content-collections.ts +37 -9
  28. package/source/helpers/{article-search.tsx → article-search.ts} +1 -53
  29. package/source/helpers/fence-languages.ts +21 -0
  30. package/source/helpers/fence-languages.unit.ts +28 -0
  31. package/source/helpers/tailwind-sources.ts +18 -0
  32. package/source/helpers/tailwind-sources.unit.ts +32 -0
  33. package/source/plugins/vite-livemark.ts +9 -0
  34. package/source/router.tsx +6 -0
  35. package/source/routes/$.tsx +3 -0
  36. 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
 
@@ -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 />
@@ -30,7 +30,9 @@ export default defineConfig({
30
30
  livemark({ config }),
31
31
  robots({ config }),
32
32
  agents({ config }),
33
- devtools(),
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({