polen 0.9.0-next.4 → 0.9.0-next.6

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 (39) hide show
  1. package/build/api/singletons/markdown/markdown.d.ts.map +1 -1
  2. package/build/api/singletons/markdown/markdown.js +33 -11
  3. package/build/api/singletons/markdown/markdown.js.map +1 -1
  4. package/build/api/vite/plugins/pages.d.ts.map +1 -1
  5. package/build/api/vite/plugins/pages.js +17 -0
  6. package/build/api/vite/plugins/pages.js.map +1 -1
  7. package/build/lib/shiki/index.d.ts +2 -0
  8. package/build/lib/shiki/index.d.ts.map +1 -0
  9. package/build/lib/shiki/index.js +2 -0
  10. package/build/lib/shiki/index.js.map +1 -0
  11. package/build/lib/shiki/shiki.d.ts +26 -0
  12. package/build/lib/shiki/shiki.d.ts.map +1 -0
  13. package/build/lib/shiki/shiki.js +105 -0
  14. package/build/lib/shiki/shiki.js.map +1 -0
  15. package/build/template/components/CodeBlock.d.ts +17 -0
  16. package/build/template/components/CodeBlock.d.ts.map +1 -0
  17. package/build/template/components/CodeBlock.jsx +42 -0
  18. package/build/template/components/CodeBlock.jsx.map +1 -0
  19. package/build/template/components/sidebar/Sidebar.d.ts +5 -3
  20. package/build/template/components/sidebar/Sidebar.d.ts.map +1 -1
  21. package/build/template/components/sidebar/Sidebar.jsx +3 -3
  22. package/build/template/components/sidebar/Sidebar.jsx.map +1 -1
  23. package/build/template/entry.client.jsx +1 -0
  24. package/build/template/entry.client.jsx.map +1 -1
  25. package/build/template/routes/root.d.ts.map +1 -1
  26. package/build/template/routes/root.jsx +56 -29
  27. package/build/template/routes/root.jsx.map +1 -1
  28. package/package.json +9 -1
  29. package/src/api/singletons/markdown/markdown.test.ts +89 -0
  30. package/src/api/singletons/markdown/markdown.ts +35 -13
  31. package/src/api/vite/plugins/pages.ts +17 -0
  32. package/src/lib/shiki/index.ts +1 -0
  33. package/src/lib/shiki/shiki.test.ts +107 -0
  34. package/src/lib/shiki/shiki.ts +161 -0
  35. package/src/template/components/CodeBlock.tsx +73 -0
  36. package/src/template/components/sidebar/Sidebar.tsx +7 -5
  37. package/src/template/entry.client.tsx +1 -0
  38. package/src/template/routes/root.tsx +90 -46
  39. package/src/template/styles/code-block.css +186 -0
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { parse } from './markdown.js'
3
+
4
+ describe(`markdown parser with syntax highlighting`, () => {
5
+ test(`parse highlights code blocks`, async () => {
6
+ const markdown = `
7
+ # Hello
8
+
9
+ \`\`\`javascript
10
+ const x = 42
11
+ console.log(x)
12
+ \`\`\`
13
+ `
14
+ const result = await parse(markdown)
15
+
16
+ expect(result).toContain(`<h1>Hello</h1>`)
17
+ expect(result).toContain(`<pre`)
18
+ expect(result).toContain(`shiki`)
19
+ expect(result).toContain(`console`)
20
+ expect(result).toContain(`42`)
21
+ })
22
+
23
+ // Note: parseSync cannot be used with async rehype plugins like Shiki
24
+ // This is a known limitation - syntax highlighting requires async processing
25
+
26
+ test(`parse supports GraphQL syntax`, async () => {
27
+ const markdown = `
28
+ \`\`\`graphql
29
+ type Query {
30
+ user(id: ID!): User
31
+ }
32
+ \`\`\`
33
+ `
34
+ const result = await parse(markdown)
35
+
36
+ expect(result).toContain(`type`)
37
+ expect(result).toContain(`Query`)
38
+ // Check that both ID and ! are present (they may be in separate spans)
39
+ expect(result).toContain(`> ID<`)
40
+ expect(result).toContain(`>!</`)
41
+ })
42
+
43
+ test(`parse handles inline code`, async () => {
44
+ const markdown = `This is \`inline code\` in a sentence.`
45
+ const result = await parse(markdown)
46
+
47
+ expect(result).toContain(`<code>inline code</code>`)
48
+ })
49
+
50
+ test(`parse supports GitHub Flavored Markdown`, async () => {
51
+ const markdown = `
52
+ | Column 1 | Column 2 |
53
+ |----------|----------|
54
+ | Cell 1 | Cell 2 |
55
+
56
+ - [x] Task 1
57
+ - [ ] Task 2
58
+ `
59
+ const result = await parse(markdown)
60
+
61
+ expect(result).toContain(`<table>`)
62
+ expect(result).toContain(`<input`)
63
+ expect(result).toContain(`checked`)
64
+ })
65
+
66
+ test(`parse handles code blocks without language`, async () => {
67
+ const markdown = `
68
+ \`\`\`
69
+ plain text without language
70
+ \`\`\`
71
+ `
72
+ const result = await parse(markdown)
73
+
74
+ expect(result).toContain(`<pre`)
75
+ expect(result).toContain(`plain text without language`)
76
+ })
77
+
78
+ test(`parse preserves theme CSS variables`, async () => {
79
+ const markdown = `
80
+ \`\`\`javascript
81
+ const theme = "light"
82
+ \`\`\`
83
+ `
84
+ const result = await parse(markdown)
85
+
86
+ expect(result).toContain(`--shiki-light`)
87
+ expect(result).toContain(`--shiki-dark`)
88
+ })
89
+ })
@@ -1,21 +1,43 @@
1
- import * as Remark from 'remark'
2
- import RemarkGfm from 'remark-gfm'
3
- import RemarkHtml from 'remark-html'
1
+ import { unified } from 'unified'
2
+ import remarkParse from 'remark-parse'
3
+ import remarkGfm from 'remark-gfm'
4
+ import remarkRehype from 'remark-rehype'
5
+ import rehypeShiki from '@shikijs/rehype'
6
+ import rehypeStringify from 'rehype-stringify'
4
7
 
