@livemark/markdown 0.0.0-stage → 0.45.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 (184) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +5 -2
  3. package/build/actions/markdown/compile.d.ts +27 -0
  4. package/build/actions/markdown/compile.js +48 -0
  5. package/build/actions/processor/create.d.ts +17 -0
  6. package/build/actions/processor/create.js +212 -0
  7. package/build/components/Abbr.d.ts +5 -0
  8. package/build/components/Abbr.js +7 -0
  9. package/build/components/AnsiCode.d.ts +4 -0
  10. package/build/components/AnsiCode.js +7 -0
  11. package/build/components/Audio.d.ts +4 -0
  12. package/build/components/Audio.js +9 -0
  13. package/build/components/Badge.d.ts +5 -0
  14. package/build/components/Badge.js +7 -0
  15. package/build/components/Button.d.ts +7 -0
  16. package/build/components/Button.js +11 -0
  17. package/build/components/Callout.d.ts +8 -0
  18. package/build/components/Callout.js +41 -0
  19. package/build/components/Card.d.ts +7 -0
  20. package/build/components/Card.js +16 -0
  21. package/build/components/Cards.d.ts +4 -0
  22. package/build/components/Cards.js +6 -0
  23. package/build/components/CodeBlock.d.ts +1 -0
  24. package/build/components/CodeBlock.js +50 -0
  25. package/build/components/CodeTabs.d.ts +6 -0
  26. package/build/components/CodeTabs.js +48 -0
  27. package/build/components/Column.d.ts +4 -0
  28. package/build/components/Column.js +6 -0
  29. package/build/components/Columns.d.ts +5 -0
  30. package/build/components/Columns.js +8 -0
  31. package/build/components/DefinitionList.d.ts +7 -0
  32. package/build/components/DefinitionList.js +14 -0
  33. package/build/components/Details.d.ts +5 -0
  34. package/build/components/Details.js +10 -0
  35. package/build/components/Filetree.d.ts +4 -0
  36. package/build/components/Filetree.js +22 -0
  37. package/build/components/FootnoteRef.d.ts +4 -0
  38. package/build/components/FootnoteRef.js +33 -0
  39. package/build/components/Heading.d.ts +8 -0
  40. package/build/components/Heading.js +18 -0
  41. package/build/components/Icon.d.ts +5 -0
  42. package/build/components/Icon.js +10 -0
  43. package/build/components/Markdown.d.ts +13 -0
  44. package/build/components/Markdown.js +75 -0
  45. package/build/components/Mermaid.d.ts +4 -0
  46. package/build/components/Mermaid.js +10 -0
  47. package/build/components/MermaidRenderer.d.ts +4 -0
  48. package/build/components/MermaidRenderer.js +44 -0
  49. package/build/components/PackageTabs.d.ts +9 -0
  50. package/build/components/PackageTabs.js +26 -0
  51. package/build/components/SortableTable.d.ts +5 -0
  52. package/build/components/SortableTable.js +63 -0
  53. package/build/components/Tab.d.ts +5 -0
  54. package/build/components/Tab.js +6 -0
  55. package/build/components/Tabs.d.ts +5 -0
  56. package/build/components/Tabs.js +46 -0
  57. package/build/components/Toc.d.ts +4 -0
  58. package/build/components/Toc.js +14 -0
  59. package/build/components/Video.d.ts +4 -0
  60. package/build/components/Video.js +13 -0
  61. package/build/components/ZoomImage.d.ts +4 -0
  62. package/build/components/ZoomImage.js +26 -0
  63. package/build/elements/badge.d.ts +7 -0
  64. package/build/elements/badge.js +36 -0
  65. package/build/elements/button.d.ts +8 -0
  66. package/build/elements/button.js +37 -0
  67. package/build/elements/card.d.ts +11 -0
  68. package/build/elements/card.js +25 -0
  69. package/build/elements/collapsible.d.ts +5 -0
  70. package/build/elements/collapsible.js +13 -0
  71. package/build/elements/hover-card.d.ts +5 -0
  72. package/build/elements/hover-card.js +14 -0
  73. package/build/elements/separator.d.ts +3 -0
  74. package/build/elements/separator.js +8 -0
  75. package/build/elements/table.d.ts +9 -0
  76. package/build/elements/table.js +28 -0
  77. package/build/elements/tooltip.d.ts +6 -0
  78. package/build/elements/tooltip.js +17 -0
  79. package/build/helpers/ansi.d.ts +3 -0
  80. package/build/helpers/ansi.js +208 -0
  81. package/build/helpers/article-link.d.ts +7 -0
  82. package/build/helpers/article-link.js +8 -0
  83. package/build/helpers/code-icon.d.ts +2 -0
  84. package/build/helpers/code-icon.js +57 -0
  85. package/build/helpers/content-warnings.d.ts +8 -0
  86. package/build/helpers/content-warnings.js +47 -0
  87. package/build/helpers/dynamic-icon-resolver.d.ts +5 -0
  88. package/build/helpers/dynamic-icon-resolver.js +11 -0
  89. package/build/helpers/dynamic-icon.d.ts +5 -0
  90. package/build/helpers/dynamic-icon.js +14 -0
  91. package/build/helpers/frontmatter.d.ts +5 -0
  92. package/build/helpers/frontmatter.js +16 -0
  93. package/build/helpers/icon-names.d.ts +11 -0
  94. package/build/helpers/icon-names.js +35 -0
  95. package/build/helpers/message-format.d.ts +2 -0
  96. package/build/helpers/message-format.js +7 -0
  97. package/build/helpers/node-text.d.ts +9 -0
  98. package/build/helpers/node-text.js +9 -0
  99. package/build/helpers/site-messages.d.ts +86 -0
  100. package/build/helpers/site-messages.js +7 -0
  101. package/build/helpers/toc-context.d.ts +5 -0
  102. package/build/helpers/toc-context.js +8 -0
  103. package/build/helpers/toc-outline.d.ts +3 -0
  104. package/build/helpers/toc-outline.js +16 -0
  105. package/build/helpers/youtube-id.d.ts +2 -0
  106. package/build/helpers/youtube-id.js +25 -0
  107. package/build/hooks/icons.d.ts +13 -0
  108. package/build/hooks/icons.js +12 -0
  109. package/build/hooks/messages.d.ts +95 -0
  110. package/build/hooks/messages.js +17 -0
  111. package/build/models/message.d.ts +177 -0
  112. package/build/models/message.js +88 -0
  113. package/build/models/plugins.d.ts +10 -0
  114. package/build/models/plugins.js +6 -0
  115. package/build/models/theme.d.ts +76 -0
  116. package/build/models/theme.js +71 -0
  117. package/build/models/toc.d.ts +5 -0
  118. package/build/models/toc.js +2 -0
  119. package/build/plugins/twoslash.d.ts +13 -0
  120. package/build/plugins/twoslash.js +146 -0
  121. package/package.json +63 -3
  122. package/source/actions/markdown/compile.ts +82 -0
  123. package/source/actions/processor/create.ts +235 -0
  124. package/source/components/Abbr.tsx +13 -0
  125. package/source/components/AnsiCode.tsx +17 -0
  126. package/source/components/Audio.tsx +18 -0
  127. package/source/components/Badge.tsx +9 -0
  128. package/source/components/Button.tsx +33 -0
  129. package/source/components/Callout.tsx +68 -0
  130. package/source/components/Card.tsx +48 -0
  131. package/source/components/Cards.tsx +8 -0
  132. package/source/components/CodeBlock.tsx +144 -0
  133. package/source/components/CodeTabs.tsx +81 -0
  134. package/source/components/Column.tsx +4 -0
  135. package/source/components/Columns.tsx +10 -0
  136. package/source/components/DefinitionList.tsx +18 -0
  137. package/source/components/Details.tsx +30 -0
  138. package/source/components/Filetree.tsx +63 -0
  139. package/source/components/FootnoteRef.tsx +52 -0
  140. package/source/components/Heading.tsx +20 -0
  141. package/source/components/Icon.tsx +10 -0
  142. package/source/components/Markdown.tsx +89 -0
  143. package/source/components/Mermaid.tsx +20 -0
  144. package/source/components/MermaidRenderer.tsx +48 -0
  145. package/source/components/PackageTabs.tsx +72 -0
  146. package/source/components/SortableTable.tsx +81 -0
  147. package/source/components/Tab.tsx +4 -0
  148. package/source/components/Tabs.tsx +75 -0
  149. package/source/components/Toc.tsx +30 -0
  150. package/source/components/Video.tsx +28 -0
  151. package/source/components/ZoomImage.tsx +25 -0
  152. package/source/elements/badge.tsx +53 -0
  153. package/source/elements/button.tsx +61 -0
  154. package/source/elements/card.tsx +102 -0
  155. package/source/elements/collapsible.tsx +15 -0
  156. package/source/elements/hover-card.tsx +47 -0
  157. package/source/elements/separator.tsx +22 -0
  158. package/source/elements/table.tsx +103 -0
  159. package/source/elements/tooltip.tsx +60 -0
  160. package/source/helpers/ansi.ts +210 -0
  161. package/source/helpers/article-link.ts +11 -0
  162. package/source/helpers/code-icon.ts +59 -0
  163. package/source/helpers/content-warnings.ts +56 -0
  164. package/source/helpers/dynamic-icon-resolver.tsx +9 -0
  165. package/source/helpers/dynamic-icon.tsx +19 -0
  166. package/source/helpers/frontmatter.ts +16 -0
  167. package/source/helpers/icon-names.ts +37 -0
  168. package/source/helpers/message-format.ts +5 -0
  169. package/source/helpers/node-text.ts +14 -0
  170. package/source/helpers/site-messages.ts +8 -0
  171. package/source/helpers/toc-context.ts +10 -0
  172. package/source/helpers/toc-outline.ts +23 -0
  173. package/source/helpers/youtube-id.ts +23 -0
  174. package/source/hooks/icons.ts +18 -0
  175. package/source/hooks/messages.ts +28 -0
  176. package/source/models/message.ts +96 -0
  177. package/source/models/plugins.ts +11 -0
  178. package/source/models/theme.ts +78 -0
  179. package/source/models/toc.ts +5 -0
  180. package/source/plugins/twoslash.ts +163 -0
  181. package/source/standalone.css +7 -0
  182. package/source/styles/general.css +214 -0
  183. package/source/styles/markdown.css +231 -0
  184. package/source/styles/shape.css +14 -0
