nuxt-ai-ready 0.5.2 → 0.6.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 +2 -1
- package/dist/module.d.mts +9 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +178 -22
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/mcp.d.ts +32 -0
- package/dist/runtime/mcp.js +5 -0
- package/dist/runtime/server/db/dump.d.ts +29 -0
- package/dist/runtime/server/db/dump.js +29 -0
- package/dist/runtime/server/db/index.d.ts +12 -0
- package/dist/runtime/server/db/index.js +56 -0
- package/dist/runtime/server/db/queries.d.ts +84 -0
- package/dist/runtime/server/db/queries.js +79 -0
- package/dist/runtime/server/db/schema.d.ts +8 -0
- package/dist/runtime/server/db/schema.js +121 -0
- package/dist/runtime/server/mcp/tools/search-pages.js +19 -0
- package/dist/runtime/server/middleware/markdown.prerender.js +11 -2
- package/dist/runtime/server/plugins/db-restore.d.ts +2 -0
- package/dist/runtime/server/plugins/db-restore.js +24 -0
- package/dist/runtime/server/plugins/page-indexer.d.ts +2 -0
- package/dist/runtime/server/plugins/page-indexer.js +68 -0
- package/dist/runtime/server/utils/indexPage.d.ts +38 -0
- package/dist/runtime/server/utils/indexPage.js +76 -0
- package/dist/runtime/server/utils/keywords.d.ts +8 -0
- package/dist/runtime/server/utils/keywords.js +282 -0
- package/dist/runtime/server/utils/pageData.d.ts +4 -2
- package/dist/runtime/server/utils/pageData.js +19 -41
- package/dist/runtime/server/utils.d.ts +4 -2
- package/dist/runtime/server/utils.js +11 -2
- package/dist/runtime/types.d.ts +64 -0
- package/mcp.d.ts +4 -0
- package/package.json +29 -19
- package/dist/runtime/server/mcp/tools/search-pages-fuzzy.js +0 -25
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
function normalizeRouteKey(route) {
|
|
2
|
+
return route.replace(/^\//, "").replace(/\//g, ":") || "index";
|
|
3
|
+
}
|
|
4
|
+
function rowToEntry(row) {
|
|
5
|
+
return {
|
|
6
|
+
route: row.route,
|
|
7
|
+
title: row.title,
|
|
8
|
+
description: row.description,
|
|
9
|
+
headings: row.headings,
|
|
10
|
+
keywords: JSON.parse(row.keywords || "[]"),
|
|
11
|
+
updatedAt: row.updated_at
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function getAllPages(db) {
|
|
15
|
+
const rows = await db.all("SELECT * FROM pages WHERE is_error = 0");
|
|
16
|
+
return rows.map(rowToEntry);
|
|
17
|
+
}
|
|
18
|
+
export async function getPage(db, route) {
|
|
19
|
+
const row = await db.first("SELECT * FROM pages WHERE route = ?", [route]);
|
|
20
|
+
return row ? rowToEntry(row) : void 0;
|
|
21
|
+
}
|
|
22
|
+
export async function getPageWithMarkdown(db, route) {
|
|
23
|
+
const row = await db.first("SELECT * FROM pages WHERE route = ?", [route]);
|
|
24
|
+
return row ? { ...rowToEntry(row), markdown: row.markdown } : void 0;
|
|
25
|
+
}
|
|
26
|
+
export async function searchPages(db, query, opts = {}) {
|
|
27
|
+
const { limit = 10 } = opts;
|
|
28
|
+
const sanitized = query.replace(/[*:^"()]/g, " ").trim();
|
|
29
|
+
if (!sanitized) return [];
|
|
30
|
+
const terms = sanitized.split(/\s+/).map((t) => `${t}*`).join(" ");
|
|
31
|
+
return db.all(`
|
|
32
|
+
SELECT p.route, p.title, p.description, bm25(pages_fts, 5.0, 3.0, 1.0, 0.5, 2.0, 2.0) as score
|
|
33
|
+
FROM pages_fts
|
|
34
|
+
JOIN pages p ON pages_fts.rowid = p.id
|
|
35
|
+
WHERE pages_fts MATCH ?
|
|
36
|
+
ORDER BY score
|
|
37
|
+
LIMIT ?
|
|
38
|
+
`, [terms, limit]);
|
|
39
|
+
}
|
|
40
|
+
export async function upsertPage(db, page) {
|
|
41
|
+
const routeKey = normalizeRouteKey(page.route);
|
|
42
|
+
const keywordsJson = JSON.stringify(page.keywords);
|
|
43
|
+
const indexedAt = Date.now();
|
|
44
|
+
await db.exec(`
|
|
45
|
+
INSERT INTO pages (route, route_key, title, description, markdown, headings, keywords, updated_at, indexed_at, is_error)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
ON CONFLICT(route) DO UPDATE SET
|
|
48
|
+
title = excluded.title,
|
|
49
|
+
description = excluded.description,
|
|
50
|
+
markdown = excluded.markdown,
|
|
51
|
+
headings = excluded.headings,
|
|
52
|
+
keywords = excluded.keywords,
|
|
53
|
+
updated_at = excluded.updated_at,
|
|
54
|
+
indexed_at = excluded.indexed_at,
|
|
55
|
+
is_error = excluded.is_error
|
|
56
|
+
`, [page.route, routeKey, page.title, page.description, page.markdown, page.headings, keywordsJson, page.updatedAt, indexedAt, page.isError ? 1 : 0]);
|
|
57
|
+
}
|
|
58
|
+
export async function getErrorRoutes(db) {
|
|
59
|
+
const rows = await db.all("SELECT route FROM pages WHERE is_error = 1");
|
|
60
|
+
return rows.map((r) => r.route);
|
|
61
|
+
}
|
|
62
|
+
export async function isPageFresh(db, route, ttlSeconds) {
|
|
63
|
+
if (ttlSeconds <= 0) return false;
|
|
64
|
+
const row = await db.first("SELECT indexed_at FROM pages WHERE route = ?", [route]);
|
|
65
|
+
if (!row) return false;
|
|
66
|
+
const age = (Date.now() - row.indexed_at) / 1e3;
|
|
67
|
+
return age < ttlSeconds;
|
|
68
|
+
}
|
|
69
|
+
export async function deletePage(db, route) {
|
|
70
|
+
await db.exec("DELETE FROM pages WHERE route = ?", [route]);
|
|
71
|
+
}
|
|
72
|
+
export async function getPageCount(db) {
|
|
73
|
+
const row = await db.first("SELECT COUNT(*) as count FROM pages WHERE is_error = 0");
|
|
74
|
+
return row?.count || 0;
|
|
75
|
+
}
|
|
76
|
+
export async function hasPages(db) {
|
|
77
|
+
const count = await getPageCount(db);
|
|
78
|
+
return count > 0;
|
|
79
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const SCHEMA_VERSION = "v1.0.0";
|
|
2
|
+
export declare const createTablesSQL = "\nCREATE TABLE IF NOT EXISTS pages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n route TEXT UNIQUE NOT NULL,\n route_key TEXT UNIQUE NOT NULL,\n title TEXT NOT NULL DEFAULT '',\n description TEXT NOT NULL DEFAULT '',\n markdown TEXT NOT NULL DEFAULT '',\n headings TEXT NOT NULL DEFAULT '[]',\n keywords TEXT NOT NULL DEFAULT '[]',\n updated_at TEXT NOT NULL,\n indexed_at INTEGER NOT NULL,\n is_error INTEGER NOT NULL DEFAULT 0\n);\n\nCREATE INDEX IF NOT EXISTS idx_pages_route ON pages(route);\nCREATE INDEX IF NOT EXISTS idx_pages_is_error ON pages(is_error);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(\n route, title, description, markdown, headings, keywords,\n content=pages, content_rowid=id\n);\n\nCREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN\n INSERT INTO pages_fts(rowid, route, title, description, markdown, headings, keywords)\n VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN\n INSERT INTO pages_fts(pages_fts, rowid, route, title, description, markdown, headings, keywords)\n VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN\n INSERT INTO pages_fts(pages_fts, rowid, route, title, description, markdown, headings, keywords)\n VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);\n INSERT INTO pages_fts(rowid, route, title, description, markdown, headings, keywords)\n VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);\nEND;\n\nCREATE TABLE IF NOT EXISTS _ai_ready_info (\n id TEXT PRIMARY KEY,\n version TEXT,\n checksum TEXT,\n ready INTEGER DEFAULT 0\n);\n";
|
|
3
|
+
export interface DatabaseAdapter {
|
|
4
|
+
all: <T>(sql: string, params?: unknown[]) => Promise<T[]>;
|
|
5
|
+
first: <T>(sql: string, params?: unknown[]) => Promise<T | undefined>;
|
|
6
|
+
exec: (sql: string, params?: unknown[]) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function initSchema(db: DatabaseAdapter): Promise<void>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export const SCHEMA_VERSION = "v1.0.0";
|
|
2
|
+
const DROP_TABLES_SQL = [
|
|
3
|
+
"DROP TABLE IF EXISTS pages_fts",
|
|
4
|
+
"DROP TABLE IF EXISTS pages",
|
|
5
|
+
"DROP TABLE IF EXISTS _ai_ready_info"
|
|
6
|
+
];
|
|
7
|
+
export const createTablesSQL = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
route TEXT UNIQUE NOT NULL,
|
|
11
|
+
route_key TEXT UNIQUE NOT NULL,
|
|
12
|
+
title TEXT NOT NULL DEFAULT '',
|
|
13
|
+
description TEXT NOT NULL DEFAULT '',
|
|
14
|
+
markdown TEXT NOT NULL DEFAULT '',
|
|
15
|
+
headings TEXT NOT NULL DEFAULT '[]',
|
|
16
|
+
keywords TEXT NOT NULL DEFAULT '[]',
|
|
17
|
+
updated_at TEXT NOT NULL,
|
|
18
|
+
indexed_at INTEGER NOT NULL,
|
|
19
|
+
is_error INTEGER NOT NULL DEFAULT 0
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_pages_route ON pages(route);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_pages_is_error ON pages(is_error);
|
|
24
|
+
|
|
25
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
|
|
26
|
+
route, title, description, markdown, headings, keywords,
|
|
27
|
+
content=pages, content_rowid=id
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
|
|
31
|
+
INSERT INTO pages_fts(rowid, route, title, description, markdown, headings, keywords)
|
|
32
|
+
VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
|
|
36
|
+
INSERT INTO pages_fts(pages_fts, rowid, route, title, description, markdown, headings, keywords)
|
|
37
|
+
VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);
|
|
38
|
+
END;
|
|
39
|
+
|
|
40
|
+
CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
|
|
41
|
+
INSERT INTO pages_fts(pages_fts, rowid, route, title, description, markdown, headings, keywords)
|
|
42
|
+
VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);
|
|
43
|
+
INSERT INTO pages_fts(rowid, route, title, description, markdown, headings, keywords)
|
|
44
|
+
VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);
|
|
45
|
+
END;
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS _ai_ready_info (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
version TEXT,
|
|
50
|
+
checksum TEXT,
|
|
51
|
+
ready INTEGER DEFAULT 0
|
|
52
|
+
);
|
|
53
|
+
`;
|
|
54
|
+
export async function initSchema(db) {
|
|
55
|
+
const needsRebuild = await checkSchemaVersion(db);
|
|
56
|
+
if (needsRebuild) {
|
|
57
|
+
for (const sql of DROP_TABLES_SQL) {
|
|
58
|
+
await db.exec(sql);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const statements = [
|
|
62
|
+
// Main table
|
|
63
|
+
`CREATE TABLE IF NOT EXISTS pages (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
route TEXT UNIQUE NOT NULL,
|
|
66
|
+
route_key TEXT UNIQUE NOT NULL,
|
|
67
|
+
title TEXT NOT NULL DEFAULT '',
|
|
68
|
+
description TEXT NOT NULL DEFAULT '',
|
|
69
|
+
markdown TEXT NOT NULL DEFAULT '',
|
|
70
|
+
headings TEXT NOT NULL DEFAULT '[]',
|
|
71
|
+
keywords TEXT NOT NULL DEFAULT '[]',
|
|
72
|
+
updated_at TEXT NOT NULL,
|
|
73
|
+
indexed_at INTEGER NOT NULL,
|
|
74
|
+
is_error INTEGER NOT NULL DEFAULT 0
|
|
75
|
+
)`,
|
|
76
|
+
// Indexes
|
|
77
|
+
`CREATE INDEX IF NOT EXISTS idx_pages_route ON pages(route)`,
|
|
78
|
+
`CREATE INDEX IF NOT EXISTS idx_pages_is_error ON pages(is_error)`,
|
|
79
|
+
// FTS5 virtual table
|
|
80
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
|
|
81
|
+
route, title, description, markdown, headings, keywords,
|
|
82
|
+
content=pages, content_rowid=id
|
|
83
|
+
)`,
|
|
84
|
+
// Triggers for FTS sync
|
|
85
|
+
`CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
|
|
86
|
+
INSERT INTO pages_fts(rowid, route, title, description, markdown, headings, keywords)
|
|
87
|
+
VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);
|
|
88
|
+
END`,
|
|
89
|
+
`CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
|
|
90
|
+
INSERT INTO pages_fts(pages_fts, rowid, route, title, description, markdown, headings, keywords)
|
|
91
|
+
VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);
|
|
92
|
+
END`,
|
|
93
|
+
`CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
|
|
94
|
+
INSERT INTO pages_fts(pages_fts, rowid, route, title, description, markdown, headings, keywords)
|
|
95
|
+
VALUES('delete', old.id, old.route, old.title, old.description, old.markdown, old.headings, old.keywords);
|
|
96
|
+
INSERT INTO pages_fts(rowid, route, title, description, markdown, headings, keywords)
|
|
97
|
+
VALUES (new.id, new.route, new.title, new.description, new.markdown, new.headings, new.keywords);
|
|
98
|
+
END`,
|
|
99
|
+
// Info table
|
|
100
|
+
`CREATE TABLE IF NOT EXISTS _ai_ready_info (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
version TEXT,
|
|
103
|
+
checksum TEXT,
|
|
104
|
+
ready INTEGER DEFAULT 0
|
|
105
|
+
)`
|
|
106
|
+
];
|
|
107
|
+
for (const statement of statements) {
|
|
108
|
+
await db.exec(statement);
|
|
109
|
+
}
|
|
110
|
+
await db.exec(
|
|
111
|
+
"INSERT OR REPLACE INTO _ai_ready_info (id, version) VALUES (?, ?)",
|
|
112
|
+
["schema", SCHEMA_VERSION]
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
async function checkSchemaVersion(db) {
|
|
116
|
+
const info = await db.first(
|
|
117
|
+
"SELECT version FROM _ai_ready_info WHERE id = ?",
|
|
118
|
+
["schema"]
|
|
119
|
+
).catch(() => null);
|
|
120
|
+
return !info || info.version !== SCHEMA_VERSION;
|
|
121
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { useDatabase } from "../../db/index.js";
|
|
3
|
+
import { searchPages } from "../../db/queries.js";
|
|
4
|
+
const inputSchema = {
|
|
5
|
+
query: z.string().describe("Search query"),
|
|
6
|
+
limit: z.number().optional().default(10).describe("Max results")
|
|
7
|
+
};
|
|
8
|
+
const tool = {
|
|
9
|
+
name: "search_pages",
|
|
10
|
+
description: "Search pages by title, description, route, headings, keywords, and content using full-text search.",
|
|
11
|
+
inputSchema,
|
|
12
|
+
cache: "5m",
|
|
13
|
+
async handler({ query, limit }) {
|
|
14
|
+
const db = await useDatabase();
|
|
15
|
+
const results = await searchPages(db, query, { limit });
|
|
16
|
+
return { content: [{ type: "text", text: JSON.stringify(results) }] };
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export default tool;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { withSiteUrl } from "#site-config/server/composables/utils";
|
|
2
2
|
import { createError, defineEventHandler } from "h3";
|
|
3
3
|
import { useRuntimeConfig } from "nitropack/runtime";
|
|
4
|
+
import { extractKeywords, stripMarkdown } from "../utils/keywords.js";
|
|
4
5
|
import { convertHtmlToMarkdownMeta, getMarkdownRenderInfo } from "../utils.js";
|
|
5
6
|
export default defineEventHandler(async (event) => {
|
|
6
7
|
if (!import.meta.prerender) {
|
|
@@ -26,10 +27,18 @@ export default defineEventHandler(async (event) => {
|
|
|
26
27
|
message: `Page rendered as error: ${path}`
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
|
-
const result = convertHtmlToMarkdownMeta(
|
|
30
|
+
const result = await convertHtmlToMarkdownMeta(
|
|
30
31
|
html,
|
|
31
32
|
withSiteUrl(event, path),
|
|
32
33
|
runtimeConfig.mdreamOptions
|
|
33
34
|
);
|
|
34
|
-
|
|
35
|
+
const keywords = extractKeywords(stripMarkdown(result.markdown), result.metaKeywords);
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
markdown: result.markdown,
|
|
38
|
+
title: result.title,
|
|
39
|
+
description: result.description,
|
|
40
|
+
headings: result.headings,
|
|
41
|
+
keywords,
|
|
42
|
+
...result.updatedAt && { updatedAt: result.updatedAt }
|
|
43
|
+
});
|
|
35
44
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineNitroPlugin } from "nitropack/runtime";
|
|
2
|
+
import { useDatabase } from "../db/index.js";
|
|
3
|
+
import { hasPages } from "../db/queries.js";
|
|
4
|
+
import { decompressDump, importDump } from "../db/dump.js";
|
|
5
|
+
import { logger } from "../logger.js";
|
|
6
|
+
export default defineNitroPlugin(async () => {
|
|
7
|
+
if (import.meta.prerender) return;
|
|
8
|
+
if (import.meta.dev) return;
|
|
9
|
+
const db = await useDatabase();
|
|
10
|
+
if (await hasPages(db)) {
|
|
11
|
+
logger.debug("[db-restore] Database already has data, skipping restore");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const dumpData = await globalThis.$fetch("/__ai-ready/pages.dump", {
|
|
15
|
+
responseType: "text"
|
|
16
|
+
}).catch(() => null);
|
|
17
|
+
if (!dumpData) {
|
|
18
|
+
logger.debug("[db-restore] No dump found, starting with empty database");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const rows = await decompressDump(dumpData);
|
|
22
|
+
await importDump(db, rows);
|
|
23
|
+
logger.info(`[db-restore] Restored ${rows.length} pages from dump`);
|
|
24
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
|
|
2
|
+
import { useDatabase } from "../db/index.js";
|
|
3
|
+
import { getPage, isPageFresh, upsertPage } from "../db/queries.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import { convertHtmlToMarkdownMeta } from "../utils.js";
|
|
6
|
+
import { extractKeywords, stripMarkdown } from "../utils/keywords.js";
|
|
7
|
+
export default defineNitroPlugin((nitro) => {
|
|
8
|
+
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
9
|
+
const ttl = config.ttl ?? 0;
|
|
10
|
+
nitro.hooks.hook("afterResponse", (event, response) => {
|
|
11
|
+
const body = response?.body;
|
|
12
|
+
const path = event.path;
|
|
13
|
+
if (path.startsWith("/api") || path.startsWith("/_") || path.startsWith("/@") || path.endsWith(".md") || path.includes(".")) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const contentType = event.node.res.getHeader("content-type");
|
|
17
|
+
if (!contentType?.toString().includes("text/html"))
|
|
18
|
+
return;
|
|
19
|
+
const status = event.node.res.statusCode;
|
|
20
|
+
if (status >= 400)
|
|
21
|
+
return;
|
|
22
|
+
event.waitUntil((async () => {
|
|
23
|
+
const html = typeof body === "string" ? body : null;
|
|
24
|
+
if (!html) {
|
|
25
|
+
logger.debug(`[runtime-indexing] Skipping ${path} - no HTML body`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const db = await useDatabase(event);
|
|
29
|
+
if (ttl > 0 && await isPageFresh(db, path, ttl)) {
|
|
30
|
+
logger.debug(`[runtime-indexing] Skipping ${path} - still fresh`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const existing = await getPage(db, path);
|
|
34
|
+
const isUpdate = !!existing;
|
|
35
|
+
const isError = html.includes("__NUXT_ERROR__") || html.includes("nuxt-error-page");
|
|
36
|
+
const result = await convertHtmlToMarkdownMeta(html, path, config.mdreamOptions);
|
|
37
|
+
const updatedAt = result.updatedAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
38
|
+
const headings = JSON.stringify(result.headings);
|
|
39
|
+
const keywords = extractKeywords(stripMarkdown(result.markdown), result.metaKeywords);
|
|
40
|
+
await upsertPage(db, {
|
|
41
|
+
route: path,
|
|
42
|
+
title: result.title,
|
|
43
|
+
description: result.description,
|
|
44
|
+
markdown: result.markdown,
|
|
45
|
+
headings,
|
|
46
|
+
keywords,
|
|
47
|
+
updatedAt,
|
|
48
|
+
isError
|
|
49
|
+
});
|
|
50
|
+
logger.debug(`[runtime-indexing] Indexed: ${path} "${result.title}"`);
|
|
51
|
+
if (!isError) {
|
|
52
|
+
const hookContext = {
|
|
53
|
+
route: path,
|
|
54
|
+
title: result.title,
|
|
55
|
+
description: result.description,
|
|
56
|
+
headings,
|
|
57
|
+
keywords,
|
|
58
|
+
markdown: result.markdown,
|
|
59
|
+
updatedAt,
|
|
60
|
+
isUpdate
|
|
61
|
+
};
|
|
62
|
+
await nitro.hooks.callHook("ai-ready:page:indexed", hookContext);
|
|
63
|
+
}
|
|
64
|
+
})().catch((err) => {
|
|
65
|
+
logger.error(`[runtime-indexing] Failed to index ${path}:`, err);
|
|
66
|
+
}));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
export interface IndexPageOptions {
|
|
3
|
+
/** Skip if page was indexed within TTL (uses config ttl if not specified) */
|
|
4
|
+
ttl?: number;
|
|
5
|
+
/** Force re-index even if fresh */
|
|
6
|
+
force?: boolean;
|
|
7
|
+
/** Skip calling the ai-ready:page:indexed hook */
|
|
8
|
+
skipHook?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface IndexPageResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
/** True if page was already fresh and skipped */
|
|
13
|
+
skipped?: boolean;
|
|
14
|
+
/** True if this was an update vs new index */
|
|
15
|
+
isUpdate?: boolean;
|
|
16
|
+
/** Page data if successful */
|
|
17
|
+
data?: {
|
|
18
|
+
route: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
headings: string;
|
|
22
|
+
keywords: string[];
|
|
23
|
+
markdown: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
};
|
|
26
|
+
/** Error message if failed */
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Manually index a page's HTML content into database
|
|
31
|
+
* Use this from custom plugins or API routes to trigger indexing
|
|
32
|
+
*/
|
|
33
|
+
export declare function indexPage(route: string, html: string, options?: IndexPageOptions): Promise<IndexPageResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Index a page by fetching its HTML first
|
|
36
|
+
* Convenience wrapper around indexPage that handles fetching
|
|
37
|
+
*/
|
|
38
|
+
export declare function indexPageByRoute(route: string, event?: H3Event, options?: IndexPageOptions): Promise<IndexPageResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
|
|
2
|
+
import { useDatabase } from "../db/index.js";
|
|
3
|
+
import { getPage, isPageFresh, upsertPage } from "../db/queries.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import { convertHtmlToMarkdownMeta } from "../utils.js";
|
|
6
|
+
import { extractKeywords, stripMarkdown } from "./keywords.js";
|
|
7
|
+
export async function indexPage(route, html, options = {}) {
|
|
8
|
+
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
9
|
+
const db = await useDatabase();
|
|
10
|
+
const ttl = options.ttl ?? config.ttl ?? 0;
|
|
11
|
+
if (!options.force && await isPageFresh(db, route, ttl)) {
|
|
12
|
+
logger.debug(`[indexPage] Skipping ${route} - still fresh`);
|
|
13
|
+
return { success: true, skipped: true, isUpdate: true };
|
|
14
|
+
}
|
|
15
|
+
const existing = await getPage(db, route);
|
|
16
|
+
const isUpdate = !!existing;
|
|
17
|
+
const isError = html.includes("__NUXT_ERROR__") || html.includes("nuxt-error-page");
|
|
18
|
+
const result = await convertHtmlToMarkdownMeta(html, route, config.mdreamOptions);
|
|
19
|
+
const updatedAt = result.updatedAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
20
|
+
const headings = JSON.stringify(result.headings);
|
|
21
|
+
const keywords = extractKeywords(stripMarkdown(result.markdown), result.metaKeywords);
|
|
22
|
+
await upsertPage(db, {
|
|
23
|
+
route,
|
|
24
|
+
title: result.title,
|
|
25
|
+
description: result.description,
|
|
26
|
+
markdown: result.markdown,
|
|
27
|
+
headings,
|
|
28
|
+
keywords,
|
|
29
|
+
updatedAt,
|
|
30
|
+
isError
|
|
31
|
+
});
|
|
32
|
+
logger.debug(`[indexPage] Indexed: ${route} "${result.title}"`);
|
|
33
|
+
if (!options.skipHook) {
|
|
34
|
+
const nitro = useNitroApp();
|
|
35
|
+
const hookContext = {
|
|
36
|
+
route,
|
|
37
|
+
title: result.title,
|
|
38
|
+
description: result.description,
|
|
39
|
+
headings,
|
|
40
|
+
keywords,
|
|
41
|
+
markdown: result.markdown,
|
|
42
|
+
updatedAt,
|
|
43
|
+
isUpdate
|
|
44
|
+
};
|
|
45
|
+
await nitro.hooks.callHook("ai-ready:page:indexed", hookContext);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
isUpdate,
|
|
50
|
+
data: {
|
|
51
|
+
route,
|
|
52
|
+
title: result.title,
|
|
53
|
+
description: result.description,
|
|
54
|
+
headings,
|
|
55
|
+
keywords,
|
|
56
|
+
markdown: result.markdown,
|
|
57
|
+
updatedAt
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export async function indexPageByRoute(route, event, options = {}) {
|
|
62
|
+
const fetchFn = event?.fetch || globalThis.$fetch;
|
|
63
|
+
const html = await fetchFn(route, {
|
|
64
|
+
headers: { "x-ai-ready-internal": "1" }
|
|
65
|
+
}).catch((err) => {
|
|
66
|
+
logger.error(`[indexPageByRoute] Failed to fetch ${route}:`, err);
|
|
67
|
+
return null;
|
|
68
|
+
});
|
|
69
|
+
if (!html) {
|
|
70
|
+
return { success: false, error: `Failed to fetch HTML for ${route}` };
|
|
71
|
+
}
|
|
72
|
+
if (typeof html !== "string") {
|
|
73
|
+
return { success: false, error: `Response for ${route} is not HTML` };
|
|
74
|
+
}
|
|
75
|
+
return indexPage(route, html, options);
|
|
76
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract keywords from text using word frequency
|
|
3
|
+
*/
|
|
4
|
+
export declare function extractKeywords(text: string, metaKeywords?: string, max?: number): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Strip markdown formatting to get plain text
|
|
7
|
+
*/
|
|
8
|
+
export declare function stripMarkdown(markdown: string): string;
|