@dogsbay/ui 0.2.0-beta.115 → 0.2.0-beta.117

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/ui",
3
- "version": "0.2.0-beta.115",
3
+ "version": "0.2.0-beta.117",
4
4
  "description": "Accessible UI components for Astro, inspired by Base UI and shadcn",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,19 +10,21 @@
10
10
  "tailwind-variants": "^0.3.1"
11
11
  },
12
12
  "devDependencies": {
13
- "vitest": "^4.1.10"
13
+ "vitest": "^4.1.10",
14
+ "@shikijs/transformers": "^4.3.1",
15
+ "katex": "^0.16.47",
16
+ "maplibre-gl": "^5.24.0",
17
+ "shiki": "^4.3.1"
14
18
  },
15
19
  "peerDependencies": {
16
20
  "astro": "^5.0.0 || ^6.0.0 || ^7.0.0",
17
- "@dogsbay/primitives": "0.2.0-beta.115",
18
- "@dogsbay/elements": "0.2.0-beta.115",
19
- "@dogsbay/icons": "0.2.0-beta.115"
20
- },
21
- "optionalDependencies": {
22
21
  "@shikijs/transformers": "^4.3.1",
23
22
  "katex": "^0.16.47",
24
23
  "maplibre-gl": "^5.24.0",
25
- "shiki": "^4.3.1"
24
+ "shiki": "^4.3.1",
25
+ "@dogsbay/elements": "0.2.0-beta.117",
26
+ "@dogsbay/primitives": "0.2.0-beta.117",
27
+ "@dogsbay/icons": "0.2.0-beta.117"
26
28
  },
