blume 0.4.0 → 0.5.0

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 (67) hide show
  1. package/dist/cli/index.js +1137 -722
  2. package/dist/cli/index.js.map +28 -23
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +154 -15
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/docs/advanced/api-reference.mdx +33 -23
  8. package/docs/advanced/bridge.mdx +74 -0
  9. package/docs/advanced/meta.ts +8 -1
  10. package/docs/advanced/migrate.mdx +119 -0
  11. package/docs/configuration/index.mdx +1 -1
  12. package/docs/content/components.mdx +55 -2
  13. package/docs/content/i18n.mdx +1 -1
  14. package/docs/content/syntax.mdx +2 -2
  15. package/docs/index.mdx +2 -2
  16. package/docs/reference/cli.mdx +29 -1
  17. package/docs/reference/frontmatter.mdx +5 -0
  18. package/package.json +11 -1
  19. package/src/astro/generate.ts +18 -8
  20. package/src/astro/templates.ts +28 -4
  21. package/src/cli/commands/build.ts +107 -63
  22. package/src/cli/commands/check.ts +20 -0
  23. package/src/cli/dev-lock.ts +13 -5
  24. package/src/cli/prepare.ts +3 -0
  25. package/src/components/BlumePage.astro +6 -0
  26. package/src/components/Icon.astro +13 -10
  27. package/src/components/content/ApiField.astro +75 -0
  28. package/src/components/content/ParamField.astro +39 -0
  29. package/src/components/content/RequestField.astro +23 -0
  30. package/src/components/content/ResponseField.astro +23 -0
  31. package/src/components/content/Step.astro +1 -1
  32. package/src/components/layout/Breadcrumbs.astro +7 -2
  33. package/src/components/layout/NavTree.astro +24 -8
  34. package/src/components/layout/RootLayout.astro +56 -34
  35. package/src/components/layout/Search.astro +1 -1
  36. package/src/components/openapi/ApiOverview.astro +84 -0
  37. package/src/components/openapi/MethodBadge.astro +28 -0
  38. package/src/components/openapi/Operation.astro +140 -0
  39. package/src/components/openapi/ParametersTable.astro +97 -0
  40. package/src/components/openapi/RequestBody.astro +58 -0
  41. package/src/components/openapi/RequestPanel.astro +169 -0
  42. package/src/components/openapi/Responses.astro +91 -0
  43. package/src/components/openapi/SchemaProperty.astro +118 -0
  44. package/src/components/openapi/SchemaTable.astro +86 -0
  45. package/src/components/openapi/helpers.ts +238 -0
  46. package/src/components/openapi/panel.ts +59 -0
  47. package/src/components/openapi/snippets.ts +201 -0
  48. package/src/core/builtin-tags.ts +5 -0
  49. package/src/core/data.ts +2 -0
  50. package/src/core/project-graph.ts +5 -1
  51. package/src/core/project.ts +25 -3
  52. package/src/core/schema.ts +47 -6
  53. package/src/core/sources/mintlify.ts +1 -1
  54. package/src/core/sources/resolve.ts +28 -6
  55. package/src/core/types.ts +7 -0
  56. package/src/migrate/mintlify/config.ts +153 -1
  57. package/src/migrate/mintlify/content.ts +8 -2
  58. package/src/migrate/mintlify/index.ts +58 -1
  59. package/src/openapi/model.ts +174 -0
  60. package/src/openapi/parse.ts +48 -0
  61. package/src/openapi/references.ts +164 -0
  62. package/src/openapi/render-mdx.ts +76 -0
  63. package/src/openapi/scalar.ts +15 -103
  64. package/src/openapi/source.ts +140 -0
  65. package/src/registry/eject.ts +15 -2
  66. package/src/theme/chrome-icons.ts +22 -0
  67. package/src/theme/icons.ts +151 -161
