livemark 0.0.0-dev → 0.7.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 (94) hide show
  1. package/README.md +5 -1
  2. package/build/actions/changelog/build.d.ts +14 -0
  3. package/build/actions/changelog/build.js +158 -0
  4. package/build/actions/changelog/build.unit.d.ts +1 -0
  5. package/build/actions/changelog/build.unit.js +66 -0
  6. package/build/actions/config/define.unit.js +11 -16
  7. package/build/actions/config/load.js +6 -3
  8. package/build/actions/config/load.unit.js +3 -3
  9. package/build/commands/build.js +4 -3
  10. package/build/commands/escape.d.ts +9 -0
  11. package/build/commands/escape.js +56 -0
  12. package/build/docs/markdown/includes/example.d.ts +53 -4
  13. package/build/docs/markdown/includes/example.js +2 -2
  14. package/build/livemark.config.d.ts +53 -4
  15. package/build/livemark.config.js +22 -2
  16. package/build/models/config.d.ts +315 -8
  17. package/build/models/config.js +25 -5
  18. package/build/models/link.d.ts +13 -0
  19. package/build/models/link.js +9 -0
  20. package/build/models/patch.d.ts +21 -0
  21. package/build/models/patch.js +7 -0
  22. package/build/models/section.d.ts +75 -0
  23. package/build/models/section.js +26 -0
  24. package/build/models/theme.d.ts +72 -0
  25. package/build/models/theme.js +73 -0
  26. package/build/program.js +4 -2
  27. package/build/vite.config.d.ts +1 -1
  28. package/build/website/assets/logo.svg +117 -0
  29. package/build/website/components/Article.tsx +172 -0
  30. package/build/website/components/BackToTop.tsx +32 -0
  31. package/build/website/components/Banner.tsx +2 -2
  32. package/build/website/components/BlogIndex.tsx +121 -0
  33. package/build/website/components/BlogSidebar.tsx +162 -0
  34. package/build/website/components/Breadcrumbs.tsx +5 -2
  35. package/build/website/components/Card.tsx +48 -0
  36. package/build/website/components/Cards.tsx +0 -58
  37. package/build/website/components/ChangelogIndex.tsx +111 -0
  38. package/build/website/components/ChangelogSidebar.tsx +120 -0
  39. package/build/website/components/CodeBlock.tsx +57 -40
  40. package/build/website/components/Column.tsx +4 -0
  41. package/build/website/components/Columns.tsx +0 -5
  42. package/build/website/components/Footer.tsx +12 -5
  43. package/build/website/components/InlineIcon.tsx +2 -13
  44. package/build/website/components/Layout.tsx +155 -185
  45. package/build/website/components/Mermaid.tsx +14 -33
  46. package/build/website/components/MermaidRenderer.tsx +37 -0
  47. package/build/website/components/MiniSidebar.tsx +49 -0
  48. package/build/website/components/PageToolbar.tsx +21 -11
  49. package/build/website/components/PrevNext.tsx +20 -12
  50. package/build/website/components/Search.tsx +28 -0
  51. package/build/website/components/SearchDialog.tsx +10 -10
  52. package/build/website/components/Sidebar.tsx +160 -0
  53. package/build/website/components/SidebarLinks.tsx +70 -0
  54. package/build/website/components/SidebarSections.tsx +71 -0
  55. package/build/website/components/SiteTitle.tsx +13 -8
  56. package/build/website/components/TagIndex.tsx +120 -0
  57. package/build/website/components/Theme.tsx +0 -1
  58. package/build/website/components/Toc.tsx +128 -1
  59. package/build/website/components/ZoomImage.tsx +15 -20
  60. package/build/website/content/article.ts +78 -0
  61. package/build/website/content/tag.ts +29 -0
  62. package/build/website/content-collections.ts +53 -26
  63. package/build/website/elements/sidebar.tsx +4 -4
  64. package/build/website/env.d.ts +4 -0
  65. package/build/website/helpers/article-icon.ts +2 -0
  66. package/build/website/helpers/article.ts +90 -0
  67. package/build/website/helpers/changelog-source.ts +16 -0
  68. package/build/website/helpers/changelog-source.unit.ts +38 -0
  69. package/build/website/helpers/dynamic-icon-resolver.tsx +19 -0
  70. package/build/website/helpers/dynamic-icon.tsx +12 -0
  71. package/build/website/helpers/favicon.ts +7 -0
  72. package/build/website/helpers/model.ts +4 -0
  73. package/build/website/helpers/resolve-asset-path.ts +9 -0
  74. package/build/website/helpers/section.ts +50 -0
  75. package/build/website/helpers/section.unit.ts +71 -0
  76. package/build/website/helpers/toc-context.ts +1 -1
  77. package/build/website/helpers/toc.ts +1 -6
  78. package/build/website/models/article.ts +48 -33
  79. package/build/website/models/toc.ts +5 -0
  80. package/build/website/plugins/rehype-task-list-a11y.ts +33 -0
  81. package/build/website/plugins/remark-ansi.ts +1 -0
  82. package/build/website/plugins/remark-image.ts +22 -13
  83. package/build/website/plugins/vite-livemark.ts +91 -0
  84. package/build/website/routeTree.gen.ts +9 -9
  85. package/build/website/routes/$.tsx +74 -131
  86. package/build/website/routes/__root.tsx +36 -23
  87. package/build/website/styles/general.css +26 -210
  88. package/build/website/styles/markdown.css +214 -0
  89. package/build/website/vite.config.ts +38 -3
  90. package/package.json +25 -16
  91. package/build/website/globals.d.ts +0 -8
  92. package/build/website/helpers/articles.ts +0 -24
  93. package/build/website/public/logo.svg +0 -72
  94. package/build/website/routes/index.tsx +0 -13
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vite-plus/test"
2
+ import { matchSection, partitionBySection } from "./section.ts"
3
+
4
+ describe("matchSection", () => {
5
+ const sections = [
6
+ { title: "Docs", prefix: "/" },
7
+ { title: "API", prefix: "/api/" },
8
+ { title: "API Auth", prefix: "/api/auth/" },
9
+ ]
10
+
11
+ it("should match the most specific prefix", () => {
12
+ expect(matchSection("/api/auth/login/", sections)?.title).toBe("API Auth")
13
+ })
14
+
15
+ it("should match a less specific prefix when no deeper match", () => {
16
+ expect(matchSection("/api/users/", sections)?.title).toBe("API")
17
+ })
18
+
19
+ it("should match root section as fallback", () => {
20
+ expect(matchSection("/getting-started/", sections)?.title).toBe("Docs")
21
+ })
22
+
23
+ it("should return undefined when no section matches", () => {
24
+ const narrow = [{ title: "API", prefix: "/api/" }]
25
+ expect(matchSection("/getting-started/", narrow)).toBeUndefined()
26
+ })
27
+
28
+ it("should match a single root section for all articles", () => {
29
+ const root = [{ title: "Docs", prefix: "/" }]
30
+ expect(matchSection("/anything/deep/path/", root)?.title).toBe("Docs")
31
+ })
32
+ })
33
+
34
+ describe("partitionBySection", () => {
35
+ const sections = [
36
+ { title: "Docs", prefix: "/" },
37
+ { title: "API", prefix: "/api/" },
38
+ ]
39
+
40
+ const articles = [
41
+ { path: "/getting-started/" },
42
+ { path: "/guide/" },
43
+ { path: "/api/users/" },
44
+ { path: "/api/auth/" },
45
+ ]
46
+
47
+ it("should split articles into section buckets", () => {
48
+ const result = partitionBySection(articles, sections)
49
+ expect(result.get("/")?.map(a => a.path)).toEqual([
50
+ "/getting-started/",
51
+ "/guide/",
52
+ ])
53
+ expect(result.get("/api/")?.map(a => a.path)).toEqual([
54
+ "/api/users/",
55
+ "/api/auth/",
56
+ ])
57
+ })
58
+
59
+ it("should put unmatched articles in __default__ bucket", () => {
60
+ const narrow = [{ title: "API", prefix: "/api/" }]
61
+ const result = partitionBySection(articles, narrow)
62
+ expect(result.get("__default__")?.map(a => a.path)).toEqual([
63
+ "/getting-started/",
64
+ "/guide/",
65
+ ])
66
+ expect(result.get("/api/")?.map(a => a.path)).toEqual([
67
+ "/api/users/",
68
+ "/api/auth/",
69
+ ])
70
+ })
71
+ })
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext } from "react"
2
- import type { TocItem } from "./toc.ts"
2
+ import type { TocItem } from "../models/toc.ts"
3
3
 