5
- export const parse = async (content: string): Promise<string> => {
6
- const result = await Remark.remark()
7
- .use(RemarkGfm)
8
- .use(RemarkHtml)
9
- .process(content)
8
+ // Create a processor with Shiki for syntax highlighting (async only)
9
+ const createProcessorWithShiki = () => {
10
+ return unified()
11
+ .use(remarkParse)
12
+ .use(remarkGfm)
13
+ .use(remarkRehype)
14
+ .use(rehypeShiki, {
15
+ themes: {
16
+ light: `github-light`,
17
+ dark: `tokyo-night`,
18
+ },
19
+ defaultColor: false,
20
+ cssVariablePrefix: `--shiki-`,
21
+ })
22
+ .use(rehypeStringify)
23
+ }
24
+
25
+ // Create a processor without syntax highlighting for sync processing
26
+ const createProcessorSync = () => {
27
+ return unified()
28
+ .use(remarkParse)
29
+ .use(remarkGfm)
30
+ .use(remarkRehype)
31
+ .use(rehypeStringify)
32
+ }
10
33
 
34
+ export const parse = async (content: string): Promise<string> => {
35
+ const result = await createProcessorWithShiki().process(content)
11
36
  return String(result)
12
37
  }
13
38
 
14
39
  export const parseSync = (content: string): string => {
15
- const result = Remark.remark()
16
- .use(RemarkGfm)
17
- .use(RemarkHtml)
18
- .processSync(content)
19
-
40
+ // Note: Syntax highlighting is not available in sync mode due to @shikijs/rehype being async-only
41
+ const result = createProcessorSync().processSync(content)
20
42
  return String(result)
21
43
  }
@@ -10,6 +10,7 @@ import { superjson } from '#singletons/superjson'
10
10
  import mdx from '@mdx-js/rollup'
11
11
  import { Path, Str } from '@wollybeard/kit'
12
12
  import remarkGfm from 'remark-gfm'
13
+ import rehypeShiki from '@shikijs/rehype'
13
14
 
14
15
  const _debug = debug.sub(`vite-plugin-pages`)
15
16
 
@@ -123,6 +124,22 @@ export const Pages = ({
123
124
  ...mdx({
124
125
  jsxImportSource: `polen/react`,
125
126
  remarkPlugins: [remarkGfm],
127
+ rehypePlugins: [
128
+ [
129
+ rehypeShiki,
130
+ {
131
+ themes: {
132
+ light: `github-light`,
133
+ dark: `tokyo-night`,
134
+ },
135
+ defaultColor: false,
136
+ cssVariablePrefix: `--shiki-`,
137
+ transformers: [
138
+ // Line numbers will be handled via CSS
139
+ ],
140
+ },
141
+ ],
142
+ ],
126
143
  }),
127
144
  },
128
145
 
@@ -0,0 +1 @@
1
+ export * from './shiki.ts'
@@ -0,0 +1,107 @@
1
+ import { expect, test } from 'vitest'
2
+ import { getHighlighter, highlightCode } from './shiki.js'
3
+
4
+ test(`getHighlighter returns singleton instance`, async () => {
5
+ const highlighter1 = await getHighlighter()
6
+ const highlighter2 = await getHighlighter()
7
+
8
+ expect(highlighter1).toBe(highlighter2)
9
+ })
10
+
11
+ test(`highlightCode generates HTML with syntax highlighting`, async () => {
12
+ const code = `const hello = "world"`
13
+ const result = await highlightCode({
14
+ code,
15
+ lang: `javascript`,
16
+ theme: `light`,
17
+ })
18
+
19
+ expect(result).toContain(`<pre`)
20
+ expect(result).toContain(`shiki`)
21
+ expect(result).toContain(`hello`)
22
+ expect(result).toContain(`world`)
23
+ })
24
+
25
+ test(`highlightCode supports TypeScript`, async () => {
26
+ const code = `interface User { name: string }`
27
+ const result = await highlightCode({
28
+ code,
29
+ lang: `typescript`,
30
+ theme: `light`,
31
+ })
32
+
33
+ expect(result).toContain(`interface`)
34
+ expect(result).toContain(`User`)
35
+ expect(result).toContain(`string`)
36
+ })
37
+
38
+ test(`highlightCode supports GraphQL`, async () => {
39
+ const code = `type Query { user: User }`
40
+ const result = await highlightCode({
41
+ code,
42
+ lang: `graphql`,
43
+ theme: `light`,
44
+ })
45
+
46
+ expect(result).toContain(`type`)
47
+ expect(result).toContain(`Query`)
48
+ expect(result).toContain(`User`)
49
+ })
50
+
51
+ test(`highlightCode handles unknown language gracefully`, async () => {
52
+ const code = `some random text`
53
+ // Use 'text' as fallback for unknown languages
54
+ const result = await highlightCode({
55
+ code,
56
+ lang: `text`,
57
+ theme: `light`,
58
+ })
59
+
60
+ // Should still return highlighted HTML
61
+ expect(result).toContain(`<pre`)
62
+ expect(result).toContain(`some random text`)
63
+ })
64
+
65
+ test(`highlightCode applies line numbers when requested`, async () => {
66
+ const code = `line 1\nline 2\nline 3`
67
+ const result = await highlightCode({
68
+ code,
69
+ lang: `text`,
70
+ theme: `light`,
71
+ showLineNumbers: true,
72
+ })
73
+
74
+ expect(result).toContain(`data-line-numbers="true"`)
75
+ })
76
+
77
+ test(`highlightCode applies line highlighting`, async () => {
78
+ const code = `line 1\nline 2\nline 3`
79
+ const result = await highlightCode({
80
+ code,
81
+ lang: `text`,
82
+ theme: `light`,
83
+ highlightLines: [2],
84
+ })
85
+
86
+ expect(result).toContain(`data-highlighted="true"`)
87
+ })
88
+
89
+ test(`highlightCode supports both light and dark themes`, async () => {
90
+ const code = `const x = 1`
91
+
92
+ const lightResult = await highlightCode({
93
+ code,
94
+ lang: `javascript`,
95
+ theme: `light`,
96
+ })
97
+
98
+ const darkResult = await highlightCode({
99
+ code,
100
+ lang: `javascript`,
101
+ theme: `dark`,
102
+ })
103
+
104
+ // Both should contain theme CSS variables
105
+ expect(lightResult).toContain(`--shiki-light`)
106
+ expect(darkResult).toContain(`--shiki-dark`)
107
+ })
@@ -0,0 +1,161 @@
1
+ import { createHighlighter, type Highlighter, type BundledTheme, type BundledLanguage } from 'shiki'
2
+ import {
3
+ transformerNotationHighlight,
4
+ transformerNotationDiff,
5
+ transformerNotationFocus,
6
+ transformerRenderWhitespace,
7
+ } from '@shikijs/transformers'
8
+
9
+ export interface ShikiOptions {
10
+ themes?: {
11
+ light: BundledTheme
12
+ dark: BundledTheme
13
+ }
14
+ langs?: BundledLanguage[]
15
+ defaultTheme?: 'light' | 'dark'
16
+ }
17
+
18
+ const DEFAULT_THEMES = {
19
+ light: `github-light` as BundledTheme,
20
+ dark: `tokyo-night` as BundledTheme,
21
+ }
22
+
23
+ const DEFAULT_LANGS: BundledLanguage[] = [
24
+ `typescript`,
25
+ `javascript`,
26
+ `jsx`,
27
+ `tsx`,
28
+ `graphql`,
29
+ `json`,
30
+ `yaml`,
31
+ `markdown`,
32
+ `bash`,
33
+ `shell`,
34
+ `css`,
35
+ `html`,
36
+ `sql`,
37
+ `python`,
38
+ `rust`,
39
+ `go`,
40
+ `java`,
41
+ `csharp`,
42
+ `php`,
43
+ `ruby`,
44
+ `swift`,
45
+ `kotlin`,
46
+ `scala`,
47
+ `r`,
48
+ `matlab`,
49
+ `latex`,
50
+ `dockerfile`,
51
+ `makefile`,
52
+ `nginx`,
53
+ `apache`,
54
+ `xml`,
55
+ `toml`,
56
+ `ini`,
57
+ `diff`,
58
+ ]
59
+
60
+ // Singleton highlighter instance
61
+ let highlighterInstance: Highlighter | null = null
62
+ let highlighterPromise: Promise<Highlighter> | null = null
63
+
64
+ export async function getHighlighter(options: ShikiOptions = {}): Promise<Highlighter> {
65
+ if (highlighterInstance) {
66
+ return highlighterInstance
67
+ }
68
+
69
+ if (!highlighterPromise) {
70
+ const themes = options.themes || DEFAULT_THEMES
71
+ const langs = options.langs || DEFAULT_LANGS
72
+
73
+ highlighterPromise = createHighlighter({
74
+ themes: [themes.light, themes.dark],
75
+ langs,
76
+ }).then(highlighter => {
77
+ highlighterInstance = highlighter
78
+ return highlighter
79
+ })
80
+ }
81
+
82
+ return highlighterPromise
83
+ }
84
+
85
+ export interface CodeHighlightOptions {
86
+ code: string
87
+ lang?: string
88
+ theme?: 'light' | 'dark'
89
+ showLineNumbers?: boolean
90
+ highlightLines?: number[]
91
+ diffLines?: { add: number[], remove: number[] }
92
+ focusLines?: number[]
93
+ showInvisibles?: boolean
94
+ }
95
+
96
+ export async function highlightCode({
97
+ code,
98
+ lang = `text`,
99
+ theme = `light`,
100
+ showLineNumbers = false,
101
+ highlightLines = [],
102
+ diffLines,
103
+ focusLines = [],
104
+ showInvisibles = false,
105
+ }: CodeHighlightOptions): Promise<string> {
106
+ const highlighter = await getHighlighter()
107
+
108
+ const themes = {
109
+ light: DEFAULT_THEMES.light,
110
+ dark: DEFAULT_THEMES.dark,
111
+ }
112
+
113
+ const transformers = []
114
+
115
+ // Add line numbers transformer if needed
116
+ if (showLineNumbers) {
117
+ // Custom line numbers will be handled in CSS
118
+ transformers.push({
119
+ name: `line-numbers`,
120
+ pre(node: any) {
121
+ node.properties[`data-line-numbers`] = `true`
122
+ }
123
+ })
124
+ }
125
+
126
+ // Add highlight transformer
127
+ if (highlightLines.length > 0) {
128
+ transformers.push({
129
+ name: `highlight-lines`,
130
+ line(node: any, line: number) {
131
+ if (highlightLines.includes(line)) {
132
+ node.properties[`data-highlighted`] = `true`
133
+ }
134
+ }
135
+ })
136
+ }
137
+
138
+ // Add standard transformers
139
+ transformers.push(
140
+ transformerNotationHighlight(),
141
+ transformerNotationDiff(),
142
+ transformerNotationFocus(),
143
+ )
144
+
145
+ if (showInvisibles) {
146
+ transformers.push(transformerRenderWhitespace())
147
+ }
148
+
149
+ // Generate HTML with CSS variables for theme switching
150
+ const html = highlighter.codeToHtml(code, {
151
+ lang,
152
+ themes,
153
+ defaultColor: false,
154
+ transformers,
155
+ })
156
+
157
+ return html
158
+ }
159
+
160
+ // Re-export types
161
+ export type { Highlighter, BundledTheme, BundledLanguage } from 'shiki'
@@ -0,0 +1,73 @@
1
+ import { highlightCode } from '#lib/shiki/index'
2
+ import React, { useEffect, useState } from 'react'
3
+
4
+ interface CodeBlockProps {
5
+ children: string
6
+ language?: string
7
+ className?: string
8
+ showLineNumbers?: boolean
9
+ highlightLines?: number[]
10
+ diffLines?: { add: number[]; remove: number[] }
11
+ focusLines?: number[]
12
+ showInvisibles?: boolean
13
+ }
14
+
15
+ export const CodeBlock: React.FC<CodeBlockProps> = ({
16
+ children,
17
+ language = `text`,
18
+ className = ``,
19
+ showLineNumbers = false,
20
+ highlightLines = [],
21
+ diffLines,
22
+ focusLines = [],
23
+ showInvisibles = false,
24
+ }) => {
25
+ const [html, setHtml] = useState<string>(``)
26
+ const [isLoading, setIsLoading] = useState(true)
27
+
28
+ // TODO: Implement proper theme detection
29
+ // For now, we'll rely on CSS to handle theme switching
30
+ const theme = `light` // Default to light theme
31
+
32
+ useEffect(() => {
33
+ const renderCode = async () => {
34
+ try {
35
+ const output = await highlightCode({
36
+ code: children,
37
+ lang: language,
38
+ theme,
39
+ showLineNumbers,
40
+ highlightLines,
41
+ diffLines,
42
+ focusLines,
43
+ showInvisibles,
44
+ })
45
+
46
+ setHtml(output)
47
+ } catch (error) {
48
+ console.error(`Failed to highlight code:`, error)
49
+ // Fallback to plain text
50
+ setHtml(`<pre><code>${children}</code></pre>`)
51
+ } finally {
52
+ setIsLoading(false)
53
+ }
54
+ }
55
+
56
+ renderCode()
57
+ }, [children, language, showLineNumbers, highlightLines, diffLines, focusLines, showInvisibles])
58
+
59
+ if (isLoading) {
60
+ return (
61
+ <pre className={className}>
62
+ <code>{children}</code>
63
+ </pre>
64
+ )
65
+ }
66
+
67
+ return (
68
+ <div
69
+ className={`code-block ${className}`}
70
+ dangerouslySetInnerHTML={{ __html: html }}
71
+ />
72
+ )
73
+ }
@@ -1,17 +1,19 @@
1
1
  import type { FileRouter } from '#lib/file-router/index'
2
2
  import { Box } from '@radix-ui/themes'
3
+ import type { BoxOwnProps, LayoutProps, MarginProps } from '@radix-ui/themes/props'
3
4
  import { Items } from './SidebarItem.tsx'
4
5
 
5
- interface SidebarProps {
6
- items: FileRouter.Sidebar.Item[]
6
+ interface SidebarProps extends LayoutProps, MarginProps, BoxOwnProps {
7
+ data: FileRouter.Sidebar.Item[]
8
+ style?: React.CSSProperties
7
9
  }
8
10
 
9
- export const Sidebar = ({ items }: SidebarProps) => {
11
+ export const Sidebar = ({ data, ...props }: SidebarProps) => {
10
12
  return (
11
13
  <Box
12
14
  data-testid='sidebar'
13
15
  role='Sidebar'
14
- flexShrink='0'
16
+ {...props}
15
17
  >
16
18
  <style>
17
19
  {`
@@ -20,7 +22,7 @@ export const Sidebar = ({ items }: SidebarProps) => {
20
22
  }
21
23
  `}
22
24
  </style>
23
- <Items items={items} />
25
+ <Items items={data} />
24
26
  </Box>
25
27
  )
26
28
  }
@@ -2,6 +2,7 @@
2
2
  // But then, we won't get it from the client manifest. But we could get it from the server manifest. Should we do that?
3
3
  // But then, that wouldn't work for SPA. Does that matter? Just put a conditional here e.g. if (import.meta.env.PROD) ...?
4
4
  import '@radix-ui/themes/styles.css'
5
+ // import './styles/code-block.css' // TODO: Handle CSS in build process
5
6
  import { ReactDomClient } from '#dep/react-dom-client/index'
6
7
  import { StrictMode } from 'react'
7
8
  import { createBrowserRouter, RouterProvider } from 'react-router'