@sakura-ui/sakura-ui 0.5.0 → 0.5.2

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 (134) hide show
  1. package/package.json +5 -5
  2. package/packages/core/package.json +1 -1
  3. package/packages/core/src/components/Card.tsx +52 -56
  4. package/packages/core/src/components/Code.tsx +6 -6
  5. package/packages/core/src/components/Faq.tsx +30 -26
  6. package/packages/core/src/components/Heading.tsx +50 -48
  7. package/packages/core/src/components/Icon.tsx +34 -34
  8. package/packages/core/src/components/LangSelector.tsx +2 -2
  9. package/packages/core/src/components/Link.tsx +13 -12
  10. package/packages/core/src/components/LinkCard.tsx +87 -76
  11. package/packages/core/src/components/List.tsx +20 -13
  12. package/packages/core/src/components/OverflowContainer.tsx +9 -9
  13. package/packages/core/src/components/PopoverMenu.tsx +2 -13
  14. package/packages/core/src/components/Pre.tsx +9 -9
  15. package/packages/core/src/components/Table.tsx +30 -25
  16. package/packages/core/src/components/index.ts +1 -0
  17. package/packages/core/src/components/styles.ts +42 -0
  18. package/packages/core/src/index.ts +2 -0
  19. package/packages/forms/.turbo/turbo-build.log +4 -4
  20. package/packages/forms/dist/index.cjs.js +13 -21
  21. package/packages/forms/dist/index.es.js +91 -99
  22. package/packages/forms/package.json +1 -1
  23. package/packages/helper/.turbo/turbo-build.log +3 -3
  24. package/packages/helper/dist/index.cjs.js +6 -14
  25. package/packages/helper/dist/index.es.js +18 -26
  26. package/packages/helper/dist/types/styles.d.ts +2 -4
  27. package/packages/helper/dist/types/styles.d.ts.map +1 -1
  28. package/packages/helper/node_modules/.bin/vitest +2 -2
  29. package/packages/helper/package.json +1 -1
  30. package/packages/helper/src/styles.ts +2 -14
  31. package/packages/markdown/.turbo/turbo-build.log +4 -4
  32. package/packages/markdown/README.md +240 -4
  33. package/packages/markdown/dist/index.cjs.js +71 -145
  34. package/packages/markdown/dist/index.es.js +2624 -16747
  35. package/packages/markdown/dist/types/components/Markdown.d.ts +15 -10
  36. package/packages/markdown/dist/types/components/Markdown.d.ts.map +1 -1
  37. package/packages/markdown/dist/types/components/index.d.ts +1 -1
  38. package/packages/markdown/dist/types/components/index.d.ts.map +1 -1
  39. package/packages/markdown/dist/types/decorate.d.ts +24 -0
  40. package/packages/markdown/dist/types/decorate.d.ts.map +1 -0
  41. package/packages/markdown/dist/types/index.d.ts +3 -1
  42. package/packages/markdown/dist/types/index.d.ts.map +1 -1
  43. package/packages/markdown/dist/types/marked/attributes.d.ts +31 -0
  44. package/packages/markdown/dist/types/marked/attributes.d.ts.map +1 -0
  45. package/packages/markdown/dist/types/marked/directives/card.d.ts +3 -0
  46. package/packages/markdown/dist/types/marked/directives/card.d.ts.map +1 -0
  47. package/packages/markdown/dist/types/marked/directives/context.d.ts +36 -0
  48. package/packages/markdown/dist/types/marked/directives/context.d.ts.map +1 -0
  49. package/packages/markdown/dist/types/marked/directives/faq.d.ts +11 -0
  50. package/packages/markdown/dist/types/marked/directives/faq.d.ts.map +1 -0
  51. package/packages/markdown/dist/types/marked/directives/grid.d.ts +9 -0
  52. package/packages/markdown/dist/types/marked/directives/grid.d.ts.map +1 -0
  53. package/packages/markdown/dist/types/marked/directives/index.d.ts +8 -0
  54. package/packages/markdown/dist/types/marked/directives/index.d.ts.map +1 -0
  55. package/packages/markdown/dist/types/marked/directives/linkButton.d.ts +6 -0
  56. package/packages/markdown/dist/types/marked/directives/linkButton.d.ts.map +1 -0
  57. package/packages/markdown/dist/types/marked/directives/youtube.d.ts +3 -0
  58. package/packages/markdown/dist/types/marked/directives/youtube.d.ts.map +1 -0
  59. package/packages/markdown/dist/types/marked/html.d.ts +15 -0
  60. package/packages/markdown/dist/types/marked/html.d.ts.map +1 -0
  61. package/packages/markdown/dist/types/marked/index.d.ts +13 -0
  62. package/packages/markdown/dist/types/marked/index.d.ts.map +1 -0
  63. package/packages/markdown/dist/types/marked/registry.d.ts +8 -0
  64. package/packages/markdown/dist/types/marked/registry.d.ts.map +1 -0
  65. package/packages/markdown/dist/types/marked/renderer.d.ts +16 -0
  66. package/packages/markdown/dist/types/marked/renderer.d.ts.map +1 -0
  67. package/packages/markdown/dist/types/marked/tokenizer.d.ts +40 -0
  68. package/packages/markdown/dist/types/marked/tokenizer.d.ts.map +1 -0
  69. package/packages/markdown/dist/types/render.d.ts +22 -0
  70. package/packages/markdown/dist/types/render.d.ts.map +1 -0
  71. package/packages/markdown/dist/types/sanitize.d.ts +11 -0
  72. package/packages/markdown/dist/types/sanitize.d.ts.map +1 -0
  73. package/packages/markdown/node_modules/.bin/marked +55 -0
  74. package/packages/markdown/package.json +6 -19
  75. package/packages/markdown/src/components/Markdown.tsx +44 -346
  76. package/packages/markdown/src/components/index.ts +1 -1
  77. package/packages/markdown/src/decorate.ts +164 -0
  78. package/packages/markdown/src/index.ts +3 -1
  79. package/packages/markdown/src/marked/attributes.ts +88 -0
  80. package/packages/markdown/src/marked/directives/card.ts +77 -0
  81. package/packages/markdown/src/marked/directives/context.ts +39 -0
  82. package/packages/markdown/src/marked/directives/faq.ts +29 -0
  83. package/packages/markdown/src/marked/directives/grid.ts +50 -0
  84. package/packages/markdown/src/marked/directives/index.ts +29 -0
  85. package/packages/markdown/src/marked/directives/linkButton.ts +20 -0
  86. package/packages/markdown/src/marked/directives/youtube.ts +46 -0
  87. package/packages/markdown/src/marked/html.ts +55 -0
  88. package/packages/markdown/src/marked/index.ts +45 -0
  89. package/packages/markdown/src/marked/registry.ts +34 -0
  90. package/packages/markdown/src/marked/renderer.ts +52 -0
  91. package/packages/markdown/src/marked/tokenizer.ts +175 -0
  92. package/packages/markdown/src/render.ts +55 -0
  93. package/packages/markdown/src/sanitize.ts +194 -0
  94. package/packages/markdown/tests/Markdown.test.tsx +122 -0
  95. package/packages/markdown/tests/__snapshots__/Markdown.test.tsx.snap +451 -0
  96. package/packages/markdown/tests/anchors.test.ts +108 -0
  97. package/packages/markdown/tests/fixtures.ts +100 -0
  98. package/packages/markdown/tests/sanitize.test.ts +168 -0
  99. package/packages/markdown/tests/server.test.tsx +51 -0
  100. package/packages/markdown/tests/vitest.setup.ts +4 -0
  101. package/packages/markdown/tsconfig.test.json +11 -0
  102. package/packages/markdown/vite.config.ts +5 -1
  103. package/packages/tailwind-theme-plugin/.turbo/turbo-build.log +1 -1
  104. package/packages/tailwind-theme-plugin/package.json +1 -1
  105. package/packages/markdown/dist/types/plugins/attr.d.ts +0 -3
  106. package/packages/markdown/dist/types/plugins/attr.d.ts.map +0 -1
  107. package/packages/markdown/dist/types/plugins/card.d.ts +0 -3
  108. package/packages/markdown/dist/types/plugins/card.d.ts.map +0 -1
  109. package/packages/markdown/dist/types/plugins/cell.d.ts +0 -3
  110. package/packages/markdown/dist/types/plugins/cell.d.ts.map +0 -1
  111. package/packages/markdown/dist/types/plugins/faq.d.ts +0 -3
  112. package/packages/markdown/dist/types/plugins/faq.d.ts.map +0 -1
  113. package/packages/markdown/dist/types/plugins/grid.d.ts +0 -3
  114. package/packages/markdown/dist/types/plugins/grid.d.ts.map +0 -1
  115. package/packages/markdown/dist/types/plugins/headings.d.ts +0 -11
  116. package/packages/markdown/dist/types/plugins/headings.d.ts.map +0 -1
  117. package/packages/markdown/dist/types/plugins/helper.d.ts +0 -9
  118. package/packages/markdown/dist/types/plugins/helper.d.ts.map +0 -1
  119. package/packages/markdown/dist/types/plugins/index.d.ts +0 -9
  120. package/packages/markdown/dist/types/plugins/index.d.ts.map +0 -1
  121. package/packages/markdown/dist/types/plugins/linkButton.d.ts +0 -3
  122. package/packages/markdown/dist/types/plugins/linkButton.d.ts.map +0 -1
  123. package/packages/markdown/dist/types/plugins/youtube.d.ts +0 -3
  124. package/packages/markdown/dist/types/plugins/youtube.d.ts.map +0 -1
  125. package/packages/markdown/src/plugins/attr.ts +0 -19
  126. package/packages/markdown/src/plugins/card.ts +0 -71
  127. package/packages/markdown/src/plugins/cell.ts +0 -47
  128. package/packages/markdown/src/plugins/faq.ts +0 -40
  129. package/packages/markdown/src/plugins/grid.ts +0 -54
  130. package/packages/markdown/src/plugins/headings.ts +0 -38
  131. package/packages/markdown/src/plugins/helper.ts +0 -27
  132. package/packages/markdown/src/plugins/index.ts +0 -8
  133. package/packages/markdown/src/plugins/linkButton.ts +0 -26
  134. package/packages/markdown/src/plugins/youtube.ts +0 -49
