@sakura-ui/sakura-ui 0.5.1 → 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.
- package/package.json +2 -2
- package/packages/core/package.json +1 -1
- package/packages/core/src/components/Card.tsx +52 -56
- package/packages/core/src/components/Code.tsx +6 -6
- package/packages/core/src/components/Faq.tsx +30 -26
- package/packages/core/src/components/Heading.tsx +50 -48
- package/packages/core/src/components/Icon.tsx +34 -34
- package/packages/core/src/components/Link.tsx +13 -12
- package/packages/core/src/components/LinkCard.tsx +87 -76
- package/packages/core/src/components/List.tsx +20 -13
- package/packages/core/src/components/OverflowContainer.tsx +9 -9
- package/packages/core/src/components/Pre.tsx +9 -9
- package/packages/core/src/components/Table.tsx +29 -24
- package/packages/core/src/components/index.ts +1 -0
- package/packages/core/src/components/styles.ts +42 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/forms/.turbo/turbo-build.log +2 -2
- package/packages/helper/.turbo/turbo-build.log +1 -1
- package/packages/helper/node_modules/.bin/vitest +2 -2
- package/packages/markdown/.turbo/turbo-build.log +4 -4
- package/packages/markdown/README.md +240 -4
- package/packages/markdown/dist/index.cjs.js +71 -137
- package/packages/markdown/dist/index.es.js +2624 -16739
- package/packages/markdown/dist/types/components/Markdown.d.ts +15 -10
- package/packages/markdown/dist/types/components/Markdown.d.ts.map +1 -1
- package/packages/markdown/dist/types/components/index.d.ts +1 -1
- package/packages/markdown/dist/types/components/index.d.ts.map +1 -1
- package/packages/markdown/dist/types/decorate.d.ts +24 -0
- package/packages/markdown/dist/types/decorate.d.ts.map +1 -0
- package/packages/markdown/dist/types/index.d.ts +3 -1
- package/packages/markdown/dist/types/index.d.ts.map +1 -1
- package/packages/markdown/dist/types/marked/attributes.d.ts +31 -0
- package/packages/markdown/dist/types/marked/attributes.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/card.d.ts +3 -0
- package/packages/markdown/dist/types/marked/directives/card.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/context.d.ts +36 -0
- package/packages/markdown/dist/types/marked/directives/context.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/faq.d.ts +11 -0
- package/packages/markdown/dist/types/marked/directives/faq.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/grid.d.ts +9 -0
- package/packages/markdown/dist/types/marked/directives/grid.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/index.d.ts +8 -0
- package/packages/markdown/dist/types/marked/directives/index.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/linkButton.d.ts +6 -0
- package/packages/markdown/dist/types/marked/directives/linkButton.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/directives/youtube.d.ts +3 -0
- package/packages/markdown/dist/types/marked/directives/youtube.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/html.d.ts +15 -0
- package/packages/markdown/dist/types/marked/html.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/index.d.ts +13 -0
- package/packages/markdown/dist/types/marked/index.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/registry.d.ts +8 -0
- package/packages/markdown/dist/types/marked/registry.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/renderer.d.ts +16 -0
- package/packages/markdown/dist/types/marked/renderer.d.ts.map +1 -0
- package/packages/markdown/dist/types/marked/tokenizer.d.ts +40 -0
- package/packages/markdown/dist/types/marked/tokenizer.d.ts.map +1 -0
- package/packages/markdown/dist/types/render.d.ts +22 -0
- package/packages/markdown/dist/types/render.d.ts.map +1 -0
- package/packages/markdown/dist/types/sanitize.d.ts +11 -0
- package/packages/markdown/dist/types/sanitize.d.ts.map +1 -0
- package/packages/markdown/node_modules/.bin/marked +55 -0
- package/packages/markdown/package.json +6 -19
- package/packages/markdown/src/components/Markdown.tsx +44 -346
- package/packages/markdown/src/components/index.ts +1 -1
- package/packages/markdown/src/decorate.ts +164 -0
- package/packages/markdown/src/index.ts +3 -1
- package/packages/markdown/src/marked/attributes.ts +88 -0
- package/packages/markdown/src/marked/directives/card.ts +77 -0
- package/packages/markdown/src/marked/directives/context.ts +39 -0
- package/packages/markdown/src/marked/directives/faq.ts +29 -0
- package/packages/markdown/src/marked/directives/grid.ts +50 -0
- package/packages/markdown/src/marked/directives/index.ts +29 -0
- package/packages/markdown/src/marked/directives/linkButton.ts +20 -0
- package/packages/markdown/src/marked/directives/youtube.ts +46 -0
- package/packages/markdown/src/marked/html.ts +55 -0
- package/packages/markdown/src/marked/index.ts +45 -0
- package/packages/markdown/src/marked/registry.ts +34 -0
- package/packages/markdown/src/marked/renderer.ts +52 -0
- package/packages/markdown/src/marked/tokenizer.ts +175 -0
- package/packages/markdown/src/render.ts +55 -0
- package/packages/markdown/src/sanitize.ts +194 -0
- package/packages/markdown/tests/Markdown.test.tsx +122 -0
- package/packages/markdown/tests/__snapshots__/Markdown.test.tsx.snap +451 -0
- package/packages/markdown/tests/anchors.test.ts +108 -0
- package/packages/markdown/tests/fixtures.ts +100 -0
- package/packages/markdown/tests/sanitize.test.ts +168 -0
- package/packages/markdown/tests/server.test.tsx +51 -0
- package/packages/markdown/tests/vitest.setup.ts +4 -0
- package/packages/markdown/tsconfig.test.json +11 -0
- package/packages/markdown/vite.config.ts +5 -1
- package/packages/tailwind-theme-plugin/.turbo/turbo-build.log +2 -2
- package/packages/markdown/dist/types/plugins/attr.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/attr.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/card.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/card.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/cell.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/cell.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/faq.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/faq.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/grid.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/grid.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/headings.d.ts +0 -11
- package/packages/markdown/dist/types/plugins/headings.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/helper.d.ts +0 -9
- package/packages/markdown/dist/types/plugins/helper.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/index.d.ts +0 -9
- package/packages/markdown/dist/types/plugins/index.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/linkButton.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/linkButton.d.ts.map +0 -1
- package/packages/markdown/dist/types/plugins/youtube.d.ts +0 -3
- package/packages/markdown/dist/types/plugins/youtube.d.ts.map +0 -1
- package/packages/markdown/src/plugins/attr.ts +0 -19
- package/packages/markdown/src/plugins/card.ts +0 -71
- package/packages/markdown/src/plugins/cell.ts +0 -47
- package/packages/markdown/src/plugins/faq.ts +0 -40
- package/packages/markdown/src/plugins/grid.ts +0 -54
- package/packages/markdown/src/plugins/headings.ts +0 -38
- package/packages/markdown/src/plugins/helper.ts +0 -27
- package/packages/markdown/src/plugins/index.ts +0 -8
- package/packages/markdown/src/plugins/linkButton.ts +0 -26
- package/packages/markdown/src/plugins/youtube.ts +0 -49
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { styles } from '@sakura-ui/core'
|
|
2
|
+
import { classNames, cleanUrl } from '../html'
|
|
3
|
+
import type { DirectiveRenderer } from './context'
|
|
4
|
+
|
|
5
|
+
const cardImgClass = classNames(styles.cardImgStyle, 'w-full aspect-[352/226]')
|
|
6
|
+
|
|
7
|
+
const iconHtml = (name: string, altText: string): string =>
|
|
8
|
+
`<span aria-hidden="true" class="${classNames(
|
|
9
|
+
styles.iconSizeStyle[16],
|
|
10
|
+
styles.iconStyle
|
|
11
|
+
)}">${name}</span><span class="sr-only">${altText}</span>`
|
|
12
|
+
|
|
13
|
+
export const cardRenderers: Record<string, DirectiveRenderer> = {
|
|
14
|
+
card: ({ token, attrs, root, body, nl }) => {
|
|
15
|
+
// The tokenizer withholds linked from a card with no title, which has no
|
|
16
|
+
// link to give and so must not look as though it had one.
|
|
17
|
+
const isLink = Boolean(token.linked)
|
|
18
|
+
const cls = classNames(
|
|
19
|
+
styles.cardStyle,
|
|
20
|
+
isLink && styles.linkCardPositionStyle,
|
|
21
|
+
isLink && styles.linkCardHoverStyle,
|
|
22
|
+
isLink && styles.linkCardFocusStyle,
|
|
23
|
+
attrs.class
|
|
24
|
+
)
|
|
25
|
+
const card = `<div${root({ class: cls })}>${body()}</div>`
|
|
26
|
+
// A card in a grid of cards is one of a list; the tokenizer says which.
|
|
27
|
+
return token.listed ? `<li class="sm:grid">${card}</li>${nl}` : `${card}${nl}`
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
'card-img': ({ attrs, root, nl }) =>
|
|
31
|
+
`<img${root({
|
|
32
|
+
class: classNames(cardImgClass, attrs.class),
|
|
33
|
+
src: cleanUrl(attrs.src),
|
|
34
|
+
alt: attrs.alt ?? ''
|
|
35
|
+
})}>${nl}`,
|
|
36
|
+
|
|
37
|
+
'card-title': ({ token, attrs, root, own, body, nl, options }) => {
|
|
38
|
+
const tag = options.cardHeadingLevel
|
|
39
|
+
const href = token.inherited ? cleanUrl(token.inherited.href) : undefined
|
|
40
|
+
if (href) {
|
|
41
|
+
// The anchor covers the card from here, so the title is the whole of the
|
|
42
|
+
// name the link is read out under.
|
|
43
|
+
return `<${tag}${root({
|
|
44
|
+
class: classNames(styles.cardHeaderStyle, styles.linkCardHeadingStyle)
|
|
45
|
+
})}><a${own({
|
|
46
|
+
class: classNames(styles.linkCardOverlayStyle),
|
|
47
|
+
href
|
|
48
|
+
})}>${body()}</a></${tag}>${nl}`
|
|
49
|
+
}
|
|
50
|
+
return `<${tag}${root({
|
|
51
|
+
class: classNames(styles.cardHeaderStyle, attrs.class)
|
|
52
|
+
})}>${body()}</${tag}>${nl}`
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
'card-description': ({ attrs, root, body, nl }) =>
|
|
56
|
+
`<div${root({
|
|
57
|
+
class: classNames(styles.cardBodyStyle, attrs.class)
|
|
58
|
+
})}>${body()}</div>${nl}`,
|
|
59
|
+
|
|
60
|
+
'card-footer': ({ token, attrs, root, body, nl }) => {
|
|
61
|
+
const isLink = Boolean(token.inherited)
|
|
62
|
+
const cls = classNames(
|
|
63
|
+
styles.cardFooterPositionStyle,
|
|
64
|
+
styles.cardBodyStyle,
|
|
65
|
+
isLink && styles.linkCardFooterStyle,
|
|
66
|
+
attrs.class
|
|
67
|
+
)
|
|
68
|
+
if (isLink) {
|
|
69
|
+
const arrow = `<span class="${classNames(
|
|
70
|
+
styles.linkCardArrowStyle,
|
|
71
|
+
styles.linkCardArrowHoverStyle
|
|
72
|
+
)}">${iconHtml('arrow_forward', '')}</span>`
|
|
73
|
+
return `<div${root({ class: cls })}><span>${body()}</span>${arrow}</div>${nl}`
|
|
74
|
+
}
|
|
75
|
+
return `<div${root({ class: cls })}>${body()}</div>${nl}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Token } from 'marked'
|
|
2
|
+
import type { Attrs } from '../attributes'
|
|
3
|
+
import type { AttrValue } from '../html'
|
|
4
|
+
import type { DirectiveToken } from '../tokenizer'
|
|
5
|
+
|
|
6
|
+
export interface Parser {
|
|
7
|
+
parse: (tokens: Token[]) => string
|
|
8
|
+
parseInline: (tokens: Token[], renderer?: unknown) => string
|
|
9
|
+
textRenderer: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RendererOptions {
|
|
13
|
+
/** The element a card title renders as, already shifted. */
|
|
14
|
+
cardHeadingLevel: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Values = Record<string, AttrValue>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What each renderer is handed.
|
|
21
|
+
*
|
|
22
|
+
* `root` and `own` differ in one thing: `root` adds the id asked for by
|
|
23
|
+
* `{#name}`. It belongs on the element the directive **is**, and `own` on
|
|
24
|
+
* anything nested inside that, or the id would be written twice.
|
|
25
|
+
*/
|
|
26
|
+
export interface DirectiveContext {
|
|
27
|
+
token: DirectiveToken
|
|
28
|
+
attrs: Attrs
|
|
29
|
+
/** A newline after a block directive, nothing after an inline one. */
|
|
30
|
+
nl: string
|
|
31
|
+
root: (values: Values) => string
|
|
32
|
+
own: (values: Values) => string
|
|
33
|
+
/** The children, rendered — inline for a leaf, block for a container. */
|
|
34
|
+
body: () => string
|
|
35
|
+
parser: Parser
|
|
36
|
+
options: RendererOptions
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DirectiveRenderer = (context: DirectiveContext) => string
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { styles } from '@sakura-ui/core'
|
|
2
|
+
import { classNames } from '../html'
|
|
3
|
+
import type { DirectiveRenderer } from './context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A description list, which is what a run of questions and answers is. The Q
|
|
7
|
+
* and the A are decoration and are hidden from a screen reader, which is told
|
|
8
|
+
* the same thing by the <dt> and <dd>.
|
|
9
|
+
*
|
|
10
|
+
* No schema.org FAQPage markup here. Google discontinued the FAQ rich result in
|
|
11
|
+
* May 2026, so it is not implemented.
|
|
12
|
+
*/
|
|
13
|
+
export const faqRenderers: Record<string, DirectiveRenderer> = {
|
|
14
|
+
faq: ({ root, body, nl }) =>
|
|
15
|
+
`<dl${root({ class: classNames(styles.faqStyle) })}>${body()}</dl>${nl}`,
|
|
16
|
+
|
|
17
|
+
'faq-q': ({ root, body, nl }) =>
|
|
18
|
+
`<dt${root({
|
|
19
|
+
class: classNames(styles.questionStyle, styles.faqMarkerStyle)
|
|
20
|
+
})}><span aria-hidden="true">Q</span><span>${body()}</span></dt>${nl}`,
|
|
21
|
+
|
|
22
|
+
'faq-a': ({ root, body, nl }) =>
|
|
23
|
+
`<dd${root({
|
|
24
|
+
class: classNames(styles.answerStyle)
|
|
25
|
+
})}><span class="${classNames(
|
|
26
|
+
styles.faqMarkerStyle,
|
|
27
|
+
'!leading-none'
|
|
28
|
+
)}" aria-hidden="true">A</span><span>${body()}</span></dd>${nl}`
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { classNames, cleanUrl } from '../html'
|
|
2
|
+
import type { DirectiveRenderer } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Beyond six the columns are too narrow to read, so 7 through 11 stop there.
|
|
6
|
+
* Twelve is kept because it is the grid people reach for when they are dividing
|
|
7
|
+
* a row rather than listing things.
|
|
8
|
+
*/
|
|
9
|
+
const GRID_CLASS: Record<number, string> = {
|
|
10
|
+
1: 'md:grid-cols-1',
|
|
11
|
+
2: 'md:grid-cols-2',
|
|
12
|
+
3: 'md:grid-cols-3',
|
|
13
|
+
4: 'md:grid-cols-4',
|
|
14
|
+
5: 'md:grid-cols-5',
|
|
15
|
+
6: 'md:grid-cols-6',
|
|
16
|
+
7: 'md:grid-cols-6',
|
|
17
|
+
8: 'md:grid-cols-6',
|
|
18
|
+
9: 'md:grid-cols-6',
|
|
19
|
+
10: 'md:grid-cols-6',
|
|
20
|
+
11: 'md:grid-cols-6',
|
|
21
|
+
12: 'md:grid-cols-12'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `grid-cols-N` for any N the registry allows. A grid of cards is a `<ul>` and
|
|
26
|
+
* anything else a `<div>`; the tokenizer decides which, because by the time the
|
|
27
|
+
* children are rendered they are one string with no seams.
|
|
28
|
+
*/
|
|
29
|
+
export const grid: DirectiveRenderer = ({ token, root, body, nl }) => {
|
|
30
|
+
const columns = Number(token.name.slice('grid-cols-'.length))
|
|
31
|
+
const cls = classNames(
|
|
32
|
+
'flex flex-col md:grid',
|
|
33
|
+
GRID_CLASS[columns],
|
|
34
|
+
'gap-8'
|
|
35
|
+
)
|
|
36
|
+
const tag = token.listed ? 'ul' : 'div'
|
|
37
|
+
return `<${tag}${root({ class: cls })}>${body()}</${tag}>${nl}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const cellRenderers: Record<string, DirectiveRenderer> = {
|
|
41
|
+
cell: ({ attrs, root, body, nl }) =>
|
|
42
|
+
`<div${root({ class: attrs.class })}>${body()}</div>${nl}`,
|
|
43
|
+
|
|
44
|
+
'cell-img': ({ attrs, root, nl }) =>
|
|
45
|
+
`<img${root({
|
|
46
|
+
class: classNames('mb-4', attrs.class),
|
|
47
|
+
src: cleanUrl(attrs.src),
|
|
48
|
+
alt: attrs.alt ?? ''
|
|
49
|
+
})}>${nl}`
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { GRID_RE } from '../registry'
|
|
2
|
+
import { cardRenderers } from './card'
|
|
3
|
+
import type { DirectiveRenderer } from './context'
|
|
4
|
+
import { faqRenderers } from './faq'
|
|
5
|
+
import { cellRenderers, grid } from './grid'
|
|
6
|
+
import { linkButtonRenderers } from './linkButton'
|
|
7
|
+
import { youtubeRenderers } from './youtube'
|
|
8
|
+
|
|
9
|
+
const BY_NAME: Record<string, DirectiveRenderer> = {
|
|
10
|
+
...cardRenderers,
|
|
11
|
+
...cellRenderers,
|
|
12
|
+
...faqRenderers,
|
|
13
|
+
...youtubeRenderers,
|
|
14
|
+
...linkButtonRenderers
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `grid-cols-N` is the one name that is not a name but a shape, so it is looked
|
|
19
|
+
* for after the others rather than listed among them.
|
|
20
|
+
*/
|
|
21
|
+
export const findRenderer = (name: string): DirectiveRenderer | undefined =>
|
|
22
|
+
BY_NAME[name] ?? (GRID_RE.test(name) ? grid : undefined)
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
DirectiveContext,
|
|
26
|
+
DirectiveRenderer,
|
|
27
|
+
Parser,
|
|
28
|
+
RendererOptions
|
|
29
|
+
} from './context'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { styles } from '@sakura-ui/core'
|
|
2
|
+
import { classNames, cleanUrl } from '../html'
|
|
3
|
+
import type { DirectiveRenderer } from './context'
|
|
4
|
+
|
|
5
|
+
const linkButtonClass = classNames(
|
|
6
|
+
styles.buttonBaseStyle,
|
|
7
|
+
styles.getButtonVariantStyle('secondary'),
|
|
8
|
+
styles.getButtonSizeStyle('lg')
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Written inline, so it takes no newline after it — it sits in a sentence.
|
|
13
|
+
*/
|
|
14
|
+
export const linkButtonRenderers: Record<string, DirectiveRenderer> = {
|
|
15
|
+
'link-button': ({ attrs, root, body }) =>
|
|
16
|
+
`<a${root({
|
|
17
|
+
class: linkButtonClass,
|
|
18
|
+
href: cleanUrl(attrs.href)
|
|
19
|
+
})}>${body()}</a>`
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DirectiveRenderer } from './context'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fixed rather than taken from the document. What an embedded frame may reach
|
|
5
|
+
* for, and how much of the address it hands to the other end, are the page's to
|
|
6
|
+
* settle; the person writing the prose is not the one to ask.
|
|
7
|
+
*/
|
|
8
|
+
const ALLOW =
|
|
9
|
+
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
|
10
|
+
const REFERRER_POLICY = 'strict-origin-when-cross-origin'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The video, from its id or from any of the addresses a writer has to hand —
|
|
14
|
+
* the watch page, the share link, an embed already written, a short. An id is
|
|
15
|
+
* eleven characters of that alphabet, which is what tells one from the other.
|
|
16
|
+
*
|
|
17
|
+
* Anything else is handed on as it came: a video that does not exist says so
|
|
18
|
+
* where it would have played, and a directive that rendered nothing would not.
|
|
19
|
+
*/
|
|
20
|
+
const ID = /^[\w-]{11}$/
|
|
21
|
+
const ID_IN_URL = /(?:youtu\.be\/|[?&]v=|\/embed\/|\/shorts\/)([\w-]{11})/
|
|
22
|
+
|
|
23
|
+
const videoId = (value: string | undefined): string => {
|
|
24
|
+
if (!value) return ''
|
|
25
|
+
if (ID.test(value)) return value
|
|
26
|
+
return ID_IN_URL.exec(value)?.[1] ?? value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const youtubeRenderers: Record<string, DirectiveRenderer> = {
|
|
30
|
+
youtube: ({ token, attrs, root, nl, parser }) => {
|
|
31
|
+
// The label, as text: it becomes the frame's title, which is the name a
|
|
32
|
+
// screen reader reads the embed out under.
|
|
33
|
+
const title = parser.parseInline(token.tokens, parser.textRenderer)
|
|
34
|
+
return `<iframe${root({
|
|
35
|
+
title,
|
|
36
|
+
src: `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId(attrs.video))}`,
|
|
37
|
+
class: 'aspect-video w-full max-w-[470px]',
|
|
38
|
+
style: attrs.width ? `width:${attrs.width}px` : undefined,
|
|
39
|
+
frameborder: '0',
|
|
40
|
+
allow: ALLOW,
|
|
41
|
+
referrerpolicy: REFERRER_POLICY,
|
|
42
|
+
allowfullscreen: true,
|
|
43
|
+
loading: 'lazy'
|
|
44
|
+
})}></iframe>${nl}`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const ESCAPES: Record<string, string> = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
"'": '''
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const escapeHtml = (value: unknown): string =>
|
|
10
|
+
String(value).replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Takes control characters out by code point rather than by pattern. A tab or a
|
|
14
|
+
* newline placed between the letters hides the scheme from a reader while the
|
|
15
|
+
* browser still follows it, and a regular expression holding those characters
|
|
16
|
+
* is refused by the linter either way.
|
|
17
|
+
*/
|
|
18
|
+
const stripControl = (value: string): string =>
|
|
19
|
+
Array.from(value)
|
|
20
|
+
.filter((char) => {
|
|
21
|
+
const code = char.codePointAt(0) ?? 0
|
|
22
|
+
return code > 0x1f && code !== 0x7f
|
|
23
|
+
})
|
|
24
|
+
.join('')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rejects the schemes that turn a link into script. marked keeps its own
|
|
28
|
+
* version of this to itself, so a renderer writing href or src has to bring one.
|
|
29
|
+
*/
|
|
30
|
+
export const cleanUrl = (href: string | undefined): string | undefined => {
|
|
31
|
+
if (href == null || href === '') return undefined
|
|
32
|
+
const trimmed = stripControl(String(href)).trim()
|
|
33
|
+
if (/^(?:javascript|vbscript|file):/i.test(trimmed)) return undefined
|
|
34
|
+
// Images written as data: used to be kept here and dropped nowhere, since
|
|
35
|
+
// DOMPurify allows the scheme on an img whatever ALLOWED_URI_REGEXP says. The
|
|
36
|
+
// content has no use for it, so both this and the sanitiser refuse it now.
|
|
37
|
+
if (/^data:/i.test(trimmed)) return undefined
|
|
38
|
+
return trimmed
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type AttrValue = string | boolean | undefined | null
|
|
42
|
+
|
|
43
|
+
/** `{a: 'x', b: true, c: undefined}` becomes ` a="x" b` */
|
|
44
|
+
export const attrsToHtml = (attrs: Record<string, AttrValue>): string =>
|
|
45
|
+
Object.entries(attrs)
|
|
46
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== false && v !== '')
|
|
47
|
+
.map(([k, v]) => (v === true ? ` ${k}` : ` ${k}="${escapeHtml(v)}"`))
|
|
48
|
+
.join('')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Turns the multi-line class strings the components use into a single line, so
|
|
52
|
+
* that the markup this package produces does not carry their indentation.
|
|
53
|
+
*/
|
|
54
|
+
export const classNames = (...values: (string | undefined | false)[]): string =>
|
|
55
|
+
values.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).join(' ')
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MarkedExtension, Tokens } from 'marked'
|
|
2
|
+
import { directiveRenderer, type RendererOptions } from './renderer'
|
|
3
|
+
import {
|
|
4
|
+
blockStart,
|
|
5
|
+
blockTokenizer,
|
|
6
|
+
type DirectiveToken,
|
|
7
|
+
inlineStart,
|
|
8
|
+
inlineTokenizer
|
|
9
|
+
} from './tokenizer'
|
|
10
|
+
|
|
11
|
+
export type { DirectiveToken } from './tokenizer'
|
|
12
|
+
export type { RendererOptions } from './renderer'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `:::name` syntax, as a marked extension.
|
|
16
|
+
*
|
|
17
|
+
* Both levels register under the same name because marked looks the renderer up
|
|
18
|
+
* by the token type, so a second name would mean a second renderer saying the
|
|
19
|
+
* same thing.
|
|
20
|
+
*/
|
|
21
|
+
export const directiveExtension = (
|
|
22
|
+
options: RendererOptions
|
|
23
|
+
): MarkedExtension => ({
|
|
24
|
+
extensions: [
|
|
25
|
+
{
|
|
26
|
+
name: 'directive',
|
|
27
|
+
level: 'inline',
|
|
28
|
+
start: inlineStart,
|
|
29
|
+
tokenizer: inlineTokenizer
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'directive',
|
|
33
|
+
level: 'block',
|
|
34
|
+
start: blockStart,
|
|
35
|
+
tokenizer: blockTokenizer,
|
|
36
|
+
renderer(token: Tokens.Generic) {
|
|
37
|
+
return directiveRenderer.call(
|
|
38
|
+
this as never,
|
|
39
|
+
token as DirectiveToken,
|
|
40
|
+
options
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const CONTAINER = 'container'
|
|
2
|
+
export const LEAF = 'leaf'
|
|
3
|
+
export const TEXT = 'text'
|
|
4
|
+
|
|
5
|
+
export type DirectiveKind = typeof CONTAINER | typeof LEAF | typeof TEXT
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The whitelist. A name that is not here is not a directive, and the text
|
|
9
|
+
* carrying it is left alone.
|
|
10
|
+
*
|
|
11
|
+
* The remark pipeline this replaces turned any name into a tag of the same
|
|
12
|
+
* name, so ordinary prose lost characters: "HH:MM" rendered as "HH", and
|
|
13
|
+
* "chat:write" as "chat".
|
|
14
|
+
*/
|
|
15
|
+
const STATIC: Record<string, DirectiveKind[]> = {
|
|
16
|
+
card: [CONTAINER],
|
|
17
|
+
'card-img': [LEAF],
|
|
18
|
+
'card-title': [LEAF, CONTAINER],
|
|
19
|
+
'card-description': [LEAF, CONTAINER],
|
|
20
|
+
'card-footer': [LEAF, CONTAINER],
|
|
21
|
+
faq: [CONTAINER],
|
|
22
|
+
'faq-q': [LEAF, CONTAINER],
|
|
23
|
+
'faq-a': [LEAF, CONTAINER],
|
|
24
|
+
cell: [CONTAINER],
|
|
25
|
+
'cell-img': [LEAF],
|
|
26
|
+
youtube: [LEAF, CONTAINER],
|
|
27
|
+
'link-button': [TEXT]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The one directive whose name carries a number. Shared with the renderer. */
|
|
31
|
+
export const GRID_RE = /^grid-cols-(?:1[0-2]|[1-9])$/
|
|
32
|
+
|
|
33
|
+
export const isKnown = (name: string, kind: DirectiveKind): boolean =>
|
|
34
|
+
GRID_RE.test(name) ? kind === CONTAINER : (STATIC[name] ?? []).includes(kind)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ANCHOR } from './attributes'
|
|
2
|
+
import { attrsToHtml } from './html'
|
|
3
|
+
import { CONTAINER, TEXT } from './registry'
|
|
4
|
+
import type { DirectiveToken } from './tokenizer'
|
|
5
|
+
import {
|
|
6
|
+
type DirectiveContext,
|
|
7
|
+
findRenderer,
|
|
8
|
+
type Parser,
|
|
9
|
+
type RendererOptions
|
|
10
|
+
} from './directives'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds what a directive renderer is given, then hands the token to the one
|
|
14
|
+
* that knows it. The renderers themselves live in `directives/`, a file to a
|
|
15
|
+
* feature: `card.ts` holds the card and everything written inside one, because
|
|
16
|
+
* a title has to know whether the card gave it an href.
|
|
17
|
+
*
|
|
18
|
+
* A name with no renderer falls through to its own children, which is what a
|
|
19
|
+
* directive the registry allows but nobody has drawn yet looks like.
|
|
20
|
+
*/
|
|
21
|
+
export function directiveRenderer(
|
|
22
|
+
this: { parser: Parser },
|
|
23
|
+
token: DirectiveToken,
|
|
24
|
+
options: RendererOptions
|
|
25
|
+
): string {
|
|
26
|
+
const { name, kind, attrs, tokens } = token
|
|
27
|
+
const nl = kind === TEXT ? '' : '\n'
|
|
28
|
+
|
|
29
|
+
// Marks what the directive dressed itself, so that the pass which styles the
|
|
30
|
+
// rest of the document leaves it alone.
|
|
31
|
+
const own: DirectiveContext['own'] = (values) =>
|
|
32
|
+
attrsToHtml({ ...values, 'data-sakura': name })
|
|
33
|
+
|
|
34
|
+
const context: DirectiveContext = {
|
|
35
|
+
token,
|
|
36
|
+
attrs,
|
|
37
|
+
nl,
|
|
38
|
+
own,
|
|
39
|
+
root: (values) => own({ ...values, id: attrs[ANCHOR] }),
|
|
40
|
+
body: () =>
|
|
41
|
+
kind === CONTAINER
|
|
42
|
+
? this.parser.parse(tokens)
|
|
43
|
+
: this.parser.parseInline(tokens),
|
|
44
|
+
parser: this.parser,
|
|
45
|
+
options
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const render = findRenderer(name)
|
|
49
|
+
return render ? render(context) : context.body() + nl
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type { RendererOptions } from './directives'
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Token, Tokens } from 'marked'
|
|
2
|
+
import { type Attrs, readTail } from './attributes'
|
|
3
|
+
import { CONTAINER, type DirectiveKind, isKnown, LEAF, TEXT } from './registry'
|
|
4
|
+
|
|
5
|
+
export interface DirectiveToken extends Tokens.Generic {
|
|
6
|
+
type: 'directive'
|
|
7
|
+
kind: DirectiveKind
|
|
8
|
+
name: string
|
|
9
|
+
attrs: Attrs
|
|
10
|
+
tokens: Token[]
|
|
11
|
+
raw: string
|
|
12
|
+
/** A link card puts its href on the title; the tokenizer copies it down. */
|
|
13
|
+
inherited?: { behavior: 'link'; href: string }
|
|
14
|
+
/** Set on a card once it is known to have a title to put its link on. */
|
|
15
|
+
linked?: boolean
|
|
16
|
+
/** Set on a grid of cards, and on each of the cards in it. */
|
|
17
|
+
listed?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BLOCK_HEAD = /^(:{2,})([A-Za-z][A-Za-z0-9_-]*)/
|
|
21
|
+
const INLINE_HEAD = /^:([A-Za-z][A-Za-z0-9_-]*)/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The closing fence is the first line made only of at least as many colons as
|
|
25
|
+
* the opening one. Nesting needs no bookkeeping: an inner directive uses fewer
|
|
26
|
+
* colons, so it cannot be mistaken for the close of the outer one.
|
|
27
|
+
*/
|
|
28
|
+
const findClose = (
|
|
29
|
+
body: string,
|
|
30
|
+
fenceLength: number
|
|
31
|
+
): { contentEnd: number; rawEnd: number } | null => {
|
|
32
|
+
const re = new RegExp(`^:{${fenceLength},}[ \\t]*$`)
|
|
33
|
+
let offset = 0
|
|
34
|
+
for (const line of body.split('\n')) {
|
|
35
|
+
if (re.test(line)) {
|
|
36
|
+
return {
|
|
37
|
+
contentEnd: offset,
|
|
38
|
+
rawEnd: Math.min(offset + line.length + 1, body.length)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
offset += line.length + 1
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* marked passes src.slice(1) to start() and clips the paragraph it was about to
|
|
48
|
+
* take at the returned index plus one, so what is wanted here is the position of
|
|
49
|
+
* the newline before the directive. Without it a directive following a
|
|
50
|
+
* paragraph is swallowed by that paragraph.
|
|
51
|
+
*/
|
|
52
|
+
export const blockStart = (src: string): number | undefined => {
|
|
53
|
+
const m = /\n:{2,}[A-Za-z]/.exec(src)
|
|
54
|
+
return m ? m.index : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const inlineStart = (src: string): number | undefined => {
|
|
58
|
+
const m = /:[A-Za-z][A-Za-z0-9_-]*\[/.exec(src)
|
|
59
|
+
return m ? m.index : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A grid of cards is a list. The cards are things of one kind, and how many
|
|
64
|
+
* there are is part of what the page says; a grid holding anything else, or
|
|
65
|
+
* cards and something else together, is a layout — prose beside a figure is not
|
|
66
|
+
* two of something. Nothing is written to ask for this, the way nothing is
|
|
67
|
+
* written to ask a run of "- " for a <ul>.
|
|
68
|
+
*
|
|
69
|
+
* Marked here rather than worked out while rendering, because by then the
|
|
70
|
+
* children have become one string and the boundaries between them are gone.
|
|
71
|
+
*/
|
|
72
|
+
const markCardList = (token: DirectiveToken): void => {
|
|
73
|
+
if (!/^grid-cols-\d+$/.test(token.name)) return
|
|
74
|
+
const children = token.tokens.filter((child) => child.type !== 'space')
|
|
75
|
+
const cards = children.filter(
|
|
76
|
+
(child): child is DirectiveToken =>
|
|
77
|
+
child.type === 'directive' && (child as DirectiveToken).name === 'card'
|
|
78
|
+
)
|
|
79
|
+
if (cards.length === 0 || cards.length !== children.length) return
|
|
80
|
+
token.listed = true
|
|
81
|
+
for (const card of cards) card.listed = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A card with an href is a link card; there is nothing else an href on a card
|
|
86
|
+
* could mean, so it is not also asked for by name. The href belongs on the
|
|
87
|
+
* title, and the footer needs to know it is one. A card with no title has
|
|
88
|
+
* nowhere to put the link, so it is left as a plain card rather than dressed as
|
|
89
|
+
* something that answers to a click.
|
|
90
|
+
*/
|
|
91
|
+
const propagateCardLink = (token: DirectiveToken): void => {
|
|
92
|
+
if (token.name !== 'card' || !token.attrs.href) return
|
|
93
|
+
const children = token.tokens as DirectiveToken[]
|
|
94
|
+
const hasTitle = children.some(
|
|
95
|
+
(child) => child.type === 'directive' && child.name === 'card-title'
|
|
96
|
+
)
|
|
97
|
+
if (!hasTitle) return
|
|
98
|
+
token.linked = true
|
|
99
|
+
for (const child of children) {
|
|
100
|
+
if (child.type !== 'directive') continue
|
|
101
|
+
if (child.name === 'card-title' || child.name === 'card-footer') {
|
|
102
|
+
child.inherited = { behavior: 'link', href: token.attrs.href ?? '' }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function blockTokenizer(
|
|
108
|
+
this: {
|
|
109
|
+
lexer: {
|
|
110
|
+
blockTokens: (src: string) => Token[]
|
|
111
|
+
inline: (src: string, tokens: Token[]) => void
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
src: string
|
|
115
|
+
): DirectiveToken | undefined {
|
|
116
|
+
const head = BLOCK_HEAD.exec(src)
|
|
117
|
+
if (!head) return undefined
|
|
118
|
+
const [, fence, name] = head
|
|
119
|
+
const kind = fence.length >= 3 ? CONTAINER : LEAF
|
|
120
|
+
if (!isKnown(name, kind)) return undefined
|
|
121
|
+
|
|
122
|
+
const tail = readTail(src, head[0].length)
|
|
123
|
+
const eol = /^[ \t]*(?:\n|$)/.exec(src.slice(tail.end))
|
|
124
|
+
if (!eol) return undefined
|
|
125
|
+
const openLength = tail.end + eol[0].length
|
|
126
|
+
|
|
127
|
+
const token: DirectiveToken = {
|
|
128
|
+
type: 'directive',
|
|
129
|
+
kind,
|
|
130
|
+
name,
|
|
131
|
+
attrs: tail.attrs,
|
|
132
|
+
tokens: [],
|
|
133
|
+
raw: src.slice(0, openLength)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (kind === LEAF) {
|
|
137
|
+
// Queued rather than tokenized now: marked drains the inline queue after the
|
|
138
|
+
// whole block pass, which is what lets a link reference defined further down
|
|
139
|
+
// the document resolve.
|
|
140
|
+
if (tail.label) this.lexer.inline(tail.label, token.tokens)
|
|
141
|
+
return token
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const body = src.slice(openLength)
|
|
145
|
+
const close = findClose(body, fence.length)
|
|
146
|
+
token.raw = src.slice(0, openLength + (close ? close.rawEnd : body.length))
|
|
147
|
+
token.tokens = this.lexer.blockTokens(
|
|
148
|
+
close ? body.slice(0, close.contentEnd) : body
|
|
149
|
+
)
|
|
150
|
+
propagateCardLink(token)
|
|
151
|
+
markCardList(token)
|
|
152
|
+
return token
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function inlineTokenizer(
|
|
156
|
+
this: { lexer: { inlineTokens: (src: string) => Token[] } },
|
|
157
|
+
src: string
|
|
158
|
+
): DirectiveToken | undefined {
|
|
159
|
+
const head = INLINE_HEAD.exec(src)
|
|
160
|
+
if (!head) return undefined
|
|
161
|
+
const [, name] = head
|
|
162
|
+
if (!isKnown(name, TEXT)) return undefined
|
|
163
|
+
|
|
164
|
+
const tail = readTail(src, head[0].length)
|
|
165
|
+
if (tail.label === null) return undefined
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
type: 'directive',
|
|
169
|
+
kind: TEXT,
|
|
170
|
+
name,
|
|
171
|
+
attrs: tail.attrs,
|
|
172
|
+
tokens: this.lexer.inlineTokens(tail.label),
|
|
173
|
+
raw: src.slice(0, tail.end)
|
|
174
|
+
}
|
|
175
|
+
}
|