Export template source from 488f7e229afed8f03fc3bfa4abe707da0c613460:examples/local-first/packages/gutenberg-runtime

This commit is contained in:
Quixos Template Publisher
2000-01-01 00:00:00 +00:00
commit a9f25b30ad
14 changed files with 627 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
export interface Book {
ebook: number;
title: string;
author: string;
language: string;
}
/** RFC 4180 records, including quoted commas, escaped quotes, and multiline titles. */
export function parseIndex(text: string): Book[] {
const rows: string[][] = [];
let row: string[] = [],
field = "",
quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"') {
if (quoted && text[i + 1] === '"') {
field += '"';
i++;
} else if (quoted || field === "") quoted = !quoted;
else throw Error("Unexpected CSV quote");
} else if (!quoted && (ch === "," || ch === "\n" || ch === "\r")) {
row.push(field);
field = "";
if (ch !== ",") {
rows.push(row);
row = [];
if (ch === "\r" && text[i + 1] === "\n") i++;
}
} else field += ch;
}
if (quoted) throw Error("Unclosed CSV quote");
if (field || row.length) {
row.push(field);
rows.push(row);
}
const header = rows.shift()!;
const columns = ["Text#", "Type", "Title", "Language", "Authors"].map((name) => header.indexOf(name));
if (columns.some((i) => i < 0)) throw Error("Unrecognized Gutenberg CSV header");
const books: Book[] = [],
seen = new Set<number>();
for (const row of rows) {
if (row.length === 1 && row[0] === "") continue;
if (row.length !== header.length) throw Error("Invalid Gutenberg CSV row");
if (row[columns[1]!] !== "Text") continue;
const ebook = Number(row[columns[0]!]!);
if (!Number.isSafeInteger(ebook) || ebook < 1 || seen.has(ebook)) throw Error("Invalid or repeated book ID");
seen.add(ebook);
books.push({
ebook,
title: row[columns[2]!]!.replace(/\s+/g, " ").trim(),
language: row[columns[3]!]!,
author: row[columns[4]!]!,
});
}
return books;
}
const normalized = (text: string) =>
text
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
const grams = (text: string) =>
new Set(Array.from({ length: Math.max(0, text.length - 2) }, (_, i) => text.slice(i, i + 3)));
export function searchIndex(books: readonly Book[]) {
const index = books.map((book) => ({
book,
title: normalized(book.title),
words: normalized(book.title + " " + book.author),
grams: grams(normalized(book.title)),
}));
return (query: string): Book[] => {
const q = normalized(query).slice(0, 120);
if (q.length < 2) return [];
const tokens = q.split(" "),
qgrams = grams(q);
const matches: { book: Book; score: number }[] = [];
for (const entry of index) {
const overlap = [...qgrams].filter((g) => entry.grams.has(g)).length;
const similarity = (2 * overlap) / Math.max(1, qgrams.size + entry.grams.size);
const score =
(entry.title === q ? 10 : entry.title.includes(q) ? 5 : 0) +
(tokens.every((t) => entry.words.includes(t)) ? 2 : 0) +
similarity;
if (score >= 0.24) matches.push({ book: entry.book, score });
}
return matches
.sort((a, b) => b.score - a.score || a.book.ebook - b.book.ebook)
.slice(0, 20)
.map((entry) => entry.book);
};
}
export interface Excerpt {
text: string;
words: number;
}
/** The first paragraph boundary at or after roughly 1,000 words. Streaming
* callers wait for that boundary (or EOF), not the rest of the book. */
export function openingExcerpt(text: string, complete = false): Excerpt | null {
let body = text.replace(/^\uFEFF/, "").replaceAll("\r", "");
const start = /\*\*\*\s*START OF (?:THE|THIS) PROJECT GUTENBERG EBOOK[^\n]*\n/i.exec(body);
if (start) body = body.slice(start.index + start[0].length);
else if (!complete) return null;
const end = /\*\*\*\s*END OF (?:THE|THIS) PROJECT GUTENBERG EBOOK/i.exec(body);
if (end) {
body = body.slice(0, end.index);
complete = true;
}
const paragraphs = body.split(/\n[ \t]*\n+/);
if (!complete) paragraphs.pop();
const result: string[] = [];
let words = 0;
for (const paragraph of paragraphs) {
const part = paragraph.trim();
if (!part) continue;
result.push(part);
words += part.split(/\s+/u).length;
if (words >= 1000) break;
}
// An unusually large single paragraph must still fit the document contract.
// Prefer its last word boundary; ordinary excerpts always keep paragraphs whole.
if (result.join("\n\n").length > 56000 || (body.length >= 56000 && words < 1000)) {
const prefix = body.slice(0, 56000),
at = prefix.lastIndexOf(" ");
const bounded = (at > 0 ? prefix.slice(0, at) : prefix).replace(/[\uD800-\uDBFF]$/, "").trim();
return { text: bounded, words: bounded ? bounded.split(/\s+/u).length : 0 };
}
if (words < 1000 && !complete) return null;
return { text: result.join("\n\n"), words };
}
+82
View File
@@ -0,0 +1,82 @@
import { openingExcerpt, type Excerpt } from "./catalog.js";
/** User-requested downloads only, from Gutenberg's listed generated-files mirror.
* One fetch at a time, at least two seconds apart; coalesce requests and bound the cache. */
export function createBookLoader(fetchText: (ebook: number) => Promise<string>, delayMs = 2000) {
const cache = new Map<number, { excerpt: Excerpt; bytes: number }>();
const pending = new Map<number, Promise<Excerpt>>();
let queue = Promise.resolve(),
next = 0,
bytes = 0;
return (ebook: number): Promise<Excerpt> => {
const cached = cache.get(ebook);
if (cached) {
cache.delete(ebook);
cache.set(ebook, cached);
return Promise.resolve(cached.excerpt);
}
const existing = pending.get(ebook);
if (existing) return existing;
if (pending.size >= 8) return Promise.reject(Error("Too many book downloads are queued. Try again shortly."));
const task = queue.then(async () => {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, next - Date.now())));
next = Date.now() + delayMs;
const text = await fetchText(ebook),
excerpt = openingExcerpt(text, true)!,
size = excerpt.text.length * 2;
while (cache.size && (cache.size >= 8 || bytes + size > 32 * 1024 * 1024)) {
const key = cache.keys().next().value!;
bytes -= cache.get(key)!.bytes;
cache.delete(key);
}
if (size <= 32 * 1024 * 1024) {
cache.set(ebook, { excerpt, bytes: size });
bytes += size;
}
return excerpt;
});
queue = task.then(
() => {},
() => {},
);
pending.set(ebook, task);
void task.finally(() => pending.delete(ebook)).catch(() => {});
return task;
};
}
export const bookURL = (ebook: number) => `https://gutenberg.pglaf.org/cache/epub/${ebook}/pg${ebook}.txt`;
export async function fetchBook(ebook: number): Promise<string> {
const response = await fetch(bookURL(ebook), {
redirect: "error",
signal: AbortSignal.timeout(20000),
headers: { "User-Agent": "Quixos-Gutenberg-reader/1.0 (on-demand plain-text reader)" },
});
if (!response.ok)
throw Error(
response.status === 404
? "No UTF-8 plain-text edition is available on this mirror."
: `Book download failed (${response.status}). Try again later.`,
);
if (!response.body) throw Error("Empty book response");
const reader = response.body.getReader(),
decoder = new TextDecoder("utf-8", { fatal: true });
let size = 0,
text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
text += decoder.decode();
return text;
}
size += value.byteLength;
if (size > 12 * 1024 * 1024) throw Error("This book exceeds the 12 MiB download limit.");
text += decoder.decode(value, { stream: true });
if (/^\s*<!doctype html|^\s*<html/i.test(text))
throw Error("The mirror returned an HTML page instead of book text.");
if (openingExcerpt(text)) return text;
}
} finally {
await reader.cancel();
reader.releaseLock();
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { PortableImplementation } from "./gen/qx.js";
export const handlers: PortableImplementation = {
create: ({ ports }) => ports.catalog.create({ name: "Project Gutenberg" }, { key: "catalog" }),
};
import { createRegistry } from "./gen/qx.js";
export const registry = createRegistry(handlers);
+21
View File
@@ -0,0 +1,21 @@
import { createRegistry } from "./gen/remote.js";
import books from "./index-data.js";
import { searchIndex } from "./catalog.js";
import { createBookLoader, fetchBook } from "./download.js";
const search = searchIndex(books),
byId = new Map(books.map((book) => [book.ebook, book]));
const load = createBookLoader(fetchBook);
export const registry = createRegistry({
search: ({ input }) => search(input),
readBook: async ({ input, signal }) => {
const book = byId.get(input.ebook);
if (!book) throw Error("Book is not in this catalog snapshot.");
if (signal.aborted) throw Error("Book request cancelled.");
const excerpt = await load(input.ebook);
return {
book,
url: `https://www.gutenberg.org/ebooks/${book.ebook}`,
...excerpt,
};
},
});