@@ -1,63 +1,13 @@
1
- import React, { Children } from 'react'
2
- import * as production from 'react/jsx-runtime'
3
- import { remark } from 'remark'
4
- import { unified } from 'unified'
5
- import remarkParse from 'remark-parse'
6
- import remarkGfm from 'remark-gfm'
7
- import remarkDirective from 'remark-directive'
8
- import remarkRehype from 'remark-rehype'
9
- import remarkBreaks from 'remark-breaks'
10
- import rehypeRaw from 'rehype-raw'
11
- import rehypeExternalLinks from 'rehype-external-links'
12
- import rebypeShiftHeding from 'rehype-shift-heading'
13
- import rehypeReact from 'rehype-react'
14
- import rehypeSlug from 'rehype-slug'
15
- import { cx } from '@sakura-ui/helper'
16
- import {
17
- Link,
18
- H1,
19
- H2,
20
- H3,
21
- H4,
22
- H5,
23
- H6,
24
- Table,
25
- Caption,
26
- Thead,
27
- Tbody,
28
- Th,
29
- Tr,
30
- Td,
31
- Ul,
32
- Ol,
33
- Pre,
34
- Code,
35
- Faq,
36
- Question,
37
- Answer,
38
- Button,
39
- Card,
40
- CardImg,
41
- CardHeader,
42
- CardBody,
43
- CardFooter,
44
- LinkCard,
45
- LinkCardHeader,
46
- LinkCardFooter,
47
- type CardHeaderAs,
48
- OverflowContainer
49
- } from '@sakura-ui/core'
50
- import {
51
- attrPlugin,
52
- youtubePlugin,
53
- linkButtonPlugin,
54
- gridPlugin,
55
- cellPlugin,
56
- cardPlugin,
57
- faqPlugin,
58
- headingsPlugin,
59
- type HeadingItem
60
- } from '../plugins'
1
+ import { H2, Link, Ul } from '@sakura-ui/core'
2
+ import React from 'react'
3
+ import { type HeadingItem, render, type RenderResult } from '../render'
4
+
5
+ /**
6
+ * What the server, and the first render in the browser, have to show. The
7
+ * pipeline sanitises and styles through the DOM, so it cannot run where there
8
+ * is no document.
9
+ */
10
+ const NOTHING_YET: RenderResult = { html: '', headings: [] }
61
11
 