@@ -0,0 +1,76 @@
1
+ import type { ApiOperationRef, ApiSpecData } from "./model.ts";
2
+
3
+ /**
4
+ * Lower a parsed spec into MDX for the staged content source. Each operation and
5
+ * the spec overview become a thin MDX page: the frontmatter carries the
6
+ * searchable `title` (so operations flow into Blume's search, OG, and llms.txt),
7
+ * the operation/overview **description is emitted as markdown in the body** so it
8
+ * renders parsed (links, formatting) and is indexed, and the structured UI is
9
+ * deferred to a Blume-owned component (`<Operation>` / `<ApiOverview>`). The
10
+ * catch-all renders the frontmatter title as the page `<h1>`, so the components
11
+ * omit their own top heading.
12
+ */
13
+
14
+ // Neutralize the few characters MDX treats specially (`{` expressions, `<` JSX)
15
+ // so an arbitrary spec description can be embedded in the body verbatim without
16
+ // breaking compilation. They render as their literal selves.
17
+ const MDX_UNSAFE = /[<>{}]/gu;
18
+ const ENTITIES: Record<string, string> = {
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ "{": "&#123;",
22
+ "}": "&#125;",
23
+ };
24
+ const mdxSafe = (text: string): string =>
25
+ text.replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char);
26
+
27
+ /** Frontmatter + body for one operation or overview page. */
28
+ export interface RenderedPage {
29
+ data: Record<string, unknown>;
30
+ body: string;
31
+ }
32
+
33
+ /** Prepend a markdown description (if any) above a component invocation. */
34
+ const withDescription = (description: string, component: string): string =>
35
+ description.trim()
36
+ ? `${mdxSafe(description.trim())}\n\n${component}`
37
+ : component;
38
+
39
+ export const operationMdx = (
40
+ spec: ApiSpecData,
41
+ operation: ApiOperationRef
42
+ ): RenderedPage => {
43
+ const method = operation.method.toUpperCase();
44
+ const title = operation.summary || `${method} ${operation.path}`;
45
+ // Skip the body description when it only repeats the summary (the `<h1>`) —
46
+ // common in specs that set summary and description to the same string.
47
+ const description =
48
+ operation.description.trim() === operation.summary.trim()
49
+ ? ""
50
+ : operation.description;
51
+ return {
52
+ body: withDescription(
53
+ description,
54
+ `<Operation source="${spec.slug}" id="${operation.key}" />`
55
+ ),
56
+ data: {
57
+ ...(operation.deprecated ? { deprecated: true } : {}),
58
+ search: { tags: [operation.tag, method] },
59
+ sidebar: { badge: method, label: operation.summary || operation.path },
60
+ title,
61
+ // Signals the two-column API layout (request panel instead of the TOC).
62
+ type: "openapi-operation",
63
+ },
64
+ };
65
+ };
66
+
67
+ export const overviewMdx = (spec: ApiSpecData): RenderedPage => ({
68
+ body: withDescription(
69
+ spec.description,
70
+ `<ApiOverview source="${spec.slug}" />`
71
+ ),
72
+ data: {
73
+ sidebar: { label: "Overview" },
74
+ title: spec.title || spec.label,
75
+ },
76
+ });
@@ -4,32 +4,19 @@ import { isAbsolute, join } from "pathe";
4
4
 
5
5
  import { scalarReferenceTemplate } from "../astro/templates.ts";
6
6
  import type { ResolvedConfig } from "../core/schema.ts";
7
- import type { NavTab } from "../core/types.ts";
8
7
  import { resolveAccent, resolveRadius } from "../theme/palette.ts";
8
+ import { resolveReferences } from "./references.ts";
9
+ import type { ReferenceSource } from "./references.ts";
9
10
 
