@sakura-ui/sakura-ui 0.5.1 → 0.5.3

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 (122) hide show
  1. package/package.json +3 -3
  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/Link.tsx +13 -12
  9. package/packages/core/src/components/LinkCard.tsx +87 -76
  10. package/packages/core/src/components/List.tsx +20 -13
  11. package/packages/core/src/components/OverflowContainer.tsx +9 -9
  12. package/packages/core/src/components/Pre.tsx +9 -9
  13. package/packages/core/src/components/Table.tsx +29 -24
  14. package/packages/core/src/components/index.ts +1 -0
  15. package/packages/core/src/components/styles.ts +42 -0
  16. package/packages/core/src/index.ts +2 -0
  17. package/packages/forms/.turbo/turbo-build.log +2 -2
  18. package/packages/helper/.turbo/turbo-build.log +2 -2
  19. package/packages/helper/node_modules/.bin/vitest +2 -2
  20. package/packages/markdown/.turbo/turbo-build.log +4 -4
  21. package/packages/markdown/README.md +240 -4
  22. package/packages/markdown/dist/index.cjs.js +71 -137
  23. package/packages/markdown/dist/index.es.js +2624 -16739
  24. package/packages/markdown/dist/types/components/Markdown.d.ts +15 -10
  25. package/packages/markdown/dist/types/components/Markdown.d.ts.map +1 -1
  26. package/packages/markdown/dist/types/components/index.d.ts +1 -1
  27. package/packages/markdown/dist/types/components/index.d.ts.map +1 -1
  28. package/packages/markdown/dist/types/decorate.d.ts +24 -0
  29. package/packages/markdown/dist/types/decorate.d.ts.map +1 -0
  30. package/packages/markdown/dist/types/index.d.ts +3 -1
  31. package/packages/markdown/dist/types/index.d.ts.map +1 -1
  32. package/packages/markdown/dist/types/marked/attributes.d.ts +31 -0
  33. package/packages/markdown/dist/types/marked/attributes.d.ts.map +1 -0
  34. package/packages/markdown/dist/types/marked/directives/card.d.ts +3 -0
  35. package/packages/markdown/dist/types/marked/directives/card.d.ts.map +1 -0
  36. package/packages/markdown/dist/types/marked/directives/context.d.ts +36 -0
  37. package/packages/markdown/dist/types/marked/directives/context.d.ts.map +1 -0
  38. package/packages/markdown/dist/types/marked/directives/faq.d.ts +11 -0
  39. package/packages/markdown/dist/types/marked/directives/faq.d.ts.map +1 -0
  40. package/packages/markdown/dist/types/marked/directives/grid.d.ts +9 -0
  41. package/packages/markdown/dist/types/marked/directives/grid.d.ts.map +1 -0
  42. package/packages/markdown/dist/types/marked/directives/index.d.ts +8 -0
  43. package/packages/markdown/dist/types/marked/directives/index.d.ts.map +1 -0
  44. package/packages/markdown/dist/types/marked/directives/linkButton.d.ts +6 -0
  45. package/packages/markdown/dist/types/marked/directives/linkButton.d.ts.map +1 -0
  46. package/packages/markdown/dist/types/marked/directives/youtube.d.ts +3 -0
  47. package/packages/markdown/dist/types/marked/directives/youtube.d.ts.map +1 -0
  48. package/packages/markdown/dist/types/marked/html.d.ts +15 -0
  49. package/packages/markdown/dist/types/marked/html.d.ts.map +1 -0
  50. package/packages/markdown/dist/types/marked/index.d.ts +13 -0
  51. package/packages/markdown/dist/types/marked/index.d.ts.map +1 -0
  52. package/packages/markdown/dist/types/marked/registry.d.ts +8 -0
  53. package/packages/markdown/dist/types/marked/registry.d.ts.map +1 -0
  54. package/packages/markdown/dist/types/marked/renderer.d.ts +16 -0
  55. package/packages/markdown/dist/types/marked/renderer.d.ts.map +1 -0
  56. package/packages/markdown/dist/types/marked/tokenizer.d.ts +40 -0
  57. package/packages/markdown/dist/types/marked/tokenizer.d.ts.map +1 -0
  58. package/packages/markdown/dist/types/render.d.ts +22 -0
  59. package/packages/markdown/dist/types/render.d.ts.map +1 -0
  60. package/packages/markdown/dist/types/sanitize.d.ts +11 -0
  61. package/packages/markdown/dist/types/sanitize.d.ts.map +1 -0
  62. package/packages/markdown/node_modules/.bin/marked +55 -0
  63. package/packages/markdown/package.json +6 -19
  64. package/packages/markdown/src/components/Markdown.tsx +49 -343
  65. package/packages/markdown/src/components/index.ts +1 -1
  66. package/packages/markdown/src/decorate.ts +164 -0
  67. package/packages/markdown/src/index.ts +3 -1
  68. package/packages/markdown/src/marked/attributes.ts +88 -0
  69. package/packages/markdown/src/marked/directives/card.ts +77 -0
  70. package/packages/markdown/src/marked/directives/context.ts +39 -0
  71. package/packages/markdown/src/marked/directives/faq.ts +29 -0
  72. package/packages/markdown/src/marked/directives/grid.ts +50 -0
  73. package/packages/markdown/src/marked/directives/index.ts +29 -0
  74. package/packages/markdown/src/marked/directives/linkButton.ts +20 -0
  75. package/packages/markdown/src/marked/directives/youtube.ts +46 -0
  76. package/packages/markdown/src/marked/html.ts +55 -0
  77. package/packages/markdown/src/marked/index.ts +45 -0
  78. package/packages/markdown/src/marked/registry.ts +34 -0
  79. package/packages/markdown/src/marked/renderer.ts +52 -0
  80. package/packages/markdown/src/marked/tokenizer.ts +175 -0
  81. package/packages/markdown/src/render.ts +55 -0
  82. package/packages/markdown/src/sanitize.ts +194 -0
  83. package/packages/markdown/tests/Markdown.test.tsx +122 -0
  84. package/packages/markdown/tests/__snapshots__/Markdown.test.tsx.snap +446 -0
  85. package/packages/markdown/tests/anchors.test.ts +108 -0
  86. package/packages/markdown/tests/fixtures.ts +100 -0
  87. package/packages/markdown/tests/sanitize.test.ts +168 -0
  88. package/packages/markdown/tests/server.test.tsx +51 -0
  89. package/packages/markdown/tests/vitest.setup.ts +4 -0
  90. package/packages/markdown/tsconfig.test.json +11 -0
  91. package/packages/markdown/vite.config.ts +5 -1
  92. package/packages/tailwind-theme-plugin/.turbo/turbo-build.log +1 -1
  93. package/packages/markdown/dist/types/plugins/attr.d.ts +0 -3
  94. package/packages/markdown/dist/types/plugins/attr.d.ts.map +0 -1
  95. package/packages/markdown/dist/types/plugins/card.d.ts +0 -3
  96. package/packages/markdown/dist/types/plugins/card.d.ts.map +0 -1
  97. package/packages/markdown/dist/types/plugins/cell.d.ts +0 -3
  98. package/packages/markdown/dist/types/plugins/cell.d.ts.map +0 -1
  99. package/packages/markdown/dist/types/plugins/faq.d.ts +0 -3
  100. package/packages/markdown/dist/types/plugins/faq.d.ts.map +0 -1
  101. package/packages/markdown/dist/types/plugins/grid.d.ts +0 -3
  102. package/packages/markdown/dist/types/plugins/grid.d.ts.map +0 -1
  103. package/packages/markdown/dist/types/plugins/headings.d.ts +0 -11
  104. package/packages/markdown/dist/types/plugins/headings.d.ts.map +0 -1
  105. package/packages/markdown/dist/types/plugins/helper.d.ts +0 -9
  106. package/packages/markdown/dist/types/plugins/helper.d.ts.map +0 -1
  107. package/packages/markdown/dist/types/plugins/index.d.ts +0 -9
  108. package/packages/markdown/dist/types/plugins/index.d.ts.map +0 -1
  109. package/packages/markdown/dist/types/plugins/linkButton.d.ts +0 -3
  110. package/packages/markdown/dist/types/plugins/linkButton.d.ts.map +0 -1
  111. package/packages/markdown/dist/types/plugins/youtube.d.ts +0 -3
  112. package/packages/markdown/dist/types/plugins/youtube.d.ts.map +0 -1
  113. package/packages/markdown/src/plugins/attr.ts +0 -19
  114. package/packages/markdown/src/plugins/card.ts +0 -71
  115. package/packages/markdown/src/plugins/cell.ts +0 -47
  116. package/packages/markdown/src/plugins/faq.ts +0 -40
  117. package/packages/markdown/src/plugins/grid.ts +0 -54
  118. package/packages/markdown/src/plugins/headings.ts +0 -38
  119. package/packages/markdown/src/plugins/helper.ts +0 -27
  120. package/packages/markdown/src/plugins/index.ts +0 -8
  121. package/packages/markdown/src/plugins/linkButton.ts +0 -26
  122. package/packages/markdown/src/plugins/youtube.ts +0 -49