4
4
  /** React context that provides TOC items to inline components */
5
5
  export const TocContext = createContext<TocItem[]>([])
@@ -1,10 +1,5 @@
1
1
  import GithubSlugger from "github-slugger"
2
-
3
- export interface TocItem {
4
- url: string
5
- title: string
6
- depth: number
7
- }
2
+ import type { TocItem } from "../models/toc.ts"
8
3
 
9
4
  const CUSTOM_ID_PATTERN = /\s*\[#([^\]]+)\]\s*$/
10
5
  const TOC_HIDDEN_PATTERN = /\s*\[!toc\]\s*$/
@@ -1,40 +1,55 @@
1
+ import { z } from "zod"
2
+ import { nonEmptyString } from "../helpers/model.ts"
3
+ import type { TocItem } from "./toc.ts"
4
+
5
+ /** Frontmatter schema for a markdown article. */
6
+ export type Article = z.infer<typeof Article>
7
+ export const Article = z.object({
8
+ content: z.string(),
9
+ title: nonEmptyString.optional(),
10
+ label: nonEmptyString.optional(),
11
+ description: nonEmptyString.optional(),
12
+ icon: nonEmptyString.optional(),
13
+ order: z.number().optional(),
14
+ path: nonEmptyString.optional(),
15
+ group: nonEmptyString.optional(),
16
+ sidebar: z.boolean().default(true),
17
+ toc: z.boolean().default(true),
18
+ image: nonEmptyString.optional(),
19
+ author: z.union([nonEmptyString, z.array(nonEmptyString)]).optional(),
20
+ date: nonEmptyString.optional(),
21
+ tags: z.array(nonEmptyString).optional(),
22
+ })
23
+
24
+ /** Processed article ready for page rendering (result of content-collections transform). */
25
+ export interface ArticleView {
26
+ path: string
27
+ content: string
28
+ file: string
29
+ mdx: string
30
+ tocItems: TocItem[]
31
+ toc?: boolean
32
+ lastUpdated?: string
33
+ sidebar?: boolean
34
+ image?: string
35
+ author?: string | string[]
36
+ date?: string
37
+ tags?: string[]
38
+ }
39
+
1
40
  export interface ArticleNode {
2
- pathname: string
41
+ path: string
3
42
  title: string
43
+ label?: string
4
44
  icon: string
5
45
  children: ArticleNode[]
6
46
  }
7
47
 
8
- /** Build a tree from a flat sorted list of articles based on pathname hierarchy */
9
- export function buildArticleTree(
10
- articles: { pathname: string; title: string; icon: string }[],
11
- ) {
12
- const nodeMap = new Map<string, ArticleNode>()
13
-
14
- for (const article of articles) {
15
- nodeMap.set(article.pathname, {
16
- pathname: article.pathname,
17
- title: article.title,
18
- icon: article.icon,
19
- children: [],
20
- })
21
- }
22
-
23
- const roots: ArticleNode[] = []
24
-
25
- for (const article of articles) {
26
- const node = nodeMap.get(article.pathname)!
27
- const trimmed = article.pathname.replace(/\/$/, "")
28
- const lastSlash = trimmed.lastIndexOf("/")
29
- const parentPath = lastSlash > 0 ? `${trimmed.slice(0, lastSlash)}/` : ""
30
- const parent = parentPath ? nodeMap.get(parentPath) : undefined
31
-
32
- if (parent) {
33
- parent.children.push(node)
34
- } else {
35
- roots.push(node)
36
- }
37
- }
38
-
39
- return roots
48
+ /**
49
+ * A contiguous section of root articles sharing the same `group` label.
50
+ * `name: undefined` represents the leading ungrouped section (at most one).
51
+ */
52
+ export interface ArticleGroup {
53
+ name?: string
54
+ nodes: ArticleNode[]
40
55
  }
@@ -0,0 +1,5 @@
1
+ export interface TocItem {
2
+ url: string
3
+ title: string
4
+ depth: number
5
+ }
@@ -0,0 +1,33 @@
1
+ import { visit } from "unist-util-visit"
2
+
3
+ interface HastNode {
4
+ type: string
5
+ tagName?: string
6
+ properties?: Record<string, unknown>
7
+ children?: HastNode[]
8
+ }
9
+
10
+ /** Give each GFM task-list checkbox an accessible name so screen readers and
11
+ * axe-based audits don't flag them as form controls without labels. */
12
+ export function rehypeTaskListA11y() {
13
+ return (tree: HastNode) => {
14
+ visit(tree, "element", (node: HastNode) => {
15
+ if (node.tagName !== "li") return
16
+ const classes = node.properties?.className
17
+ const hasTaskClass =
18
+ Array.isArray(classes) && classes.includes("task-list-item")
19
+ if (!hasTaskClass) return
20
+
21
+ for (const child of node.children ?? []) {
22
+ if (child.type !== "element") continue
23
+ if (child.tagName !== "input") continue
24
+ if (child.properties?.type !== "checkbox") continue
25
+ child.properties = {
26
+ ...child.properties,
27
+ ariaLabel: child.properties?.checked ? "Completed task" : "Task",
28
+ }
29
+ break
30
+ }
31
+ })
32
+ }
33
+ }
@@ -172,6 +172,7 @@ function applyParams(state: AnsiState, params: ReadonlyArray<number>) {
172
172
 
173
173
  /** Convert a string containing ANSI escape sequences into HTML with inline styles */
174
174
  export function ansiToHtml(input: string): string {
175
+ // oxlint-disable-next-line no-control-regex -- ANSI escape sequences start with U+001B
175
176
  const sgr = /\u001b\[([0-9;]*)m/g
176
177
  const state = makeDefaultState()
177
178
  const parts: string[] = []
@@ -1,25 +1,34 @@
1
- import { dirname, relative, resolve } from "node:path"
2
- import type { Image, Root } from "mdast"
1
+ import type { Root } from "mdast"
3
2
  import type { Plugin } from "unified"
4
3
  import { visit } from "unist-util-visit"
4
+ import { resolveAssetPath } from "../helpers/resolve-asset-path.ts"
5
5
 
6
- /** Remark plugin that resolves relative image paths to URLs serveable by Vite */
6
+ /** Remark plugin that resolves relative image paths and unwraps image-only paragraphs */
7
7
  export const remarkImage: Plugin<
8
8
  [{ filePath: string; root: string }],
9
9
  Root
10
10
  > = ({ filePath, root }) => {
11
- const fileDir = dirname(filePath)
12
-
13
11
  return tree => {
14
- visit(tree, "image", (node: Image) => {
15
- if (
16
- node.url.startsWith("http") ||
17
- node.url.startsWith("/") ||
18
- node.url.startsWith("data:")
12
+ visit(tree, "image", node => {
13
+ node.url = resolveAssetPath(node.url, filePath, root)
14
+ })
15
+
16
+ visit(tree, "paragraph", (node, index, parent) => {
17
+ if (!parent || typeof index !== "number") return
18
+ const isImageOnly = node.children.every(
19
+ child =>
20
+ child.type === "image" ||
21
+ child.type === "imageReference" ||
22
+ child.type === "break" ||
23
+ (child.type === "text" && child.value.trim() === ""),
24
+ )
25
+ if (!isImageOnly) return
26
+ const images = node.children.filter(
27
+ child => child.type === "image" || child.type === "imageReference",
19
28
  )
20
- return
21
- const absolute = resolve(fileDir, node.url)
22
- node.url = "/" + relative(root, absolute)
29
+ if (images.length === 0) return
30
+ parent.children.splice(index, 1, ...images)
31
+ return index + images.length
23
32
  })
24
33
  }
25
34
  }
@@ -0,0 +1,91 @@
1
+ import { existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import type { Plugin } from "vite"
4
+
5
+ /** Sub-directories of `website/` that participate in the override mechanism. */
6
+ export const OVERRIDE_SUBDIRS: readonly string[] = [
7
+ "components",
8
+ "elements",
9
+ "styles",
10
+ ]
11
+
12
+ export interface LivemarkOptions {
13
+ defaultsRoot: string
14
+ overridesRoot: string
15
+ subdirs: string[]
16
+ configPath: string
17
+ }
18
+
19
+ /** Livemark dev/build integration:
20
+ * - Redirects imports that land in `<defaultsRoot>/<subdir>/` to the matching
21
+ * user file under `<overridesRoot>/<subdir>/` when it exists.
22
+ * - Watches `livemark.config.ts` and `.livemark/**` so edits, additions and
23
+ * deletions trigger the right reload without manual restart. */
24
+ export function livemark(opts: LivemarkOptions): Plugin {
25
+ const EXT_RE = /\.(tsx|ts|jsx|js|css)(\?|$)/
26
+ return {
27
+ name: "livemark",
28
+ enforce: "pre",
29
+ async resolveId(id, importer, options) {
30
+ if (!importer || !EXT_RE.test(id)) return null
31
+ const resolved = await this.resolve(id, importer, {
32
+ ...options,
33
+ skipSelf: true,
34
+ })
35
+ if (!resolved) return null
36
+ const queryIdx = resolved.id.indexOf("?")
37
+ const path =
38
+ queryIdx === -1 ? resolved.id : resolved.id.slice(0, queryIdx)
39
+ const query = queryIdx === -1 ? "" : resolved.id.slice(queryIdx)
40
+ for (const subdir of opts.subdirs) {
41
+ const defaultsSubdir = `${join(opts.defaultsRoot, subdir)}/`
42
+ if (!path.startsWith(defaultsSubdir)) continue
43
+ const rel = path.slice(defaultsSubdir.length)
44
+ const userPath = join(opts.overridesRoot, subdir, rel)
45
+ if (existsSync(userPath)) return userPath + query
46
+ return null
47
+ }
48
+ return null
49
+ },
50
+ configureServer(server) {
51
+ server.watcher.add(opts.configPath)
52
+ server.watcher.add(join(opts.overridesRoot, "**/*"))
53
+
54
+ const overrideRoots = opts.subdirs.map(
55
+ subdir => `${join(opts.overridesRoot, subdir)}/`,
56
+ )
57
+ const routesRoot = `${join(opts.overridesRoot, "routes")}/`
58
+
59
+ const onChange = (file: string) => {
60
+ if (file === opts.configPath) {
61
+ server.restart()
62
+ return
63
+ }
64
+
65
+ if (file.startsWith(routesRoot)) {
66
+ server.restart()
67
+ return
68
+ }
69
+
70
+ for (let i = 0; i < opts.subdirs.length; i++) {
71
+ const root = overrideRoots[i]
72
+ if (!root || !file.startsWith(root)) continue
73
+ const rel = file.slice(root.length)
74
+ const subdir = opts.subdirs[i]
75
+ if (!subdir) continue
76
+ const defaultPath = join(opts.defaultsRoot, subdir, rel)
77
+ const mod = server.moduleGraph.getModuleById(defaultPath)
78
+ if (mod) server.moduleGraph.invalidateModule(mod)
79
+ server.ws.send({ type: "full-reload" })
80
+ return
81
+ }
82
+ }
83
+
84
+ server.watcher.on("add", onChange)
85
+ server.watcher.on("unlink", onChange)
86
+ server.watcher.on("change", file => {
87
+ if (file === opts.configPath) server.restart()
88
+ })
89
+ },
90
+ }
91
+ }
@@ -9,10 +9,10 @@
9
9
  // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
- import { Route as SplatRouteImport } from './routes/$'
13
- import { Route as IndexRouteImport } from './routes/index'
12
+ import { Route as splatRouteImport } from './routes/$'
13
+ import { Route as IndexRouteImport } from './../.livemark/routes/index'
14
14
 
15
- const SplatRoute = SplatRouteImport.update({
15
+ const splatRoute = splatRouteImport.update({
16
16
  id: '/$',
17
17
  path: '/$',
18
18
  getParentRoute: () => rootRouteImport,
@@ -25,16 +25,16 @@ const IndexRoute = IndexRouteImport.update({
25
25
 
26
26
  export interface FileRoutesByFullPath {
27
27
  '/': typeof IndexRoute
28
- '/$': typeof SplatRoute
28
+ '/$': typeof splatRoute
29
29
  }
30
30
  export interface FileRoutesByTo {
31
31
  '/': typeof IndexRoute
32
- '/$': typeof SplatRoute
32
+ '/$': typeof splatRoute
33
33
  }
34
34
  export interface FileRoutesById {
35
35
  __root__: typeof rootRouteImport
36
36
  '/': typeof IndexRoute
37
- '/$': typeof SplatRoute
37
+ '/$': typeof splatRoute
38
38
  }
39
39
  export interface FileRouteTypes {
40
40
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -46,7 +46,7 @@ export interface FileRouteTypes {
46
46
  }
47
47
  export interface RootRouteChildren {
48
48
  IndexRoute: typeof IndexRoute
49
- SplatRoute: typeof SplatRoute
49
+ splatRoute: typeof splatRoute
50
50
  }
51
51
 
52
52
  declare module '@tanstack/react-router' {
@@ -55,7 +55,7 @@ declare module '@tanstack/react-router' {
55
55
  id: '/$'
56
56
  path: '/$'
57
57
  fullPath: '/$'
58
- preLoaderRoute: typeof SplatRouteImport
58
+ preLoaderRoute: typeof splatRouteImport
59
59
  parentRoute: typeof rootRouteImport
60
60
  }
61
61
  '/': {
@@ -70,7 +70,7 @@ declare module '@tanstack/react-router' {
70
70
 
71
71
  const rootRouteChildren: RootRouteChildren = {
72
72
  IndexRoute: IndexRoute,
73
- SplatRoute: SplatRoute,
73
+ splatRoute: splatRoute,
74
74
  }
75
75
  export const routeTree = rootRouteImport
76
76
  ._addFileChildren(rootRouteChildren)
@@ -1,149 +1,92 @@
1
- import { MDXContent } from "@content-collections/mdx/react"
2
- import { createFileRoute, notFound } from "@tanstack/react-router"
3
- import { sortedArticles } from "../helpers/articles.ts"
4
- import { Callout } from "../components/Callout.tsx"
5
- import { Columns, Column } from "../components/Columns.tsx"
6
- import { Card, Cards } from "../components/Cards.tsx"
7
- import { CodeBlock } from "../components/CodeBlock.tsx"
8
- import { headingComponents } from "../components/Heading.tsx"
9
- import { Calendar } from "lucide-react"
10
- import { CodeTabs } from "../components/CodeTabs.tsx"
11
- import { ContentTab } from "../components/ContentTab.tsx"
12
- import { ContentTabs } from "../components/ContentTabs.tsx"
13
- import { Details } from "../components/Details.tsx"
14
- import { FileTree } from "../components/FileTree.tsx"
15
- import { InlineIcon } from "../components/InlineIcon.tsx"
16
- import { Abbr } from "../components/Abbr.tsx"
17
- import { FootnoteRef } from "../components/FootnoteRef.tsx"
1
+ import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
18
2
  import {
19
- DefinitionList,
20
- DefinitionTerm,
21
- DefinitionDetail,
22
- } from "../components/DefinitionList.tsx"
23
- import { AnsiCode } from "../components/AnsiCode.tsx"
24
- import { InlineBadge } from "../components/InlineBadge.tsx"
25
- import { LinkButton } from "../components/LinkButton.tsx"
26
- import { Mermaid } from "../components/Mermaid.tsx"
27
- import { PackageTabs } from "../components/PackageTabs.tsx"
28
- import { SoundCloud } from "../components/SoundCloud.tsx"
29
- import { YouTube } from "../components/YouTube.tsx"
30
- import { InlineToc } from "../components/InlineToc.tsx"
31
- import { PageToolbar } from "../components/PageToolbar.tsx"
32
- import { PrevNext } from "../components/PrevNext.tsx"
33
- import { Toc } from "../components/Toc.tsx"
34
- import { ZoomImage } from "../components/ZoomImage.tsx"
35
- import { Separator } from "../elements/separator.tsx"
36
- import { TocContext } from "../helpers/toc-context.ts"
37
- import {
38
- SortableTable,
39
- TableHeader,
40
- TableBody,
41
- TableRow,
42
- TableHead,
43
- TableCell,
44
- } from "../components/SortableTable.tsx"
3
+ currentSection,
4
+ firstArticle,
5
+ homeArticle,
6
+ sortedArticles,
7
+ } from "../content/article.ts"
8
+ import { sectionTags } from "../content/tag.ts"
9
+ import { Article } from "../components/Article.tsx"
10
+ import { BlogIndex } from "../components/BlogIndex.tsx"
11
+ import { ChangelogIndex } from "../components/ChangelogIndex.tsx"
12
+ import { TagIndex } from "../components/TagIndex.tsx"
13
+ import markdownCss from "../styles/markdown.css?url"
45
14
 
46
15
  export const Route = createFileRoute("/$")({
47
16
  loader: ({ params }) => {
17
+ if (!params._splat) {
18
+ if (homeArticle) return { ...homeArticle, sidebar: homeArticle.sidebar }
19
+ if (!firstArticle) throw notFound()
20
+ throw redirect({
21
+ to: "/$",
22
+ params: { _splat: firstArticle.path.replace(/^\/|\/$/g, "") },
23
+ })
24
+ }
48
25
  const splat = `/${params._splat}/`
49
- const article = sortedArticles.find(a => a.pathname === splat)
50
- if (!article) throw notFound()
51
- return article
26
+ const article = sortedArticles.find(a => a.path === splat)
27
+ if (article) return article
28
+
29
+ const section = currentSection(splat)
30
+ if (section?.type === "changelog" && section.prefix === splat) {
31
+ return {
32
+ changelogIndex: true as const,
33
+ sectionPrefix: section.prefix,
34
+ title: section.title,
35
+ sidebar: true,
36
+ }
37
+ }
38
+ if (section?.type === "blog") {
39
+ if (section.prefix === splat) {
40
+ return {
41
+ blogIndex: true as const,
42
+ sectionPrefix: section.prefix,
43
+ title: section.title,
44
+ sidebar: true,
45
+ }
46
+ }
47
+
48
+ const tagMatch = splat.match(
49
+ new RegExp(`^${section.prefix}tags/([^/]+)/$`),
50
+ )
51
+ const tag = tagMatch?.[1]
52
+ if (tag) {
53
+ const tagMap = sectionTags.get(section.prefix)
54
+ if (tagMap?.has(tag)) {
55
+ return {
56
+ tagPage: true as const,
57
+ sectionPrefix: section.prefix,
58
+ tag,
59
+ title: `Posts tagged '${tag}'`,
60
+ sidebar: true,
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ throw notFound()
52
67
  },
53
68
  head: ({ loaderData }) => ({
54
69
  meta: [
55
70
  ...(loaderData ? [{ title: loaderData.title }] : []),
56
- ...(loaderData?.description
71
+ ...("description" in loaderData && loaderData.description
57
72
  ? [{ name: "description", content: loaderData.description }]
58
73
  : []),
59
74
  ],
75
+ links: [{ rel: "stylesheet", href: markdownCss }],
60
76
  }),
61
77
  component: Component,
62
78
  })
63
79
 
64
80
  function Component() {
65
- const article = Route.useLoaderData()
66
- const lastUpdated = formatDate(article.lastUpdated)
67
-
68
- const H1 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (
69
- <>
70
- <h1 {...props}>
71
- {props.id ? (
72
- <a href={`#${props.id}`}>{props.children}</a>
73
- ) : (
74
- props.children
75
- )}
76
- </h1>
77
- {lastUpdated && (
78
- <p className="flex items-center gap-1.5 text-sm text-muted-foreground !-mt-4 !mb-6">
79
- <Calendar className="size-3.5" />
80
- Last updated {lastUpdated}
81
- </p>
82
- )}
83
- </>
84
- )
85
-
86
- return (
87
- <TocContext.Provider value={article.toc}>
88
- <div className="flex flex-1 gap-10 p-6 md:p-10">
89
- <div className="flex-1 min-w-0 mx-auto max-w-3xl">
90
- <div className="prose dark:prose-invert max-w-none">
91
- <MDXContent
92
- code={article.mdx}
93
- components={{
94
- hr: Separator,
95
- img: ZoomImage,
96
- pre: CodeBlock,
97
- table: SortableTable,
98
- thead: TableHeader,
99
- tbody: TableBody,
100
- tr: TableRow,
101
- th: TableHead,
102
- td: TableCell,
103
- a: FootnoteRef,
104
- Abbr,
105
- AnsiCode,
106
- Callout,
107
- Card,
108
- Cards,
109
- Column,
110
- Columns,
111
- CodeTabs,
112
- ContentTab,
113
- ContentTabs,
114
- DefinitionDetail,
115
- DefinitionList,
116
- DefinitionTerm,
117
- Details,
118
- FileTree,
119
- InlineBadge,
120
- InlineIcon,
121
- InlineToc,
122
- LinkButton,
123
- Mermaid,
124
- PackageTabs,
125
- SoundCloud,
126
- YouTube,
127
- ...headingComponents,
128
- h1: H1,
129
- }}
130
- />
131
- </div>
132
- <PrevNext pathname={article.pathname} />
133
- </div>
134
- <Toc items={article.toc}>
135
- <PageToolbar filePath={article.filePath} content={article.content} />
136
- </Toc>
137
- </div>
138
- </TocContext.Provider>
139
- )
140
- }
141
-
142
- function formatDate(iso?: string) {
143
- if (!iso) return undefined
144
- return new Date(iso).toLocaleDateString("en-US", {
145
- year: "numeric",
146
- month: "short",
147
- day: "numeric",
148
- })
81
+ const data = Route.useLoaderData()
82
+ if ("blogIndex" in data) {
83
+ return <BlogIndex sectionPrefix={data.sectionPrefix} />
84
+ }
85
+ if ("changelogIndex" in data) {
86
+ return <ChangelogIndex sectionPrefix={data.sectionPrefix} />
87
+ }
88
+ if ("tagPage" in data) {
89
+ return <TagIndex sectionPrefix={data.sectionPrefix} tag={data.tag} />
90
+ }
91
+ return <Article article={data} />
149
92
  }