@@ -0,0 +1,235 @@
1
+ import { textlane } from "textlane"
2
+ import * as html from "textlane/html"
3
+ import * as md from "textlane/md"
4
+ import * as mdx from "textlane/mdx"
5
+ import type { MarkdownPlugins } from "../../models/plugins.ts"
6
+ import { twoslashStage } from "../../plugins/twoslash.ts"
7
+
8
+ /** The frozen textlane processor Markdown compiles with: Markdown or MDX, with the
9
+ * twoslash stage only for documents that have a `twoslash` fence, and the `plugins`
10
+ * in their two slots. */
11
+ export function createProcessor(props: {
12
+ format: "md" | "mdx"
13
+ twoslash: boolean
14
+ outputFormat: "program" | "function-body"
15
+ allowDangerousHtml: boolean
16
+ codeThemeLight: string
17
+ codeThemeDark: string
18
+ plugins?: MarkdownPlugins
19
+ root?: string
20
+ base?: string
21
+ }) {
22
+ const themes = { light: props.codeThemeLight, dark: props.codeThemeDark }
23
+ const processor = textlane()
24
+ .use(md.parse())
25
+ .use(md.gfm())
26
+ .use(md.gemoji())
27
+ .use(md.definitionList())
28
+ .use(md.math())
29
+ .use(md.directive())
30
+ if (props.format === "mdx") processor.use(md.mdx())
31
+ // Before md.component, which renders an unknown directive as its source text
32
+ processor
33
+ .use(props.plugins?.md ?? [])
34
+ .use(md.headingId({ attributes: true, markers: HEADING_MARKERS }))
35
+ .use(md.packageCommand({ component: "PackageTabs" }))
36
+ .use(md.component(COMPONENTS))
37
+ if (props.root !== undefined) {
38
+ processor.use(
39
+ md.pathRewrite({ root: props.root, base: props.base, nodes: ["image"] }),
40
+ )
41
+ }
42
+ processor.use(
43
+ md.toHtml({
44
+ allowDangerousHtml: props.allowDangerousHtml,
45
+ taskListLabels: TASK_LIST_LABELS,
46
+ }),
47
+ )
48
+ // html.katex first, or display math is a `<pre><code>` html.highlight takes for code
49
+ processor.use(html.katex()).use(
50
+ html.highlight({
51
+ themes,
52
+ // a fence with no or an unknown language still gets padding, theme colors and
53
+ // transformers
54
+ defaultLanguage: "text",
55
+ fallbackLanguage: "text",
56
+ // catppuccin-latte's green (3.2:1) and comment grey (3.8:1) sit under WCAG AA on
57
+ // the code background
58
+ colorReplacements: {
59
+ "catppuccin-latte": { "#40a02b": "#2a7e16", "#7c7f93": "#6c6f85" },
60
+ },
61
+ addLanguageClass: true,
62
+ metaProperty: "data-meta",
63
+ inline: "tailing-curly-colon",
64
+ transformers: [
65
+ "notationDiff",
66
+ "notationFocus",
67
+ "notationHighlight",
68
+ "notationErrorLevel",
69
+ "metaHighlight",
70
+ "metaWordHighlight",
71
+ ],
72
+ }),
73
+ )
74
+ // html.raw rebuilds every element and drops `data`, where code keeps its meta for
75
+ // html.highlight
76
+ // Without allowDangerousHtml md.toHtml has dropped raw HTML, which html.raw would parse
77
+ if (props.format === "md" && props.allowDangerousHtml) {
78
+ processor.use(html.raw({ passThrough: MDX_NODE_TYPES }))
79
+ }
80
+ processor.use(html.unwrapImages())
81
+ if (props.twoslash) processor.use(twoslashStage, { themes })
82
+ return processor
83
+ .use(props.plugins?.html ?? [])
84
+ .use(html.slug())
85
+ .use(html.extractToc({ skip: ["data-step", "data-toc-hidden"] }))
86
+ .use(mdx.compile({ outputFormat: props.outputFormat }))
87
+ .freeze()
88
+ }
89
+
90
+ const HEADING_MARKERS = {
91
+ "[step]": { "data-step": true },
92
+ "[!toc]": { "data-toc-hidden": true },
93
+ "[toc]": { hidden: true },
94
+ }
95
+
96
+ const TASK_LIST_LABELS = { checked: "Completed task", unchecked: "Task" }
97
+
98
+ // md.toHtml hands MDX nodes to hast, which html.raw must leave as they are
99
+ const MDX_NODE_TYPES = [
100
+ "mdxFlowExpression",
101
+ "mdxJsxFlowElement",
102
+ "mdxJsxTextElement",
103
+ "mdxTextExpression",
104
+ "mdxjsEsm",
105
+ ]
106
+
107
+ const CALLOUTS = ["note", "tip", "info", "warning", "danger"]
108
+
109
+ // Each livemark directive, alert and node as the component it renders
110
+ const COMPONENTS = {
111
+ directives: [
112
+ {
113
+ name: CALLOUTS,
114
+ kind: "container" as const,
115
+ component: "Callout",
116
+ nameAs: "type",
117
+ labelAs: "title",
118
+ attributes: [],
119
+ },
120
+ {
121
+ name: "card",
122
+ kind: "container" as const,
123
+ component: "Card",
124
+ attributes: [{ name: "title", default: "" }, "href", "icon"],
125
+ group: { component: "Cards" },
126
+ },
127
+ {
128
+ name: "column",
129
+ kind: "container" as const,
130
+ component: "Column",
131
+ attributes: [],
132
+ group: { component: "Columns" },
133
+ },
134
+ {
135
+ name: "tab",
136
+ kind: "container" as const,
137
+ component: "Tab",
138
+ attributes: [{ name: "title", default: "" }, "sync"],
139
+ group: { component: "Tabs", min: 1, inherit: ["sync"] },
140
+ },
141
+ {
142
+ name: "details",
143
+ kind: "container" as const,
144
+ component: "Details",
145
+ labelAs: "summary",
146
+ labelDefault: "Details",
147
+ attributes: [],
148
+ },
149
+ {
150
+ name: "filetree",
151
+ kind: "container" as const,
152
+ component: "Filetree",
153
+ attributes: [],
154
+ treeAs: "tree",
155
+ },
156
+ {
157
+ name: "badge",
158
+ kind: "text" as const,
159
+ component: "Badge",
160
+ labelAs: "label",
161
+ attributes: ["variant"],
162
+ },
163
+ {
164
+ name: "button",
165
+ kind: "leaf" as const,
166
+ component: "Button",
167
+ labelAs: "label",
168
+ attributes: ["href", "label", "variant", "size"],
169
+ required: ["href", "label"],
170
+ },
171
+ {
172
+ name: "icon",
173
+ kind: "text" as const,
174
+ component: "Icon",
175
+ attributes: ["name", "className"],
176
+ required: ["name"],
177
+ },
178
+ {
179
+ name: "abbr",
180
+ kind: "text" as const,
181
+ component: "Abbr",
182
+ labelAs: "text",
183
+ attributes: [{ name: "title", default: "" }],
184
+ },
185
+ {
186
+ name: "toc",
187
+ kind: "leaf" as const,
188
+ component: "Toc",
189
+ attributes: ["maxLevel"],
190
+ },
191
+ {
192
+ name: "video",
193
+ kind: "leaf" as const,
194
+ where: { type: "youtube" },
195
+ component: "Video",
196
+ attributes: ["url"],
197
+ required: ["url"],
198
+ },
199
+ {
200
+ name: "audio",
201
+ kind: "leaf" as const,
202
+ where: { type: "soundcloud" },
203
+ component: "Audio",
204
+ attributes: ["url"],
205
+ required: ["url"],
206
+ },
207
+ ],
208
+ alerts: {
209
+ component: "Callout",
210
+ typeAs: "type",
211
+ types: {
212
+ NOTE: "note",
213
+ TIP: "tip",
214
+ WARNING: "warning",
215
+ CAUTION: "danger",
216
+ IMPORTANT: "info",
217
+ },
218
+ },
219
+ nodes: {
220
+ defList: "DefinitionList",
221
+ defListTerm: "DefinitionTerm",
222
+ defListDescription: "DefinitionDetail",
223
+ },
224
+ codes: [
225
+ { lang: "mermaid", component: "Mermaid" },
226
+ { lang: "ansi", component: "AnsiCode" },
227
+ { meta: "tab", group: { component: "CodeTabs", collect: "tabs", inherit: ["sync"] } },
228
+ ],
229
+ unknownDirectives: "source" as const,
230
+ }
231
+
232
+ /** Every directive name an article may use; any other renders as its source text. */
233
+ export const DIRECTIVE_NAMES = new Set(
234
+ COMPONENTS.directives.flatMap(directive => directive.name),
235
+ )
@@ -0,0 +1,13 @@
1
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../elements/tooltip.tsx"
2
+
3
+ /** Renders an abbreviation with a shadcn tooltip showing its full form */
4
+ export function Abbr(props: { text: string; title: string }) {
5
+ return (
6
+ <Tooltip>
7
+ <TooltipTrigger className="border-b border-dotted border-muted-foreground cursor-help">
8
+ {props.text}
9
+ </TooltipTrigger>
10
+ <TooltipContent>{props.title}</TooltipContent>
11
+ </Tooltip>
12
+ )
13
+ }
@@ -0,0 +1,17 @@
1
+ import { ansiToHtml } from "../helpers/ansi.ts"
2
+
3
+ /** Renders terminal output, coloring its ANSI escape sequences */
4
+ export function AnsiCode(props: { children: string }) {
5
+ return (
6
+ <figure className="shiki not-prose my-4 relative rounded-card border shadow-sm overflow-hidden text-sm bg-sidebar dark:bg-sidebar">
7
+ <div className="text-[0.8125rem] py-3.5 overflow-auto">
8
+ <pre className="min-w-full w-max">
9
+ <code
10
+ className="flex flex-col"
11
+ dangerouslySetInnerHTML={{ __html: ansiToHtml(props.children) }}
12
+ />
13
+ </pre>
14
+ </div>
15
+ </figure>
16
+ )
17
+ }
@@ -0,0 +1,18 @@
1
+ import { useMessages } from "../hooks/messages.ts"
2
+ /** Renders a SoundCloud iframe embed */
3
+ export function Audio(props: { url: string }) {
4
+ const t = useMessages()
5
+ const src = `https://w.soundcloud.com/player/?url=${encodeURIComponent(props.url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`
6
+
7
+ return (
8
+ <div className="not-prose my-4 overflow-hidden rounded-card border">
9
+ <iframe
10
+ className="w-full"
11
+ height={166}
12
+ src={src}
13
+ title={t("media.soundcloud")}
14
+ allow="autoplay"
15
+ />
16
+ </div>
17
+ )
18
+ }
@@ -0,0 +1,9 @@
1
+ import { Badge as BadgeElement } from "../elements/badge.tsx"
2
+
3
+ /** Inline badge rendered from :badge text directives */
4
+ export function Badge(props: {
5
+ label: string
6
+ variant?: "default" | "secondary" | "positive" | "warning" | "destructive" | "outline"
7
+ }) {
8
+ return <BadgeElement variant={props.variant}>{props.label}</BadgeElement>
9
+ }
@@ -0,0 +1,33 @@
1
+ import { buttonVariants } from "../elements/button.tsx"
2
+ import { cn } from "cn"
3
+
4
+ /** Styled link button rendered from ::button leaf directives */
5
+ export function Button(props: {
6
+ href: string
7
+ label: string
8
+ variant?:
9
+ | "default"
10
+ | "outline"
11
+ | "secondary"
12
+ | "ghost"
13
+ | "positive"
14
+ | "warning"
15
+ | "destructive"
16
+ | "link"
17
+ size?: "default" | "sm" | "lg"
18
+ }) {
19
+ return (
20
+ <a
21
+ href={props.href}
22
+ className={cn(
23
+ buttonVariants({
24
+ variant: props.variant,
25
+ size: props.size,
26
+ }),
27
+ "no-underline",
28
+ )}
29
+ >
30
+ {props.label}
31
+ </a>
32
+ )
33
+ }
@@ -0,0 +1,68 @@
1
+ import { AlertTriangle, Flame, Info, Lightbulb } from "lucide-react"
2
+ import { cn } from "cn"
3
+ import type { MessageKey } from "../models/message.ts"
4
+ import { useMessages } from "../hooks/messages.ts"
5
+
6
+ type CalloutType = "note" | "tip" | "info" | "warning" | "danger"
7
+
8
+ interface CalloutConfig {
9
+ icon: React.ElementType
10
+ label: MessageKey
11
+ classes: string
12
+ }
13
+
14
+ const configs: Record<CalloutType, CalloutConfig> = {
15
+ note: {
16
+ icon: Info,
17
+ label: "callout.note",
18
+ classes:
19
+ "border-blue-500/30 bg-blue-50/50 text-blue-900 dark:bg-blue-950/30 dark:text-blue-200",
20
+ },
21
+ info: {
22
+ icon: Info,
23
+ label: "callout.info",
24
+ classes:
25
+ "border-blue-500/30 bg-blue-50/50 text-blue-900 dark:bg-blue-950/30 dark:text-blue-200",
26
+ },
27
+ tip: {
28
+ icon: Lightbulb,
29
+ label: "callout.tip",
30
+ classes: "border-positive/30 bg-positive-tint/50 text-positive-tint-foreground",
31
+ },
32
+ warning: {
33
+ icon: AlertTriangle,
34
+ label: "callout.warning",
35
+ classes: "border-warning/30 bg-warning-tint/50 text-warning-tint-foreground",
36
+ },
37
+ danger: {
38
+ icon: Flame,
39
+ label: "callout.danger",
40
+ classes:
41
+ "border-destructive/30 bg-destructive-tint/50 text-destructive-tint-foreground",
42
+ },
43
+ }
44
+
45
+ /** Styled callout block for notes, tips, warnings, and other admonitions */
46
+ export function Callout(props: {
47
+ type?: CalloutType
48
+ title?: string
49
+ children: React.ReactNode
50
+ }) {
51
+ const t = useMessages()
52
+ const type = props.type ?? "note"
53
+ const config = configs[type]
54
+ const Icon = config.icon
55
+ const title = props.title ?? t(config.label)
56
+
57
+ return (
58
+ <aside className={cn("not-prose my-4 rounded-card border px-4 py-3", config.classes)}>
59
+ <div className="flex items-center gap-2 font-semibold text-sm mb-2">
60
+ <Icon className="size-4 shrink-0" />
61
+ <span>{title}</span>
62
+ </div>
63
+ <div className="text-sm [&>p]:my-1 [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
64
+ {props.children}
65
+ </div>
66
+ </aside>
67
+ )
68
+ }
@@ -0,0 +1,48 @@
1
+ import { Link } from "@tanstack/react-router"
2
+ import { DynamicIcon } from "../helpers/dynamic-icon.tsx"
3
+ import {
4
+ Card as CardElement,
5
+ CardContent,
6
+ CardHeader,
7
+ CardTitle,
8
+ } from "../elements/card.tsx"
9
+
10
+ /** Card component with optional icon, title, description, and link */
11
+ export function Card(props: {
12
+ title: string
13
+ href?: string
14
+ icon?: string
15
+ children?: React.ReactNode
16
+ }) {
17
+ const card = (
18
+ <CardElement size="sm" className="transition-colors hover:bg-accent">
19
+ <CardHeader>
20
+ <CardTitle className="flex items-center gap-2">
21
+ {props.icon && (
22
+ <DynamicIcon name={props.icon} className="size-5 text-primary" />
23
+ )}
24
+ {props.title}
25
+ </CardTitle>
26
+ </CardHeader>
27
+ {props.children && (
28
+ <CardContent>
29
+ <div className="text-muted-foreground text-sm">{props.children}</div>
30
+ </CardContent>
31
+ )}
32
+ </CardElement>
33
+ )
34
+
35
+ if (props.href?.startsWith("http")) {
36
+ return (
37
+ <a href={props.href} target="_blank" rel="noopener noreferrer">
38
+ {card}
39
+ </a>
40
+ )
41
+ }
42
+
43
+ if (props.href) {
44
+ return <Link to={props.href}>{card}</Link>
45
+ }
46
+
47
+ return card
48
+ }
@@ -0,0 +1,8 @@
1
+ /** Grid container for card components */
2
+ export function Cards(props: { children: React.ReactNode }) {
3
+ return (
4
+ <div className="not-prose grid grid-cols-1 gap-4 my-4 sm:grid-cols-2">
5
+ {props.children}
6
+ </div>
7
+ )
8
+ }
@@ -0,0 +1,144 @@
1
+ import { Check, ChevronDown, Clipboard, WrapText } from "lucide-react"
2
+ import { isValidElement, useRef, useState } from "react"
3
+ import type { ReactNode } from "react"
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../elements/tooltip.tsx"
5
+ import { readCodeIcon } from "../helpers/code-icon.ts"
6
+ import { useMessages } from "../hooks/messages.ts"
7
+
8
+ export function CodeBlock(props: React.ComponentProps<"pre">) {
9
+ const [copied, setCopied] = useState(false)
10
+ const t = useMessages()
11
+ const [wrap, setWrap] = useState(false)
12
+ const [expanded, setExpanded] = useState(false)
13
+ const preRef = useRef<HTMLPreElement>(null)
14
+ const extra = props as Record<string, unknown>
15
+ const meta = extra["data-meta"] as string | undefined
16
+ const title = meta?.match(/title="([^"]+)"/)?.[1]
17
+ const maxLines = meta?.match(/maxLines=(\d+)/)?.[1]
18
+ const icon = readCodeIcon(readLanguage(props.children))
19
+ const lineNumbers = meta?.match(/\blineNumbers(?:=(\d+))?(?!\S)/)
20
+ const style = props.style as Record<string, string> | undefined
21
+ const collapsedHeight = maxLines ? `${Number(maxLines) * 1.5 + 1.75}rem` : undefined
22
+
23
+ const handleCopy = () => {
24
+ const text = preRef.current?.textContent ?? ""
25
+ navigator.clipboard.writeText(text)
26
+ setCopied(true)
27
+ setTimeout(() => setCopied(false), 2000)
28
+ }
29
+
30
+ const handleWrapToggle = () => {
31
+ setWrap(prev => !prev)
32
+ }
33
+
34
+ return (
35
+ <figure
36
+ className="shiki not-prose my-4 relative rounded-card border shadow-sm overflow-hidden text-sm bg-sidebar dark:bg-sidebar"
37
+ style={{ ...style, backgroundColor: undefined }}
38
+ >
39
+ {title ? (
40
+ <div className="flex items-center gap-2 h-9.5 border-b px-4 text-muted-foreground">
41
+ {icon && (
42
+ <span
43
+ className="size-3.5 [&>svg]:size-full"
44
+ dangerouslySetInnerHTML={{ __html: icon }}
45
+ />
46
+ )}
47
+ <span className="flex-1 truncate text-xs font-medium">{title}</span>
48
+ <Tooltip>
49
+ <TooltipTrigger
50
+ onClick={handleWrapToggle}
51
+ aria-label={wrap ? t("code.wrapOff") : t("code.wrapOn")}
52
+ className={`-me-2 p-1.5 rounded-control hover:text-foreground transition-colors ${wrap ? "text-foreground" : ""}`}
53
+ >
54
+ <WrapText className="size-3.5" />
55
+ </TooltipTrigger>
56
+ <TooltipContent>{wrap ? t("code.wrapOff") : t("code.wrapOn")}</TooltipContent>
57
+ </Tooltip>
58
+ <Tooltip>
59
+ <TooltipTrigger
60
+ onClick={handleCopy}
61
+ aria-label={copied ? t("code.copied") : t("code.copy")}
62
+ className="-me-2 p-1.5 rounded-control hover:text-foreground transition-colors"
63
+ >
64
+ {copied ? (
65
+ <Check className="size-3.5" />
66
+ ) : (
67
+ <Clipboard className="size-3.5" />
68
+ )}
69
+ </TooltipTrigger>
70
+ <TooltipContent>{copied ? t("code.copied") : t("code.copy")}</TooltipContent>
71
+ </Tooltip>
72
+ </div>
73
+ ) : (
74
+ <div className="empty:hidden absolute top-2 right-2 z-10 backdrop-blur-lg rounded-control text-muted-foreground">
75
+ <Tooltip>
76
+ <TooltipTrigger
77
+ onClick={handleWrapToggle}
78
+ aria-label={wrap ? t("code.wrapOff") : t("code.wrapOn")}
79
+ className={`p-1.5 rounded-control hover:text-foreground transition-colors ${wrap ? "text-foreground" : ""}`}
80
+ >
81
+ <WrapText className="size-3.5" />
82
+ </TooltipTrigger>
83
+ <TooltipContent>{wrap ? t("code.wrapOff") : t("code.wrapOn")}</TooltipContent>
84
+ </Tooltip>
85
+ <Tooltip>
86
+ <TooltipTrigger
87
+ onClick={handleCopy}
88
+ aria-label={copied ? t("code.copied") : t("code.copy")}
89
+ className="p-1.5 rounded-control hover:text-foreground transition-colors"
90
+ >
91
+ {copied ? (
92
+ <Check className="size-3.5" />
93
+ ) : (
94
+ <Clipboard className="size-3.5" />
95
+ )}
96
+ </TooltipTrigger>
97
+ <TooltipContent>{copied ? t("code.copied") : t("code.copy")}</TooltipContent>
98
+ </Tooltip>
99
+ </div>
100
+ )}
101
+ <div
102
+ className="text-[0.8125rem] py-3.5 overflow-auto transition-[max-height] duration-300"
103
+ style={{
104
+ maxHeight: collapsedHeight && !expanded ? collapsedHeight : "600px",
105
+ }}
106
+ >
107
+ <pre
108
+ {...props}
109
+ ref={preRef}
110
+ style={
111
+ lineNumbers
112
+ ? { counterReset: `line ${Number(lineNumbers[1] ?? 1) - 1}` }
113
+ : undefined
114
+ }
115
+ className={[
116
+ props.className,
117
+ lineNumbers && "line-numbers",
118
+ wrap
119
+ ? "min-w-full whitespace-pre-wrap break-words [&>code]:flex [&>code]:flex-col"
120
+ : "min-w-full w-max [&>code]:flex [&>code]:flex-col",
121
+ ]
122
+ .filter(Boolean)
123
+ .join(" ")}
124
+ />
125
+ </div>
126
+ {collapsedHeight && !expanded && (
127
+ <button
128
+ type="button"
129
+ onClick={() => setExpanded(true)}
130
+ className="flex items-center justify-center gap-1 w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border-t cursor-pointer transition-colors"
131
+ >
132
+ <ChevronDown className="size-3.5" />
133
+ {t("code.expand")}
134
+ </button>
135
+ )}
136
+ </figure>
137
+ )
138
+ }
139
+
140
+ // html.highlight names the language in a class of the `<code>` inside `<pre>`
141
+ function readLanguage(children: ReactNode) {
142
+ if (!isValidElement<{ className?: string }>(children)) return undefined
143
+ return children.props.className?.match(/\blanguage-(\S+)/)?.[1]
144
+ }
@@ -0,0 +1,81 @@
1
+ import { Children, useEffect, useState } from "react"
2
+
3
+ /** Tabbed code block component for grouping multiple code blocks with optional sync */
4
+ export function CodeTabs(props: {
5
+ tabs: string
6
+ sync?: string
7
+ children: React.ReactNode
8
+ }) {
9
+ const names = JSON.parse(props.tabs) as string[]
10
+ const children = Children.toArray(props.children)
11
+
12
+ const getInitialIndex = () => {
13
+ if (!props.sync) return 0
14
+ const saved =
15
+ typeof window !== "undefined"
16
+ ? localStorage.getItem("livemark-tab-" + props.sync)
17
+ : null
18
+ if (!saved) return 0
19
+ const idx = names.indexOf(saved)
20
+ return idx >= 0 ? idx : 0
21
+ }
22
+
23
+ const [active, setActive] = useState(getInitialIndex)
24
+
25
+ const handleSelect = (i: number) => {
26
+ setActive(i)
27
+ if (props.sync) {
28
+ localStorage.setItem("livemark-tab-" + props.sync, names[i]!)
29
+ window.dispatchEvent(
30
+ new CustomEvent("livemark-tab-sync", {
31
+ detail: { sync: props.sync, title: names[i] },
32
+ }),
33
+ )
34
+ }
35
+ }
36
+
37
+ useEffect(() => {
38
+ if (!props.sync) return
39
+ const handler = (e: Event) => {
40
+ const { sync, title } = (e as CustomEvent<{ sync: string; title: string }>).detail
41
+ if (sync !== props.sync) return
42
+ const idx = names.indexOf(title)
43
+ if (idx >= 0) setActive(idx)
44
+ }
45
+ window.addEventListener("livemark-tab-sync", handler)
46
+ return () => window.removeEventListener("livemark-tab-sync", handler)
47
+ }, [props.sync, names])
48
+
49
+ return (
50
+ <div className="not-prose my-4 rounded-card border shadow-sm overflow-hidden text-sm bg-sidebar dark:bg-sidebar">
51
+ <div className="flex border-b">
52
+ {names.map((name, i) => (
53
+ <button
54
+ key={name}
55
+ type="button"
56
+ onClick={() => handleSelect(i)}
57
+ className={`px-4 py-2 text-xs font-medium transition-colors cursor-pointer ${
58
+ i === active
59
+ ? "text-foreground border-b-2 border-primary -mb-px"
60
+ : "text-muted-foreground hover:text-foreground"
61
+ }`}
62
+ >
63
+ {name}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ {children.map((child, i) => (
68
+ <div
69
+ key={names[i]}
70
+ className={
71
+ i === active
72
+ ? "[&>figure]:my-0 [&>figure]:rounded-none [&>figure]:border-0 [&>figure]:shadow-none"
73
+ : "hidden"
74
+ }
75
+ >
76
+ {child}
77
+ </div>
78
+ ))}
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,4 @@
1
+ /** Single column within a Columns grid */
2
+ export function Column(props: { children: React.ReactNode }) {
3
+ return <div className="max-w-none">{props.children}</div>
4
+ }