@@ -0,0 +1,55 @@
1
+ import { Marked } from 'marked'
2
+ import { decorate, type HeadingItem } from './decorate'
3
+ import { directiveExtension } from './marked'
4
+ import { sanitize } from './sanitize'
5
+
6
+ export type { HeadingItem } from './decorate'
7
+
8
+ export type RenderOptions = {
9
+ /** Moves every heading down by this many levels, as rehype-shift-heading did. */
10
+ shiftHeading?: number
11
+ /** How deep the table of contents goes. Headings below this are still rendered. */
12
+ tocMaxDepth?: number
13
+ }
14
+
15
+ export type RenderResult = {
16
+ html: string
17
+ headings: HeadingItem[]
18
+ }
19
+
20
+ const clampLevel = (level: number) => Math.min(6, Math.max(1, level))
21
+
22
+ /**
23
+ * Markdown to HTML, in three steps: marked writes the markup, DOMPurify takes
24
+ * out what must not reach the DOM, and decorate puts the design system on what
25
+ * is left.
26
+ *
27
+ * Styling last is what lets a table written as raw HTML look like one written
28
+ * with pipes, and it is why the heading ids are generated in one place.
29
+ */
30
+ export const render = (
31
+ markdown: string,
32
+ options: RenderOptions = {}
33
+ ): RenderResult => {
34
+ const { shiftHeading = 0, tocMaxDepth = 2 } = options
35
+
36
+ // A fresh Marked per call: marked.use() would reach every other caller.
37
+ const marked = new Marked(
38
+ { gfm: true, breaks: true },
39
+ directiveExtension({
40
+ // The card title is a heading too, and the shift has to reach it.
41
+ cardHeadingLevel: `h${clampLevel(3 + shiftHeading)}`
42
+ })
43
+ )
44
+
45
+ const fragment = sanitize(marked.parse(markdown, { async: false }) as string)
46
+ const { headings } = decorate(fragment, document, {
47
+ shiftHeading,
48
+ tocMaxDepth
49
+ })
50
+
51
+ const container = document.createElement('div')
52
+ container.appendChild(fragment)
53
+
54
+ return { html: container.innerHTML, headings }
55
+ }
@@ -0,0 +1,194 @@
1
+ import createDOMPurify, { type DOMPurify } from 'dompurify'
2
+
3
+ /**
4
+ * Markdown is allowed to carry raw HTML, so the output is sanitised before it
5
+ * reaches the DOM.
6
+ *
7
+ * The lists below come from measuring what the documents actually contain
8
+ * rather than from what HTML permits: 27 tags, 24 CSS properties, and no
9
+ * script, no event handler, no javascript: or data: URL anywhere.
10
+ */
11
+
12
+ /** Tags DOMPurify does not allow by default but the content needs. */
13
+ const ADD_TAGS = ['caption', 'col', 'colgroup', 'small', 'u', 'iframe']
14
+
15
+ /** Attributes beyond the default set. */
16
+ const ADD_ATTR = [
17
+ 'target',
18
+ 'role',
19
+ 'colspan',
20
+ 'rowspan',
21
+ 'open',
22
+ 'allow',
23
+ 'allowfullscreen',
24
+ 'frameborder',
25
+ 'referrerpolicy',
26
+ 'loading'
27
+ ]
28
+
29
+ /**
30
+ * iframe is allowed only so that the youtube directive can embed a video, and
31
+ * only from the host that directive writes. Nothing in the content writes an
32
+ * iframe of its own, so the list stays at one host; an embed pointing anywhere
33
+ * else is dropped rather than trusted.
34
+ */
35
+ const IFRAME_HOSTS = new Set(['www.youtube-nocookie.com'])
36
+
37
+ const isAllowedIframe = (src: string | null): boolean => {
38
+ if (!src) return false
39
+ try {
40
+ return IFRAME_HOSTS.has(new URL(src).host)
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The CSS properties an inline style may set.
48
+ *
49
+ * Letting style through untouched would leave clickjacking on the table: a
50
+ * fixed, transparent box over the page takes every click. None of the content
51
+ * needs position beyond relative, so the list stays closed.
52
+ */
53
+ const ALLOWED_CSS_PROPERTIES = new Set([
54
+ 'background-color',
55
+ 'border',
56
+ 'border-bottom',
57
+ 'border-collapse',
58
+ 'border-left',
59
+ 'border-radius',
60
+ 'border-right',
61
+ 'border-top',
62
+ 'color',
63
+ 'color-scheme',
64
+ 'font-size',
65
+ 'font-weight',
66
+ 'height',
67
+ 'line-height',
68
+ 'list-style',
69
+ 'list-style-type',
70
+ 'margin',
71
+ 'margin-bottom',
72
+ 'margin-left',
73
+ 'margin-right',
74
+ 'margin-top',
75
+ 'max-width',
76
+ 'min-width',
77
+ 'padding',
78
+ 'padding-bottom',
79
+ 'padding-left',
80
+ 'padding-right',
81
+ 'padding-top',
82
+ 'text-align',
83
+ 'text-indent',
84
+ 'text-orientation',
85
+ 'vertical-align',
86
+ 'white-space',
87
+ 'width',
88
+ 'writing-mode'
89
+ ])
90
+
91
+ /** position is allowed, but only the value that cannot cover the page. */
92
+ const RESTRICTED_VALUES: Record<string, Set<string>> = {
93
+ position: new Set(['relative', 'static'])
94
+ }
95
+
96
+ const filterStyle = (style: string): string =>
97
+ style
98
+ .split(';')
99
+ .map((declaration) => declaration.trim())
100
+ .filter(Boolean)
101
+ .filter((declaration) => {
102
+ const [rawProperty, ...rest] = declaration.split(':')
103
+ const property = rawProperty.trim().toLowerCase()
104
+ const value = rest.join(':').trim().toLowerCase()
105
+ // url(), expression() and @import never appear in the content, and each
106
+ // is a way back out of the sanitiser. Asked before the property is looked
107
+ // at, so that a property added to RESTRICTED_VALUES later does not slip
108
+ // past it.
109
+ if (/url\(|expression\(|@import|behavior:/i.test(value)) return false
110
+ if (!ALLOWED_CSS_PROPERTIES.has(property)) {
111
+ return Boolean(RESTRICTED_VALUES[property]?.has(value))
112
+ }
113
+ return true
114
+ })
115
+ .map((declaration) => `${declaration};`)
116
+ .join(' ')
117
+
118
+ /**
119
+ * Whitespace and control characters taken out, so that a scheme spelt with a
120
+ * tab in the middle of it is read the way the browser will read it. Written by
121
+ * code point because the linter refuses a regular expression holding them.
122
+ */
123
+ const withoutBlanks = (value: string): string =>
124
+ Array.from(value)
125
+ .filter((char) => (char.codePointAt(0) ?? 0) > 0x20)
126
+ .join('')
127
+ .toLowerCase()
128
+
129
+ let instance: DOMPurify | null = null
130
+
131
+ /**
132
+ * An instance of our own, not the shared default export. A hook added to that
133
+ * one runs for every other caller on the page, and any of them calling
134
+ * removeAllHooks would take the iframe check and the style filter away with it
135
+ * — silently, since sanitize would go on returning a fragment either way.
136
+ */
137
+ const purifier = (): DOMPurify => {
138
+ if (instance) return instance
139
+ instance = createDOMPurify(window)
140
+ instance.addHook('afterSanitizeAttributes', (node) => {
141
+ const element = node as Element
142
+ if (!element.getAttribute) return
143
+
144
+ if (element.tagName === 'IFRAME') {
145
+ if (!isAllowedIframe(element.getAttribute('src'))) {
146
+ element.remove()
147
+ return
148
+ }
149
+ }
150
+
151
+ // DOMPurify allows data: on an img src whatever ALLOWED_URI_REGEXP says,
152
+ // through its own list of tags that may carry one. The content has no use
153
+ // for it, and leaving it would make raw HTML the loose way in while the
154
+ // directives refused the same URL.
155
+ for (const name of ['src', 'href']) {
156
+ const value = element.getAttribute(name)
157
+ if (value && withoutBlanks(value).startsWith('data:')) {
158
+ element.removeAttribute(name)
159
+ }
160
+ }
161
+
162
+ const style = element.getAttribute('style')
163
+ if (style) {
164
+ const filtered = filterStyle(style)
165
+ if (filtered) {
166
+ element.setAttribute('style', filtered)
167
+ } else {
168
+ element.removeAttribute('style')
169
+ }
170
+ }
171
+ // A link that opens elsewhere must not hand the opener over with it.
172
+ if (element.getAttribute('target') === '_blank') {
173
+ element.setAttribute('rel', 'noopener noreferrer')
174
+ }
175
+ })
176
+ return instance
177
+ }
178
+
179
+ /**
180
+ * Returns a fragment rather than a string: the caller has to walk the result to
181
+ * put the design system on it, and going back through the parser would only
182
+ * throw away what has just been built.
183
+ */
184
+ export const sanitize = (html: string): DocumentFragment => {
185
+ return purifier().sanitize(html, {
186
+ ADD_TAGS,
187
+ ADD_ATTR,
188
+ RETURN_DOM_FRAGMENT: true,
189
+ ALLOWED_URI_REGEXP:
190
+ /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i
191
+ })
192
+ }
193
+
194
+ export const __testing = { filterStyle, ALLOWED_CSS_PROPERTIES }
@@ -0,0 +1,122 @@
1
+ import React from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+
5
+ import { Markdown } from '../src'
6
+ import {
7
+ directives,
8
+ headings,
9
+ malformedHtml,
10
+ notDirectives,
11
+ plain,
12
+ rawHtml
13
+ } from './fixtures'
14
+
15
+ /**
16
+ * Renders the component and returns the DOM it produced. The component converts
17
+ * inside an effect, so the first paint is empty and the result has to be waited
18
+ * for.
19
+ */
20
+ const renderMarkdown = async (
21
+ md: string,
22
+ // Typed, so that a prop that no longer exists is a compile error rather than
23
+ // a test that passes while measuring nothing.
24
+ props: Omit<React.ComponentProps<typeof Markdown>, 'children'> = {}
25
+ ) => {
26
+ const { container } = render(<Markdown {...props}>{md}</Markdown>)
27
+ await waitFor(() => expect(container.firstElementChild?.innerHTML).not.toBe(''))
28
+ return container.innerHTML
29
+ }
30
+
31
+ /**
32
+ * These snapshots exist to catch the difference when the pipeline is replaced,
33
+ * not to describe what the output ought to be. Some of them pin behaviour that
34
+ * is wrong today; those say so.
35
+ */
36
+ describe('Markdown', () => {
37
+ describe('plain markdown', () => {
38
+ for (const [name, md] of Object.entries(plain)) {
39
+ it(`should render ${name}`, async () => {
40
+ expect(await renderMarkdown(md)).toMatchSnapshot()
41
+ })
42
+ }
43
+ })
44
+
45
+ describe('raw html', () => {
46
+ for (const [name, md] of Object.entries(rawHtml)) {
47
+ it(`should render ${name}`, async () => {
48
+ expect(await renderMarkdown(md)).toMatchSnapshot()
49
+ })
50
+ }
51
+ })
52
+
53
+ describe('malformed html', () => {
54
+ for (const [name, md] of Object.entries(malformedHtml)) {
55
+ it(`should recover from ${name}`, async () => {
56
+ expect(await renderMarkdown(md)).toMatchSnapshot()
57
+ })
58
+ }
59
+ })
60
+
61
+ describe('directives', () => {
62
+ for (const [name, md] of Object.entries(directives)) {
63
+ it(`should render ${name}`, async () => {
64
+ expect(await renderMarkdown(md)).toMatchSnapshot()
65
+ })
66
+ }
67
+
68
+ it('should not dress a card as a link when it has no title', async () => {
69
+ // The link goes on the title, so a card without one has nowhere to put
70
+ // it. It used to keep the hover and the arrow regardless: a card that
71
+ // answered the pointer and then did nothing.
72
+ const html = await renderMarkdown(directives.linkCardWithoutTitle)
73
+ expect(html).not.toContain('<a')
74
+ expect(html).not.toContain('hover:bg-solid-gray-50')
75
+ expect(html).not.toContain('arrow_forward')
76
+ })
77
+ })
78
+
79
+ describe('text that only looks like a directive', () => {
80
+ for (const [name, md] of Object.entries(notDirectives)) {
81
+ it(`should leave ${name} alone`, async () => {
82
+ expect(await renderMarkdown(md)).toMatchSnapshot()
83
+ })
84
+ }
85
+ })
86
+
87
+ describe('headings', () => {
88
+ for (const [name, md] of Object.entries(headings)) {
89
+ it(`should render ${name}`, async () => {
90
+ expect(await renderMarkdown(md)).toMatchSnapshot()
91
+ })
92
+ }
93
+ })
94
+
95
+ describe('table of contents', () => {
96
+ it('should render the table of contents when asked for it', async () => {
97
+ const html = await renderMarkdown(
98
+ '# 章1\n\n## 節1\n\n### 項1\n\n## 節2\n\n# 章2',
99
+ { showToc: true }
100
+ )
101
+ expect(html).toMatchSnapshot()
102
+ })
103
+
104
+ it('should use the given title', async () => {
105
+ render(
106
+ <Markdown showToc tocTitle="もくじ">
107
+ {'# 章1'}
108
+ </Markdown>
109
+ )
110
+ await waitFor(() => expect(screen.getByText('もくじ')).toBeInTheDocument())
111
+ })
112
+ })
113
+
114
+ describe('heading shift', () => {
115
+ it('should move the headings down by the given amount', async () => {
116
+ const html = await renderMarkdown('# 見出し1\n\n## 見出し2', {
117
+ shiftHeading: 1
118
+ })
119
+ expect(html).toMatchSnapshot()
120
+ })
121
+ })
122
+ })