@docubook/flame 1.4.2 → 1.4.3

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.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { cn } from "../node/utils";
4
+ import { cn, docsHtmlHref } from "../node/utils";
5
5
  import { Dropdown, DropdownItem } from "@docubook/ui-react/dropdown";
6
6
  import { routes, config as docuConfig } from "../node/client-routes";
7
7
  import { ChevronsUpDown, Check } from "lucide-react";
@@ -68,7 +68,7 @@ export function Context({ className }: ContextProps) {
68
68
  {contextRoutes.map((route) => {
69
69
  const isActive = activeRoute?.href === route.href;
70
70
  const firstItemPath = getFirstItemHref(route);
71
- const contextPath = `/docs${firstItemPath}`;
71
+ const contextPath = docsHtmlHref(`/docs${firstItemPath}`);
72
72
 
73
73
  return (
74
74
  <DropdownItem
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { ReactNode } from "react";
4
4
  import { getPreviousNext } from "../node/route";
5
+ import { docsHtmlHref } from "../node/utils";
5
6
  import { PaginationDocs } from "@docubook/ui-react/pagination";
6
7
 
7
8
  interface PaginationProps {
@@ -27,8 +28,8 @@ export default function Pagination({
27
28
 
28
29
  return (
29
30
  <PaginationDocs
30
- prev={prev ? { href: `/docs${prev.href}`, title: prev.title } : undefined}
31
- next={next ? { href: `/docs${next.href}`, title: next.title } : undefined}
31
+ prev={prev ? { href: docsHtmlHref(`/docs${prev.href}`), title: prev.title } : undefined}
32
+ next={next ? { href: docsHtmlHref(`/docs${next.href}`), title: next.title } : undefined}
32
33
  className={className}
33
34
  prevIcon={prevIcon}
34
35
  nextIcon={nextIcon}
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from "react";
4
4
  import { ChevronDown } from "lucide-react";
5
5
  import Anchor from "./Anchor";
6
6
  import type { DocuRoute } from "../node/types";
7
- import { cn } from "../node/utils";
7
+ import { cn, docsHtmlHref } from "../node/utils";
8
8
 
9
9
  interface SublinkProps extends DocuRoute {
10
10
  level: number;
@@ -48,7 +48,7 @@ export default function Sublink({
48
48
  if (!items) {
49
49
  const link = (
50
50
  <Anchor
51
- href={fullHref}
51
+ href={docsHtmlHref(fullHref)}
52
52
  className="text-foreground hover:text-foreground/80 text-sm transition-colors"
53
53
  activeClassName="text-primary font-medium"
54
54
  activeWhen={(path) => path === fullHref || path === `${fullHref}.html`}
@@ -91,7 +91,7 @@ export default function Sublink({
91
91
  <span>{title}</span>
92
92
  ) : (
93
93
  <Anchor
94
- href={fullHref}
94
+ href={docsHtmlHref(fullHref)}
95
95
  className="text-foreground hover:text-foreground/80 transition-colors"
96
96
  activeClassName="text-primary"
97
97
  activeWhen={(path) => path === fullHref || path === `${fullHref}.html`}
package/.docu/node/mdx.ts CHANGED
@@ -11,6 +11,96 @@ import {
11
11
  import { createMdxComponents } from "@docubook/mdx-content";
12
12
  import { getGitLastModified, getGitLastModifiedBatch } from "./utils";
13
13
 
14
+ /**
15
+ * Return the value with `.html` appended, or null if the value should be left
16
+ * unchanged. Rules:
17
+ * - Must be a string
18
+ * - Must start with /docs/ (the /docs root index needs no suffix)
19
+ * - Must not be an external URL, contain a fragment, or already end in .html
20
+ */
21
+ function appendHtml(value: unknown): string | null {
22
+ if (typeof value !== "string") return null;
23
+ if (/^https?:\/\//.test(value)) return null;
24
+ if (!value.startsWith("/docs/")) return null;
25
+ if (value.includes("#")) return null;
26
+ if (value.endsWith(".html")) return null;
27
+ return `${value}.html`;
28
+ }
29
+
30
+ interface HastNode {
31
+ type: string;
32
+ tagName?: string;
33
+ properties?: Record<string, unknown>;
34
+ children?: HastNode[];
35
+ }
36
+
37
+ interface MdastNode {
38
+ type: string;
39
+ // MDX JSX nodes carry their props as an attributes array
40
+ attributes?: { type: string; name: string; value: unknown }[];
41
+ children?: MdastNode[];
42
+ }
43
+
44
+ /**
45
+ * Rehype plugin: append `.html` to internal `/docs/` hrefs on HTML `<a>` nodes.
46
+ *
47
+ * This covers standard markdown links: `[text](/docs/page)` → `<a href="…">`.
48
+ * It runs in the HAST (HTML AST) phase, where `<a>` elements are real nodes.
49
+ *
50
+ * Skips: external URLs, anchor-only links, paths that already end in `.html`,
51
+ * and the `/docs` root index (no trailing slash segment).
52
+ */
53
+ function rehypeDocsHtmlLinks() {
54
+ return (tree: HastNode) => {
55
+ function walk(node: HastNode): void {
56
+ if (node.type === "element" && node.tagName === "a") {
57
+ const fixed = appendHtml(node.properties?.href);
58
+ if (fixed) node.properties!.href = fixed;
59
+ }
60
+ if (node.children) {
61
+ for (const child of node.children) walk(child);
62
+ }
63
+ }
64
+ walk(tree);
65
+ return tree;
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Remark plugin: append `.html` to internal `/docs/` hrefs on MDX JSX nodes.
71
+ *
72
+ * MDX JSX elements (`<Card href="…">`, `<LinkCard href="…">`, etc.) live in
73
+ * the MDAST as `mdxJsxFlowElement` / `mdxJsxTextElement` nodes. They are
74
+ * compiled directly to JavaScript by the MDX compiler *before* rehype runs,
75
+ * so a rehype plugin can never see them as `<a>` elements. This remark plugin
76
+ * intercepts them at the MDAST phase where their `attributes` array is still
77
+ * accessible and mutable.
78
+ *
79
+ * Skips: same rules as `appendHtml` (external URLs, anchors, already `.html`).
80
+ */
81
+ function remarkMdxJsxDocsHtmlLinks() {
82
+ return (tree: MdastNode) => {
83
+ function walk(node: MdastNode): void {
84
+ if (
85
+ (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") &&
86
+ node.attributes
87
+ ) {
88
+ for (const attr of node.attributes) {
89
+ if (attr.type === "mdxJsxAttribute" && attr.name === "href") {
90
+ const fixed = appendHtml(attr.value);
91
+ if (fixed) attr.value = fixed;
92
+ }
93
+ }
94
+ }
95
+ if (node.children) {
96
+ for (const child of node.children) walk(child);
97
+ }
98
+ }
99
+ walk(tree);
100
+ return tree;
101
+ };
102
+ }
103
+
14
104
  export { getGitLastModifiedBatch };
15
105
 
16
106
  export interface MdxResult {
@@ -46,8 +136,11 @@ export async function compileMdx(
46
136
  const defaultRemark = createDefaultRemarkPlugins();
47
137
  const defaultRehype = createDefaultRehypePlugins();
48
138
 
49
- const finalRemark = remarkPlugins?.length ? [...defaultRemark, ...remarkPlugins] : defaultRemark;
50
- const finalRehype = rehypePlugins?.length ? [...defaultRehype, ...rehypePlugins] : defaultRehype;
139
+ // remarkMdxJsxDocsHtmlLinks must run before user plugins so custom remark
140
+ // transforms see already-fixed hrefs. rehypeDocsHtmlLinks handles plain
141
+ // markdown [text](path) → <a> elements in the HAST phase.
142
+ const finalRemark = [...defaultRemark, remarkMdxJsxDocsHtmlLinks, ...(remarkPlugins ?? [])];
143
+ const finalRehype = [...defaultRehype, rehypeDocsHtmlLinks, ...(rehypePlugins ?? [])];
51
144
 
52
145
  const serialized = await serialize(strippedContent, {
53
146
  mdxOptions: {
@@ -15,7 +15,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
15
15
  import { resolve, join } from "node:path";
16
16
  import { extractFrontmatterWithContent } from "@docubook/core";
17
17
  import { DOCS_DIR, ASSETS_DIR, loadDocuConfig } from "./paths";
18
- import { scanMdxFiles } from "./utils";
18
+ import { scanMdxFiles, docsHtmlHref } from "./utils";
19
19
 
20
20
  const docuConfig = loadDocuConfig();
21
21
 
@@ -72,7 +72,7 @@ export function stripJsx(content: string): string {
72
72
  export function extractRecords(filePath: string, raw: string): SearchRecord[] {
73
73
  const { frontmatter, strippedContent: content } = extractFrontmatterWithContent<Frontmatter>(raw);
74
74
  const records: SearchRecord[] = [];
75
- const url = `/docs/${filePath}`;
75
+ const url = docsHtmlHref(`/docs/${filePath}`);
76
76
  const lvl0 = getSectionTitle(filePath);
77
77
  const lvl1 = frontmatter.title || null;
78
78
 
@@ -16,6 +16,7 @@ import {
16
16
  type ServerState,
17
17
  } from "./server-routes";
18
18
  import { wrapPluginResponse } from "./security";
19
+ import { stripDocsHtmlSuffix } from "./utils";
19
20
 
20
21
  const docuConfig = loadDocuConfig();
21
22
 
@@ -103,7 +104,9 @@ const server = Bun.serve({
103
104
 
104
105
  async fetch(req) {
105
106
  const url = new URL(req.url);
106
- const pathname = url.pathname;
107
+ // Generated links carry `.html` (matching the static build output);
108
+ // route them to the same handler as their extensionless form.
109
+ const pathname = stripDocsHtmlSuffix(url.pathname);
107
110
  const startTime = performance.now();
108
111
 
109
112
  if (builder) {
@@ -150,7 +153,7 @@ const server = Bun.serve({
150
153
  if (staticRes) return staticRes;
151
154
  }
152
155
 
153
- const match = router?.match(req);
156
+ const match = router?.match(pathname);
154
157
  let response: Response;
155
158
 
156
159
  if (match) {
@@ -1,3 +1,6 @@
1
+ import type { ThemeConfig } from "@docubook/themes-colors";
2
+ import type { PluginEntry } from "./plugin";
3
+
1
4
  export interface DocuRouteContext {
2
5
  title?: string;
3
6
  icon?: string;
@@ -73,9 +76,6 @@ export interface HomeConfig {
73
76
  features?: HomeFeature[];
74
77
  }
75
78
 
76
- import type { ThemeConfig } from "@docubook/themes-colors";
77
- import type { PluginEntry } from "./plugin";
78
-
79
79
  export interface DocuConfig {
80
80
  meta: DocuMeta;
81
81
  home?: HomeConfig;
@@ -44,6 +44,20 @@ export function isExternalUrl(url: string): boolean {
44
44
  return /^(https?:\/\/|\/\/)/.test(url);
45
45
  }
46
46
 
47
+ /** Suffix an internal docs link with `.html` to match the flat static build output. */
48
+ export function docsHtmlHref(path: string): string {
49
+ return `${path}.html`;
50
+ }
51
+
52
+ /** Map a `/docs{.html,/*.html}` request back to its extensionless route (dev server).
53
+ * Handles both `/docs/*.html` (pages) and `/docs.html` (edge case). */
54
+ export function stripDocsHtmlSuffix(pathname: string): string {
55
+ if (pathname === "/docs.html") return "/docs";
56
+ if (pathname.startsWith("/docs/") && pathname.endsWith(".html"))
57
+ return pathname.slice(0, -".html".length);
58
+ return pathname;
59
+ }
60
+
47
61
  export function getPath(url: string): string {
48
62
  try {
49
63
  return new URL(url).pathname;
@@ -1,4 +1,5 @@
1
1
  import { loadDocuConfig } from "../node/paths";
2
+ import { docsHtmlHref, isExternalUrl } from "../node/utils";
2
3
  import { Hero, Features } from "../components/home";
3
4
  import type { HomeFeature } from "../node/types";
4
5
 
@@ -21,23 +22,42 @@ export default function IndexPage() {
21
22
  const { meta, home } = docuConfig;
22
23
  const routes = (docuConfig.routes as RouteItem[]) || [];
23
24
 
25
+ // Docs pages under /docs/ are flat .html files; the root /docs is
26
+ // docs/index.html via directory index — no .html suffix needed.
27
+ const linkWithHtml = (link: string) => {
28
+ if (isExternalUrl(link)) return link;
29
+ if (link.startsWith("/docs/")) return `${link}.html`;
30
+ return link;
31
+ };
32
+
24
33
  // Use home.features if configured, otherwise fallback to routes with context
25
34
  const features: HomeFeature[] =
26
- home?.features ||
35
+ home?.features?.map((f) => ({
36
+ ...f,
37
+ link: f.link ? linkWithHtml(f.link) : undefined,
38
+ })) ||
27
39
  routes
28
40
  .filter((r) => r.context)
29
41
  .map((route) => ({
30
42
  icon: route.context?.icon,
31
43
  title: route.context?.title || route.title,
32
44
  description: route.context?.description || "",
33
- link: `/docs${route.href}${route.items?.[0]?.href || ""}`,
45
+ link: docsHtmlHref(`/docs${route.href}${route.items?.[0]?.href || ""}`),
34
46
  }));
35
47
 
36
48
  // Use home.hero if configured, otherwise fallback to meta
37
- const hero = home?.hero || {
38
- headline: meta.title,
39
- description: meta.description,
40
- };
49
+ const hero = home?.hero
50
+ ? {
51
+ ...home.hero,
52
+ actions: home.hero.actions?.map((a) => ({
53
+ ...a,
54
+ link: linkWithHtml(a.link),
55
+ })),
56
+ }
57
+ : {
58
+ headline: meta.title,
59
+ description: meta.description,
60
+ };
41
61
 
42
62
  return (
43
63
  <div className="bg-base-100 relative isolate min-h-screen overflow-hidden">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,8 +54,8 @@
54
54
  "unified": "^11.0.0",
55
55
  "@docubook/core": "^1.8.0",
56
56
  "@docubook/mdx-content": "^3.4.0",
57
- "@docubook/ui-react": "^0.1.4",
58
- "@docubook/themes-colors": "^0.10.2"
57
+ "@docubook/themes-colors": "^0.10.2",
58
+ "@docubook/ui-react": "^0.1.4"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@sentry/bun": "^10.0.0"