blume 0.3.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.
- package/dist/cli/index.js +1631 -940
- package/dist/cli/index.js.map +62 -50
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +442 -292
- package/dist/types/core/types.d.ts +7 -0
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +21 -54
- package/docs/advanced/api-reference.mdx +34 -51
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +21 -78
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/ai.mdx +42 -103
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +40 -73
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +67 -87
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +17 -48
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +95 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +25 -52
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +23 -62
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +12 -41
- package/docs/reference/cli.mdx +47 -30
- package/docs/reference/frontmatter.mdx +7 -5
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/integration.ts +26 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +6 -1
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +76 -30
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +129 -62
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/commands/dev.ts +11 -2
- package/src/cli/commands/doctor.ts +10 -1
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +21 -1
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +12 -1
- package/src/cli/dev-lock.ts +92 -0
- package/src/cli/log.ts +11 -0
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +8 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/islands/ask-ai.tsx +14 -14
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/components/props.ts +3 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +6 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +6 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/links.ts +60 -19
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +54 -6
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/normalize.ts +6 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +7 -0
- package/src/deploy/rss.ts +1 -8
- package/src/deploy/sitemap.ts +20 -1
- package/src/deploy/xml.ts +8 -0
- package/src/markdown/directives.ts +15 -7
- package/src/markdown/package-commands.ts +26 -4
- package/src/migrate/fumadocs/content.ts +14 -1
- package/src/migrate/fumadocs/groups.ts +7 -0
- package/src/migrate/fumadocs/index.ts +5 -2
- package/src/migrate/mintlify/assets.ts +46 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +111 -46
- package/src/migrate/shared.ts +12 -27
- package/src/og/card.ts +14 -2
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +28 -5
- package/src/registry/registry.ts +6 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/search/documents.ts +23 -5
- package/src/search/sync/algolia.ts +5 -1
- package/src/search/sync/typesense.ts +24 -16
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
- package/src/theme/palette.ts +26 -7
|
@@ -6,6 +6,7 @@ import { join } from "pathe";
|
|
|
6
6
|
|
|
7
7
|
import { loadConfig } from "../../core/config.ts";
|
|
8
8
|
import { resolveProjectContext } from "../../core/project.ts";
|
|
9
|
+
import { parsePort } from "../args.ts";
|
|
9
10
|
import { logger } from "../log.ts";
|
|
10
11
|
|
|
11
12
|
export const previewCommand = defineCommand({
|
|
@@ -32,7 +33,7 @@ export const previewCommand = defineCommand({
|
|
|
32
33
|
root: context.outDir,
|
|
33
34
|
server: {
|
|
34
35
|
host: args.host ?? false,
|
|
35
|
-
port:
|
|
36
|
+
port: parsePort(args.port),
|
|
36
37
|
},
|
|
37
38
|
});
|
|
38
39
|
},
|
|
@@ -3,12 +3,18 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { join } from "pathe";
|
|
5
5
|
|
|
6
|
+
import { resolveAssetMounts } from "../../core/assets.ts";
|
|
6
7
|
import { BlumeError } from "../../core/diagnostics.ts";
|
|
7
8
|
import { validateLinks } from "../../core/links.ts";
|
|
8
9
|
import { scanProject } from "../../core/project-graph.ts";
|
|
9
10
|
import type { Diagnostic } from "../../core/types.ts";
|
|
10
11
|
import { reportInternalError } from "../internal-error.ts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
flushStdout,
|
|
14
|
+
logger,
|
|
15
|
+
reportDiagnostics,
|
|
16
|
+
reportDiagnosticsJson,
|
|
17
|
+
} from "../log.ts";
|
|
12
18
|
|
|
13
19
|
export const validateCommand = defineCommand({
|
|
14
20
|
args: {
|
|
@@ -42,6 +48,7 @@ export const validateCommand = defineCommand({
|
|
|
42
48
|
const publicDir = join(root, "public");
|
|
43
49
|
diagnostics.push(
|
|
44
50
|
...(await validateLinks(project.graph, {
|
|
51
|
+
assetMounts: resolveAssetMounts(root, project.config.content.assets),
|
|
45
52
|
checkExternal: Boolean(args.external),
|
|
46
53
|
publicDir: existsSync(publicDir) ? publicDir : null,
|
|
47
54
|
redirects: project.config.redirects,
|
|
@@ -57,8 +64,12 @@ export const validateCommand = defineCommand({
|
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
if (args.json) {
|
|
67
|
+
// Drain stdout before exiting non-zero: `process.exit` would otherwise
|
|
68
|
+
// truncate the JSON payload mid-write when stdout is a pipe — exactly how
|
|
69
|
+
// `--json` is consumed in CI/editors.
|
|
60
70
|
const hadErrors = reportDiagnosticsJson(diagnostics, root);
|
|
61
71
|
if (hadErrors || (Boolean(args.strict) && diagnostics.length > 0)) {
|
|
72
|
+
await flushStdout();
|
|
62
73
|
process.exit(1);
|
|
63
74
|
}
|
|
64
75
|
return;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
|
|
9
|
+
import { join } from "pathe";
|
|
10
|
+
|
|
11
|
+
import { resolveRuntimeDir } from "../core/project.ts";
|
|
12
|
+
import { logger } from "./log.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A best-effort PID lock in the shared `.blume/` runtime dir. `blume dev`
|
|
16
|
+
* regenerates and serves `.blume` continuously, so a concurrent `build`,
|
|
17
|
+
* `eject`, or `sync --force` that regenerates or deletes it out from under the
|
|
18
|
+
* running Vite server corrupts the dev session. The lock lets those commands
|
|
19
|
+
* detect a live dev server and refuse.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const lockPath = (outDir: string): string => join(outDir, "dev.lock");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether another live `blume dev` holds the lock on `outDir`. A lock left by a
|
|
26
|
+
* process that has since exited (stale) is treated as absent.
|
|
27
|
+
*/
|
|
28
|
+
export const isDevLocked = (outDir: string): boolean => {
|
|
29
|
+
const path = lockPath(outDir);
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
34
|
+
if (!(Number.isInteger(pid) && pid > 0)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
// Signal 0 probes liveness without actually signaling the process.
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write the current process's dev lock into `outDir` and return a release
|
|
48
|
+
* function. The release only removes the file if it's still ours, so a newer
|
|
49
|
+
* dev server's lock is never clobbered.
|
|
50
|
+
*/
|
|
51
|
+
export const acquireDevLock = (outDir: string): (() => void) => {
|
|
52
|
+
const path = lockPath(outDir);
|
|
53
|
+
mkdirSync(outDir, { recursive: true });
|
|
54
|
+
writeFileSync(path, String(process.pid));
|
|
55
|
+
let released = false;
|
|
56
|
+
return () => {
|
|
57
|
+
if (released) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
released = true;
|
|
61
|
+
try {
|
|
62
|
+
if (
|
|
63
|
+
existsSync(path) &&
|
|
64
|
+
readFileSync(path, "utf-8").trim() === String(process.pid)
|
|
65
|
+
) {
|
|
66
|
+
rmSync(path, { force: true });
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort cleanup; a stale lock is handled by the liveness check.
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Exit with an error when a live `blume dev` owns the runtime dir under `root`.
|
|
76
|
+
* `action` names the operation being refused (e.g. "building"). `runtimeDir`
|
|
77
|
+
* relocates the checked dir: an isolated verify (`.blume-verify`) targets a dir
|
|
78
|
+
* dev never locks, so it proceeds; a default or `--runtime-dir .blume` run still
|
|
79
|
+
* refuses.
|
|
80
|
+
*/
|
|
81
|
+
export const refuseIfDevRunning = (
|
|
82
|
+
root: string,
|
|
83
|
+
action: string,
|
|
84
|
+
runtimeDir?: string
|
|
85
|
+
): void => {
|
|
86
|
+
if (isDevLocked(resolveRuntimeDir(root, runtimeDir))) {
|
|
87
|
+
logger.error(
|
|
88
|
+
`A \`blume dev\` server is running against .blume; ${action} would corrupt it. Stop the dev server, or re-run with --isolated to build/verify against .blume-verify without touching it.`
|
|
89
|
+
);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
};
|
package/src/cli/log.ts
CHANGED
|
@@ -11,6 +11,17 @@ import type { Diagnostic } from "../core/types.ts";
|
|
|
11
11
|
|
|
12
12
|
export const logger = consola.withTag("blume");
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolve once stdout has drained. `process.exit` doesn't flush a piped stdout,
|
|
16
|
+
* so await this before exiting non-zero after writing machine-readable output
|
|
17
|
+
* (e.g. `--json`), otherwise the payload can be truncated in CI.
|
|
18
|
+
*/
|
|
19
|
+
export const flushStdout = (): Promise<void> =>
|
|
20
|
+
// oxlint-disable-next-line promise/avoid-new -- adapt stdout's write callback
|
|
21
|
+
new Promise((resolve) => {
|
|
22
|
+
process.stdout.write("", () => resolve());
|
|
23
|
+
});
|
|
24
|
+
|
|
14
25
|
/**
|
|
15
26
|
* Print diagnostics as a JSON document on stdout for CI and editors: each is
|
|
16
27
|
* enriched with its `docsUrl` and its `file` made root-relative. Returns whether
|
package/src/cli/prepare.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface PrepareOptions {
|
|
|
24
24
|
refresh?: boolean;
|
|
25
25
|
/** CLI config overrides (e.g. `--output`, `--content-dir`). */
|
|
26
26
|
overrides?: ConfigOverrides;
|
|
27
|
+
/** Relocate the generated runtime (e.g. `.blume-verify` for `--isolated`). */
|
|
28
|
+
runtimeDir?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -45,6 +47,7 @@ export const prepareProject = async (
|
|
|
45
47
|
overrides: options.overrides,
|
|
46
48
|
preview: options.preview,
|
|
47
49
|
refresh: options.refresh,
|
|
50
|
+
runtimeDir: options.runtimeDir,
|
|
48
51
|
});
|
|
49
52
|
} catch (error) {
|
|
50
53
|
if (error instanceof BlumeError) {
|
|
@@ -38,7 +38,10 @@ import FileTree from "./content/FileTree.astro";
|
|
|
38
38
|
import Frame from "./content/Frame.astro";
|
|
39
39
|
import GithubInfo from "./content/GithubInfo.astro";
|
|
40
40
|
import Panel from "./content/Panel.astro";
|
|
41
|
+
import ParamField from "./content/ParamField.astro";
|
|
41
42
|
import Prompt from "./content/Prompt.astro";
|
|
43
|
+
import RequestField from "./content/RequestField.astro";
|
|
44
|
+
import ResponseField from "./content/ResponseField.astro";
|
|
42
45
|
import Step from "./content/Step.astro";
|
|
43
46
|
import Steps from "./content/Steps.astro";
|
|
44
47
|
import Tab from "./content/Tab.astro";
|
|
@@ -50,6 +53,7 @@ import TreeFile from "./content/TreeFile.astro";
|
|
|
50
53
|
import TreeFolder from "./content/TreeFolder.astro";
|
|
51
54
|
import TypeTable from "./content/TypeTable.astro";
|
|
52
55
|
import Visibility from "./content/Visibility.astro";
|
|
56
|
+
import YouTube from "./content/YouTube.astro";
|
|
53
57
|
|
|
54
58
|
interface Props {
|
|
55
59
|
/** Astro collection the entry lives in. Defaults to `"docs"`. */
|
|
@@ -86,7 +90,10 @@ const components = {
|
|
|
86
90
|
GithubInfo,
|
|
87
91
|
Icon,
|
|
88
92
|
Panel,
|
|
93
|
+
ParamField,
|
|
89
94
|
Prompt,
|
|
95
|
+
RequestField,
|
|
96
|
+
ResponseField,
|
|
90
97
|
Step,
|
|
91
98
|
Steps,
|
|
92
99
|
Tab,
|
|
@@ -96,6 +103,7 @@ const components = {
|
|
|
96
103
|
Tree,
|
|
97
104
|
TypeTable,
|
|
98
105
|
Visibility,
|
|
106
|
+
YouTube,
|
|
99
107
|
...extra,
|
|
100
108
|
};
|
|
101
109
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
+
import data from "blume:data";
|
|
2
3
|
import { resolveIcon } from "../theme/icons.ts";
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
name?: unknown;
|
|
6
7
|
icon?: unknown;
|
|
7
8
|
iconType?: string;
|
|
9
|
+
/** Icon library for a bare name; defaults to the project's `icons.library`. */
|
|
10
|
+
library?: string;
|
|
8
11
|
size?: number;
|
|
9
12
|
class?: string;
|
|
10
13
|
className?: string;
|
|
@@ -16,6 +19,7 @@ const {
|
|
|
16
19
|
color,
|
|
17
20
|
icon,
|
|
18
21
|
iconType,
|
|
22
|
+
library,
|
|
19
23
|
name,
|
|
20
24
|
size = 16,
|
|
21
25
|
class: astroClass,
|
|
@@ -37,8 +41,12 @@ const isImageIcon = (value: string): boolean =>
|
|
|
37
41
|
/\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
|
|
38
42
|
const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
|
|
39
43
|
const resolvedIcon =
|
|
40
|
-
iconName && !(imageSrc || rawSvg)
|
|
41
|
-
|
|
44
|
+
iconName && !(imageSrc || rawSvg)
|
|
45
|
+
? resolveIcon(iconName, {
|
|
46
|
+
iconType,
|
|
47
|
+
library: library ?? data.config.icons.library,
|
|
48
|
+
})
|
|
49
|
+
: null;
|
|
42
50
|
const resolvedClass = astroClass ?? className;
|
|
43
51
|
const customStyle = `display:inline-flex;width:${size}px;height:${size}px;${
|
|
44
52
|
color ? `color:${color}` : ""
|
|
@@ -76,21 +84,16 @@ const style = color ? `color:${color}` : undefined;
|
|
|
76
84
|
width={size}
|
|
77
85
|
/>
|
|
78
86
|
) : (
|
|
79
|
-
|
|
87
|
+
resolvedIcon && (
|
|
80
88
|
<svg
|
|
81
89
|
aria-hidden={label ? undefined : "true"}
|
|
82
90
|
aria-label={label}
|
|
83
91
|
class={resolvedClass}
|
|
84
|
-
fill="none"
|
|
85
92
|
height={size}
|
|
86
93
|
role={label ? "img" : undefined}
|
|
87
|
-
set:html={
|
|
88
|
-
stroke="currentColor"
|
|
89
|
-
stroke-linecap="round"
|
|
90
|
-
stroke-linejoin="round"
|
|
91
|
-
stroke-width="2"
|
|
94
|
+
set:html={resolvedIcon.body}
|
|
92
95
|
style={style}
|
|
93
|
-
viewBox=
|
|
96
|
+
viewBox={resolvedIcon.viewBox}
|
|
94
97
|
width={size}
|
|
95
98
|
xmlns="http://www.w3.org/2000/svg"
|
|
96
99
|
/>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Shared field row behind the Mintlify-compat <ParamField>/<ResponseField>/
|
|
3
|
+
// <RequestField> components: a labeled name + type + badges, with the field's
|
|
4
|
+
// description rendered from the slot (which may hold rich MDX, including nested
|
|
5
|
+
// <Expandable> or further fields). Styled to match the native OpenAPI reference
|
|
6
|
+
// rows (src/components/openapi/), so a migrated site reads consistently.
|
|
7
|
+
interface Props {
|
|
8
|
+
default?: unknown;
|
|
9
|
+
deprecated?: unknown;
|
|
10
|
+
location?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
required?: unknown;
|
|
13
|
+
type?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
default: defaultValue,
|
|
18
|
+
deprecated,
|
|
19
|
+
location,
|
|
20
|
+
name,
|
|
21
|
+
required,
|
|
22
|
+
type,
|
|
23
|
+
} = Astro.props;
|
|
24
|
+
|
|
25
|
+
// Booleans arrive as MDX shorthand (`required`) or strings (`required="true"`).
|
|
26
|
+
const isTrue = (value: unknown): boolean => value === true || value === "true";
|
|
27
|
+
const hasDefault =
|
|
28
|
+
defaultValue !== undefined && defaultValue !== null && defaultValue !== "";
|
|
29
|
+
const hasDescription = Astro.slots.has("default");
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div class="not-prose my-2 rounded-blume border border-border px-4 py-3">
|
|
33
|
+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
34
|
+
{name && <code class="font-mono text-foreground text-sm">{name}</code>}
|
|
35
|
+
{
|
|
36
|
+
location && (
|
|
37
|
+
<span class="rounded bg-muted px-1.5 py-0.5 font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide">
|
|
38
|
+
{location}
|
|
39
|
+
</span>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
{type && <span class="text-muted-foreground text-xs">{type}</span>}
|
|
43
|
+
{
|
|
44
|
+
isTrue(required) && (
|
|
45
|
+
<span class="font-medium text-[0.625rem] text-red-600 uppercase tracking-wide dark:text-red-400">
|
|
46
|
+
required
|
|
47
|
+
</span>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
{
|
|
51
|
+
isTrue(deprecated) && (
|
|
52
|
+
<span class="font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide line-through">
|
|
53
|
+
deprecated
|
|
54
|
+
</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
</div>
|
|
58
|
+
{
|
|
59
|
+
hasDefault && (
|
|
60
|
+
<div class="mt-1 text-muted-foreground text-xs">
|
|
61
|
+
Default:{" "}
|
|
62
|
+
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
63
|
+
{String(defaultValue)}
|
|
64
|
+
</code>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
{
|
|
69
|
+
hasDescription && (
|
|
70
|
+
<div class="mt-1.5 text-muted-foreground text-sm [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
|
71
|
+
<slot />
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Mintlify-compat <ParamField>. Mintlify encodes the parameter's location in the
|
|
3
|
+
// attribute name (`<ParamField path="id" />`, `query`, `header`, `body`); the
|
|
4
|
+
// attribute's value is the field name. Falls back to a plain `name` prop.
|
|
5
|
+
import ApiField from "./ApiField.astro";
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
body,
|
|
9
|
+
default: defaultValue,
|
|
10
|
+
deprecated,
|
|
11
|
+
header,
|
|
12
|
+
name,
|
|
13
|
+
path,
|
|
14
|
+
query,
|
|
15
|
+
required,
|
|
16
|
+
type,
|
|
17
|
+
} = Astro.props;
|
|
18
|
+
|
|
19
|
+
const locations = [
|
|
20
|
+
{ key: "path", value: path },
|
|
21
|
+
{ key: "query", value: query },
|
|
22
|
+
{ key: "header", value: header },
|
|
23
|
+
{ key: "body", value: body },
|
|
24
|
+
] as const;
|
|
25
|
+
const located = locations.find((entry) => typeof entry.value === "string");
|
|
26
|
+
const fieldName = located ? located.value : name;
|
|
27
|
+
const location = located?.key;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<ApiField
|
|
31
|
+
default={defaultValue}
|
|
32
|
+
deprecated={deprecated}
|
|
33
|
+
location={location}
|
|
34
|
+
name={fieldName}
|
|
35
|
+
required={required}
|
|
36
|
+
type={type}
|
|
37
|
+
>
|
|
38
|
+
<slot />
|
|
39
|
+
</ApiField>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Mintlify-compat <RequestField>: same shape as <ResponseField> — a named field
|
|
3
|
+
// (`name`, `type`, `required`, `default`, `deprecated`) with a slotted body.
|
|
4
|
+
import ApiField from "./ApiField.astro";
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
default: defaultValue,
|
|
8
|
+
deprecated,
|
|
9
|
+
name,
|
|
10
|
+
required,
|
|
11
|
+
type,
|
|
12
|
+
} = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<ApiField
|
|
16
|
+
default={defaultValue}
|
|
17
|
+
deprecated={deprecated}
|
|
18
|
+
name={name}
|
|
19
|
+
required={required}
|
|
20
|
+
type={type}
|
|
21
|
+
>
|
|
22
|
+
<slot />
|
|
23
|
+
</ApiField>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Mintlify-compat <ResponseField>: a named field (`name`, `type`, `required`,
|
|
3
|
+
// `default`, `deprecated`) with its description in the slot.
|
|
4
|
+
import ApiField from "./ApiField.astro";
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
default: defaultValue,
|
|
8
|
+
deprecated,
|
|
9
|
+
name,
|
|
10
|
+
required,
|
|
11
|
+
type,
|
|
12
|
+
} = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<ApiField
|
|
16
|
+
default={defaultValue}
|
|
17
|
+
deprecated={deprecated}
|
|
18
|
+
name={name}
|
|
19
|
+
required={required}
|
|
20
|
+
type={type}
|
|
21
|
+
>
|
|
22
|
+
<slot />
|
|
23
|
+
</ApiField>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { parseYouTubeId, youtubeEmbedUrl } from "./youtube.ts";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** A YouTube video id (e.g. `dQw4w9WgXcQ`). */
|
|
6
|
+
id?: string;
|
|
7
|
+
/** Start playback this many seconds in. */
|
|
8
|
+
start?: number;
|
|
9
|
+
/** Accessible title for the embedded player. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** A full YouTube URL to extract the id from, as an alternative to `id`. */
|
|
12
|
+
url?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { id, start, title = "YouTube video player", url } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const videoId = parseYouTubeId(id ?? url ?? "");
|
|
18
|
+
const src = videoId ? youtubeEmbedUrl(videoId, { start }) : null;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
src && (
|
|
23
|
+
<div class="not-prose my-6 aspect-video overflow-hidden rounded-blume border border-border bg-muted/30">
|
|
24
|
+
<iframe
|
|
25
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
26
|
+
allowfullscreen
|
|
27
|
+
class="h-full w-full"
|
|
28
|
+
loading="lazy"
|
|
29
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
30
|
+
src={src}
|
|
31
|
+
title={title}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the `<YouTube>` content component. Kept in a sibling `.ts` (like
|
|
3
|
+
* `diff.ts`/`github-info.ts`) so the id parsing and embed-URL building are pure,
|
|
4
|
+
* unit-testable functions — the `.astro` file stays a thin presentational shell.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// A YouTube video id is 11 characters of [A-Za-z0-9_-].
|
|
8
|
+
const BARE_ID = /^[\w-]{11}$/u;
|
|
9
|
+
|
|
10
|
+
// Pull the id out of any common YouTube URL: youtu.be/<id>, watch?v=<id>,
|
|
11
|
+
// /embed/<id>, /shorts/<id>, /live/<id>.
|
|
12
|
+
const URL_ID =
|
|
13
|
+
/(?:youtu\.be\/|\/embed\/|\/shorts\/|\/live\/|[?&]v=)(?<id>[\w-]{11})/u;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a YouTube video id from either a bare id or a full URL. Returns `null`
|
|
17
|
+
* when nothing that looks like an id can be found, so the component can render
|
|
18
|
+
* nothing rather than a broken embed.
|
|
19
|
+
*/
|
|
20
|
+
export const parseYouTubeId = (input: string): string | null => {
|
|
21
|
+
const value = input.trim();
|
|
22
|
+
if (!value) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (BARE_ID.test(value)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return URL_ID.exec(value)?.groups?.id ?? null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a privacy-enhanced (`youtube-nocookie.com`) embed URL, optionally
|
|
33
|
+
* starting at `start` seconds.
|
|
34
|
+
*/
|
|
35
|
+
export const youtubeEmbedUrl = (
|
|
36
|
+
id: string,
|
|
37
|
+
options: { start?: number } = {}
|
|
38
|
+
): string => {
|
|
39
|
+
const base = `https://www.youtube-nocookie.com/embed/${id}`;
|
|
40
|
+
const { start } = options;
|
|
41
|
+
if (start && start > 0) {
|
|
42
|
+
const params = new URLSearchParams({ start: String(Math.floor(start)) });
|
|
43
|
+
return `${base}?${params.toString()}`;
|
|
44
|
+
}
|
|
45
|
+
return base;
|
|
46
|
+
};
|
|
@@ -93,21 +93,21 @@ const AskAI = ({ strings }: { strings?: UIStrings["ask"] }) => {
|
|
|
93
93
|
headers: { "content-type": "application/json" },
|
|
94
94
|
method: "POST",
|
|
95
95
|
});
|
|
96
|
-
|
|
96
|
+
// A 4xx/5xx still has a body; without this guard its error text would be
|
|
97
|
+
// decoded and shown as the assistant's answer instead of the error notice.
|
|
98
|
+
if (!(response.ok && response.body)) {
|
|
99
|
+
throw new Error(`Ask AI request failed (${response.status}).`);
|
|
100
|
+
}
|
|
101
|
+
const reader = response.body.getReader();
|
|
97
102
|
const decoder = new TextDecoder();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
setMessages((current) => [
|
|
107
|
-
...current.slice(0, -1),
|
|
108
|
-
{ ...assistant },
|
|
109
|
-
]);
|
|
110
|
-
}
|
|
103
|
+
let done = false;
|
|
104
|
+
while (!done) {
|
|
105
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential stream reads
|
|
106
|
+
const chunk = await reader.read();
|
|
107
|
+
({ done } = chunk);
|
|
108
|
+
if (chunk.value) {
|
|
109
|
+
assistant.content += decoder.decode(chunk.value);
|
|
110
|
+
setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
} catch {
|
|
@@ -4,9 +4,11 @@ import type { Crumb } from "./nav-utils.ts";
|
|
|
4
4
|
interface Props {
|
|
5
5
|
/** Full breadcrumb trail from the site root to the current page. */
|
|
6
6
|
crumbs: Crumb[];
|
|
7
|
+
/** Left-align full width (for the wide API layout) instead of the prose measure. */
|
|
8
|
+
wide?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
const { crumbs } = Astro.props;
|
|
11
|
+
const { crumbs, wide = false } = Astro.props;
|
|
10
12
|
|
|
11
13
|
// The built-in shows a single "eyebrow" crumb — the parent group — rather than
|
|
12
14
|
// the whole trail. An override receives the complete list and can render more.
|
|
@@ -17,7 +19,10 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
|
|
|
17
19
|
eyebrowCrumb && (
|
|
18
20
|
<nav
|
|
19
21
|
aria-label="Breadcrumb"
|
|
20
|
-
class=
|
|
22
|
+
class:list={[
|
|
23
|
+
"mb-2 text-muted-foreground text-sm",
|
|
24
|
+
wide ? "max-w-none" : "mx-auto max-w-[42rem]",
|
|
25
|
+
]}
|
|
21
26
|
>
|
|
22
27
|
{eyebrowCrumb.route ? (
|
|
23
28
|
<a class="hover:text-foreground" href={eyebrowCrumb.route}>
|