27
29
  "files": [
28
30
  "src",
@@ -38,6 +40,23 @@
38
40
  "bugs": {
39
41
  "url": "https://github.com/dogsbay/dogsbay/issues"
40
42
  },
43
+ "peerDependenciesMeta": {
44
+ "astro": {
45
+ "optional": true
46
+ },
47
+ "@shikijs/transformers": {
48
+ "optional": true
49
+ },
50
+ "katex": {
51
+ "optional": true
52
+ },
53
+ "maplibre-gl": {
54
+ "optional": true
55
+ },
56
+ "shiki": {
57
+ "optional": true
58
+ }
59
+ },
41
60
  "scripts": {
42
61
  "test": "vitest run"
43
62
  }
@@ -1,17 +1,10 @@
1
1
  ---
2
2
  import { tv, type VariantProps } from "tailwind-variants";
3
+ import { BADGE_BASE, BADGE_VARIANTS } from "./variants.js";
3
4
 
4
5
  const badge = tv({
5
- base: "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
6
- variants: {
7
- variant: {
8
- default: "border-transparent bg-primary text-primary-foreground",
9
- secondary: "border-transparent bg-secondary text-secondary-foreground",
10
- destructive:
11
- "border-transparent bg-destructive text-destructive-foreground",
12
- outline: "text-foreground",
13
- },
14
- },
6
+ base: BADGE_BASE,
7
+ variants: { variant: BADGE_VARIANTS },
15
8
  defaultVariants: {
16
9
  variant: "default",
17
10
  },
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Badge classes, shared by Badge.astro, ContentRenderer and @dogsbay/render-html
3
+ * (which copies this file with scripts/sync-sources.mjs), so a badge
4
+ * looks the same however a page is produced.
5
+ */
6
+ export const BADGE_BASE =
7
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors";
8
+
9
+ export const BADGE_VARIANTS = {
10
+ default: "border-transparent bg-primary text-primary-foreground",
11
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
12
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
13
+ outline: "text-foreground",
14
+ // Status colours, from the callout palette (`:badge[Beta]{variant="warning"}`).
15
+ // The TEXT is the accent mixed 60% toward --foreground; the tint keeps the
16
+ // accent at full strength. The accent used raw measured 1.85-2.6:1 in light
17
+ // mode (danger 3.3-3.9:1 in dark too), from painted pixels. --foreground is
18
+ // near-black in light and near-white in dark, so one rule serves both themes
19
+ // (worst case 4.95:1); a fixed dark colour would fail dark mode. The same
20
+ // rule as the in-cell admonitions in format-astro's theme-css.ts.
21
+ // The `color:` hint is required: tailwind-merge 2.x (Badge.astro's tv())
22
+ // reads a bare text-[... 60% ...] as a FONT SIZE and drops text-xs.
23
+ // Checked per preset by @dogsbay/render-html tests/contrast.test.ts, and
24
+ // through tv() by ui tests/badge.test.ts.
25
+ info: "border-transparent bg-info/15 text-[color:color-mix(in_oklab,var(--info)_60%,var(--foreground))]",
26
+ success: "border-transparent bg-success/15 text-[color:color-mix(in_oklab,var(--success)_60%,var(--foreground))]",
27
+ warning: "border-transparent bg-warning/15 text-[color:color-mix(in_oklab,var(--warning)_60%,var(--foreground))]",
28
+ danger: "border-transparent bg-danger/15 text-[color:color-mix(in_oklab,var(--danger)_60%,var(--foreground))]",
29
+ } as const;
30
+
31
+ export type BadgeVariant = keyof typeof BADGE_VARIANTS;
32
+
33
+ /** The class list for a variant; an unknown variant gets the base classes only. */
34
+ export function badgeClass(variant: string | undefined): string {
35
+ const v = BADGE_VARIANTS[(variant ?? "default") as BadgeVariant];
36
+ return v ? `${BADGE_BASE} ${v}` : BADGE_BASE;
37
+ }
@@ -21,6 +21,10 @@ interface Props {
21
21
  lang?: string;
22
22
  /** Optional title displayed in the header bar */
23
23
  title?: string;
24
+ /*
25
+ * A `caption` slot makes the block a figure: the header bar is its
26
+ * <figcaption>, showing the caption with the language beside it.
27
+ */
24
28
  /** Show copy button (default: true) */
25
29
  copy?: boolean;
26
30
  /** Show the header title bar. true = always, false = never, "auto" = only when title prop is set (default: true) */
@@ -55,22 +59,38 @@ const shikiLang = LANG_ALIASES[lang.toLowerCase()] ?? lang.toLowerCase();
55
59
  // - true: show when there's a title or a declared language (default, original behavior)
56
60
  // - false: never show
57
61
  // - "auto": only show when an explicit title is provided (not lang-only)
62
+ const hasCaption = Astro.slots.has("caption");
63
+ const Wrapper = hasCaption ? "figure" : "div";
64
+ const Header = hasCaption ? "figcaption" : "div";
58
65
  const showHeader =
59
- showTitle === false ? false
66
+ hasCaption ? true
67
+ : showTitle === false ? false
60
68
  : showTitle === "auto" ? !!title
61
69
  : !!title || lang !== "plaintext";
62
70
  // When no header, copy button floats inside the code body
63
71
  const floatingCopy = copy && !showHeader;
64
72
  ---
65
73
 
66
- <div
67
- class={codeBlockWrapper({ class: className })}
74
+ <Wrapper
75
+ class={codeBlockWrapper({ class: [hasCaption && "m-0", className] })}
68
76
  data-component="code-block"
69
77
  {...rest}
70
78
  >
71
79
  {showHeader && (
72
- <div class={codeBlockHeader()}>
73
- <span class="select-none">{title || lang}</span>
80
+ <Header class={codeBlockHeader()}>
81
+ {/*
82
+ Left: what the code is (caption or filename title), or the language when that is
83
+ all there is. Right: the language beside the copy button, as secondary detail
84
+ */}
85
+ {hasCaption ? (
86
+ <span class="min-w-0 font-medium text-code-fg" data-part="caption"><slot name="caption" /></span>
87
+ ) : title ? (
88
+ <span class="min-w-0 font-medium text-code-fg" data-part="title">{title}</span>
89
+ ) : (
90
+ <span class="select-none">{lang}</span>
91
+ )}
92
+ <span class="flex shrink-0 items-center gap-2">
93
+ {(hasCaption || title) && lang !== "plaintext" && <span class="select-none" data-part="language">{lang}</span>}
74
94
  {copy && (
75
95
  <button
76
96
  class={codeBlockCopy()}
@@ -113,7 +133,8 @@ const floatingCopy = copy && !showHeader;
113
133
  </svg>
114
134
  </button>
115
135
  )}
116
- </div>
136
+ </span>
137
+ </Header>
117
138
  )}
118
139
  <div class="relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:px-4 [&_pre]:py-3">
119
140
  <Code code={code} lang={shikiLang} themes={{ light: "github-light-default", dark: theme }} defaultColor={false} />
@@ -135,7 +156,7 @@ const floatingCopy = copy && !showHeader;
135
156
  </button>
136
157
  )}
137
158
  </div>
138
- </div>
159
+ </Wrapper>
139
160
 
140
161
  <script>
141
162
  function setupCodeBlockCopy() {
@@ -33,6 +33,10 @@ interface Props {
33
33
  lang?: string;
34
34
  /** Optional title/filename displayed in the header bar */
35
35
  title?: string;
36
+ /*
37
+ * A `caption` slot makes the block a figure: the header bar is its
38
+ * <figcaption>, showing the caption with the language beside it.
39
+ */
36
40
  /** Show copy button (default: true) */
37
41
  copy?: boolean;
38
42
  /** Show line numbers (default: false) */
@@ -212,21 +216,36 @@ const html = await codeToHtml(highlightCode, {
212
216
  });
213
217
 
214
218
  // Show header bar only when there's a title or a declared language
215
- const hasLabel = !!title || (lang !== "plaintext");
219
+ const hasCaption = Astro.slots.has("caption");
220
+ const Wrapper = hasCaption ? "figure" : "div";
221
+ const Header = hasCaption ? "figcaption" : "div";
222
+ const hasLabel = hasCaption || !!title || (lang !== "plaintext");
216
223
  const showHeader = hasLabel;
217
224
  // When no header, copy button floats inside the code body
218
225
  const floatingCopy = copy && !showHeader;
219
226
  ---
220
227
 
221
- <div
222
- class={codeRichWrapper({ class: className })}
228
+ <Wrapper
229
+ class={codeRichWrapper({ class: [hasCaption && "m-0", className] })}
223
230
  data-component="code-rich"
224
231
  data-line-numbers={lineNumbers || undefined}
225
232
  {...rest}
226
233
  >
227
234
  {showHeader && (
228
- <div class={codeRichHeader()}>
229
- <span class="select-none">{title || lang}</span>
235
+ <Header class={codeRichHeader()}>
236
+ {/*
237
+ Left: what the code is (caption or filename title), or the language when that is
238
+ all there is. Right: the language beside the copy button, as secondary detail
239
+ */}
240
+ {hasCaption ? (
241
+ <span class="min-w-0 font-medium text-code-fg" data-part="caption"><slot name="caption" /></span>
242
+ ) : title ? (
243
+ <span class="min-w-0 font-medium text-code-fg" data-part="title">{title}</span>
244
+ ) : (
245
+ <span class="select-none">{lang}</span>
246
+ )}
247
+ <span class="flex shrink-0 items-center gap-2">
248
+ {(hasCaption || title) && lang !== "plaintext" && <span class="select-none" data-part="language">{lang}</span>}
230
249
  {copy && (
231
250
  <button
232
251
  class={codeRichCopy()}
@@ -269,7 +288,8 @@ const floatingCopy = copy && !showHeader;
269
288
  </svg>
270
289
  </button>
271
290
  )}
272
- </div>
291
+ </span>
292
+ </Header>
273
293
  )}
274
294
  <div class="code-rich-body relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:!bg-transparent [&_pre]:px-4 [&_pre]:py-3">
275
295
  <Fragment set:html={html} />
@@ -291,7 +311,7 @@ const floatingCopy = copy && !showHeader;
291
311
  </button>
292
312
  )}
293
313
  </div>
294
- </div>
314
+ </Wrapper>
295
315
 
296
316
  <script>
297
317
  function setupCodeRichCopy() {
@@ -2,7 +2,9 @@
2
2
 
3
3
  /* ── All lines ────────────────────────────────────────── */
4
4
  .code-rich-body .line {
5
- display: inline-flex;
5
+ /* inline-block, not inline-flex: a flex line makes each token span a flex item, and
6
+ browsers then copy a hand-made selection with a line break between tokens. */
7
+ display: inline-block;
6
8
  width: 100%;
7
9
  }
8
10
 
@@ -36,6 +36,8 @@ const BARE_MARKER = /[ \t]*\[!code[^\]]*\]/g;
36
36
  * @param isPlainGrammar true when highlighting falls back to plaintext
37
37
  */
38
38
  export function stripUnprocessableMarkers(code: string, isPlainGrammar: boolean): string {
39
+ // Every marker has "[!code"; without one there is nothing to strip, and MARKER's leading [ \t]* is quadratic on a long run of spaces.
40
+ if (!code.includes("[!code")) return code;
39
41
  return code
40
42
  .split("\n")
41
43
  .map((line) => {
@@ -29,6 +29,10 @@
29
29
  * api-params, api-symbol, api-doc, api-source, youtube, image, grid
30
30
  */
31
31
  import Alert from "../alert/Alert.astro";
32
+ import { badgeClass } from "../badge/variants.js";
33
+
34
+ /** Attribute-safe text (also safe as element text). */
35
+ const escAttr = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32
36
  import AlertTitle from "../alert/AlertTitle.astro";
33
37
  import AlertDescription from "../alert/AlertDescription.astro";
34
38
  import CollapsibleAlert from "../alert/CollapsibleAlert.astro";
@@ -128,6 +132,7 @@ function inlineToHtml(nodes: InlineNode[]): string {
128
132
  case "link": return `<a href="${n.href || ""}">${inlineToHtml((n.children || []) as InlineNode[])}</a>`;
129
133
  case "image": return `<img src="${withBaseUrl(n.src as string | undefined)}" alt="${n.alt || ""}">`;
130
134
  case "kbd": return (n.keys as string[] || []).map((k: string) => `<kbd>${k}</kbd>`).join("+");
135
+ case "badge": return `<span class="${badgeClass(n.variant as string | undefined)}" data-variant="${escAttr(String(n.variant || "default"))}">${escAttr(String(n.text ?? ""))}</span>`;
131
136
  case "html-inline": return String(n.html || "");
132
137
  case "break": return "<br>";
133
138
  default: return "";
@@ -387,7 +392,9 @@ function withBaseUrl(src: string | undefined): string {
387
392
  <li id={(node.props?.id as string) || ""} class="text-sm text-muted-foreground">
388
393
  {node.inline
389
394
  ? <span>{node.inline.map(span => <InlineRenderer node={span} icons={icons} images={images} />)}</span>
390
- : <Fragment set:html={node.html} />
395
+ : children.length > 0
396
+ ? children.map(child => <Astro.self node={child} images={images} icons={icons} />)
397
+ : <Fragment set:html={node.html} />
391
398
  }
392
399
  {" "}<a href={`#${node.props?.backrefId || ""}`} class="text-primary hover:underline" aria-label={`Back to reference ${node.props?.label}`}>↩</a>
393
400
  </li>
@@ -3,6 +3,7 @@
3
3
  * InlineRenderer — renders structured InlineNode[] to HTML elements.
4
4
  */
5
5
  import MathKatex from "../math/MathKatex.astro";
6
+ import Badge from "../badge/Badge.astro";
6
7
 
7
8
  interface InlineNode {
8
9
  type: string;
@@ -135,6 +136,10 @@ const isEmojiIcon = node.type === "icon" && (node.library === "emoji" || (iconSv
135
136
  </a>
136
137
  )}
137
138
 
139
+ {node.type === "badge" && (
140
+ <Badge variant={(node as { variant?: string }).variant ?? "default"}>{node.text}</Badge>
141
+ )}
142
+
138
143
  {node.type === "kbd" && (
139
144
  <span class="inline-flex items-center gap-0.5">
140
145
  {(node.keys || []).map((key, i) => (
@@ -0,0 +1,16 @@
1
+ ---
2
+ /**
3
+ * FileTree — the `:::files` directive: a nested list shown as folders and files.
4
+ * The markup is fileTreeHtml (markup.ts), shared with dogsbay-ssg.
5
+ */
6
+ import { fileTreeHtml, type FileTreeItem } from "./markup.js";
7
+
8
+ interface Props {
9
+ items: FileTreeItem[];
10
+ class?: string;
11
+ }
12
+
13
+ const { items, class: className } = Astro.props;
14
+ ---
15
+
16
+ <Fragment set:html={fileTreeHtml(items, className ?? "")} />
@@ -0,0 +1,2 @@
1
+ export { default as FileTree } from "./FileTree.astro";
2
+ export { fileTreeHtml, type FileTreeItem } from "./markup.js";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The `:::files` tree, written once for both pipelines: FileTree.astro renders it,
3
+ * and dogsbay-ssg copies this file (scripts/sync-astro-styles.mjs) and calls it.
4
+ * Each pipeline turns an entry's inline Markdown into `label` HTML itself.
5
+ */
6
+
7
+ export interface FileTreeItem {
8
+ /** The entry's label as HTML (already escaped). */
9
+ label: string;
10
+ /** A folder: it has entries, or its name ends in "/". */
11
+ folder: boolean;
12
+ children: FileTreeItem[];
13
+ }
14
+
15
+ const svg = (body: string) =>
16
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" focusable="false">${body}</svg>`;
17
+ // Lucide folder and file (ISC licence).
18
+ const FOLDER = svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>');
19
+ const FILE = svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>');
20
+
21
+ function list(items: FileTreeItem[], nested: boolean): string {
22
+ const cls = nested ? "m-0 ml-2 list-none border-l border-border pl-4" : "m-0 list-none p-0";
23
+ return `<ul class="${cls}">${items
24
+ .map(
25
+ (it) =>
26
+ `<li class="my-0.5" data-kind="${it.folder ? "folder" : "file"}"><span class="inline-flex items-center gap-1.5">${it.folder ? FOLDER : FILE}<span>${it.label}</span></span>${it.children.length ? list(it.children, true) : ""}</li>`,
27
+ )
28
+ .join("")}</ul>`;
29
+ }
30
+
31
+ /** The whole tree, in a bordered, monospaced box. */
32
+ export function fileTreeHtml(items: FileTreeItem[], extraClass = ""): string {
33
+ return `<div class="not-prose my-4 overflow-x-auto rounded-lg border bg-card p-3 font-mono text-sm${extraClass ? ` ${extraClass}` : ""}" data-component="files">${list(items, false)}</div>`;
34
+ }
package/src/kbd/Kbd.astro CHANGED
@@ -1,9 +1,8 @@
1
1
  ---
2
2
  import { tv } from "tailwind-variants";
3
+ import { KBD_CLASS } from "./variants.js";
3
4
 
4
- const kbd = tv({
5
- base: "pointer-events-none inline-flex h-5 items-center justify-center gap-1 rounded border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-[0_1px_0_1px] shadow-muted",
6
- });
5
+ const kbd = tv({ base: KBD_CLASS });
7
6
 
8
7
  interface Props {
9
8
  class?: string;
@@ -0,0 +1,3 @@
1
+ /** Kbd.astro's classes, shared with dogsbay-ssg (copied by scripts/sync-astro-styles.mjs). */
2
+ export const KBD_CLASS =
3
+ "pointer-events-none inline-flex h-5 items-center justify-center gap-1 rounded border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-[0_1px_0_1px] shadow-muted";
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { tv } from "tailwind-variants";
3
+ import Icon from "../icon/Icon.astro";
3
4
 
4
5
  const linkCard = tv({
5
6
  base: "group flex flex-col gap-1 rounded-lg border bg-card p-4 text-card-foreground shadow-sm transition-colors hover:bg-accent/50",
@@ -17,16 +18,21 @@ interface Props {
17
18
  title: string;
18
19
  description?: string;
19
20
  href: string;
21
+ /** An icon before the title: a name (`rocket`, `mdi:home`) as the Icon component takes. */
22
+ icon?: string;
20
23
  class?: string;
21
24
  [key: string]: unknown;
22
25
  }
23
26
 
24
- const { title, description, href, class: className, ...rest } = Astro.props;
27
+ const { title, description, href, icon, class: className, ...rest } = Astro.props;
25
28
  ---
26
29
 
27
30
  <a href={href} class={linkCard({ class: className })} {...rest}>
28
31
  <div class="flex items-center justify-between">
29
- <span class={linkCardTitle()}>{title}</span>
32
+ <span class="flex min-w-0 items-center gap-2">
33
+ {icon && <Icon name={icon} class="size-4 shrink-0 text-muted-foreground" />}
34
+ <span class={linkCardTitle()}>{title}</span>
35
+ </span>
30
36
  <svg
31
37
  xmlns="http://www.w3.org/2000/svg"
32
38
  width="16"
@@ -202,7 +202,7 @@ function openMobile(provider: HTMLElement) {
202
202
  }
203
203
 
204
204
  // Lock scroll
205
- ScrollLock.lock();
205
+ ScrollLock.activate();
206
206
 
207
207
  // Focus first focusable element in sidebar
208
208
  requestAnimationFrame(() => {
@@ -237,7 +237,7 @@ function closeMobile(provider: HTMLElement) {
237
237
  }
238
238
 
239
239
  // Unlock scroll
240
- ScrollLock.unlock();
240
+ ScrollLock.deactivate();
241
241
 
242
242
  // Return focus to trigger
243
243
  const trigger = instance.provider.querySelector(
@@ -6,22 +6,33 @@ const tableContainer = tv({
6
6
  });
7
7
 
8
8
  const table = tv({
9
- base: "w-full caption-bottom text-sm",
9
+ base: "w-full caption-top text-sm",
10
10
  });
11
11
 
12
12
  interface Props {
13
13
  class?: string;
14
- /** Optional table caption, rendered as a `<caption>` (bottom-aligned). */
14
+ /**
15
+ * Optional table caption, rendered as a `<caption>`: the table's title,
16
+ * above it and left-aligned.
17
+ * For a caption with markup, use the `caption` slot instead.
18
+ */
15
19
  caption?: string;
16
20
  [key: string]: unknown;
17
21
  }
18
22
 
19
23
  const { class: className, caption, ...rest } = Astro.props;
24
+
25
+ // A table's caption is its title: above the table, left-aligned, in the body color
26
+ const captionClass = "mb-2 text-left text-sm font-medium text-foreground";
20
27
  ---
21
28
 
22
29
  <div class={tableContainer()}>
23
30
  <table class={table({ class: className })} {...rest}>
24
- {caption && <caption class="text-sm text-muted-foreground mt-2">{caption}</caption>}
31
+ {Astro.slots.has("caption") ? (
32
+ <caption class={captionClass}><slot name="caption" /></caption>
33
+ ) : (
34
+ caption && <caption class={captionClass}>{caption}</caption>
35
+ )}
25
36
  <slot />
26
37
  </table>
27
38
  </div>