blume 1.0.3 → 1.0.4
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 +29 -0
- package/dist/cli/index.js +441 -259
- package/dist/cli/index.js.map +12 -11
- package/dist/types/core/config-input.d.ts +27 -8
- package/dist/types/core/data.d.ts +12 -0
- package/dist/types/core/i18n-ui.d.ts +136 -136
- package/dist/types/core/schema.d.ts +420 -350
- package/dist/types/core/types.d.ts +10 -0
- package/dist/types/openapi/references.d.ts +12 -7
- package/docs/advanced/api-reference.mdx +11 -3
- package/docs/configuration/seo.mdx +20 -1
- package/docs/content/components.mdx +1 -2
- package/docs/content/navigation.mdx +10 -0
- package/docs/content/syntax.mdx +116 -4
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +170 -0
- package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
- package/skills/blume-migrate/references/docusaurus.md +95 -0
- package/skills/blume-migrate/references/fumadocs.md +95 -0
- package/skills/blume-migrate/references/mintlify.md +155 -0
- package/skills/blume-migrate/references/monorepo.md +224 -0
- package/skills/blume-migrate/references/nextra.md +76 -0
- package/skills/blume-migrate/references/starlight.md +116 -0
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
- package/src/astro/component-slots.ts +3 -2
- package/src/astro/generate.ts +82 -23
- package/src/astro/templates.ts +93 -34
- package/src/components/content/Callout.astro +8 -2
- package/src/components/content/Prompt.astro +25 -13
- package/src/components/layout/Header.astro +4 -8
- package/src/components/layout/Logo.astro +13 -1
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +4 -8
- package/src/components/layout/Pagination.astro +6 -6
- package/src/components/layout/RootLayout.astro +4 -8
- package/src/components/layout/Search.astro +1 -1
- package/src/components/layout/nav-utils.ts +9 -7
- package/src/core/config-input.ts +29 -8
- package/src/core/data.ts +9 -1
- package/src/core/navigation.ts +55 -13
- package/src/core/schema.ts +17 -1
- package/src/core/sources/watch.ts +5 -0
- package/src/core/types.ts +10 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/language-icon.ts +2 -1
- package/src/markdown/table-wrap.ts +43 -0
- package/src/og/card.ts +39 -12
- package/src/og/index.ts +1 -1
- package/src/og/logo.ts +21 -0
- package/src/openapi/references.ts +19 -16
- package/src/theme/entry.ts +50 -5
|
@@ -30,15 +30,15 @@ const s = { ...EN_UI.page, ...strings };
|
|
|
30
30
|
>
|
|
31
31
|
{prev ? (
|
|
32
32
|
<a
|
|
33
|
-
class="flex max-w-[48%] flex-1 items-center gap-2 rounded-
|
|
33
|
+
class="flex max-w-[48%] flex-1 items-center gap-2 rounded-blume border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
|
|
34
34
|
href={withBase(prev.route)}
|
|
35
35
|
>
|
|
36
36
|
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
37
|
-
<span>
|
|
37
|
+
<span class="min-w-0">
|
|
38
38
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
39
39
|
{s.previous}
|
|
40
40
|
</span>
|
|
41
|
-
<span class="block font-medium">{prev.label}</span>
|
|
41
|
+
<span class="block truncate font-medium">{prev.label}</span>
|
|
42
42
|
</span>
|
|
43
43
|
</a>
|
|
44
44
|
) : (
|
|
@@ -46,14 +46,14 @@ const s = { ...EN_UI.page, ...strings };
|
|
|
46
46
|
)}
|
|
47
47
|
{next && (
|
|
48
48
|
<a
|
|
49
|
-
class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-
|
|
49
|
+
class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-blume border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
|
|
50
50
|
href={withBase(next.route)}
|
|
51
51
|
>
|
|
52
|
-
<span>
|
|
52
|
+
<span class="min-w-0">
|
|
53
53
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
54
54
|
{s.next}
|
|
55
55
|
</span>
|
|
56
|
-
<span class="block font-medium">{next.label}</span>
|
|
56
|
+
<span class="block truncate font-medium">{next.label}</span>
|
|
57
57
|
</span>
|
|
58
58
|
<Icon class="rtl:-scale-x-100" name="arrow-right" size={16} />
|
|
59
59
|
</a>
|
|
@@ -27,10 +27,10 @@ import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
|
27
27
|
import Header from "./Header.astro";
|
|
28
28
|
import Icon from "../Icon.astro";
|
|
29
29
|
import {
|
|
30
|
+
activeTabForRoute,
|
|
30
31
|
findBreadcrumbs,
|
|
31
32
|
flattenPages,
|
|
32
33
|
getPagination,
|
|
33
|
-
isUnderPath,
|
|
34
34
|
sidebarForRoute,
|
|
35
35
|
} from "./nav-utils.ts";
|
|
36
36
|
import NavTree from "./NavTree.astro";
|
|
@@ -307,6 +307,7 @@ const mcpUrl =
|
|
|
307
307
|
// active tab's section, so a multi-section site drills each tab into its own
|
|
308
308
|
// pages. Without tabs — or on a route under none — this is the full sidebar.
|
|
309
309
|
const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
|
|
310
|
+
const activeTab = activeTabForRoute(navigation.tabs, page.route);
|
|
310
311
|
const crumbs = findBreadcrumbs(sidebar, page.route);
|
|
311
312
|
const { prev, next } = getPagination(flattenPages(sidebar), page.route);
|
|
312
313
|
|
|
@@ -526,14 +527,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
526
527
|
{navigation.tabs.map((tab) => (
|
|
527
528
|
<li>
|
|
528
529
|
<a
|
|
529
|
-
aria-current={
|
|
530
|
-
page.route === tab.path ||
|
|
531
|
-
(tab.path !== "/" && isUnderPath(page.route, tab.path))
|
|
532
|
-
? "page"
|
|
533
|
-
: undefined
|
|
534
|
-
}
|
|
530
|
+
aria-current={tab === activeTab ? "page" : undefined}
|
|
535
531
|
class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
|
|
536
|
-
href={withBase(tab.path)}
|
|
532
|
+
href={withBase(tab.href ?? tab.path)}
|
|
537
533
|
>
|
|
538
534
|
{tab.label}
|
|
539
535
|
</a>
|
|
@@ -58,7 +58,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
58
58
|
|
|
59
59
|
<dialog
|
|
60
60
|
aria-label={s.label}
|
|
61
|
-
class="
|
|
61
|
+
class="mx-auto mt-[8vh] mb-auto h-[min(480px,90dvh)] w-[min(62.5rem,94vw)] flex-col overflow-hidden rounded-blume border border-border bg-background/80 p-0 text-foreground shadow-2xl outline-none backdrop-blur-xl backdrop:bg-black/30 open:flex sm:mt-auto"
|
|
62
62
|
data-blume-search-dialog
|
|
63
63
|
>
|
|
64
64
|
<div
|
|
@@ -80,14 +80,16 @@ export const isUnderPath = (route: string, base: string): boolean =>
|
|
|
80
80
|
base === "/" || route === base || route.startsWith(`${base}/`);
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* The tab whose `path` is the longest prefix of `route
|
|
84
|
-
*
|
|
85
|
-
* so never scopes the sidebar.
|
|
83
|
+
* The tab whose `path` is the longest prefix of `route`. The root tab (`/`)
|
|
84
|
+
* acts as the fallback when no more specific tab matches.
|
|
86
85
|
*/
|
|
87
|
-
const
|
|
86
|
+
export const activeTabForRoute = (
|
|
87
|
+
tabs: NavTab[],
|
|
88
|
+
route: string
|
|
89
|
+
): NavTab | null => {
|
|
88
90
|
let match: NavTab | null = null;
|
|
89
91
|
for (const tab of tabs) {
|
|
90
|
-
if (
|
|
92
|
+
if (!isUnderPath(route, tab.path)) {
|
|
91
93
|
continue;
|
|
92
94
|
}
|
|
93
95
|
if (!match || tab.path.length > match.path.length) {
|
|
@@ -187,8 +189,8 @@ export const sidebarForRoute = (
|
|
|
187
189
|
tabs: NavTab[],
|
|
188
190
|
route: string
|
|
189
191
|
): NavNode[] => {
|
|
190
|
-
const tab =
|
|
191
|
-
if (tab) {
|
|
192
|
+
const tab = activeTabForRoute(tabs, route);
|
|
193
|
+
if (tab && tab.path !== "/") {
|
|
192
194
|
return sectionChildren(sidebar, tab.path) ?? [];
|
|
193
195
|
}
|
|
194
196
|
const scoped = withoutTabSections(sidebar, tabs);
|
package/src/core/config-input.ts
CHANGED
|
@@ -711,6 +711,34 @@ export interface RssConfig {
|
|
|
711
711
|
types?: string[];
|
|
712
712
|
}
|
|
713
713
|
|
|
714
|
+
/** Colors used by generated Open Graph cards. Values must be hex colors. */
|
|
715
|
+
export interface OgPaletteConfig {
|
|
716
|
+
/** Fallback mark color. Defaults to the light theme accent. */
|
|
717
|
+
accent?: string;
|
|
718
|
+
/** Card background. */
|
|
719
|
+
background?: string;
|
|
720
|
+
/** Footer divider. */
|
|
721
|
+
border?: string;
|
|
722
|
+
/** Headline and `currentColor` logo color. */
|
|
723
|
+
foreground?: string;
|
|
724
|
+
/** Description and footer text. */
|
|
725
|
+
muted?: string;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Per-page Open Graph image generation. */
|
|
729
|
+
export interface OgConfig {
|
|
730
|
+
/**
|
|
731
|
+
* Generate an OG image per page. Defaults to on once a deployment `site`
|
|
732
|
+
* URL is known and off otherwise (`og:image` must be absolute). An explicit
|
|
733
|
+
* value always wins.
|
|
734
|
+
*/
|
|
735
|
+
enabled?: boolean;
|
|
736
|
+
/** Local SVG used in the generated card instead of the site logo. */
|
|
737
|
+
logo?: string;
|
|
738
|
+
/** Optional generated-card colors. */
|
|
739
|
+
palette?: OgPaletteConfig;
|
|
740
|
+
}
|
|
741
|
+
|
|
714
742
|
/** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
|
|
715
743
|
export interface SeoConfig {
|
|
716
744
|
/**
|
|
@@ -721,14 +749,7 @@ export interface SeoConfig {
|
|
|
721
749
|
/** robots.txt `Content-Signal` usage declaration. Defaults to `true`. */
|
|
722
750
|
contentSignals?: ContentSignalsConfig;
|
|
723
751
|
/** Per-page Open Graph image generation. */
|
|
724
|
-
og?:
|
|
725
|
-
/**
|
|
726
|
-
* Generate an OG image per page. Defaults to on once a deployment `site`
|
|
727
|
-
* URL is known and off otherwise (`og:image` must be absolute). An explicit
|
|
728
|
-
* value always wins.
|
|
729
|
-
*/
|
|
730
|
-
enabled?: boolean;
|
|
731
|
-
};
|
|
752
|
+
og?: OgConfig;
|
|
732
753
|
/** Generate robots.txt (with a Sitemap reference when available). Defaults to `true`. */
|
|
733
754
|
robots?: boolean;
|
|
734
755
|
/** RSS/Atom feeds. */
|
package/src/core/data.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface BlumeLogo {
|
|
|
15
15
|
svg?: string;
|
|
16
16
|
light?: string;
|
|
17
17
|
dark?: string;
|
|
18
|
+
dimensions?: {
|
|
19
|
+
dark?: { height: number; width: number };
|
|
20
|
+
light?: { height: number; width: number };
|
|
21
|
+
};
|
|
18
22
|
alt: string;
|
|
19
23
|
href: string;
|
|
20
24
|
/** Wordmark text beside the mark; `undefined` falls back to the site title. */
|
|
@@ -109,7 +113,11 @@ export interface BlumeDataConfig {
|
|
|
109
113
|
/** Hosted MCP server, or `null` when MCP is off. */
|
|
110
114
|
mcp: { name: string; route: string } | null;
|
|
111
115
|
/** Open Graph image generation. */
|
|
112
|
-
og: {
|
|
116
|
+
og: {
|
|
117
|
+
enabled: boolean;
|
|
118
|
+
logo?: string;
|
|
119
|
+
palette?: ResolvedConfig["seo"]["og"]["palette"];
|
|
120
|
+
};
|
|
113
121
|
/** Repository URL for header/edit links, or `null`. */
|
|
114
122
|
repoUrl: string | null;
|
|
115
123
|
search: { enabled: boolean; provider: SearchProvider };
|
package/src/core/navigation.ts
CHANGED
|
@@ -433,6 +433,43 @@ const buildConfigSidebar = (
|
|
|
433
433
|
return nodes;
|
|
434
434
|
};
|
|
435
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Resolve a tab's clickable target. A tab's `path` scopes its sidebar section
|
|
438
|
+
* but need not be a real route — a section with no index page would 404 if the
|
|
439
|
+
* tab linked straight to it. Prefer an exact page/group at the path; otherwise
|
|
440
|
+
* fall back to the first linkable route in the section (sidebar order).
|
|
441
|
+
*/
|
|
442
|
+
const resolveTabHref = (sidebar: NavNode[], path: string): string => {
|
|
443
|
+
let first: string | undefined;
|
|
444
|
+
const walk = (nodes: NavNode[]): boolean => {
|
|
445
|
+
for (const node of nodes) {
|
|
446
|
+
const { route } = node;
|
|
447
|
+
if (route === path) {
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
if (
|
|
451
|
+
first === undefined &&
|
|
452
|
+
route !== undefined &&
|
|
453
|
+
route.startsWith(`${path}/`)
|
|
454
|
+
) {
|
|
455
|
+
first = route;
|
|
456
|
+
}
|
|
457
|
+
if (node.kind === "group" && walk(node.children)) {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return false;
|
|
462
|
+
};
|
|
463
|
+
return walk(sidebar) ? path : (first ?? path);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/** Attach a resolved `href` to each tab whose section has no index page. */
|
|
467
|
+
const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
|
|
468
|
+
tabs.map((tab) => {
|
|
469
|
+
const href = resolveTabHref(sidebar, tab.path);
|
|
470
|
+
return href === tab.path ? tab : { ...tab, href };
|
|
471
|
+
});
|
|
472
|
+
|
|
436
473
|
/** Build the complete navigation model from pages, meta, and config. */
|
|
437
474
|
export const buildNavigation = (
|
|
438
475
|
pages: PageRecord[],
|
|
@@ -519,11 +556,17 @@ export const buildNavigation = (
|
|
|
519
556
|
}
|
|
520
557
|
|
|
521
558
|
if (options.sidebar) {
|
|
559
|
+
const sidebar = buildConfigSidebar(
|
|
560
|
+
options.sidebar,
|
|
561
|
+
byRoute,
|
|
562
|
+
display,
|
|
563
|
+
basePath
|
|
564
|
+
);
|
|
522
565
|
return {
|
|
523
566
|
featured,
|
|
524
567
|
selectors,
|
|
525
|
-
sidebar
|
|
526
|
-
tabs,
|
|
568
|
+
sidebar,
|
|
569
|
+
tabs: withTabHrefs(tabs, sidebar),
|
|
527
570
|
};
|
|
528
571
|
}
|
|
529
572
|
|
|
@@ -534,19 +577,18 @@ export const buildNavigation = (
|
|
|
534
577
|
// prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
|
|
535
578
|
// under a base, falsely scope a group named like the prefix).
|
|
536
579
|
const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
|
|
580
|
+
const sidebar = buildFileSystemSidebar(
|
|
581
|
+
pages,
|
|
582
|
+
options.folderMeta,
|
|
583
|
+
sharedFolderMeta,
|
|
584
|
+
metaPrefix,
|
|
585
|
+
display,
|
|
586
|
+
new Set(tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path])))
|
|
587
|
+
);
|
|
537
588
|
return {
|
|
538
589
|
featured,
|
|
539
590
|
selectors,
|
|
540
|
-
sidebar
|
|
541
|
-
|
|
542
|
-
options.folderMeta,
|
|
543
|
-
sharedFolderMeta,
|
|
544
|
-
metaPrefix,
|
|
545
|
-
display,
|
|
546
|
-
new Set(
|
|
547
|
-
tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
|
|
548
|
-
)
|
|
549
|
-
),
|
|
550
|
-
tabs,
|
|
591
|
+
sidebar,
|
|
592
|
+
tabs: withTabHrefs(tabs, sidebar),
|
|
551
593
|
};
|
|
552
594
|
};
|
package/src/core/schema.ts
CHANGED
|
@@ -672,7 +672,7 @@ const navigationConfigSchema = z.strictObject({
|
|
|
672
672
|
.transform((value) =>
|
|
673
673
|
Array.isArray(value) ? { display: "flat" as const, items: value } : value
|
|
674
674
|
),
|
|
675
|
-
tabs: z.array(navTabSchema).
|
|
675
|
+
tabs: z.array(navTabSchema).default([]),
|
|
676
676
|
});
|
|
677
677
|
|
|
678
678
|
export type AskAiProvider = (typeof askAiProviders)[number];
|
|
@@ -802,6 +802,18 @@ const xConfigSchema = z.strictObject({
|
|
|
802
802
|
handle: xHandleSchema,
|
|
803
803
|
});
|
|
804
804
|
|
|
805
|
+
const ogColorSchema = z
|
|
806
|
+
.string()
|
|
807
|
+
.regex(/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu);
|
|
808
|
+
|
|
809
|
+
const ogPaletteSchema = z.strictObject({
|
|
810
|
+
accent: ogColorSchema.optional(),
|
|
811
|
+
background: ogColorSchema.optional(),
|
|
812
|
+
border: ogColorSchema.optional(),
|
|
813
|
+
foreground: ogColorSchema.optional(),
|
|
814
|
+
muted: ogColorSchema.optional(),
|
|
815
|
+
});
|
|
816
|
+
|
|
805
817
|
const ogConfigSchema = z.strictObject({
|
|
806
818
|
/**
|
|
807
819
|
* Generate a per-page Open Graph image. Defaults to on once a deployment
|
|
@@ -810,6 +822,10 @@ const ogConfigSchema = z.strictObject({
|
|
|
810
822
|
* `loadConfig`. An explicit value here always wins.
|
|
811
823
|
*/
|
|
812
824
|
enabled: z.boolean().optional(),
|
|
825
|
+
/** Local SVG used in the generated card instead of the site logo. */
|
|
826
|
+
logo: z.string().optional(),
|
|
827
|
+
/** Optional generated-card colors. */
|
|
828
|
+
palette: ogPaletteSchema.optional(),
|
|
813
829
|
});
|
|
814
830
|
|
|
815
831
|
const rssConfigSchema = z.strictObject({
|
|
@@ -23,6 +23,11 @@ import type { WatchListener } from "node:fs";
|
|
|
23
23
|
*/
|
|
24
24
|
export const BLUME_IGNORE_DIRS = [
|
|
25
25
|
".blume",
|
|
26
|
+
// The isolated `blume check --isolated` runtime. A sibling of `.blume`, it is
|
|
27
|
+
// written while a dev server runs; without this the content-layer watcher (or
|
|
28
|
+
// a `.`-rooted fs.watch) would treat its generation as a content change and
|
|
29
|
+
// reload — the very thing `--isolated` promises not to do.
|
|
30
|
+
".blume-verify",
|
|
26
31
|
".cache",
|
|
27
32
|
".git",
|
|
28
33
|
".next",
|
package/src/core/types.ts
CHANGED
|
@@ -159,7 +159,17 @@ export type NavNode =
|
|
|
159
159
|
/** Top-level tab/section. */
|
|
160
160
|
export interface NavTab {
|
|
161
161
|
label: string;
|
|
162
|
+
/**
|
|
163
|
+
* The tab's section prefix, used to scope the sidebar and match the active
|
|
164
|
+
* tab. Not necessarily a linkable route — a section may have no index page.
|
|
165
|
+
*/
|
|
162
166
|
path: string;
|
|
167
|
+
/**
|
|
168
|
+
* The clickable target. Equals `path` when the section has an index page;
|
|
169
|
+
* otherwise it's resolved to the section's first page so the tab never links
|
|
170
|
+
* to a 404. Absent when it matches `path`.
|
|
171
|
+
*/
|
|
172
|
+
href?: string;
|
|
163
173
|
icon?: string;
|
|
164
174
|
items?: NavSelectorItem[];
|
|
165
175
|
}
|
package/src/markdown/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { languageIconTransformer } from "./language-icon.ts";
|
|
|
17
17
|
import { mathPlugin } from "./math.ts";
|
|
18
18
|
import { mermaidPlugin } from "./mermaid.ts";
|
|
19
19
|
import { packageInstallPlugin } from "./package-install.ts";
|
|
20
|
+
import { tableWrapPlugin } from "./table-wrap.ts";
|
|
20
21
|
import { DEFAULT_CODE_THEMES } from "./themes.ts";
|
|
21
22
|
import type { CodeThemes } from "./themes.ts";
|
|
22
23
|
|
|
@@ -58,6 +59,7 @@ type HastPlugin = NonNullable<
|
|
|
58
59
|
const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
|
|
59
60
|
const plugins: HastPlugin[] = [
|
|
60
61
|
inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
|
|
62
|
+
tableWrapPlugin() as unknown as HastPlugin,
|
|
61
63
|
];
|
|
62
64
|
if (options.headingAnchors !== false) {
|
|
63
65
|
plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* hex colors are skipped because dark-on-dark logos (Next.js, Rust…) vanish.
|
|
8
8
|
*
|
|
9
9
|
* The theme styles `.blume-lang-icon` and shifts the language label
|
|
10
|
-
* (`pre[data-icon]::before`) to make room
|
|
10
|
+
* (`pre[data-language][data-icon]::before`) to make room — gated on
|
|
11
|
+
* `data-language` so the icon only shows when a header bar exists to hold it.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps each `<table>` in a scroll container. Satteri does not re-descend into a
|
|
3
|
+
* visitor's returned replacement, so the wrapped table is not re-visited.
|
|
4
|
+
*
|
|
5
|
+
* The wrapper carries `tabindex="0"` so a horizontally scrolling table is
|
|
6
|
+
* reachable and scrollable by keyboard, not just pointer (WCAG 2.1.1; axe's
|
|
7
|
+
* `scrollable-region-focusable`). It's added unconditionally — whether a given
|
|
8
|
+
* table overflows isn't known at build time — which costs a tab stop on tables
|
|
9
|
+
* that happen to fit; no ARIA label is set to avoid an untranslated string.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A minimal hast node (avoids a hast type dependency). */
|
|
13
|
+
interface HastNode {
|
|
14
|
+
children?: HastNode[];
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
tagName?: string;
|
|
17
|
+
type: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A Satteri hast plugin, typed structurally to avoid a Satteri dep. */
|
|
22
|
+
export interface TableWrapPlugin {
|
|
23
|
+
name: string;
|
|
24
|
+
element: {
|
|
25
|
+
filter: string[];
|
|
26
|
+
visit: (node: HastNode) => HastNode;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const tableWrapPlugin = (): TableWrapPlugin => ({
|
|
31
|
+
element: {
|
|
32
|
+
filter: ["table"],
|
|
33
|
+
visit(node) {
|
|
34
|
+
return {
|
|
35
|
+
children: [node],
|
|
36
|
+
properties: { className: ["blume-table-scroll"], tabIndex: 0 },
|
|
37
|
+
tagName: "div",
|
|
38
|
+
type: "element",
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
name: "blume:table-wrap",
|
|
43
|
+
});
|
package/src/og/card.ts
CHANGED
|
@@ -35,6 +35,17 @@ const resolveAccent = (accent: string): string => {
|
|
|
35
35
|
return HEX_COLOR.test(accent) ? accent : "#3b82f6";
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
const resolveColor = (color: string | undefined, fallback: string): string =>
|
|
39
|
+
color && HEX_COLOR.test(color) ? color : fallback;
|
|
40
|
+
|
|
41
|
+
export interface OgCardPalette {
|
|
42
|
+
accent?: string;
|
|
43
|
+
background?: string;
|
|
44
|
+
border?: string;
|
|
45
|
+
foreground?: string;
|
|
46
|
+
muted?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
export interface OgCardOptions {
|
|
39
50
|
/** Large headline — the page title. */
|
|
40
51
|
title: string;
|
|
@@ -45,10 +56,12 @@ export interface OgCardOptions {
|
|
|
45
56
|
/** Muted subtitle under the headline (usually the site description). */
|
|
46
57
|
description?: string;
|
|
47
58
|
/**
|
|
48
|
-
* Inlined SVG markup of the configured logo
|
|
59
|
+
* Inlined SVG markup of the configured logo, painted into
|
|
49
60
|
* the brand lockup. Falls back to an accent mark when absent.
|
|
50
61
|
*/
|
|
51
62
|
logo?: string;
|
|
63
|
+
/** Optional colors for the generated card. */
|
|
64
|
+
palette?: OgCardPalette;
|
|
52
65
|
/** Footer-left repository slug, e.g. `owner/repo`. */
|
|
53
66
|
repo?: string;
|
|
54
67
|
/** Footer-right site host, e.g. `docs.acme.com`. */
|
|
@@ -67,6 +80,17 @@ const MUTED = "#737373";
|
|
|
67
80
|
const FAINT = "#a3a3a3";
|
|
68
81
|
const BORDER = "#e5e5e5";
|
|
69
82
|
|
|
83
|
+
const resolvePalette = (
|
|
84
|
+
options: OgCardOptions
|
|
85
|
+
): Required<OgCardPalette> & { faint: string } => ({
|
|
86
|
+
accent: resolveAccent(options.palette?.accent ?? options.accent ?? "blue"),
|
|
87
|
+
background: resolveColor(options.palette?.background, BG),
|
|
88
|
+
border: resolveColor(options.palette?.border, BORDER),
|
|
89
|
+
faint: resolveColor(options.palette?.muted, FAINT),
|
|
90
|
+
foreground: resolveColor(options.palette?.foreground, FOREGROUND),
|
|
91
|
+
muted: resolveColor(options.palette?.muted, MUTED),
|
|
92
|
+
});
|
|
93
|
+
|
|
70
94
|
/**
|
|
71
95
|
* Truncate to `max` code points with an ellipsis. Slices by code points, not
|
|
72
96
|
* UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
|
|
@@ -103,8 +127,8 @@ const logoAspect = (svg: string): number | null => {
|
|
|
103
127
|
// Render the configured logo as the brand mark. A `currentColor` logo carries
|
|
104
128
|
// no intrinsic color, so it is painted in the foreground to read on the light
|
|
105
129
|
// card, then handed to Takumi as a data URI sized from the SVG's aspect ratio.
|
|
106
|
-
const logoMark = (svg: string): Node => {
|
|
107
|
-
const painted = svg.replaceAll("currentColor",
|
|
130
|
+
const logoMark = (svg: string, foreground: string): Node => {
|
|
131
|
+
const painted = svg.replaceAll("currentColor", foreground);
|
|
108
132
|
const aspect = logoAspect(painted);
|
|
109
133
|
let height = MARK_HEIGHT;
|
|
110
134
|
let width = aspect ? MARK_HEIGHT * aspect : MARK_HEIGHT;
|
|
@@ -151,7 +175,8 @@ const titleSize = (title: string): number => {
|
|
|
151
175
|
|
|
152
176
|
/** Render a 1200x630 Open Graph card to a PNG buffer. */
|
|
153
177
|
export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
154
|
-
const accent
|
|
178
|
+
const { accent, background, border, faint, foreground, muted } =
|
|
179
|
+
resolvePalette(options);
|
|
155
180
|
const brand = options.brand?.trim();
|
|
156
181
|
const logo = options.logo?.trim();
|
|
157
182
|
// Slice by code point, not code unit — `charAt(0)` would split a leading
|
|
@@ -166,14 +191,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
166
191
|
// ("Ultracite Ultracite"). Without a logo, the accent tile with the brand
|
|
167
192
|
// initial stands in.
|
|
168
193
|
const header = container({
|
|
169
|
-
children: [
|
|
194
|
+
children: [
|
|
195
|
+
logo ? logoMark(logo, foreground) : initialMark(accent, initial),
|
|
196
|
+
],
|
|
170
197
|
style: { alignItems: "center", display: "flex" },
|
|
171
198
|
});
|
|
172
199
|
|
|
173
200
|
const body = container({
|
|
174
201
|
children: [
|
|
175
202
|
text(truncate(options.title, 64), {
|
|
176
|
-
color:
|
|
203
|
+
color: foreground,
|
|
177
204
|
fontSize: titleSize(options.title),
|
|
178
205
|
fontWeight: 600,
|
|
179
206
|
letterSpacing: "-0.03em",
|
|
@@ -183,7 +210,7 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
183
210
|
}),
|
|
184
211
|
description
|
|
185
212
|
? text(truncate(description, 140), {
|
|
186
|
-
color:
|
|
213
|
+
color: muted,
|
|
187
214
|
fontSize: 30,
|
|
188
215
|
lineHeight: 1.4,
|
|
189
216
|
marginTop: 28,
|
|
@@ -200,15 +227,15 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
200
227
|
? container({
|
|
201
228
|
children: [
|
|
202
229
|
container({
|
|
203
|
-
style: { backgroundColor:
|
|
230
|
+
style: { backgroundColor: border, height: 1, width: "100%" },
|
|
204
231
|
}),
|
|
205
232
|
container({
|
|
206
233
|
children: [
|
|
207
234
|
repo
|
|
208
|
-
? text(repo, { color:
|
|
235
|
+
? text(repo, { color: muted, fontSize: 22 })
|
|
209
236
|
: container({}),
|
|
210
237
|
site
|
|
211
|
-
? text(site, { color:
|
|
238
|
+
? text(site, { color: faint, fontSize: 22 })
|
|
212
239
|
: container({}),
|
|
213
240
|
],
|
|
214
241
|
style: {
|
|
@@ -227,8 +254,8 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
227
254
|
const node = container({
|
|
228
255
|
children: [header, body, footer],
|
|
229
256
|
style: {
|
|
230
|
-
backgroundColor:
|
|
231
|
-
color:
|
|
257
|
+
backgroundColor: background,
|
|
258
|
+
color: foreground,
|
|
232
259
|
display: "flex",
|
|
233
260
|
flexDirection: "column",
|
|
234
261
|
height: HEIGHT,
|
package/src/og/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { renderOgImage } from "./card.ts";
|
|
2
|
-
export type { OgCardOptions } from "./card.ts";
|
|
2
|
+
export type { OgCardOptions, OgCardPalette } from "./card.ts";
|
package/src/og/logo.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { join } from "pathe";
|
|
4
|
+
|
|
5
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
6
|
+
|
|
7
|
+
/** Resolve a configured local SVG for use in generated Open Graph cards. */
|
|
8
|
+
export const resolveOgLogo = (
|
|
9
|
+
project: BlumeProject,
|
|
10
|
+
source: string | undefined
|
|
11
|
+
): string | undefined => {
|
|
12
|
+
if (!source?.toLowerCase().endsWith(".svg")) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const relative = source.replace(/^\//u, "");
|
|
16
|
+
const file = [
|
|
17
|
+
join(project.context.root, "public", relative),
|
|
18
|
+
join(project.context.root, relative),
|
|
19
|
+
].find((path) => existsSync(path));
|
|
20
|
+
return file ? readFileSync(file, "utf-8") : undefined;
|
|
21
|
+
};
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
3
|
import { trimChar, trimEnd } from "../core/trim.ts";
|
|
4
|
-
import type { NavTab } from "../core/types.ts";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Pure resolution of the configured API reference blocks into concrete routes,
|
|
8
|
-
* labels, and a renderer choice — no file IO, so the content source, the
|
|
9
|
-
*
|
|
10
|
-
* one source of truth. Kept free of any Astro/template
|
|
11
|
-
* depend on it without a cycle.
|
|
7
|
+
* labels, and a renderer choice — no file IO, so the content source, the
|
|
8
|
+
* nav-target validation, the Scalar page generator, and the `blume:openapi`
|
|
9
|
+
* data module all share one source of truth. Kept free of any Astro/template
|
|
10
|
+
* imports so `core` can depend on it without a cycle.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
export type ReferenceKind = "openapi" | "asyncapi";
|
|
@@ -155,18 +154,22 @@ export const resolveReferences = (
|
|
|
155
154
|
),
|
|
156
155
|
];
|
|
157
156
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Mounted route for every reference, regardless of renderer. References no
|
|
159
|
+
* longer add a header tab automatically — authors point a `navigation.tabs`
|
|
160
|
+
* entry at one of these routes to surface it (and, for Blume-rendered specs, to
|
|
161
|
+
* scope its operations sidebar). These routes are whitelisted as valid nav
|
|
162
|
+
* targets so such a tab doesn't read as a broken link.
|
|
163
|
+
*/
|
|
164
|
+
export const referenceRoutes = (config: ResolvedConfig): string[] =>
|
|
165
|
+
resolveReferences(config).map((ref) =>
|
|
162
166
|
// Blume-rendered operation pages flow through the content pipeline and are
|
|
163
|
-
// mounted under `basePath
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
ref.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}));
|
|
167
|
+
// mounted under `basePath`. Scalar references are a single embedded page
|
|
168
|
+
// injected at the raw `route`, left root-anchored.
|
|
169
|
+
ref.renderer === "blume"
|
|
170
|
+
? withBasePath(config.basePath, ref.route)
|
|
171
|
+
: ref.route
|
|
172
|
+
);
|
|
170
173
|
|
|
171
174
|
/**
|
|
172
175
|
* Accept one resolved reference into the deduped Blume-rendered set, or return
|