@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,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
+ '&': '&amp;',
3
+ '<': '&lt;',
4
+ '>': '&gt;',
5
+ '"': '&quot;',
6
+ "'": '&#39;'
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
+ }