blume 1.6.4 → 1.6.6
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/CHANGELOG.md +41 -0
- package/bin/blume.mjs +3 -2
- package/dist/cli/chunk-0ewz4trd.js +679 -0
- package/dist/cli/chunk-0ewz4trd.js.map +15 -0
- package/dist/cli/chunk-27gtm2ym.js +69 -0
- package/dist/cli/chunk-27gtm2ym.js.map +11 -0
- package/dist/cli/chunk-2aj8ddew.js +72 -0
- package/dist/cli/chunk-2aj8ddew.js.map +10 -0
- package/dist/cli/chunk-3k0kzs6d.js +69 -0
- package/dist/cli/chunk-3k0kzs6d.js.map +11 -0
- package/dist/cli/chunk-3r94j3tc.js +221 -0
- package/dist/cli/chunk-3r94j3tc.js.map +10 -0
- package/dist/cli/chunk-4trphnvy.js +102 -0
- package/dist/cli/chunk-4trphnvy.js.map +11 -0
- package/dist/cli/chunk-4xyggvgf.js +21 -0
- package/dist/cli/chunk-4xyggvgf.js.map +10 -0
- package/dist/cli/chunk-5hs6gb7n.js +32 -0
- package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
- package/dist/cli/chunk-5yvt556e.js +185 -0
- package/dist/cli/chunk-5yvt556e.js.map +11 -0
- package/dist/cli/chunk-62qsssnh.js +3808 -0
- package/dist/cli/chunk-62qsssnh.js.map +36 -0
- package/dist/cli/chunk-6kzzpsx8.js +26 -0
- package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
- package/dist/cli/chunk-8gnpdsn1.js +952 -0
- package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
- package/dist/cli/chunk-9sh49q0h.js +30 -0
- package/dist/cli/chunk-9sh49q0h.js.map +10 -0
- package/dist/cli/chunk-aerwpe14.js +2370 -0
- package/dist/cli/chunk-aerwpe14.js.map +15 -0
- package/dist/cli/chunk-ag1zyr5x.js +176 -0
- package/dist/cli/chunk-ag1zyr5x.js.map +10 -0
- package/dist/cli/chunk-bawgnt8x.js +277 -0
- package/dist/cli/chunk-bawgnt8x.js.map +11 -0
- package/dist/cli/chunk-bcy492zc.js +16 -0
- package/dist/cli/chunk-bcy492zc.js.map +10 -0
- package/dist/cli/chunk-btfr9yvw.js +41 -0
- package/dist/cli/chunk-btfr9yvw.js.map +10 -0
- package/dist/cli/chunk-cbjnx4s8.js +73 -0
- package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
- package/dist/cli/chunk-cnvm6k3e.js +96 -0
- package/dist/cli/chunk-cnvm6k3e.js.map +10 -0
- package/dist/cli/chunk-etsqspj6.js +5170 -0
- package/dist/cli/chunk-etsqspj6.js.map +47 -0
- package/dist/cli/chunk-ev67ycx0.js +15 -0
- package/dist/cli/chunk-ev67ycx0.js.map +10 -0
- package/dist/cli/chunk-ey89bjj1.js +209 -0
- package/dist/cli/chunk-ey89bjj1.js.map +11 -0
- package/dist/cli/chunk-f75cqye8.js +76 -0
- package/dist/cli/chunk-f75cqye8.js.map +10 -0
- package/dist/cli/chunk-j00ezcg5.js +259 -0
- package/dist/cli/chunk-j00ezcg5.js.map +11 -0
- package/dist/cli/chunk-jtb45atp.js +467 -0
- package/dist/cli/chunk-jtb45atp.js.map +14 -0
- package/dist/cli/chunk-m3p3wahd.js +117 -0
- package/dist/cli/chunk-m3p3wahd.js.map +10 -0
- package/dist/cli/chunk-n0y172hf.js +387 -0
- package/dist/cli/chunk-n0y172hf.js.map +12 -0
- package/dist/cli/chunk-n4qjabmt.js +1062 -0
- package/dist/cli/chunk-n4qjabmt.js.map +25 -0
- package/dist/cli/chunk-nyqzjdhj.js +111 -0
- package/dist/cli/chunk-nyqzjdhj.js.map +11 -0
- package/dist/cli/chunk-pxj10x8y.js +35 -0
- package/dist/cli/chunk-pxj10x8y.js.map +10 -0
- package/dist/cli/chunk-s4jn7f1q.js +54 -0
- package/dist/cli/chunk-s4jn7f1q.js.map +10 -0
- package/dist/cli/chunk-s4k1pnvf.js +81 -0
- package/dist/cli/chunk-s4k1pnvf.js.map +10 -0
- package/dist/cli/chunk-s5e5jt53.js +227 -0
- package/dist/cli/chunk-s5e5jt53.js.map +11 -0
- package/dist/cli/chunk-sbdqrjbb.js +81 -0
- package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
- package/dist/cli/chunk-tc89yh2r.js +136 -0
- package/dist/cli/chunk-tc89yh2r.js.map +10 -0
- package/dist/cli/chunk-vt8fgygt.js +23 -0
- package/dist/cli/chunk-vt8fgygt.js.map +10 -0
- package/dist/cli/chunk-vv237fp3.js +1002 -0
- package/dist/cli/chunk-vv237fp3.js.map +13 -0
- package/dist/cli/chunk-vv3f8mb6.js +5314 -0
- package/dist/cli/chunk-vv3f8mb6.js.map +58 -0
- package/dist/cli/chunk-vxv4x1n8.js +17 -0
- package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
- package/dist/cli/chunk-wb067mv3.js +758 -0
- package/dist/cli/chunk-wb067mv3.js.map +13 -0
- package/dist/cli/chunk-wd27zjcz.js +60 -0
- package/dist/cli/chunk-wd27zjcz.js.map +10 -0
- package/dist/cli/chunk-wkq5tbtq.js +1141 -0
- package/dist/cli/chunk-wkq5tbtq.js.map +19 -0
- package/dist/cli/chunk-x1vrdjyk.js +1967 -0
- package/dist/cli/chunk-x1vrdjyk.js.map +34 -0
- package/dist/cli/chunk-x66c5yjn.js +23 -0
- package/dist/cli/chunk-x66c5yjn.js.map +10 -0
- package/dist/cli/index.js +55 -27587
- package/dist/cli/index.js.map +5 -243
- package/dist/types/ai/ask-context.d.ts +26 -0
- package/dist/types/core/code-fences.d.ts +11 -0
- package/dist/types/core/config-input.d.ts +10 -0
- package/dist/types/core/package-root.d.ts +1 -1
- package/dist/types/core/schema.d.ts +74 -1
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/analytics.mdx +21 -2
- package/docs/configuration/ask-ai.mdx +1 -1
- package/docs/configuration/customization.mdx +2 -9
- package/docs/content/syntax.mdx +1 -1
- package/docs/reference/cli.mdx +1 -1
- package/package.json +16 -14
- package/src/ai/api/handlers.ts +4 -7
- package/src/ai/api/paths.ts +8 -0
- package/src/ai/api/spec.ts +2 -1
- package/src/ai/ask-context.ts +378 -22
- package/src/astro/generate.ts +25 -29
- package/src/astro/include-hmr.ts +10 -13
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +6 -1
- package/src/astro/integration.ts +269 -53
- package/src/astro/module-types.ts +74 -0
- package/src/astro/templates.ts +85 -97
- package/src/audit/image-size.ts +10 -8
- package/src/cli/command-meta.ts +77 -0
- package/src/cli/commands/add.ts +2 -4
- package/src/cli/commands/audit.ts +2 -4
- package/src/cli/commands/build.ts +42 -346
- package/src/cli/commands/check.ts +2 -4
- package/src/cli/commands/dev.ts +31 -42
- package/src/cli/commands/doctor.ts +2 -4
- package/src/cli/commands/eject.ts +3 -41
- package/src/cli/commands/eval.ts +2 -5
- package/src/cli/commands/init.ts +2 -4
- package/src/cli/commands/mcp-stdio.ts +2 -5
- package/src/cli/commands/preview.ts +3 -5
- package/src/cli/commands/sync.ts +2 -4
- package/src/cli/commands/translate.ts +2 -5
- package/src/cli/commands/validate.ts +2 -4
- package/src/cli/commands/version.ts +2 -4
- package/src/cli/eject-scripts.ts +0 -45
- package/src/cli/host-args.ts +16 -0
- package/src/cli/index.ts +84 -35
- package/src/cli/lazy-command.ts +47 -0
- package/src/components/content/GithubInfo.astro +4 -1
- package/src/components/content/mermaid-element.ts +8 -0
- package/src/components/layout/Analytics.astro +20 -1
- package/src/components/layout/PageLayout.astro +14 -3
- package/src/components/layout/ReferenceLayout.astro +15 -4
- package/src/components/layout/RootLayout.astro +15 -4
- package/src/components/layout/analytics-client.ts +2 -1
- package/src/components/layout/page-locale.ts +29 -0
- package/src/components/openapi/AsyncApiOperation.astro +5 -3
- package/src/components/openapi/Authorization.astro +4 -6
- package/src/components/openapi/Bindings.astro +2 -2
- package/src/components/openapi/Description.astro +109 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +5 -7
- package/src/components/openapi/GraphqlOperation.astro +4 -3
- package/src/components/openapi/GraphqlType.astro +4 -6
- package/src/components/openapi/ParametersTable.astro +5 -7
- package/src/components/openapi/RequestBody.astro +2 -4
- package/src/components/openapi/Responses.astro +4 -3
- package/src/components/openapi/SchemaProperty.astro +11 -6
- package/src/components/openapi/description.ts +91 -0
- package/src/core/api-name.ts +18 -0
- package/src/core/code-fences.ts +48 -0
- package/src/core/config-input.ts +10 -0
- package/src/core/content-assets.ts +3 -7
- package/src/core/includes.ts +3 -7
- package/src/core/package-root.ts +1 -1
- package/src/core/schema.ts +27 -0
- package/src/core/sources/normalize.ts +2 -37
- package/src/core/sources/obsidian.ts +3 -2
- package/src/core/svg-dimensions.ts +97 -0
- package/src/core/version-cut.ts +2 -2
- package/src/deploy/artifacts.ts +370 -0
- package/src/deploy/cloudflare-negotiation.ts +97 -32
- package/src/deploy/function-bundle.ts +66 -20
- package/src/deploy/sitemap.ts +6 -0
- package/src/deploy/vercel-negotiation.ts +8 -30
- package/src/og/card.ts +6 -12
- package/src/openapi/render-mdx.ts +9 -5
- package/src/registry/eject.ts +0 -2
- package/src/theme/entry.ts +9 -2
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
THEME_INIT_SCRIPT,
|
|
18
18
|
} from "./head-scripts.ts";
|
|
19
19
|
import Header from "./Header.astro";
|
|
20
|
+
import { pageDirection, pageLocale } from "./page-locale.ts";
|
|
20
21
|
import { searchLocaleFor } from "./search-locale.ts";
|
|
21
22
|
|
|
22
23
|
// A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
|
|
@@ -48,6 +49,7 @@ interface Props {
|
|
|
48
49
|
key: string;
|
|
49
50
|
} | null;
|
|
50
51
|
analytics?: {
|
|
52
|
+
cloudflare?: { token: string };
|
|
51
53
|
posthog?: { host?: string; key: string };
|
|
52
54
|
scripts?: {
|
|
53
55
|
attributes?: Record<string, string>;
|
|
@@ -65,9 +67,12 @@ interface Props {
|
|
|
65
67
|
pageTitle: string;
|
|
66
68
|
/** Keep the reference route out of crawler indexes. */
|
|
67
69
|
noindex?: boolean;
|
|
68
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Active locale code for `<html lang>`. Defaults to the locale Astro
|
|
72
|
+
* resolved from the URL (`Astro.currentLocale`), then the site default.
|
|
73
|
+
*/
|
|
69
74
|
locale?: string;
|
|
70
|
-
/** Text direction for `<html dir
|
|
75
|
+
/** Text direction for `<html dir>`; defaults to the locale's configured direction. */
|
|
71
76
|
dir?: "ltr" | "rtl";
|
|
72
77
|
/** Resolved UI dictionary; English baseline when omitted. */
|
|
73
78
|
ui?: UIStrings;
|
|
@@ -87,11 +92,17 @@ const {
|
|
|
87
92
|
searchEnabled,
|
|
88
93
|
pageTitle,
|
|
89
94
|
noindex = false,
|
|
90
|
-
locale
|
|
91
|
-
dir
|
|
95
|
+
locale: localeProp,
|
|
96
|
+
dir: dirProp,
|
|
92
97
|
ui,
|
|
93
98
|
} = Astro.props;
|
|
94
99
|
|
|
100
|
+
// The locale and direction: the page's own values when it passes them (the
|
|
101
|
+
// content catch-all always does), else what Astro's i18n routing resolved for
|
|
102
|
+
// this URL, else the site default (see `page-locale.ts`).
|
|
103
|
+
const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
|
|
104
|
+
const dir = dirProp ?? pageDirection(data.config.i18n, locale);
|
|
105
|
+
|
|
95
106
|
const strings = ui ?? EN_UI;
|
|
96
107
|
// Scope search to the reference page's language on a multi-locale site.
|
|
97
108
|
const searchLocale = searchLocaleFor(data.config.i18n, locale);
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from "./nav-utils.ts";
|
|
59
59
|
import NavTree from "./NavTree.astro";
|
|
60
60
|
import { resolveSlot } from "./overrides.ts";
|
|
61
|
+
import { pageDirection, pageLocale } from "./page-locale.ts";
|
|
61
62
|
import { searchLocaleFor } from "./search-locale.ts";
|
|
62
63
|
import PageActions from "./PageActions.astro";
|
|
63
64
|
import PageFeedback from "./PageFeedback.astro";
|
|
@@ -84,6 +85,7 @@ interface Props {
|
|
|
84
85
|
key: string;
|
|
85
86
|
} | null;
|
|
86
87
|
analytics?: {
|
|
88
|
+
cloudflare?: { token: string };
|
|
87
89
|
posthog?: { host?: string; key: string };
|
|
88
90
|
scripts?: {
|
|
89
91
|
attributes?: Record<string, string>;
|
|
@@ -148,9 +150,12 @@ interface Props {
|
|
|
148
150
|
lastModified?: string | null;
|
|
149
151
|
noindex?: boolean;
|
|
150
152
|
structuredDataEnabled?: boolean;
|
|
151
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Active locale code for `<html lang>`. Defaults to the locale Astro
|
|
155
|
+
* resolved from the URL (`Astro.currentLocale`), then the site default.
|
|
156
|
+
*/
|
|
152
157
|
locale?: string;
|
|
153
|
-
/** Text direction for `<html dir
|
|
158
|
+
/** Text direction for `<html dir>`; defaults to the locale's configured direction. */
|
|
154
159
|
dir?: "ltr" | "rtl";
|
|
155
160
|
/**
|
|
156
161
|
* Direction of the page content's own language — differs from `dir` on a
|
|
@@ -241,8 +246,8 @@ const {
|
|
|
241
246
|
lastModified,
|
|
242
247
|
noindex,
|
|
243
248
|
structuredDataEnabled,
|
|
244
|
-
locale
|
|
245
|
-
dir
|
|
249
|
+
locale: localeProp,
|
|
250
|
+
dir: dirProp,
|
|
246
251
|
contentDir,
|
|
247
252
|
ui,
|
|
248
253
|
localeAlternates,
|
|
@@ -258,6 +263,12 @@ const {
|
|
|
258
263
|
contentLayout = "default",
|
|
259
264
|
} = Astro.props;
|
|
260
265
|
|
|
266
|
+
// The locale and direction: the page's own values when it passes them (the
|
|
267
|
+
// content catch-all always does), else what Astro's i18n routing resolved for
|
|
268
|
+
// this URL, else the site default (see `page-locale.ts`).
|
|
269
|
+
const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
|
|
270
|
+
const dir = dirProp ?? pageDirection(data.config.i18n, locale);
|
|
271
|
+
|
|
261
272
|
// Serialized once for island hooks; `<` escaped so content can't break the tag.
|
|
262
273
|
const clientDataJson = clientData
|
|
263
274
|
? JSON.stringify(clientData).replaceAll("<", "\\u003c")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Send a custom analytics event to every analytics platform configured in
|
|
3
3
|
* `blume.config.ts`. Mirrors the providers wired by `Analytics.astro`: Vercel
|
|
4
|
-
* Web Analytics and PostHog are first-class
|
|
4
|
+
* Web Analytics and PostHog are first-class (Cloudflare Web Analytics is too,
|
|
5
|
+
* but has no custom-event API to forward to); any other provider added through
|
|
5
6
|
* `analytics.scripts` is reached via best-effort global detection or the
|
|
6
7
|
* `blume:track` CustomEvent, which fires unconditionally so a project can bridge
|
|
7
8
|
* the event to anything. Every call no-ops cleanly when a provider isn't present
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale defaults for the document shells (`RootLayout`, `PageLayout`,
|
|
3
|
+
* `ReferenceLayout`) when the page doesn't pass `locale`/`dir` itself.
|
|
4
|
+
*
|
|
5
|
+
* The content catch-all always passes both, resolved from the route manifest
|
|
6
|
+
* (which also knows about fallback pages). Custom `.astro` pages, the 404
|
|
7
|
+
* page, and the reference shell don't have that data, so they fall back to
|
|
8
|
+
* what Astro's own i18n routing resolved for the request — `Astro.currentLocale`
|
|
9
|
+
* — which the generated config enables with Blume's locale list. Before this,
|
|
10
|
+
* a custom page under `/fr/` rendered `<html lang="en">` unless it threaded
|
|
11
|
+
* the locale through by hand.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The page's locale: the caller's explicit value, else the one Astro resolved
|
|
16
|
+
* from the URL, else the site's default locale, else `en` (no i18n).
|
|
17
|
+
*/
|
|
18
|
+
export const pageLocale = (
|
|
19
|
+
i18n: { defaultLocale: string } | null,
|
|
20
|
+
explicit?: string,
|
|
21
|
+
current?: string
|
|
22
|
+
): string => explicit ?? current ?? i18n?.defaultLocale ?? "en";
|
|
23
|
+
|
|
24
|
+
/** The configured text direction of a locale; `ltr` when unknown. */
|
|
25
|
+
export const pageDirection = (
|
|
26
|
+
i18n: { locales: { code: string; dir: "ltr" | "rtl" }[] } | null,
|
|
27
|
+
locale: string
|
|
28
|
+
): "ltr" | "rtl" =>
|
|
29
|
+
i18n?.locales.find((entry) => entry.code === locale)?.dir ?? "ltr";
|
|
@@ -23,6 +23,7 @@ import { asyncSampleLanguages } from "./async-snippets.ts";
|
|
|
23
23
|
import { languageSamplePanels } from "./sample-panels.ts";
|
|
24
24
|
import { buildMessage, defaultMessageValues } from "./message.ts";
|
|
25
25
|
import { messageModel } from "./message-model.ts";
|
|
26
|
+
import Description from "./Description.astro";
|
|
26
27
|
import MessageComposer from "./MessageComposer.astro";
|
|
27
28
|
import Authorization from "./Authorization.astro";
|
|
28
29
|
import Bindings from "./Bindings.astro";
|
|
@@ -156,9 +157,10 @@ const channelBindings = bindingGroups(channel?.bindings);
|
|
|
156
157
|
: "Message"}
|
|
157
158
|
</div>
|
|
158
159
|
{named.message.description && (
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
<Description
|
|
161
|
+
class="mb-2 text-muted-foreground text-sm"
|
|
162
|
+
text={named.message.description}
|
|
163
|
+
/>
|
|
162
164
|
)}
|
|
163
165
|
{named.message.contentType && (
|
|
164
166
|
<div class="mb-2 text-muted-foreground text-xs">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import Description from "./Description.astro";
|
|
2
3
|
import {
|
|
3
4
|
type OperationSecurity,
|
|
4
5
|
schemeCarrier,
|
|
@@ -38,7 +39,7 @@ const { security } = Astro.props;
|
|
|
38
39
|
{alternative.map((resolved) => {
|
|
39
40
|
const carrier = schemeCarrier(resolved);
|
|
40
41
|
return (
|
|
41
|
-
<div class="border-border border-t py-
|
|
42
|
+
<div class="border-border border-t py-4 first:border-t-0">
|
|
42
43
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
43
44
|
<code class="font-mono text-foreground text-sm">
|
|
44
45
|
{carrier?.name ?? resolved.key}
|
|
@@ -54,13 +55,10 @@ const { security } = Astro.props;
|
|
|
54
55
|
)}
|
|
55
56
|
</div>
|
|
56
57
|
{resolved.scheme?.description && (
|
|
57
|
-
<
|
|
58
|
-
class="mt-1 text-muted-foreground text-sm"
|
|
59
|
-
set:text={resolved.scheme.description}
|
|
60
|
-
/>
|
|
58
|
+
<Description class="mt-2 text-muted-foreground text-sm" text={resolved.scheme.description} />
|
|
61
59
|
)}
|
|
62
60
|
{resolved.scopes.length > 0 && (
|
|
63
|
-
<div class="mt-
|
|
61
|
+
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
64
62
|
<span class="text-muted-foreground">Scopes:</span>
|
|
65
63
|
{resolved.scopes.map((scope) => (
|
|
66
64
|
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
@@ -54,13 +54,13 @@ const isSchemaish = (value: unknown): value is SchemaLike => {
|
|
|
54
54
|
</div>
|
|
55
55
|
{groups.map((group) => (
|
|
56
56
|
<div class="not-prose mb-3 rounded-blume border border-border px-4 last:mb-0">
|
|
57
|
-
<div class="flex items-baseline gap-2 border-border py-
|
|
57
|
+
<div class="flex items-baseline gap-2 border-border py-4">
|
|
58
58
|
<code class="font-mono font-semibold text-foreground text-sm">
|
|
59
59
|
{group.protocol}
|
|
60
60
|
</code>
|
|
61
61
|
</div>
|
|
62
62
|
{group.rows.map((row) => (
|
|
63
|
-
<div class="border-border border-t py-
|
|
63
|
+
<div class="border-border border-t py-4">
|
|
64
64
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
65
65
|
<code class="font-mono text-foreground text-sm">{row.name}</code>
|
|
66
66
|
{!isSchemaish(row.value) && (
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { descriptionHtml } from "./description.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A spec description, rendered as Markdown.
|
|
6
|
+
*
|
|
7
|
+
* One component rather than the same three lines in six places, and that is not only tidiness:
|
|
8
|
+
* the styling below is emitted ONCE per page here, where writing it as utility classes on each
|
|
9
|
+
* element repeated it per description instead. On the largest reference page that is 2,400
|
|
10
|
+
* descriptions — the first attempt at this put 1.5 MB of identical class attributes into a single
|
|
11
|
+
* HTML document and pushed eight pages past Googlebot's 2 MB crawl limit, which `blume audit`
|
|
12
|
+
* caught. A class name and one stylesheet cost the same at one description as at two thousand.
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
/** Extra classes for the wrapper — position and base type come from the caller. */
|
|
16
|
+
class?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { class: className = "", text } = Astro.props;
|
|
21
|
+
const html = descriptionHtml(text);
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
{html && <div class:list={["blume-api-description", className]} set:html={html} />}
|
|
25
|
+
|
|
26
|
+
<style is:global>
|
|
27
|
+
/* Plain CSS on a single class, not the typography plugin's `prose`. `prose` sets its own font
|
|
28
|
+
size, colour and rhythm, all of which fight the muted small type these sit in — a property
|
|
29
|
+
description would come out larger and darker than the property name above it. Only what
|
|
30
|
+
Markdown needs is styled; everything else inherits, so a description still reads as
|
|
31
|
+
annotation rather than as body copy. */
|
|
32
|
+
.blume-api-description > :first-child {
|
|
33
|
+
margin-top: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.blume-api-description > :last-child {
|
|
37
|
+
margin-bottom: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.blume-api-description p,
|
|
41
|
+
.blume-api-description ol,
|
|
42
|
+
.blume-api-description ul,
|
|
43
|
+
.blume-api-description pre {
|
|
44
|
+
margin-block: 0.5rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.blume-api-description ol {
|
|
48
|
+
list-style: decimal;
|
|
49
|
+
padding-inline-start: 1.25rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.blume-api-description ul {
|
|
53
|
+
list-style: disc;
|
|
54
|
+
padding-inline-start: 1.25rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.blume-api-description li {
|
|
58
|
+
margin-block: 0.25rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* A heading in a description is a label for the paragraph under it, not a section of the
|
|
62
|
+
page: it keeps the description's own size and only gains the weight and colour of a label.
|
|
63
|
+
Without this it inherits the reset's unstyled heading and is indistinguishable from text. */
|
|
64
|
+
.blume-api-description :is(h1, h2, h3, h4, h5, h6) {
|
|
65
|
+
color: var(--color-foreground);
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
margin-block: 0.5rem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.blume-api-description strong {
|
|
71
|
+
color: var(--color-foreground);
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.blume-api-description a {
|
|
76
|
+
text-decoration: underline;
|
|
77
|
+
text-underline-offset: 2px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Matched to the chip the reference already draws for an enum value, so an `apiKey` in prose
|
|
81
|
+
looks like the `apiKey` in the chips beside it. */
|
|
82
|
+
.blume-api-description code {
|
|
83
|
+
background: var(--color-muted);
|
|
84
|
+
border-radius: 0.25rem;
|
|
85
|
+
color: var(--color-foreground);
|
|
86
|
+
font-family: var(--font-mono);
|
|
87
|
+
font-size: 0.75rem;
|
|
88
|
+
padding: 0.125rem 0.25rem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.blume-api-description pre {
|
|
92
|
+
background: var(--color-muted);
|
|
93
|
+
border-radius: 0.25rem;
|
|
94
|
+
overflow-x: auto;
|
|
95
|
+
padding: 0.5rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.blume-api-description pre code {
|
|
99
|
+
background: none;
|
|
100
|
+
padding: 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* The scroll frame a table gets from `descriptionHtml` carries the body's own 1.5rem rhythm,
|
|
104
|
+
which is three times what everything else in a description sits at. Only the margin is
|
|
105
|
+
restated; the frame, the cell padding and the scrolling are the renderer's. */
|
|
106
|
+
.blume-api-description .blume-table-scroll {
|
|
107
|
+
margin-block: 0.5rem;
|
|
108
|
+
}
|
|
109
|
+
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { GraphqlFieldRow } from "./graphql-helpers.ts";
|
|
3
3
|
import { isOutputField } from "./graphql-helpers.ts";
|
|
4
|
+
import Description from "./Description.astro";
|
|
4
5
|
import GraphqlChip from "./GraphqlChip.astro";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -46,7 +47,7 @@ const isRequired = (row: GraphqlFieldRow): boolean =>
|
|
|
46
47
|
const route = routes.get(row.type.name);
|
|
47
48
|
const args = isOutputField(row) ? row.args : [];
|
|
48
49
|
return (
|
|
49
|
-
<div class="border-border border-t py-
|
|
50
|
+
<div class="border-border border-t py-4 first:border-t-0">
|
|
50
51
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
51
52
|
<code class="font-mono text-foreground text-sm">
|
|
52
53
|
{row.name}
|
|
@@ -68,13 +69,10 @@ const isRequired = (row: GraphqlFieldRow): boolean =>
|
|
|
68
69
|
)}
|
|
69
70
|
</div>
|
|
70
71
|
{row.description && (
|
|
71
|
-
<
|
|
72
|
-
class="mt-1 text-muted-foreground text-sm"
|
|
73
|
-
set:text={row.description}
|
|
74
|
-
/>
|
|
72
|
+
<Description class="mt-2 text-muted-foreground text-sm" text={row.description} />
|
|
75
73
|
)}
|
|
76
74
|
{"default" in row && row.default !== undefined && (
|
|
77
|
-
<div class="mt-
|
|
75
|
+
<div class="mt-2 text-muted-foreground text-xs">
|
|
78
76
|
Default:{" "}
|
|
79
77
|
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
80
78
|
{row.default}
|
|
@@ -82,7 +80,7 @@ const isRequired = (row: GraphqlFieldRow): boolean =>
|
|
|
82
80
|
</div>
|
|
83
81
|
)}
|
|
84
82
|
{row.deprecationReason && (
|
|
85
|
-
<div class="mt-
|
|
83
|
+
<div class="mt-2 text-muted-foreground text-xs">
|
|
86
84
|
Deprecated: <span set:text={row.deprecationReason} />
|
|
87
85
|
</div>
|
|
88
86
|
)}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { buildRequest, defaultValues } from "./request.ts";
|
|
24
24
|
import { languageSamplePanels } from "./sample-panels.ts";
|
|
25
25
|
import { sampleLanguages } from "./snippets.ts";
|
|
26
|
+
import Description from "./Description.astro";
|
|
26
27
|
import GraphqlChip from "./GraphqlChip.astro";
|
|
27
28
|
import GraphqlFieldsTable from "./GraphqlFieldsTable.astro";
|
|
28
29
|
import GraphqlType from "./GraphqlType.astro";
|
|
@@ -154,9 +155,9 @@ const returnRoute = field ? routes.get(field.type.name) : undefined;
|
|
|
154
155
|
/>
|
|
155
156
|
</div>
|
|
156
157
|
{document.types[field.type.name]?.description && (
|
|
157
|
-
<
|
|
158
|
-
class="mt-
|
|
159
|
-
|
|
158
|
+
<Description
|
|
159
|
+
class="mt-2 text-muted-foreground text-sm"
|
|
160
|
+
text={document.types[field.type.name]?.description}
|
|
160
161
|
/>
|
|
161
162
|
)}
|
|
162
163
|
</section>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
graphqlRoutes,
|
|
8
8
|
graphqlUsage,
|
|
9
9
|
} from "./graphql-helpers.ts";
|
|
10
|
+
import Description from "./Description.astro";
|
|
10
11
|
import GraphqlChip from "./GraphqlChip.astro";
|
|
11
12
|
import GraphqlFieldsTable from "./GraphqlFieldsTable.astro";
|
|
12
13
|
import MethodBadge from "./MethodBadge.astro";
|
|
@@ -86,7 +87,7 @@ const SECTION_HEADING = "mb-2 font-semibold text-foreground text-sm";
|
|
|
86
87
|
</div>
|
|
87
88
|
<div class="rounded-blume border border-border px-4">
|
|
88
89
|
{(type.enumValues ?? []).map((value) => (
|
|
89
|
-
<div class="border-border border-t py-
|
|
90
|
+
<div class="border-border border-t py-4 first:border-t-0">
|
|
90
91
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
91
92
|
<code class="font-mono text-foreground text-sm">
|
|
92
93
|
{value.name}
|
|
@@ -98,13 +99,10 @@ const SECTION_HEADING = "mb-2 font-semibold text-foreground text-sm";
|
|
|
98
99
|
)}
|
|
99
100
|
</div>
|
|
100
101
|
{value.description && (
|
|
101
|
-
<
|
|
102
|
-
class="mt-1 text-muted-foreground text-sm"
|
|
103
|
-
set:text={value.description}
|
|
104
|
-
/>
|
|
102
|
+
<Description class="mt-2 text-muted-foreground text-sm" text={value.description} />
|
|
105
103
|
)}
|
|
106
104
|
{value.deprecationReason && (
|
|
107
|
-
<div class="mt-
|
|
105
|
+
<div class="mt-2 text-muted-foreground text-xs">
|
|
108
106
|
Deprecated: <span set:text={value.deprecationReason} />
|
|
109
107
|
</div>
|
|
110
108
|
)}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import Description from "./Description.astro";
|
|
2
3
|
import {
|
|
3
4
|
constraints,
|
|
4
5
|
resolveSchema,
|
|
@@ -54,7 +55,7 @@ const groups = SECTIONS.map((section) => ({
|
|
|
54
55
|
const limits = constraints(resolved);
|
|
55
56
|
const enumValues = Array.isArray(resolved.enum) ? resolved.enum : null;
|
|
56
57
|
return (
|
|
57
|
-
<div class="border-border border-t py-
|
|
58
|
+
<div class="border-border border-t py-4 first:border-t-0">
|
|
58
59
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
59
60
|
<code class="font-mono text-foreground text-sm">{param.name}</code>
|
|
60
61
|
<span class="text-muted-foreground text-xs">{type}</span>
|
|
@@ -70,18 +71,15 @@ const groups = SECTIONS.map((section) => ({
|
|
|
70
71
|
)}
|
|
71
72
|
</div>
|
|
72
73
|
{param.description && (
|
|
73
|
-
<
|
|
74
|
-
class="mt-1 text-muted-foreground text-sm"
|
|
75
|
-
set:text={param.description}
|
|
76
|
-
/>
|
|
74
|
+
<Description class="mt-2 text-muted-foreground text-sm" text={param.description} />
|
|
77
75
|
)}
|
|
78
76
|
{limits.length > 0 && (
|
|
79
|
-
<div class="mt-
|
|
77
|
+
<div class="mt-2 text-muted-foreground text-xs">
|
|
80
78
|
{limits.join(" · ")}
|
|
81
79
|
</div>
|
|
82
80
|
)}
|
|
83
81
|
{enumValues && (
|
|
84
|
-
<div class="mt-
|
|
82
|
+
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
85
83
|
<span class="text-muted-foreground">Allowed:</span>
|
|
86
84
|
{enumValues.map((value) => (
|
|
87
85
|
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import Description from "./Description.astro";
|
|
2
3
|
import type { SchemaLike } from "./helpers.ts";
|
|
3
4
|
import SchemaTable from "./SchemaTable.astro";
|
|
4
5
|
|
|
@@ -46,10 +47,7 @@ const schema = chosen?.[1]?.schema ?? {};
|
|
|
46
47
|
</div>
|
|
47
48
|
{
|
|
48
49
|
requestBody.description && (
|
|
49
|
-
<
|
|
50
|
-
class="text-muted-foreground text-sm"
|
|
51
|
-
set:text={requestBody.description}
|
|
52
|
-
/>
|
|
50
|
+
<Description class="text-muted-foreground text-sm" text={requestBody.description} />
|
|
53
51
|
)
|
|
54
52
|
}
|
|
55
53
|
<div class="mt-3">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { statusColor } from "../colors.ts";
|
|
3
|
+
import Description from "./Description.astro";
|
|
3
4
|
import type { SchemaLike } from "./helpers.ts";
|
|
4
5
|
import SchemaTable from "./SchemaTable.astro";
|
|
5
6
|
|
|
@@ -52,9 +53,9 @@ const items = Object.entries(responses);
|
|
|
52
53
|
{status}
|
|
53
54
|
</span>
|
|
54
55
|
{response.description && (
|
|
55
|
-
<
|
|
56
|
-
class="text-muted-foreground text-sm"
|
|
57
|
-
|
|
56
|
+
<Description
|
|
57
|
+
class="min-w-0 text-muted-foreground text-sm"
|
|
58
|
+
text={response.description}
|
|
58
59
|
/>
|
|
59
60
|
)}
|
|
60
61
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import Description from "./Description.astro";
|
|
2
3
|
import {
|
|
3
4
|
constraints,
|
|
4
5
|
isNullable,
|
|
@@ -54,7 +55,11 @@ const expandable =
|
|
|
54
55
|
!circular && (hasObjectShape(resolved) || Boolean(items && hasObjectShape(items)));
|
|
55
56
|
---
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
{/* The row's vertical scale is 8 / 12 / 16px, and every other reference table copies it. A
|
|
59
|
+
description is a block of Markdown, not a line: at 4px it sat closer to the label above it
|
|
60
|
+
than its own paragraphs sat to each other, and the disclosure below it touched the next row's
|
|
61
|
+
divider. Each step separates a bigger unit than the one before. */}
|
|
62
|
+
<div class="border-border border-t py-4 first:border-t-0 last:pb-0">
|
|
58
63
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
59
64
|
<code class="font-mono text-foreground text-sm">{name}</code>
|
|
60
65
|
<span class="text-muted-foreground text-xs"
|
|
@@ -77,17 +82,17 @@ const expandable =
|
|
|
77
82
|
</div>
|
|
78
83
|
{
|
|
79
84
|
description && (
|
|
80
|
-
<
|
|
85
|
+
<Description class="mt-2 text-muted-foreground text-sm" text={description} />
|
|
81
86
|
)
|
|
82
87
|
}
|
|
83
88
|
{
|
|
84
89
|
limits.length > 0 && (
|
|
85
|
-
<div class="mt-
|
|
90
|
+
<div class="mt-2 text-muted-foreground text-xs">{limits.join(" · ")}</div>
|
|
86
91
|
)
|
|
87
92
|
}
|
|
88
93
|
{
|
|
89
94
|
enumValues && (
|
|
90
|
-
<div class="mt-
|
|
95
|
+
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
91
96
|
<span class="text-muted-foreground">Allowed:</span>
|
|
92
97
|
{enumValues.map((value) => (
|
|
93
98
|
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
@@ -99,12 +104,12 @@ const expandable =
|
|
|
99
104
|
}
|
|
100
105
|
{
|
|
101
106
|
expandable && (
|
|
102
|
-
<details class="mt-
|
|
107
|
+
<details class="mt-3" open={expandAll}>
|
|
103
108
|
<summary class="cursor-pointer select-none text-accent text-xs hover:underline">
|
|
104
109
|
<span class="[details[open]>summary_&]:hidden">Show properties</span>
|
|
105
110
|
<span class="hidden [details[open]>summary_&]:inline">Hide properties</span>
|
|
106
111
|
</summary>
|
|
107
|
-
<div class="mt-
|
|
112
|
+
<div class="mt-3 border-border border-l pl-4">
|
|
108
113
|
<SchemaTable
|
|
109
114
|
schema={schema}
|
|
110
115
|
schemas={schemas}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a spec description as Markdown.
|
|
5
|
+
*
|
|
6
|
+
* An OpenAPI `description` is Markdown by specification — "CommonMark syntax MAY be used for rich
|
|
7
|
+
* text representation" — but the reference components printed it with `set:text`, so a schema
|
|
8
|
+
* property, parameter or header showed its source. On a spec generated from code docstrings that
|
|
9
|
+
* is most of them: `**Inline**` printed its asterisks, `` `apiKey` `` printed its backticks, and
|
|
10
|
+
* because HTML collapses newlines every paragraph and list ran together into one wall of text.
|
|
11
|
+
* The operation description does not have this problem — it is emitted into the MDX body and goes
|
|
12
|
+
* through the full pipeline — which is what made the difference visible page by page.
|
|
13
|
+
*
|
|
14
|
+
* `marked` rather than the site's own Markdown pipeline: the pipeline is async, plugin-laden and
|
|
15
|
+
* built for whole documents, while these are thousands of short strings per build — a large
|
|
16
|
+
* reference renders tens of thousands of them. `marked` is synchronous, already a dependency,
|
|
17
|
+
* and already how the Ask AI island renders model Markdown.
|
|
18
|
+
*/
|
|
19
|
+
const TABLE = /<table>[\s\S]*?<\/table>/gu;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An href the author can have meant: an absolute URL, a site-root path, a fragment or a mailto.
|
|
23
|
+
* A bare relative href in a spec description has never yet been a link, and the lookahead keeps
|
|
24
|
+
* `//host` out: that is a scheme-relative URL to another origin, not a site-root path.
|
|
25
|
+
*/
|
|
26
|
+
const DELIBERATE_HREF = /^(?:https?:|mailto:|#|\/(?!\/))/iu;
|
|
27
|
+
|
|
28
|
+
/** The source text of a demoted construct, made safe for `set:html`. */
|
|
29
|
+
const escapeHtml = (text: string): string =>
|
|
30
|
+
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
31
|
+
|
|
32
|
+
const markdown = new Marked({
|
|
33
|
+
// `breaks` is deliberately NOT set, unlike the Ask AI island. Docstring prose is hard-wrapped at
|
|
34
|
+
// 72 or 79 columns, so honouring single newlines would break every sentence mid-flow at exactly
|
|
35
|
+
// the width the source file happened to use.
|
|
36
|
+
breaks: false,
|
|
37
|
+
gfm: true,
|
|
38
|
+
hooks: {
|
|
39
|
+
// GFM tables reach the page through `set:html`, so they miss the `blume:table-wrap` plugin
|
|
40
|
+
// that gives every table in the body its scroll frame — and a description sits in a column
|
|
41
|
+
// narrower than the body. Wrapping here reuses that frame rather than reinventing it; the
|
|
42
|
+
// regex is safe because a table cannot nest and this HTML is `marked`'s own output.
|
|
43
|
+
postprocess: (html: string) =>
|
|
44
|
+
html.replaceAll(
|
|
45
|
+
TABLE,
|
|
46
|
+
(table) => `<div class="blume-table-scroll" tabindex="0">${table}</div>`
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
renderer: {
|
|
50
|
+
// Raw HTML is escaped rather than passed through. A description is data lifted out of a spec
|
|
51
|
+
// file, frequently generated upstream from source comments, and it is interpolated with
|
|
52
|
+
// `set:html`; the island that renders model output runs DOMPurify over it for the same
|
|
53
|
+
// reason, which needs a DOM and so is unavailable in a component that renders on the server.
|
|
54
|
+
html: ({ text }: { text: string }) => escapeHtml(text),
|
|
55
|
+
// An image is held to the same href policy as a link, for the same reason: `` is the
|
|
56
|
+
// one Markdown construct that fetches a resource, and a `javascript:` or relative source in a
|
|
57
|
+
// description is notation rather than a picture.
|
|
58
|
+
image: ({ href, raw }: { href: string; raw: string }) =>
|
|
59
|
+
DELIBERATE_HREF.test(href) ? false : escapeHtml(raw),
|
|
60
|
+
// A link is emitted only when the author clearly meant one; anything else keeps its source
|
|
61
|
+
// text verbatim. Two failures drove this, and both are prose that was never Markdown.
|
|
62
|
+
//
|
|
63
|
+
// GFM autolinks a BARE url (`raw === href`), and a spec description is full of EXAMPLE hosts
|
|
64
|
+
// — `https://myorg.my.salesforce.com`, `https://yourstore.myshopify.com`. Each became an
|
|
65
|
+
// anchor pointing at a host that does not exist and was never meant to be visited.
|
|
66
|
+
//
|
|
67
|
+
// Worse, regex and format notation reads as link syntax. Debezium's own wording for a column
|
|
68
|
+
// list is `schemaName[.]tableName[.](columnName1|columnName2)`, in which `[.](columnName1|
|
|
69
|
+
// columnName2)` is EXACTLY `[text](href)` — 48 pages of one reference linked to a path made
|
|
70
|
+
// of that notation.
|
|
71
|
+
// Rendering the demoted case as `raw` rather than as `text` is what keeps that intact: the
|
|
72
|
+
// text alone is `.`, so emitting it would silently delete the rest of the notation.
|
|
73
|
+
//
|
|
74
|
+
// The test for "meant one" is the href (see `DELIBERATE_HREF`) plus the shape of the source:
|
|
75
|
+
// an author-written link starts with `[` (inline or reference style) or `<` (an angle
|
|
76
|
+
// autolink). A GFM bare autolink never does — `https://…`, `www.…` or an email — and
|
|
77
|
+
// comparing `raw` to `href` is not enough to catch it, because marked prefixes the missing
|
|
78
|
+
// `http://` or `mailto:` for the last two, so the two strings differ exactly as they would
|
|
79
|
+
// for a deliberate link.
|
|
80
|
+
link({ href, raw }: { href: string; raw: string }) {
|
|
81
|
+
const deliberate =
|
|
82
|
+
DELIBERATE_HREF.test(href) &&
|
|
83
|
+
(raw.startsWith("[") || raw.startsWith("<"));
|
|
84
|
+
return deliberate ? false : escapeHtml(raw);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** `description` rendered to HTML, or an empty string when there is nothing to render. */
|
|
90
|
+
export const descriptionHtml = (description: string | undefined): string =>
|
|
91
|
+
description?.trim() ? markdown.parse(description, { async: false }) : "";
|