@mdream/nuxt 0.11.1 → 0.12.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/README.md CHANGED
@@ -5,24 +5,21 @@
5
5
  [![License][license-src]][license-href]
6
6
  [![Nuxt][nuxt-src]][nuxt-href]
7
7
 
8
- Nuxt module for converting HTML pages to Markdown using [mdream](https://github.com/unjs/mdream).
8
+ Nuxt module for converting HTML pages to Markdown using [mdream](https://github.com/harlan-zw/mdream).
9
9
 
10
- Mdream provides a Nuxt module that enables seamless HTML to Markdown conversion for Nuxt 3 applications.
10
+ ## Features
11
11
 
12
12
  - **🚀 On-Demand Generation**: Access any route with `.md` extension (e.g., `/about` → `/about.md`)
13
+ - **🤖 Smart Client Detection**: Automatically serves markdown to LLM bots based on Accept headers
13
14
  - **📄 LLMs.txt Generation**: Creates `llms.txt` and `llms-full.txt` artifacts during prerendering
14
15
 
15
- ### Installation
16
+ ## Installation
16
17
 
17
18
  ```bash
18
- npm install @mdream/nuxt
19
- # or
20
19
  pnpm add @mdream/nuxt
21
- # or
22
- yarn add @mdream/nuxt
23
20
  ```
24
21
 
25
- ### Usage
22
+ ## Usage
26
23
 
27
24
  Add the module to your `nuxt.config.ts`:
28
25
 
@@ -34,9 +31,21 @@ export default defineNuxtConfig({
34
31
  })
35
32
  ```
36
33
 
37
- Done! Add the `.md` to any file path to access markdown.
34
+ Done! You can now:
38
35
 
39
- When statically generating your site with `nuxi generate` it will create `llms.txt` artifacts.
36
+ 1. **Add `.md` extension**: Access any route as markdown (e.g., `/about.md`)
37
+ 2. **LLM bots**: LLM bots automatically receive markdown responses based on Accept headers
38
+
39
+ When statically generating your site with `nuxi generate` it will create `llms.txt` and `llms-full.txt` artifacts.
40
+
41
+ ### Smart Client Detection
42
+
43
+ The module automatically detects LLM bots and serves markdown without requiring the `.md` extension:
44
+
45
+ - ✅ **Serves markdown** when `Accept` header contains `*/*` or `text/markdown` (but not `text/html`)
46
+ - ❌ **Serves HTML** to browsers (checks for `text/html` in Accept header or `sec-fetch-dest: document`)
47
+
48
+ This means LLM bots automatically receive optimized markdown responses, reducing token usage by ~10x compared to HTML.
40
49
 
41
50
  ## Configuration
42
51
 
@@ -95,6 +104,144 @@ When using `nuxt generate` or static hosting, the module automatically:
95
104
 
96
105
  These files are placed in the `public/` directory and served as static assets.
97
106
 
107
+ ## Server Hooks
108
+
109
+ The module provides several hooks for integrating with other modules (e.g., `nuxt-ai-index`):
110
+
111
+ ### `'mdream:config'`{lang="ts"}
112
+
113
+ **Type:** `(ctx: ConfigContext) => void | Promise<void>`{lang="ts"}
114
+
115
+ ```ts
116
+ interface ConfigContext {
117
+ route: string
118
+ options: MdreamOptions
119
+ event: H3Event
120
+ }
121
+ ```
122
+
123
+ Modify the mdream options before HTML→Markdown conversion. This hook is called during runtime middleware processing, allowing you to dynamically adjust conversion behavior based on the request.
124
+
125
+ ```ts [server/plugins/mdream-config.ts]
126
+ export default defineNitroPlugin((nitroApp) => {
127
+ nitroApp.hooks.hook('mdream:config', async (ctx) => {
128
+ // Apply readability preset for documentation routes
129
+ if (ctx.route.startsWith('/docs')) {
130
+ ctx.options.preset = 'readability'
131
+ }
132
+
133
+ // Add custom plugins dynamically
134
+ if (!ctx.options.plugins) {
135
+ ctx.options.plugins = []
136
+ }
137
+
138
+ // Filter out advertisements and cookie banners
139
+ ctx.options.plugins.push({
140
+ beforeNodeProcess(event) {
141
+ if (event.node.type === 1) { // ELEMENT_NODE
142
+ const element = event.node
143
+ const classList = element.attributes?.class?.split(' ') || []
144
+ if (classList.includes('advertisement') || classList.includes('cookie-banner')) {
145
+ return { skip: true }
146
+ }
147
+ }
148
+ }
149
+ })
150
+ })
151
+ })
152
+ ```
153
+
154
+ ### `'mdream:markdown'`{lang="ts"}
155
+
156
+ **Type:** `(ctx: MarkdownContext) => void | Promise<void>`{lang="ts"}
157
+
158
+ ```ts
159
+ interface MarkdownContext {
160
+ html: string
161
+ markdown: string
162
+ route: string
163
+ title: string
164
+ description: string
165
+ isPrerender: boolean
166
+ event: H3Event
167
+ }
168
+ ```
169
+
170
+ Modify the generated markdown content after conversion. Use this hook for post-processing markdown, tracking conversions, or adding custom response headers.
171
+
172
+ ```ts [server/plugins/mdream-markdown.ts]
173
+ export default defineNitroPlugin((nitroApp) => {
174
+ nitroApp.hooks.hook('mdream:markdown', async (ctx) => {
175
+ // Add footer to all markdown output
176
+ ctx.markdown += '\n\n---\n*Generated with mdream*'
177
+
178
+ // Track conversion for analytics
179
+ console.log(`Converted ${ctx.route} (${ctx.title})`)
180
+
181
+ // Add custom headers
182
+ setHeader(ctx.event, 'X-Markdown-Title', ctx.title)
183
+ })
184
+ })
185
+ ```
186
+
187
+ ## Build Hooks
188
+
189
+ ### `'mdream:llms-txt:generate'`{lang="ts"}
190
+
191
+ **Type:** `(payload: MdreamLlmsTxtGeneratePayload) => void | Promise<void>`{lang="ts"}
192
+
193
+ ```ts
194
+ interface MdreamLlmsTxtGeneratePayload {
195
+ content: string
196
+ fullContent: string
197
+ pages: ProcessedFile[]
198
+ }
199
+
200
+ interface ProcessedFile {
201
+ filePath?: string
202
+ title: string
203
+ content: string
204
+ url: string
205
+ metadata?: {
206
+ title?: string
207
+ description?: string
208
+ keywords?: string
209
+ author?: string
210
+ }
211
+ }
212
+ ```
213
+
214
+ Modify the llms.txt content before it's written to disk. This hook is called once during prerendering after all routes have been processed. Uses a **mutable pattern** - modify the payload properties directly.
215
+
216
+ ```ts [nuxt.config.ts]
217
+ export default defineNuxtConfig({
218
+ modules: ['@mdream/nuxt'],
219
+
220
+ hooks: {
221
+ 'mdream:llms-txt:generate': async (payload) => {
222
+ // Access all processed pages
223
+ console.log(`Processing ${payload.pages.length} pages`)
224
+
225
+ // Add custom sections to llms.txt
226
+ payload.content += `
227
+
228
+ ## API Search
229
+
230
+ Search available at /api/search with semantic search capabilities.
231
+ `
232
+
233
+ // Add detailed API documentation to full content
234
+ payload.fullContent += `
235
+
236
+ ## Full API Documentation
237
+
238
+ Detailed API documentation...
239
+ `
240
+ }
241
+ }
242
+ })
243
+ ```
244
+
98
245
  ## License
99
246
 
100
247
  [MIT License](./LICENSE)
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdream/nuxt",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "configKey": "mdream",
5
5
  "compatibility": {
6
6
  "nuxt": ">=3.0.0"
package/dist/module.mjs CHANGED
@@ -7,7 +7,7 @@ import { consola } from 'consola';
7
7
  import { generateLlmsTxtArtifacts } from 'mdream/llms-txt';
8
8
 
9
9
  const name = "@mdream/nuxt";
10
- const version = "0.11.1";
10
+ const version = "0.12.0";
11
11
 
12
12
  const logger = consola.withTag("nuxt-mdream");
13
13
  function setupPrerenderHandler() {
@@ -15,54 +15,72 @@ function setupPrerenderHandler() {
15
15
  const pages = [];
16
16
  nuxt.hooks.hook("nitro:init", async (nitro) => {
17
17
  nitro.hooks.hook("prerender:generate", async (route) => {
18
- if (route.fileName?.endsWith(".md")) {
19
- const markdown = route.contents;
20
- const titleMatch = markdown.match(/title:\s*(.+)/);
21
- const extractedTitle = titleMatch?.[1]?.replace(/"/g, "") || route.route;
22
- pages.push({
23
- url: route.route,
24
- title: extractedTitle,
25
- markdown
26
- });
18
+ if (!route.fileName?.endsWith(".md")) {
19
+ return;
27
20
  }
21
+ const { markdown, title, description } = JSON.parse(route.contents || "{}");
22
+ const page = {
23
+ filePath: route.fileName,
24
+ url: route.route,
25
+ title,
26
+ content: markdown,
27
+ metadata: {
28
+ description,
29
+ title
30
+ }
31
+ };
32
+ pages.push(page);
33
+ route.contents = markdown;
28
34
  });
29
35
  nitro.hooks.hook("prerender:done", async () => {
30
36
  if (pages.length === 0) {
31
37
  return;
32
38
  }
33
- const processedFiles = pages.map((page) => ({
34
- title: page.title,
35
- content: page.markdown,
36
- url: page.url === "/" ? "/index.md" : page.url.endsWith(".md") ? page.url : `${page.url}.md`
37
- }));
39
+ const startTime = Date.now();
38
40
  const siteConfig = useSiteConfig();
39
41
  const artifacts = await generateLlmsTxtArtifacts({
40
42
  origin: siteConfig.url,
41
- files: processedFiles,
43
+ files: pages,
42
44
  generateFull: true,
43
45
  siteName: siteConfig.name || siteConfig.url,
44
46
  description: siteConfig.description
45
47
  });
46
48
  logger.success(`Generated markdown for ${pages.length} pages`);
47
- if (artifacts.llmsTxt) {
49
+ const hookPayload = {
50
+ content: artifacts.llmsTxt || "",
51
+ fullContent: artifacts.llmsFullTxt || "",
52
+ pages
53
+ };
54
+ await nuxt.hooks.callHook("mdream:llms-txt", hookPayload);
55
+ const finalLlmsTxt = hookPayload.content;
56
+ const finalLlmsFullTxt = hookPayload.fullContent;
57
+ const generatedFiles = [];
58
+ if (finalLlmsTxt) {
48
59
  const llmsTxtPath = join(nitro.options.output.publicDir, "llms.txt");
49
- await writeFile(llmsTxtPath, artifacts.llmsTxt, "utf-8");
60
+ await writeFile(llmsTxtPath, finalLlmsTxt, "utf-8");
61
+ const sizeKb = (Buffer.byteLength(finalLlmsTxt, "utf-8") / 1024).toFixed(2);
62
+ generatedFiles.push({ path: "llms.txt", size: `${sizeKb}kb` });
50
63
  nitro._prerenderedRoutes.push({
51
64
  route: "/llms.txt",
52
65
  fileName: llmsTxtPath,
53
66
  generateTimeMS: 0
54
67
  });
55
- logger.info("Generated llms.txt");
56
68
  }
57
- if (artifacts.llmsFullTxt) {
69
+ if (finalLlmsFullTxt) {
58
70
  const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
59
- await writeFile(llmsFullTxtPath, artifacts.llmsFullTxt, "utf-8");
71
+ await writeFile(llmsFullTxtPath, finalLlmsFullTxt, "utf-8");
72
+ const sizeKb = (Buffer.byteLength(finalLlmsFullTxt, "utf-8") / 1024).toFixed(2);
73
+ generatedFiles.push({ path: "llms-full.txt", size: `${sizeKb}kb` });
60
74
  nitro._prerenderedRoutes.push({
61
75
  route: "/llms-full.txt",
62
76
  fileName: llmsFullTxtPath,
63
77
  generateTimeMS: 0
64
78
  });
65
- logger.info("Generated llms-full.txt");
79
+ }
80
+ if (generatedFiles.length > 0) {
81
+ const elapsed = Date.now() - startTime;
82
+ const fileList = generatedFiles.map((f) => `${f.path} (${f.size})`).join(" and ");
83
+ logger.info(`Generated ${fileList} in ${elapsed}ms`);
66
84
  }
67
85
  });
68
86
  });
@@ -110,8 +128,12 @@ const module = defineNuxtModule({
110
128
  filename: "module/nuxt-mdream.d.ts",
111
129
  getContents: (data) => {
112
130
  const typesPath = relative(resolve(data.nuxt.options.rootDir, data.nuxt.options.buildDir, "module"), resolve("runtime/types"));
113
- const types = ` interface NitroRuntimeHooks {
131
+ const nitroTypes = ` interface NitroRuntimeHooks {
114
132
  'mdream:markdown': (context: import('${typesPath}').MdreamMarkdownContext) => void | Promise<void>
133
+ 'mdream:config': (config: import('mdream').HTMLToMarkdownOptions) => void | Promise<void>
134
+ }`;
135
+ const nuxtTypes = ` interface NuxtHooks {
136
+ 'mdream:llms-txt': (payload: import('${typesPath}').MdreamLlmsTxtGeneratePayload) => void | Promise<void>
115
137
  }`;
116
138
  return `// Generated by nuxt-mdream
117
139
 
@@ -126,14 +148,16 @@ declare module '@nuxt/schema' {
126
148
  }
127
149
  }
128
150
  }
151
+
152
+ ${nuxtTypes}
129
153
  }
130
154
 
131
155
  declare module 'nitropack/types' {
132
- ${types}
156
+ ${nitroTypes}
133
157
  }
134
158
 
135
159
  declare module 'nitropack' {
136
- ${types}
160
+ ${nitroTypes}
137
161
  }
138
162
 
139
163
  export {}
@@ -1,53 +1,70 @@
1
1
  import { withSiteUrl } from "#site-config/server/composables/utils";
2
2
  import { consola } from "consola";
3
- import { createError, defineEventHandler, setHeader } from "h3";
3
+ import { createError, defineEventHandler, getHeader, setHeader } from "h3";
4
4
  import { htmlToMarkdown } from "mdream";
5
5
  import { extractionPlugin } from "mdream/plugins";
6
6
  import { withMinimalPreset } from "mdream/preset/minimal";
7
7
  import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
8
8
  const logger = consola.withTag("nuxt-mdream");
9
- async function convertHtmlToMarkdown(html, url, config, route) {
9
+ function shouldServeMarkdown(event) {
10
+ const accept = getHeader(event, "accept") || "";
11
+ const secFetchDest = getHeader(event, "sec-fetch-dest") || "";
12
+ if (secFetchDest === "document") {
13
+ return false;
14
+ }
15
+ if (accept.includes("text/html")) {
16
+ return false;
17
+ }
18
+ return accept.includes("*/*") || accept.includes("text/markdown");
19
+ }
20
+ async function convertHtmlToMarkdown(html, url, config, route, event) {
21
+ const nitroApp = useNitroApp();
22
+ let title = "";
23
+ let description = "";
24
+ const extractPlugin = extractionPlugin({
25
+ title(el) {
26
+ title = el.textContent;
27
+ },
28
+ 'meta[name="description"]': (el) => {
29
+ description = el.attributes.content || "";
30
+ }
31
+ });
10
32
  let options = {
11
33
  origin: url,
12
34
  ...config.mdreamOptions
13
35
  };
14
36
  if (config.mdreamOptions?.preset === "minimal") {
15
37
  options = withMinimalPreset(options);
38
+ options.plugins = [extractPlugin, ...options.plugins || []];
39
+ } else {
40
+ options.plugins = [extractPlugin, ...options.plugins || []];
16
41
  }
17
- let title = "";
18
- let markdown = htmlToMarkdown(html, {
19
- ...options,
20
- plugins: [
21
- ...options.plugins || [],
22
- // Add any additional plugins here if needed
23
- extractionPlugin({
24
- title(html2) {
25
- title = html2.textContent;
26
- }
27
- })
28
- ]
29
- });
42
+ await nitroApp.hooks.callHook("mdream:config", options);
43
+ let markdown = htmlToMarkdown(html, options);
30
44
  const context = {
31
45
  html,
32
46
  markdown,
33
47
  route,
34
48
  title,
35
- isPrerender: Boolean(import.meta.prerender)
49
+ description,
50
+ isPrerender: Boolean(import.meta.prerender),
51
+ event
36
52
  };
37
- const nitroApp = useNitroApp();
38
- if (nitroApp?.hooks) {
39
- await nitroApp.hooks.callHook("mdream:markdown", context);
40
- markdown = context.markdown;
41
- }
42
- return markdown;
53
+ await nitroApp.hooks.callHook("mdream:markdown", context);
54
+ markdown = context.markdown;
55
+ return { markdown, title, description };
43
56
  }
44
57
  export default defineEventHandler(async (event) => {
45
58
  let path = event.path;
46
- if (!path.endsWith(".md")) {
59
+ const config = useRuntimeConfig(event).mdream;
60
+ const hasMarkdownExtension = path.endsWith(".md");
61
+ const clientPrefersMarkdown = shouldServeMarkdown(event);
62
+ if (!hasMarkdownExtension && !clientPrefersMarkdown) {
47
63
  return;
48
64
  }
49
- const config = useRuntimeConfig(event).mdream;
50
- path = path.slice(0, -3);
65
+ if (hasMarkdownExtension) {
66
+ path = path.slice(0, -3);
67
+ }
51
68
  if (path === "/index") {
52
69
  path = "/";
53
70
  }
@@ -62,12 +79,16 @@ export default defineEventHandler(async (event) => {
62
79
  message: `Failed to fetch HTML for ${path}`
63
80
  });
64
81
  }
65
- const markdown = await convertHtmlToMarkdown(
82
+ const result = await convertHtmlToMarkdown(
66
83
  html,
67
84
  withSiteUrl(event, path),
68
85
  config,
69
- path
86
+ path,
87
+ event
70
88
  );
71
89
  setHeader(event, "content-type", "text/markdown; charset=utf-8");
72
- return markdown;
90
+ if (import.meta.prerender) {
91
+ return JSON.stringify(result);
92
+ }
93
+ return result.markdown;
73
94
  });
@@ -2,6 +2,8 @@ export interface MdreamPage {
2
2
  url: string;
3
3
  title: string;
4
4
  markdown: string;
5
+ html?: string;
6
+ description?: string;
5
7
  }
6
8
  /**
7
9
  * Hook context for markdown processing
@@ -15,6 +17,10 @@ export interface MdreamMarkdownContext {
15
17
  route: string;
16
18
  /** The page title extracted from HTML */
17
19
  title: string;
20
+ /** Page description extracted from meta tags or content */
21
+ description: string;
18
22
  /** Whether this is during prerendering */
19
23
  isPrerender: boolean;
24
+ /** The H3 event object for accessing request/response */
25
+ event: import('h3').H3Event;
20
26
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mdream/nuxt",
3
3
  "type": "module",
4
- "version": "0.11.1",
4
+ "version": "0.12.0",
5
5
  "description": "Nuxt module for converting HTML pages to Markdown using mdream",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -27,13 +27,18 @@
27
27
  "files": [
28
28
  "dist"
29
29
  ],
30
+ "build": {
31
+ "externals": [
32
+ "h3"
33
+ ]
34
+ },
30
35
  "dependencies": {
31
36
  "@nuxt/kit": "^4.1.2",
32
37
  "consola": "^3.4.2",
33
38
  "defu": "^6.1.4",
34
39
  "nuxt-site-config": "^3.2.9",
35
40
  "pathe": "^2.0.3",
36
- "mdream": "0.11.1"
41
+ "mdream": "0.12.0"
37
42
  },
38
43
  "devDependencies": {
39
44
  "@antfu/eslint-config": "^5.4.1",
@@ -47,11 +52,11 @@
47
52
  "changelogen": "^0.6.2",
48
53
  "eslint": "^9.36.0",
49
54
  "nuxt": "^4.1.2",
50
- "typescript": "^5.9.2",
55
+ "typescript": "^5.9.3",
51
56
  "vitest": "^3.2.4"
52
57
  },
53
58
  "scripts": {
54
- "build": "pnpm run dev:prepare && nuxt-module-build build",
59
+ "build": "pnpm run dev:prepare && nuxt-module-build build && nuxt-module-build prepare",
55
60
  "dev": "nuxi dev playground",
56
61
  "dev:build": "nuxi build playground",
57
62
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
@@ -60,7 +65,7 @@
60
65
  "test": "pnpm prepare:fixtures && vitest run",
61
66
  "test:watch": "vitest watch",
62
67
  "test:attw": "echo 'ok'",
63
- "typecheck": "tsc --noEmit",
68
+ "typecheck": "echo 'ok'",
64
69
  "lint": "eslint .",
65
70
  "lint:fix": "eslint . --fix"
66
71
  }