62
12
  interface TocProps {
63
13
  items: HeadingItem[]
@@ -80,310 +30,58 @@ export const TableOfContents = ({ items }: TocProps) => {
80
30
  )
81
31
  }
82
32
 
83
- interface TableContainerProps extends React.ComponentPropsWithoutRef<'table'> {}
84
-
85
- const TableContainer = (props: TableContainerProps) => {
86
- const { className, children, ...restProps } = props
87
-
88
- return (
89
- <OverflowContainer>
90
- <Table className={cx(className)} {...restProps}>
91
- {children}
92
- </Table>
93
- </OverflowContainer>
94
- )
95
- }
96
-
97
- interface DataProps {
98
- [key: `data-${string}`]: unknown
99
- }
100
-
101
- interface AnchorProps extends React.ComponentPropsWithoutRef<'a'>, DataProps {}
102
-
103
- const Anchor = (props: AnchorProps) => {
104
- const { children, href, ...restProps } = props
105
-
106
- if (restProps['data-node'] === 'link-button') {
107
- return (
108
- <Button as="a" variant="secondary" href={href}>
109
- {children}
110
- </Button>
111
- )
33
+ export namespace Markdown {
34
+ export interface Props {
35
+ tocTitle?: string
36
+ showToc?: boolean
37
+ /** Moves every heading down by this many levels. */
38
+ shiftHeading?: number
39
+ /** How deep the table of contents goes. Defaults to 2. */
40
+ tocMaxDepth?: number
41
+ children: string
112
42
  }
113
-
114
- return (
115
- <Link href={href} {...restProps}>
116
- {children}
117
- </Link>
118
- )
119
- }
120
-
121
- interface ArticleProps
122
- extends React.ComponentPropsWithoutRef<'article'>,
123
- DataProps {}
124
-
125
- const Article = (props: ArticleProps) => {
126
- const { children, className, ...restProps } = props
127
-
128
- const node = restProps['data-node']
129
-
130
- if (node === 'card') {
131
- // The href now lives on the title, which is what carries the link.
132
- if (restProps['data-behavior'] === 'link') {
133
- return <LinkCard className={cx(className)}>{children}</LinkCard>
134
- }
135
- return <Card className={cx(className)}>{children}</Card>
136
- }
137
-
138
- return (
139
- <article className={className} {...restProps}>
140
- {children}
141
- </article>
142
- )
143
- }
144
-
145
- interface DivProps extends React.ComponentPropsWithoutRef<'div'>, DataProps {
146
- headingLevel?: CardHeaderAs
147
- }
148
-
149
- const Div = (props: DivProps) => {
150
- const { children, headingLevel = 'h3', ...restProps } = props
151
-
152
- const node = restProps['data-node']
153
- const isLink = restProps['data-behavior'] === 'link'
154
-
155
- if (node === 'card-title') {
156
- if (isLink) {
157
- return (
158
- <LinkCardHeader
159
- as={headingLevel}
160
- href={restProps['data-href'] as string}
161
- >
162
- {children}
163
- </LinkCardHeader>
164
- )
165
- }
166
- return <CardHeader as={headingLevel}>{children}</CardHeader>
167
- }
168
- if (node === 'card-description') {
169
- return <CardBody>{children}</CardBody>
170
- }
171
- if (node === 'card-footer') {
172
- if (isLink) {
173
- return <LinkCardFooter>{children}</LinkCardFooter>
174
- }
175
- return <CardFooter>{children}</CardFooter>
176
- }
177
-
178
- if (restProps['data-behavior'] === 'list') {
179
- // Set the css class configured for cells to the ul tag
180
- return (
181
- <ul className={restProps.className}>
182
- {Children.map(children, (child) => (
183
- <li className="sm:grid" key={Math.random()}>
184
- {child}
185
- </li>
186
- ))}
187
- </ul>
188
- )
189
- }
190
-
191
- return <div {...restProps}>{children}</div>
192
- }
193
-
194
- interface ImgProps extends React.ComponentPropsWithoutRef<'img'>, DataProps {}
195
-
196
- const Img = (props: ImgProps) => {
197
- const { children, className, ...restProps } = props
198
-
199
- if (restProps['data-node'] === 'card-img') {
200
- return (
201
- <CardImg
202
- className={cx('w-full aspect-[352/226]', className)}
203
- {...restProps}
204
- />
205
- )
206
- }
207
-
208
- if (restProps['data-node'] === 'cell-img') {
209
- // biome-ignore lint/a11y/useAltText: alt is carried by restProps from the markdown image syntax
210
- return <img className={cx('mb-4', className)} {...restProps} />
211
- }
212
-
213
- // biome-ignore lint/a11y/useAltText: alt is carried by restProps from the markdown image syntax
214
- return <img className={className} {...restProps} />
215
- }
216
-
217
- interface IframeProps extends React.ComponentPropsWithoutRef<'iframe'> {}
218
-
219
- const Iframe = (props: IframeProps) => {
220
- const { className, children, ...restProps } = props
221
-
222
- const styleMargin = `
223
- my-8
224
- `
225
-
226
- let style = `
227
- aspect-video
228
- w-full
229
- max-w-[470px]
230
- `
231
-
232
- if (props.width || props.height) {
233
- style = `
234
- aspect-video
235
- w-full
236
- lg:aspect-auto
237
- lg:w-[${props.width}px]
238
- lg:h-[${props.height}px]
239
- `
240
- }
241
-
242
- return (
243
- <iframe className={cx(styleMargin, style, className)} {...restProps}>
244
- {children}
245
- </iframe>
246
- )
247
- }
248
-
249
- const sleep = (time: number) =>
250
- new Promise((resolve) => setTimeout(resolve, time))
251
-
252
- type Props = {
253
- tocTitle?: string
254
- showToc?: boolean
255
- shiftHeding?: number
256
- children: string
257
43
  }
258
44
 
259
45
  export const Markdown = ({
260
46
  children,
261
47
  showToc,
262
48
  tocTitle = '目次',
263
- shiftHeding = 0
264
- }: Props) => {
265
- const [toc, setToc] = React.useState<HeadingItem[]>([])
266
- const [element, setElement] = React.useState(<React.Fragment />)
267
-
268
- const markdown2Headings = React.useCallback((md: string) => {
269
- // biome-ignore lint/suspicious/noExplicitAny: remark does not type the data that plugins attach to the VFile
270
- const result: any = remark().use(headingsPlugin).processSync(md)
271
-
272
- const headings: HeadingItem[] = result.data.fm.headings.filter(
273
- (obj: HeadingItem) => obj.depth < 3
274
- )
275
-
276
- return headings
277
- }, [])
278
-
279
- const markdown2ReactElements = React.useCallback(
280
- (md: string) => {
281
- // Card titles sit in the body of the document, so h3 is the natural base.
282
- // rehype-shift-heading cannot reach them because they are still divs when
283
- // it runs, so the same shift is applied here.
284
- const cardHeadingLevel = `h${Math.min(
285
- 6,
286
- Math.max(1, 3 + shiftHeding)
287
- )}` as CardHeaderAs
288
-
289
- const rhypeReactOptions = {
290
- ...production,
291
- components: {
292
- a: Anchor,
293
- article: Article,
294
- button: Button,
295
- code: Code,
296
- dl: Faq,
297
- dt: Question,
298
- dd: Answer,
299
- h1: H1,
300
- h2: H2,
301
- h3: H3,
302
- h4: H4,
303
- h5: H5,
304
- h6: H6,
305
- iframe: Iframe,
306
- img: Img,
307
- ul: Ul,
308
- ol: Ol,
309
- pre: Pre,
310
- table: TableContainer,
311
- caption: Caption,
312
- thead: Thead,
313
- tbody: Tbody,
314
- th: Th,
315
- tr: Tr,
316
- td: Td,
317
- div: (props: DivProps) => (
318
- <Div {...props} headingLevel={cardHeadingLevel} />
319
- )
320
- }
321
- }
322
-
323
- const elem = unified()
324
- .use(remarkParse) // md -> mdast (Markdown Abstract Syntax Tree)
325
- .use(remarkGfm) // mdast -> GFM mdast (GitHub Flavored Markdown Abstract Syntax Tree)
326
- .use(remarkDirective) // support for directive syntax
327
- .use(remarkBreaks)
328
- .use(attrPlugin)
329
- .use(youtubePlugin)
330
- .use(linkButtonPlugin)
331
- .use(cellPlugin)
332
- .use(cardPlugin)
333
- .use(faqPlugin)
334
- .use(gridPlugin)
335
- .use(remarkRehype, {
336
- allowDangerousHtml: true
337
- }) // mdast -> hast (HTML Abstract Syntax Tree)
338
- .use(rehypeRaw) // hast -> hast
339
- .use(rehypeExternalLinks, { target: '_blank' }) // hast -> hast
340
- .use(rebypeShiftHeding, { shift: shiftHeding }) // hast -> hast
341
- .use(rehypeSlug)
342
- // biome-ignore lint/suspicious/noExplicitAny: rehype-react's declared option type rejects the component map it accepts at runtime
343
- .use(rehypeReact, rhypeReactOptions as any) // hast -> React Elements
344
- .processSync(md).result
345
- return elem
346
- },
347
- [shiftHeding]
348
- )
49
+ shiftHeading = 0,
50
+ tocMaxDepth = 2
51
+ }: Markdown.Props) => {
52
+ // Converted in an effect, not while rendering. useMemo runs on the server as
53
+ // well, and the pipeline reaches for document, so rendering there threw
54
+ // instead of producing the empty output the caller could recover from. This
55
+ // way the server and the first render in the browser agree, and the markup
56
+ // arrives once the effect has run.
57
+ const [{ html, headings }, setResult] =
58
+ React.useState<RenderResult>(NOTHING_YET)
349
59
 
350
60
  React.useEffect(() => {
351
- setToc(markdown2Headings(children))
352
- setElement(markdown2ReactElements(children))
353
- }, [children, markdown2Headings, markdown2ReactElements])
61
+ setResult(render(children, { shiftHeading, tocMaxDepth }))
62
+ }, [children, shiftHeading, tocMaxDepth])
354
63
 