10
11
  /**
11
- * API reference support, delegated wholesale to Scalar (`@scalar/astro`). Blume
12
- * resolves the configured spec sources into routes, generates one self-contained
13
- * Scalar page per source, and adds a nav tab. OpenAPI and AsyncAPI share this
14
- * exact path Scalar auto-detects the document type so the only difference
15
- * between the two config blocks is their default route and nav label.
12
+ * The Scalar renderer: an escape hatch (`openapi.renderer: "scalar"`) and the
13
+ * path AsyncAPI still uses. Each Scalar-rendered spec becomes one self-contained
14
+ * `@scalar/astro` page loaded client-side from Scalar's CDN. Blume's own OpenAPI
15
+ * renderer (the default) lives in `source.ts` / the `components/openapi` set and
16
+ * does not pass through here.
16
17
  */
17
18
 
18
- type ReferenceKind = "openapi" | "asyncapi";
19
-
20
- /** A spec source resolved to a concrete route and nav label. */
21
- export interface ReferenceSource {
22
- kind: ReferenceKind;
23
- /** Normalized route the reference mounts at, e.g. `/reference`. */
24
- route: string;
25
- label: string;
26
- /** Local path or `http(s)` URL, verbatim from config. */
27
- spec: string;
28
- /** Per-block Scalar theme name override, if any. */
29
- theme?: string;
30
- }
31
-
32
- /** A generated reference page, ready to write under `src/pages`. */
19
+ /** A generated Scalar reference page, ready to write under `src/pages`. */
33
20
  export interface ReferenceFile {
34
21
  /** Path relative to `src/pages`, e.g. `reference.astro`, `api/events.astro`. */
35
22
  pagePath: string;
@@ -37,21 +24,7 @@ export interface ReferenceFile {
37
24
  }
38
25
 
39
26
  const URL_SPEC = /^https?:\/\//u;
40
- const NON_SLUG = /[^a-z0-9]+/gu;
41
- const SLUG_EDGES = /^-+|-+$/gu;
42
27
  const ROUTE_EDGES = /^\/+|\/+$/gu;
43
- const TRAILING_SLASH = /\/+$/u;
44
-
45
- const slugify = (text: string): string =>
46
- text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
47
-
48
- /** Normalize a configured route to a single leading slash, no trailing slash. */
49
- const normalizeRoute = (route: string): string => {
50
- const trimmed = route.trim();
51
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
52
- const noTrailing = withSlash.replace(TRAILING_SLASH, "");
53
- return noTrailing === "" ? "/" : noTrailing;
54
- };
55
28
 
56
29
  /** The `src/pages`-relative file path for a reference route. */
57
30
  const referencePagePath = (route: string): string => {
@@ -59,71 +32,6 @@ const referencePagePath = (route: string): string => {
59
32
  return `${segments === "" ? "index" : segments}.astro`;
60
33
  };
61
34
 
62
- /** A spec is a single source (`spec` shorthand prepended to any `sources`). */
63
- type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
64
-
65
- const sourcesOf = (
66
- block: Block
67
- ): { label?: string; route?: string; spec: string }[] => {
68
- const sources = [...block.sources];
69
- if (block.spec) {
70
- sources.unshift({ spec: block.spec });
71
- }
72
- return sources;
73
- };
74
-
75
- const referencesFor = (
76
- kind: ReferenceKind,
77
- block: Block,
78
- defaultLabel: string
79
- ): ReferenceSource[] => {
80
- if (!block.enabled) {
81
- return [];
82
- }
83
- const sources = sourcesOf(block);
84
- const base = normalizeRoute(block.route);
85
-
86
- return sources.map((source, index) => {
87
- const label =
88
- source.label ??
89
- (sources.length > 1 ? `${defaultLabel} ${index + 1}` : defaultLabel);
90
-
91
- let route: string;
92
- if (source.route) {
93
- route = normalizeRoute(source.route);
94
- } else if (sources.length === 1) {
95
- route = base;
96
- } else {
97
- const suffix = source.label ? slugify(source.label) : "";
98
- route = normalizeRoute(`${base}/${suffix || index + 1}`);
99
- }
100
-
101
- return { kind, label, route, spec: source.spec, theme: block.theme };
102
- });
103
- };
104
-
105
- /**
106
- * Resolve every enabled reference source into its route and label. Pure (no file
107
- * IO), so the nav and the page generator stay in sync from one source of truth.
108
- */
109
- export const resolveReferences = (
110
- config: ResolvedConfig
111
- ): ReferenceSource[] => [
112
- ...referencesFor("openapi", config.openapi, "API Reference"),
113
- ...referencesFor("asyncapi", config.asyncapi, "Events"),
114
- ];
115
-
116
- /** Nav tabs (header links) for the configured references. */
117
- export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
118
- resolveReferences(config).map((ref) => ({
119
- label: ref.label,
120
- path: ref.route,
121
- }));
122
-
123
- /** Whether any reference block is enabled (gates dependency + page wiring). */
124
- export const hasReferences = (config: ResolvedConfig): boolean =>
125
- config.openapi.enabled || config.asyncapi.enabled;
126
-
127
35
  const darkModeConfig = (
128
36
  mode: ResolvedConfig["theme"]["mode"]
129
37
  ): Record<string, boolean> => {
@@ -180,9 +88,10 @@ const specConfiguration = async (
180
88
  };
181
89
 
182
90
  /**
183
- * Build the Scalar reference page(s) for the project. Reads local specs, maps
184
- * the theme, and skips routes that collide with a content page or another
185
- * source. Returns the files to write under `src/pages` plus any warnings.
91
+ * Build the Scalar reference page(s) for the project. Only Scalar-rendered
92
+ * references are emitted here (Blume-rendered OpenAPI is staged content). Reads
93
+ * local specs, maps the theme, and skips routes that collide with a content page
94
+ * or another source. Returns the files to write under `src/pages` plus warnings.
186
95
  */
187
96
  export const buildReferenceFiles = async (options: {
188
97
  config: ResolvedConfig;
@@ -196,6 +105,9 @@ export const buildReferenceFiles = async (options: {
196
105
  const seen = new Set<string>();
197
106
  const accepted: ReferenceSource[] = [];
198
107
  for (const ref of resolveReferences(config)) {
108
+ if (ref.renderer !== "scalar") {
109
+ continue;
110
+ }
199
111
  if (seen.has(ref.route)) {
200
112
  warnings.push(
201
113
  `Two API reference sources resolve to ${ref.route}; keeping the first.`
@@ -0,0 +1,140 @@
1
+ import matter from "../core/frontmatter.ts";
2
+ import { hashText } from "../core/sources/cache.ts";
3
+ import type {
4
+ ContentSource,
5
+ SourceContext,
6
+ SourceEntry,
7
+ SourceLoadResult,
8
+ } from "../core/sources/types.ts";
9
+ import type { Diagnostic } from "../core/types.ts";
10
+ import { extractOperations } from "./model.ts";
11
+ import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
12
+ import { parseSpec } from "./parse.ts";
13
+ import type { ReferenceSource } from "./references.ts";
14
+ import { operationMdx, overviewMdx } from "./render-mdx.ts";
15
+ import type { RenderedPage } from "./render-mdx.ts";
16
+
17
+ /**
18
+ * The staged content source behind Blume's own OpenAPI renderer. Each configured
19
+ * spec is parsed once here, then lowered into one MDX page per operation plus an
20
+ * overview page — so operations become first-class Blume pages (real routes,
21
+ * sidebar, search, i18n, OG) and the parsed documents are handed to the
22
+ * generated `blume:openapi` module for the UI components to render.
23
+ */
24
+
25
+ /** A content source that also exposes the specs it parsed during `load()`. */
26
+ export interface OpenApiContentSource extends ContentSource {
27
+ readonly kind: "openapi-source";
28
+ /** Parsed spec data, populated by `load()`; `{}` before the first load. */
29
+ openApiData: () => OpenApiData;
30
+ }
31
+
32
+ /** Narrow a content source to the OpenAPI source (to read its parsed specs). */
33
+ export const isOpenApiSource = (
34
+ source: ContentSource
35
+ ): source is OpenApiContentSource =>
36
+ (source as Partial<OpenApiContentSource>).kind === "openapi-source";
37
+
38
+ /** Route (`/reference/pet/add-pet`) to a staged content ref, without extension. */
39
+ const routeToRef = (route: string): string => route.replace(/^\/+/u, "");
40
+
41
+ const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
42
+ const raw = matter.stringify(`${rendered.body}\n`, rendered.data);
43
+ return {
44
+ body: { format: "mdx", text: rendered.body },
45
+ data: rendered.data,
46
+ hash: hashText(raw),
47
+ raw,
48
+ ref,
49
+ };
50
+ };
51
+
52
+ /** All staged entries for one spec: operations first, overview last. */
53
+ const specEntries = (
54
+ spec: ApiSpecData,
55
+ operations: ApiOperationRef[]
56
+ ): SourceEntry[] => {
57
+ const entries = operations.map((operation) =>
58
+ toEntry(operationMdx(spec, operation), `${routeToRef(operation.route)}.mdx`)
59
+ );
60
+ // Overview last so an operation sets the section's routePath before the index
61
+ // page is inserted (the group's routePath is derived from its first child).
62
+ entries.push(
63
+ toEntry(overviewMdx(spec), `${routeToRef(spec.route)}/index.mdx`)
64
+ );
65
+ return entries;
66
+ };
67
+
68
+ interface LoadedSpec {
69
+ slug: string;
70
+ spec: ApiSpecData;
71
+ entries: SourceEntry[];
72
+ }
73
+
74
+ export const openApiSource = (
75
+ references: ReferenceSource[],
76
+ ctx: SourceContext
77
+ ): OpenApiContentSource => {
78
+ let parsed: OpenApiData = {};
79
+
80
+ const loadReference = async (
81
+ reference: ReferenceSource
82
+ ): Promise<LoadedSpec | Diagnostic> => {
83
+ try {
84
+ const { document } = await parseSpec(reference.spec, ctx.projectRoot);
85
+ const { operations, tags } = extractOperations(document, reference.route);
86
+ const info = document.info ?? { title: reference.label, version: "" };
87
+ const spec: ApiSpecData = {
88
+ codeSamples: reference.display.codeSamples,
89
+ description: info.description ?? "",
90
+ document,
91
+ expandSchemas: reference.display.expandSchemas,
92
+ label: reference.label,
93
+ operations: Object.fromEntries(
94
+ operations.map((operation) => [operation.key, operation])
95
+ ),
96
+ route: reference.route,
97
+ slug: reference.slug,
98
+ tags,
99
+ title: info.title ?? reference.label,
100
+ version: info.version ?? "",
101
+ };
102
+ return {
103
+ entries: specEntries(spec, operations),
104
+ slug: reference.slug,
105
+ spec,
106
+ };
107
+ } catch (error) {
108
+ return {
109
+ code: "BLUME_OPENAPI_UNAVAILABLE",
110
+ message: `Could not load OpenAPI spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
111
+ severity: "warning",
112
+ };
113
+ }
114
+ };
115
+
116
+ const load = async (): Promise<SourceLoadResult> => {
117
+ const results = await Promise.all(references.map(loadReference));
118
+ const entries: SourceEntry[] = [];
119
+ const diagnostics: Diagnostic[] = [];
120
+ const data: OpenApiData = {};
121
+ for (const result of results) {
122
+ if ("severity" in result) {
123
+ diagnostics.push(result);
124
+ continue;
125
+ }
126
+ data[result.slug] = result.spec;
127
+ entries.push(...result.entries);
128
+ }
129
+ parsed = data;
130
+ return { diagnostics, entries };
131
+ };
132
+
133
+ return {
134
+ kind: "openapi-source",
135
+ load,
136
+ name: "openapi",
137
+ openApiData: () => parsed,
138
+ staged: true,
139
+ };
140
+ };
@@ -39,7 +39,9 @@ import { scanProject } from "../core/project-graph.ts";
39
39
  import type { BlumeProject } from "../core/project-graph.ts";
40
40
  import type { ProjectContext } from "../core/types.ts";
41
41
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
42
- import { buildReferenceFiles, hasReferences } from "../openapi/scalar.ts";
42
+ import { hasScalarReferences } from "../openapi/references.ts";
43
+ import { buildReferenceFiles } from "../openapi/scalar.ts";
44
+ import { isOpenApiSource } from "../openapi/source.ts";
43
45
  import { buildSearchDocuments } from "../search/documents.ts";
44
46
  import { servesStaticIndex } from "../search/providers.ts";
45
47
  import { tailwindEntryTemplate } from "../theme/entry.ts";
@@ -48,6 +50,12 @@ import { twoslashCss } from "../theme/twoslash.ts";
48
50
 
49
51
  const POSIX = (path: string): string => path.split("\\").join("/");
50
52
 
53
+ /** The `blume:openapi` payload for the ejected app (`{}` when none). */
54
+ const ejectOpenApiData = (project: BlumeProject): unknown => {
55
+ const source = project.sources.find(isOpenApiSource);
56
+ return source ? source.openApiData() : {};
57
+ };
58
+
51
59
  /**
52
60
  * The Ask AI endpoint plus, unless the backend runs its own retrieval (Inkeep),
53
61
  * its grounding snapshot. Empty when Ask AI is disabled.
@@ -156,6 +164,7 @@ export const eject = async (root: string): Promise<string[]> => {
156
164
  needsReact,
157
165
  needsSvelte,
158
166
  needsVue,
167
+ openapiPath: "./src/generated/openapi.json",
159
168
  pages: relPages,
160
169
  searchClientPath: "./src/generated/search-client.ts",
161
170
  themePath: "./src/generated/app.css",
@@ -219,6 +228,10 @@ export const eject = async (root: string): Promise<string[]> => {
219
228
  path: join(genDir, "app.css"),
220
229
  },
221
230
  { content: buildRuntimeData(project), path: join(genDir, "data.json") },
231
+ {
232
+ content: `${JSON.stringify(ejectOpenApiData(project))}\n`,
233
+ path: join(genDir, "openapi.json"),
234
+ },
222
235
  {
223
236
  content: `${JSON.stringify(rawMarkdown)}\n`,
224
237
  path: join(genDir, "raw-markdown.json"),
@@ -302,7 +315,7 @@ export const eject = async (root: string): Promise<string[]> => {
302
315
 
303
316
  // Scalar API/AsyncAPI reference pages, mirrored from the generated runtime so
304
317
  // the ejected app keeps its reference routes.
305
- if (hasReferences(config)) {
318
+ if (hasScalarReferences(config)) {
306
319
  const references = await buildReferenceFiles({
307
320
  config,
308
321
  contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The handful of built-in glyphs Blume's own chrome renders from **client-side**
3
+ * scripts (copy/check buttons, search result rows). These stay hand-inlined and
4
+ * dependency-free so they can be bundled into client JS — the full icon
5
+ * resolver (`./icons.ts`) pulls in library data far too large to ship to the
6
+ * browser, so it must never be imported from a client script.
7
+ *
8
+ * Author-facing content icons (Cards, Steps, sidebar, `icon:` frontmatter) do
9
+ * NOT come from here — they resolve from the bundled icon libraries at build
10
+ * time via `resolveIcon` and inline as zero-JS SVG.
11
+ *
12
+ * Values are Lucide inner-SVG markup; the client `svg()` helpers wrap them in an
13
+ * `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`.
14
+ */
15
+ export const chromeIcons: Record<string, string> = {
16
+ check: '<path d="M20 6 9 17l-5-5"/>',
17
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
18
+ file: '<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"/>',
19
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
20
+ sparkles:
21
+ '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
22
+ };