355
64
  React.useEffect(() => {
356
- // Necessary to make in-page links work.
357
- const scrollToHash = async () => {
358
- await sleep(500)
359
- if (location.hash) {
360
- document
361
- .getElementById(decodeURI(location.hash.substring(1)))
362
- ?.scrollIntoView()
363
- }
364
- }
365
- scrollToHash()
366
- }, [])
367
-
368
- const style = `
369
- rounded-3xl
370
- p-10
371
- bg-yellow-50
372
- `
65
+ // The browser looked for the anchor before this markup existed, so the jump
66
+ // is made again once it does.
67
+ if (!html) return
68
+ const id = decodeURI(window.location.hash.slice(1))
69
+ if (!id) return
70
+ document.getElementById(id)?.scrollIntoView()
71
+ }, [html])
373
72
 
374
73
  return (
375
74
  <div className="py-8 flex flex-col gap-8">
376
- {showToc && (
377
- <nav className={style}>
378
- <h2 className="mb-4 font-bold text-h-xs-m sm:text-h-xs">
379
- {tocTitle}
380
- </h2>
75
+ {showToc && headings.length > 0 && (
76
+ <nav className="rounded-3xl p-10 bg-yellow-50">
77
+ <H2 className="pt-0">{tocTitle}</H2>
381
78
  <Ul>
382
- <TableOfContents items={toc} />
79
+ <TableOfContents items={headings} />
383
80
  </Ul>
384
81
  </nav>
385
82
  )}
386
- {element}
83
+ {/* The markup is sanitised in src/sanitize.ts before it gets here. */}
84
+ <div dangerouslySetInnerHTML={{ __html: html }} />
387
85
  </div>
388
86
  )
389
87
  }
@@ -1 +1 @@
1
- export { Markdown } from './Markdown'
1
+ export { Markdown, TableOfContents } from './Markdown'
@@ -0,0 +1,164 @@
1
+ import { styles } from '@sakura-ui/core'
2
+ import Slugger from 'github-slugger'
3
+ import { classNames } from './marked/html'
4
+
5
+ /**
6
+ * Applies the look of the design system to the finished document.
7
+ *
8
+ * This runs over the sanitised DOM rather than inside the markdown renderer so
9
+ * that a table written as raw HTML — 56 of the guide's documents do — is styled
10
+ * the same as one written with pipes. The pipeline this replaces had the same
11
+ * property, because rehype-raw turned the raw HTML into real nodes before the
12
+ * tag to component mapping ran.
13
+ */
14
+
15
+ export type HeadingItem = {
16
+ id?: string
17
+ depth: number
18
+ value?: string
19
+ children?: HeadingItem[]
20
+ }
21
+
22
+ /** tag name to the classes the matching component puts on it */
23
+ const CLASS_BY_TAG: Record<string, string> = {
24
+ h1: styles.headingStyle.h1,
25
+ h2: styles.headingStyle.h2,
26
+ h3: styles.headingStyle.h3,
27
+ h4: styles.headingStyle.h4,
28
+ h5: styles.headingStyle.h5,
29
+ h6: styles.headingStyle.h6,
30
+ ul: styles.ulStyle,
31
+ ol: styles.olStyle,
32
+ pre: styles.preStyle,
33
+ code: styles.codeStyle,
34
+ table: styles.tableBorderStyle,
35
+ caption: styles.captionStyle,
36
+ th: classNames(styles.thStyle, styles.tableBorderStyle),
37
+ tr: styles.tableBorderStyle,
38
+ td: classNames(styles.tdStyle, styles.tableBorderStyle),
39
+ a: styles.linkStyle
40
+ }
41
+
42
+ /** Elements a directive rendered are already dressed; leave them be. */
43
+ const isDirectiveOutput = (element: Element) =>
44
+ element.hasAttribute('data-sakura')
45
+
46
+ const addClass = (element: Element, value: string) => {
47
+ const merged = classNames(element.getAttribute('class') ?? '', value)
48
+ if (merged) element.setAttribute('class', merged)
49
+ }
50
+
51
+ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
52
+
53
+ /**
54
+ * Says that the link leaves the page. The glyph is hidden from assistive
55
+ * technology and the words next to it carry the meaning instead, which is what
56
+ * the Icon component does.
57
+ */
58
+ const appendNewTabIcon = (anchor: Element, document: Document) => {
59
+ if (anchor.querySelector('[data-sakura-new-tab]')) return
60
+ const glyph = document.createElement('span')
61
+ glyph.setAttribute('aria-hidden', 'true')
62
+ glyph.setAttribute('data-sakura-new-tab', 'true')
63
+ glyph.setAttribute(
64
+ 'class',
65
+ classNames(styles.iconSizeStyle[16], styles.iconStyle, 'ml-0.5')
66
+ )
67
+ glyph.textContent = 'open_in_new'
68
+ const label = document.createElement('span')
69
+ label.setAttribute('class', 'sr-only')
70
+ label.textContent = 'Opens in new tab'
71
+ anchor.append(glyph, label)
72
+ }
73
+
74
+ export type DecorateOptions = {
75
+ shiftHeading: number
76
+ tocMaxDepth: number
77
+ }
78
+
79
+ export type DecorateResult = {
80
+ headings: HeadingItem[]
81
+ }
82
+
83
+ const clampLevel = (level: number) => Math.min(6, Math.max(1, level))
84
+
85
+ export const decorate = (
86
+ root: DocumentFragment | Element,
87
+ document: Document,
88
+ options: DecorateOptions
89
+ ): DecorateResult => {
90
+ const { shiftHeading, tocMaxDepth } = options
91
+ const slugger = new Slugger()
92
+ const flat: HeadingItem[] = []
93
+
94
+ // The heading level has to move before the id is read, so that the id belongs
95
+ // to the element that ends up in the document.
96
+ if (shiftHeading !== 0) {
97
+ for (const heading of Array.from(
98
+ root.querySelectorAll('h1,h2,h3,h4,h5,h6')
99
+ )) {
100
+ const depth = Number(heading.tagName[1])
101
+ const moved = document.createElement(
102
+ `h${clampLevel(depth + shiftHeading)}`
103
+ )
104
+ for (const attribute of Array.from(heading.attributes)) {
105
+ moved.setAttribute(attribute.name, attribute.value)
106
+ }
107
+ moved.innerHTML = heading.innerHTML
108
+ heading.replaceWith(moved)
109
+ }
110
+ }
111
+
112
+ for (const element of Array.from(root.querySelectorAll('*'))) {
113
+ const tag = element.tagName.toLowerCase()
114
+
115
+ // One id, generated once, for headings from markdown and from raw HTML
116
+ // alike. The pipeline this replaces generated them in two passes that
117
+ // disagreed whenever a heading held an image, raw HTML or a directive, and
118
+ // the table of contents then linked to anchors that were not there.
119
+ if (HEADINGS.has(tag)) {
120
+ const text = element.textContent ?? ''
121
+ const asked = element.getAttribute('id')
122
+ // An id the author asked for is kept as written, but still handed to the
123
+ // slugger, so that a heading further down does not generate the same one.
124
+ if (asked) slugger.slug(asked)
125
+ const id = asked || slugger.slug(text)
126
+ element.setAttribute('id', id)
127
+ flat.push({ id, depth: Number(tag[1]) - shiftHeading, value: text })
128
+ }
129
+
130
+ if (isDirectiveOutput(element)) continue
131
+
132
+ if (tag === 'a') {
133
+ const href = element.getAttribute('href') ?? ''
134
+ if (
135
+ href.startsWith('http') ||
136
+ element.getAttribute('target') === '_blank'
137
+ ) {
138
+ element.setAttribute('target', '_blank')
139
+ // The content writes target="_blank" 41 times and rel not once, which
140
+ // leaves the opened page holding a handle on this one.
141
+ element.setAttribute('rel', 'noopener noreferrer')
142
+ appendNewTabIcon(element, document)
143
+ }
144
+ }
145
+
146
+ const cls = CLASS_BY_TAG[tag]
147
+ if (cls) addClass(element, cls)
148
+ }
149
+
150
+ // A wide table scrolls inside its own box rather than pushing the page.
151
+ for (const table of Array.from(root.querySelectorAll('table'))) {
152
+ const parent = table.parentElement
153
+ if (parent?.dataset?.sakuraTableContainer === 'true') continue
154
+ const container = document.createElement('div')
155
+ container.setAttribute('class', classNames(styles.overflowContainerStyle))
156
+ container.dataset.sakuraTableContainer = 'true'
157
+ table.replaceWith(container)
158
+ container.appendChild(table)
159
+ }
160
+
161
+ return {
162
+ headings: flat.filter((h) => h.depth <= tocMaxDepth) as HeadingItem[]
163
+ }
164
+ }
@@ -1 +1,3 @@
1
- export { Markdown } from './components'
1
+ export { Markdown, TableOfContents } from './components'
2
+ export { render } from './render'
3
+ export type { HeadingItem, RenderOptions, RenderResult } from './render'
@@ -0,0 +1,88 @@
1
+ const ATTR_RE =
2
+ /([#.])?([A-Za-z_:][-A-Za-z0-9_.:]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'{}]+)))?/g
3
+
4
+ export type Attrs = Record<string, string>
5
+
6
+ /**
7
+ * Where `{#name}` is kept, apart from anything written as `key=value`.
8
+ *
9
+ * A sigil says something about the element itself and means the same on every
10
+ * directive; an attribute is the directive's own, and each reads the ones it
11
+ * knows. Keeping them in one place would let a directive that wanted an
12
+ * attribute called `id` take the anchor's place, which is how the video on a
13
+ * youtube embed came to be called `id` and stayed that way.
14
+ *
15
+ * A key beginning with `#` cannot be written as `key=value`, since an attribute
16
+ * name has to start with a letter, so there is nowhere for the two to meet.
17
+ */
18
+ export const ANCHOR = '#id'
19
+
20
+ /** Parses the inside of `{...}`: `key=value key2="値 2" #id .cls` */
21
+ export const parseAttrs = (body: string | null): Attrs => {
22
+ const out: Attrs = {}
23
+ if (!body) return out
24
+ ATTR_RE.lastIndex = 0
25
+ let m = ATTR_RE.exec(body)
26
+ while (m !== null) {
27
+ const [, sigil, key, doubleQuoted, singleQuoted, bare] = m
28
+ const value = doubleQuoted ?? singleQuoted ?? bare
29
+ if (sigil === '#') {
30
+ out[ANCHOR] = key
31
+ } else if (sigil === '.') {
32
+ out.class = out.class ? `${out.class} ${key}` : key
33
+ } else {
34
+ out[key] = value ?? ''
35
+ }
36
+ m = ATTR_RE.exec(body)
37
+ }
38
+ return out
39
+ }
40
+
41
+ /**
42
+ * Reads a delimited run, counting nesting. remark-directive accepts a label
43
+ * like `[a [b] c]`, so matching `\[([^\]]*)\]` would cut it in the wrong place.
44
+ */
45
+ export const readDelimited = (
46
+ src: string,
47
+ start: number,
48
+ open: string,
49
+ close: string
50
+ ): { value: string; end: number } | null => {
51
+ if (src[start] !== open) return null
52
+ let depth = 0
53
+ for (let i = start; i < src.length; i++) {
54
+ const ch = src[i]
55
+ if (ch === '\\') {
56
+ i++
57
+ continue
58
+ }
59
+ if (ch === '\n') return null
60
+ if (ch === open) {
61
+ depth++
62
+ } else if (ch === close) {
63
+ depth--
64
+ if (depth === 0) return { value: src.slice(start + 1, i), end: i + 1 }
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /** An optional `[label]` followed by an optional `{attrs}`, starting at index i */
71
+ export const readTail = (
72
+ src: string,
73
+ i: number
74
+ ): { label: string | null; attrs: Attrs; end: number } => {
75
+ let label: string | null = null
76
+ const labelRun = readDelimited(src, i, '[', ']')
77
+ if (labelRun) {
78
+ label = labelRun.value
79
+ i = labelRun.end
80
+ }
81
+ let attrs: Attrs = {}
82
+ const attrRun = readDelimited(src, i, '{', '}')
83
+ if (attrRun) {
84
+ attrs = parseAttrs(attrRun.value)
85
+ i = attrRun.end
86
+ }
87
+ return { label, attrs, end: i }
88
+ }