Initial scaffold: MAM Tanakh corpus (bundled, lazy gzip), reference parser, transliteration search, insert modal, settings

This commit is contained in:
Saleh Penhos 2026-06-11 09:23:21 -06:00
commit cbd2cd8120
18 changed files with 5972 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
main.js
src/data/
.DS_Store
data.json

39
esbuild.config.mjs Normal file
View file

@ -0,0 +1,39 @@
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from "node:module";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === "production";
esbuild.build({
banner: { js: banner },
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules,
],
format: "cjs",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
}).catch(() => process.exit(1));

17
eslint.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
import tseslint from "typescript-eslint";
export default defineConfig([
...obsidianmd.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ["**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
},
{ ignores: ["main.js", "esbuild.config.mjs", "eslint.config.mjs", "node_modules/**"] },
]);

14
manifest.json Normal file
View file

@ -0,0 +1,14 @@
{
"id": "pasuk",
"name": "Pasuk — Tanakh Verses",
"version": "0.1.0",
"minAppVersion": "1.8.7",
"description": "Insert any Tanakh verse into your notes — search by reference or transliteration, full MAM text with nikud and te'amim bundled offline, optional translations from Sefaria.",
"author": "Saleh Penhos",
"authorUrl": "https://github.com/spenhos",
"fundingUrl": {
"Ko-fi": "https://ko-fi.com/elevalma",
"GitHub Sponsors": "https://github.com/sponsors/spenhos"
},
"isDesktopOnly": false
}

4953
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "obsidian-pasuk",
"version": "0.1.0",
"description": "Obsidian plugin to insert Tanakh verses (MAM text, offline) with optional translations",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build-corpus": "node tools/build-corpus.mjs",
"lint": "eslint src/"
},
"keywords": ["obsidian", "tanakh", "torah", "hebrew", "pasuk", "bible"],
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"esbuild": "0.17.3",
"eslint": "^9.0.0",
"eslint-plugin-obsidianmd": "^0.3.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "^5.6.0",
"typescript-eslint": "^8.0.0"
}
}

85
src/books.ts Normal file
View file

@ -0,0 +1,85 @@
// Metadata de los 39 libros del Tanaj (orden tradicional).
// `key` debe coincidir con los nombres de archivo del corpus (codigos-torah).
export interface BookInfo {
key: string; // corpus key
en: string;
es: string;
he: string;
translit: string; // nombre hebreo romanizado
aliases: string[]; // abreviaturas y variantes (sin acentos, lowercase)
}
export const BOOKS: BookInfo[] = [
// --- Torá ---
{ key: "Genesis", en: "Genesis", es: "Génesis", he: "בראשית", translit: "Bereshit", aliases: ["gen", "gn", "ber", "bereshit", "genesis"] },
{ key: "Exodus", en: "Exodus", es: "Éxodo", he: "שמות", translit: "Shemot", aliases: ["ex", "exo", "shem", "shemot", "exodo"] },
{ key: "Leviticus", en: "Leviticus", es: "Levítico", he: "ויקרא", translit: "Vayikra", aliases: ["lev", "lv", "vay", "vayikra", "levitico"] },
{ key: "Numbers", en: "Numbers", es: "Números", he: "במדבר", translit: "Bemidbar", aliases: ["num", "nm", "bam", "bem", "bamidbar", "bemidbar", "numeros"] },
{ key: "Deuteronomy", en: "Deuteronomy", es: "Deuteronomio", he: "דברים", translit: "Devarim", aliases: ["deut", "dt", "dev", "devarim", "deuteronomio"] },
// --- Neviim ---
{ key: "Joshua", en: "Joshua", es: "Josué", he: "יהושע", translit: "Yehoshúa", aliases: ["jos", "josh", "yeho", "yehoshua", "josue"] },
{ key: "Judges", en: "Judges", es: "Jueces", he: "שופטים", translit: "Shoftim", aliases: ["jue", "jud", "shof", "shoftim", "jueces"] },
{ key: "ISamuel", en: "I Samuel", es: "1 Samuel", he: "שמואל א", translit: "Shmuel Alef", aliases: ["1sam", "1sa", "1samuel", "isamuel", "shmuel1", "shmuela"] },
{ key: "IISamuel", en: "II Samuel", es: "2 Samuel", he: "שמואל ב", translit: "Shmuel Bet", aliases: ["2sam", "2sa", "2samuel", "iisamuel", "shmuel2", "shmuelb"] },
{ key: "IKings", en: "I Kings", es: "1 Reyes", he: "מלכים א", translit: "Melajim Alef", aliases: ["1re", "1rey", "1reyes", "1ki", "1kings", "ikings", "melajim1", "melachim1"] },
{ key: "IIKings", en: "II Kings", es: "2 Reyes", he: "מלכים ב", translit: "Melajim Bet", aliases: ["2re", "2rey", "2reyes", "2ki", "2kings", "iikings", "melajim2", "melachim2"] },
{ key: "Isaiah", en: "Isaiah", es: "Isaías", he: "ישעיהו", translit: "Yeshaiahu", aliases: ["isa", "is", "yesh", "yeshaiahu", "isaias"] },
{ key: "Jeremiah", en: "Jeremiah", es: "Jeremías", he: "ירמיהו", translit: "Yirmiyahu", aliases: ["jer", "yirm", "yirmiyahu", "jeremias"] },
{ key: "Ezekiel", en: "Ezekiel", es: "Ezequiel", he: "יחזקאל", translit: "Yejezkel", aliases: ["eze", "ez", "yejez", "yechezkel", "yejezkel", "ezequiel"] },
{ key: "Hosea", en: "Hosea", es: "Oseas", he: "הושע", translit: "Hoshea", aliases: ["os", "hos", "hoshea", "oseas"] },
{ key: "Joel", en: "Joel", es: "Joel", he: "יואל", translit: "Yoel", aliases: ["joe", "yoel", "joel"] },
{ key: "Amos", en: "Amos", es: "Amós", he: "עמוס", translit: "Amós", aliases: ["am", "amos"] },
{ key: "Obadiah", en: "Obadiah", es: "Abdías", he: "עובדיה", translit: "Ovadiá", aliases: ["abd", "oba", "ovadia", "abdias", "obadiah"] },
{ key: "Jonah", en: "Jonah", es: "Jonás", he: "יונה", translit: "Yoná", aliases: ["jon", "yona", "jonas", "jonah"] },
{ key: "Micah", en: "Micah", es: "Miqueas", he: "מיכה", translit: "Mijá", aliases: ["miq", "mic", "mija", "micha", "miqueas", "micah"] },
{ key: "Nahum", en: "Nahum", es: "Nahúm", he: "נחום", translit: "Najum", aliases: ["nah", "najum", "nahum"] },
{ key: "Habakkuk", en: "Habakkuk", es: "Habacuc", he: "חבקוק", translit: "Javakuk", aliases: ["hab", "javakuk", "habacuc", "habakkuk"] },
{ key: "Zephaniah", en: "Zephaniah", es: "Sofonías", he: "צפניה", translit: "Tzefaniá", aliases: ["sof", "zep", "tzefania", "sofonias", "zephaniah"] },
{ key: "Haggai", en: "Haggai", es: "Ageo", he: "חגי", translit: "Jagai", aliases: ["age", "hag", "jagai", "ageo", "haggai"] },
{ key: "Zechariah", en: "Zechariah", es: "Zacarías", he: "זכריה", translit: "Zejariá", aliases: ["zac", "zec", "zejaria", "zacarias", "zechariah"] },
{ key: "Malachi", en: "Malachi", es: "Malaquías", he: "מלאכי", translit: "Malají", aliases: ["mal", "malaji", "malaquias", "malachi"] },
// --- Ketuvim ---
{ key: "Psalms", en: "Psalms", es: "Salmos", he: "תהלים", translit: "Tehilim", aliases: ["sal", "ps", "psa", "teh", "tehilim", "salmos", "psalms", "salmo"] },
{ key: "Proverbs", en: "Proverbs", es: "Proverbios", he: "משלי", translit: "Mishlei", aliases: ["pro", "prov", "mish", "mishlei", "proverbios"] },
{ key: "Job", en: "Job", es: "Job", he: "איוב", translit: "Iyov", aliases: ["job", "iyov", "iov"] },
{ key: "SongOfSongs", en: "Song of Songs", es: "Cantar de los Cantares", he: "שיר השירים", translit: "Shir HaShirim", aliases: ["cant", "shir", "shirhashirim", "cantares", "songofsongs", "song"] },
{ key: "Ruth", en: "Ruth", es: "Rut", he: "רות", translit: "Rut", aliases: ["rut", "ruth"] },
{ key: "Lamentations", en: "Lamentations", es: "Lamentaciones", he: "איכה", translit: "Eijá", aliases: ["lam", "eija", "eicha", "lamentaciones", "lamentations"] },
{ key: "Ecclesiastes", en: "Ecclesiastes", es: "Eclesiastés", he: "קהלת", translit: "Kohélet", aliases: ["ecl", "ecc", "kohelet", "qohelet", "eclesiastes", "ecclesiastes"] },
{ key: "Esther", en: "Esther", es: "Ester", he: "אסתר", translit: "Ester", aliases: ["est", "ester", "esther"] },
{ key: "Daniel", en: "Daniel", es: "Daniel", he: "דניאל", translit: "Daniel", aliases: ["dan", "daniel"] },
{ key: "Ezra", en: "Ezra", es: "Esdras", he: "עזרא", translit: "Ezrá", aliases: ["esd", "ezr", "ezra", "esdras"] },
{ key: "Nehemiah", en: "Nehemiah", es: "Nehemías", he: "נחמיה", translit: "Nejemiá", aliases: ["neh", "nejemia", "nehemias", "nehemiah"] },
{ key: "IChronicles", en: "I Chronicles", es: "1 Crónicas", he: "דברי הימים א", translit: "Divrei HaYamim Alef", aliases: ["1cr", "1cro", "1cron", "1chronicles", "ichronicles", "divrei1"] },
{ key: "IIChronicles", en: "II Chronicles", es: "2 Crónicas", he: "דברי הימים ב", translit: "Divrei HaYamim Bet", aliases: ["2cr", "2cro", "2cron", "2chronicles", "iichronicles", "divrei2"] },
];
/** Normaliza para matching: minúsculas, sin acentos latinos, sin espacios/puntos. */
export function normName(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[\s.''׳]/g, "");
}
const lookup = new Map<string, BookInfo>();
for (const b of BOOKS) {
const names = [b.en, b.es, b.he, b.translit, b.key, ...b.aliases];
for (const n of names) lookup.set(normName(n), b);
}
/** Resuelve un nombre de libro (exacto o por prefijo único). */
export function resolveBook(input: string): BookInfo | null {
const n = normName(input);
if (!n) return null;
const exact = lookup.get(n);
if (exact) return exact;
// prefijo único
const hits = new Set<BookInfo>();
for (const [name, book] of lookup) {
if (name.startsWith(n)) hits.add(book);
}
return hits.size === 1 ? [...hits][0] : null;
}

41
src/corpus.ts Normal file
View file

@ -0,0 +1,41 @@
// Carga perezosa del corpus: cada libro se descomprime (gzip) la primera vez
// que se usa y queda cacheado en memoria.
import { CORPUS_GZ, BOOK_SHAPE } from "./data/corpus";
const cache = new Map<string, string[][]>();
export async function loadBook(key: string): Promise<string[][]> {
const hit = cache.get(key);
if (hit) return hit;
const b64 = CORPUS_GZ[key];
if (!b64) throw new Error(`Unknown book: ${key}`);
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
const text = await new Response(stream).text();
const chapters = JSON.parse(text) as string[][];
cache.set(key, chapters);
return chapters;
}
/** [versículos por capítulo] — disponible sin descomprimir. */
export function bookShape(key: string): number[] | undefined {
return BOOK_SHAPE[key];
}
/** Obtiene un rango de versículos (1-based). Devuelve null si la ref no existe. */
export async function getVerses(
key: string,
chapter: number,
verseStart: number,
verseEnd: number
): Promise<string[] | null> {
const shape = bookShape(key);
if (!shape || chapter < 1 || chapter > shape.length) return null;
const maxV = shape[chapter - 1];
if (verseStart < 1 || verseEnd < verseStart || verseStart > maxV) return null;
const end = Math.min(verseEnd, maxV);
const book = await loadBook(key);
return book[chapter - 1].slice(verseStart - 1, end);
}

27
src/hebrew.ts Normal file
View file

@ -0,0 +1,27 @@
// Utilidades de texto hebreo: quitar te'amim (cantilación) y/o nikud (vocales).
const TEAMIM_RE = /[֑-֯]/g; // cantilación
const NIKUD_RE = /[ְ-ׇֽׁׂ]/g; // vocales + dagesh + meteg + shin/sin dots
/** Quita SOLO los te'amim (U+0591U+05AF), conserva el nikud. */
export function stripTeamim(s: string): string {
return s.replace(TEAMIM_RE, "");
}
/** Quita el nikud (vocales, dagesh, meteg, puntos de shin/sin). */
export function stripNikud(s: string): string {
return s.replace(NIKUD_RE, "");
}
/** Texto consonantal puro: sin te'amim ni nikud. */
export function consonantal(s: string): string {
return stripNikud(stripTeamim(s));
}
/** Aplica el formato elegido por el usuario. */
export function formatHebrew(s: string, opts: { nikud: boolean; teamim: boolean }): string {
let out = s;
if (!opts.teamim) out = stripTeamim(out);
if (!opts.nikud) out = stripNikud(out);
return out.replace(/ {2,}/g, " ").trim();
}

86
src/i18n.ts Normal file
View file

@ -0,0 +1,86 @@
// i18n mínimo (EN/ES por ahora; extender a HE/AR/FR/RU/PT antes del release público,
// siguiendo el patrón de obsidian-diacritics-free-search).
import { getLanguage } from "obsidian";
type Lang = "en" | "es";
const SUPPORTED: Lang[] = ["en", "es"];
const translations: Record<Lang, Record<string, string>> = {
en: {
cmdInsert: "Insert pasuk (Tanakh verse)",
modalTitle: "Insert pasuk",
searchPlaceholder: "Reference (Gen 1:1) or search (bereshit / בראשית)...",
noResults: "No results",
hint: "Type a reference like “Bereshit 1:1-3”, or search by transliteration or Hebrew.",
insertHebrewOnly: "Hebrew only",
settings: "Settings",
includeNikud: "Include nikud (vowels)",
includeNikudDesc: "Insert verses with vowel points",
includeTeamim: "Include te'amim (cantillation)",
includeTeamimDesc: "Insert verses with cantillation marks",
quoteFormat: "Insert as quote block",
quoteFormatDesc: "Wrap the inserted verses in a Markdown blockquote with the reference",
maxResults: "Maximum search results",
maxResultsDesc: "Limit how many verses appear when searching by text",
support: "Support this plugin",
supportDesc: "Pasuk is free and open source. If it helps your study, you can support its development with a coffee. ☕",
supportBtn: "Support on Ko-fi",
viewGithub: "View on GitHub",
reportIssue: "Report an issue",
searching: "Searching…",
},
es: {
cmdInsert: "Insertar pasuk (versículo del Tanaj)",
modalTitle: "Insertar pasuk",
searchPlaceholder: "Referencia (Gén 1:1) o búsqueda (bereshit / בראשית)...",
noResults: "Sin resultados",
hint: "Escribe una referencia como “Bereshit 1:1-3”, o busca por transliteración o hebreo.",
insertHebrewOnly: "Solo hebreo",
settings: "Ajustes",
includeNikud: "Incluir nikud (vocales)",
includeNikudDesc: "Insertar los versículos con puntos vocálicos",
includeTeamim: "Incluir te'amim (cantilación)",
includeTeamimDesc: "Insertar los versículos con marcas de cantilación",
quoteFormat: "Insertar como cita (quote)",
quoteFormatDesc: "Envolver los versículos en un blockquote de Markdown con la referencia",
maxResults: "Máximo de resultados",
maxResultsDesc: "Límite de versículos al buscar por texto",
support: "Apoya este plugin",
supportDesc: "Pasuk es gratuito y de código abierto. Si te ayuda en tu estudio, puedes apoyar su desarrollo con un café. ☕",
supportBtn: "Apóyame en Ko-fi",
viewGithub: "Ver en GitHub",
reportIssue: "Reportar un problema",
searching: "Buscando…",
},
};
let current: Lang = "en";
function detect(): Lang {
let raw = "en";
try {
if (typeof getLanguage === "function") raw = getLanguage() || "en";
} catch {
raw = "en";
}
const two = raw.toLowerCase().split(/[-_]/)[0];
return SUPPORTED.includes(two as Lang) ? (two as Lang) : "en";
}
export function initI18n(): void {
current = detect();
}
export function currentLang(): "es" | "en" {
return current;
}
export function t(key: string, params?: Record<string, string | number>): string {
let s = translations[current]?.[key] ?? translations.en[key] ?? key;
if (params) {
for (const p of Object.keys(params)) {
s = s.split("{" + p + "}").join(String(params[p]));
}
}
return s;
}

132
src/main.ts Normal file
View file

@ -0,0 +1,132 @@
import { App, Editor, Plugin, PluginSettingTab, Setting } from "obsidian";
import { PasukModal } from "./modal";
import { initI18n, t } from "./i18n";
export interface PasukSettings {
includeNikud: boolean;
includeTeamim: boolean;
quoteFormat: boolean;
maxResults: number;
}
const DEFAULT_SETTINGS: PasukSettings = {
includeNikud: true,
includeTeamim: false,
quoteFormat: true,
maxResults: 30,
};
const GITHUB_URL = "https://github.com/spenhos/obsidian-pasuk";
const KOFI_URL = "https://ko-fi.com/elevalma";
export default class PasukPlugin extends Plugin {
settings: PasukSettings;
async onload() {
await this.loadSettings();
initI18n();
this.addCommand({
id: "insert-verse",
name: t("cmdInsert"),
editorCallback: (editor: Editor) => {
new PasukModal(this.app, editor, this.settings).open();
},
});
this.addSettingTab(new PasukSettingTab(this.app, this));
}
onunload() {}
async loadSettings() {
const data = (await this.loadData()) as Partial<PasukSettings> | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class PasukSettingTab extends PluginSettingTab {
plugin: PasukPlugin;
constructor(app: App, plugin: PasukPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const s = this.plugin.settings;
const save = () => void this.plugin.saveSettings();
new Setting(containerEl).setName(t("settings")).setHeading();
new Setting(containerEl)
.setName(t("includeNikud"))
.setDesc(t("includeNikudDesc"))
.addToggle((tg) =>
tg.setValue(s.includeNikud).onChange((v) => {
s.includeNikud = v;
save();
})
);
new Setting(containerEl)
.setName(t("includeTeamim"))
.setDesc(t("includeTeamimDesc"))
.addToggle((tg) =>
tg.setValue(s.includeTeamim).onChange((v) => {
s.includeTeamim = v;
save();
})
);
new Setting(containerEl)
.setName(t("quoteFormat"))
.setDesc(t("quoteFormatDesc"))
.addToggle((tg) =>
tg.setValue(s.quoteFormat).onChange((v) => {
s.quoteFormat = v;
save();
})
);
new Setting(containerEl)
.setName(t("maxResults"))
.setDesc(t("maxResultsDesc"))
.addText((txt) =>
txt.setValue(String(s.maxResults)).onChange((v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) {
s.maxResults = n;
save();
}
})
);
new Setting(containerEl).setName(t("support")).setHeading();
new Setting(containerEl)
.setName(t("supportBtn"))
.setDesc(t("supportDesc"))
.addButton((button) => {
button
.setButtonText("☕ " + t("supportBtn"))
.onClick(() => window.open(KOFI_URL, "_blank"));
button.buttonEl.addClass("pasuk-kofi-btn");
return button;
});
const about = containerEl.createDiv({ cls: "pasuk-about" });
about.createSpan({ text: `Pasuk v${this.plugin.manifest.version} · ` });
const gh = about.createEl("a", { text: t("viewGithub"), href: GITHUB_URL });
gh.setAttr("target", "_blank");
about.createSpan({ text: " · " });
const issue = about.createEl("a", { text: t("reportIssue"), href: GITHUB_URL + "/issues" });
issue.setAttr("target", "_blank");
}
}

165
src/modal.ts Normal file
View file

@ -0,0 +1,165 @@
// Modal de búsqueda e inserción de pesukim.
import { App, Editor, Modal } from "obsidian";
import { formatRefLabel, parseRef } from "./refparse";
import { getVerses } from "./corpus";
import { searchText, SearchHit } from "./search";
import { formatHebrew } from "./hebrew";
import { currentLang, t } from "./i18n";
import type { PasukSettings } from "./main";
interface ResultItem {
label: string; // ej. "Génesis 1:1"
verses: string[]; // texto original
preview: string; // primera línea para mostrar
}
export class PasukModal extends Modal {
private editor: Editor;
private settings: PasukSettings;
private inputEl: HTMLInputElement;
private resultsEl: HTMLElement;
private hintEl: HTMLElement;
private items: ResultItem[] = [];
private selected = 0;
private debounce: number | null = null;
private searchSeq = 0;
constructor(app: App, editor: Editor, settings: PasukSettings) {
super(app);
this.editor = editor;
this.settings = settings;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass("pasuk-modal");
this.titleEl.setText(t("modalTitle"));
this.inputEl = contentEl.createEl("input", {
type: "text",
placeholder: t("searchPlaceholder"),
cls: "pasuk-input",
});
this.hintEl = contentEl.createDiv({ cls: "pasuk-hint", text: t("hint") });
this.resultsEl = contentEl.createDiv({ cls: "pasuk-results" });
this.inputEl.addEventListener("input", () => {
if (this.debounce) window.clearTimeout(this.debounce);
this.debounce = window.setTimeout(() => void this.runSearch(), 250);
});
this.inputEl.addEventListener("keydown", (evt) => {
if (evt.key === "ArrowDown") {
evt.preventDefault();
this.select(this.selected + 1);
} else if (evt.key === "ArrowUp") {
evt.preventDefault();
this.select(this.selected - 1);
} else if (evt.key === "Enter") {
evt.preventDefault();
this.insertSelected();
} else if (evt.key === "Escape") {
this.close();
}
});
this.inputEl.focus();
}
onClose() {
this.contentEl.empty();
}
private async runSearch() {
const q = this.inputEl.value.trim();
const seq = ++this.searchSeq;
this.items = [];
this.selected = 0;
if (!q) {
this.render();
return;
}
// 1) ¿Es una referencia?
const ref = parseRef(q);
if (ref) {
const verses = await getVerses(ref.book.key, ref.chapter, ref.verseStart, ref.verseEnd);
if (seq !== this.searchSeq) return;
if (verses) {
this.items = [
{
label: formatRefLabel(ref, currentLang()),
verses,
preview: verses[0],
},
];
}
this.render();
return;
}
// 2) Búsqueda de texto (hebreo o transliteración)
this.resultsEl.setText(t("searching"));
const hits = await searchText(q, this.settings.maxResults);
if (seq !== this.searchSeq) return;
const lang = currentLang();
this.items = hits.map((h: SearchHit) => ({
label: `${lang === "es" ? h.book.es : h.book.en} ${h.chapter}:${h.verse}`,
verses: [h.text],
preview: h.text,
}));
this.render();
}
private render() {
this.resultsEl.empty();
if (!this.items.length) {
if (this.inputEl.value.trim()) {
this.resultsEl.createDiv({ cls: "pasuk-empty", text: t("noResults") });
}
return;
}
this.items.forEach((item, i) => {
const el = this.resultsEl.createDiv({
cls: "pasuk-result" + (i === this.selected ? " is-selected" : ""),
});
el.createDiv({ cls: "pasuk-result-ref", text: item.label });
el.createDiv({
cls: "pasuk-result-text",
text: formatHebrew(item.preview, {
nikud: this.settings.includeNikud,
teamim: this.settings.includeTeamim,
}),
});
el.addEventListener("click", () => {
this.selected = i;
this.insertSelected();
});
el.addEventListener("mousemove", () => this.select(i));
});
}
private select(i: number) {
if (!this.items.length) return;
this.selected = Math.max(0, Math.min(i, this.items.length - 1));
const children = Array.from(this.resultsEl.children);
children.forEach((c, idx) => c.toggleClass("is-selected", idx === this.selected));
children[this.selected]?.scrollIntoView({ block: "nearest" });
}
private insertSelected() {
const item = this.items[this.selected];
if (!item) return;
const opts = { nikud: this.settings.includeNikud, teamim: this.settings.includeTeamim };
const lines = item.verses.map((v) => formatHebrew(v, opts));
let text: string;
if (this.settings.quoteFormat) {
const quoted = lines.map((l) => `> ${l}`).join("\n");
text = `${quoted}\n> — ${item.label}\n`;
} else {
text = `${lines.join(" ")} (${item.label})`;
}
this.editor.replaceSelection(text);
this.close();
}
}

43
src/refparse.ts Normal file
View file

@ -0,0 +1,43 @@
// Parser de referencias: "Gen 1:1", "bereshit 1:1-3", "Salmos 23", "תהלים כג" (futuro).
import { BookInfo, resolveBook } from "./books";
import { bookShape } from "./corpus";
export interface ParsedRef {
book: BookInfo;
chapter: number;
verseStart: number;
verseEnd: number;
wholeChapter: boolean;
}
// "<libro> <cap>[:<v>[-<v2>]]" — separadores aceptados: ":", ".", ","
const REF_RE = /^(.+?)\s+(\d{1,3})(?:\s*[:.,]\s*(\d{1,3})(?:\s*[-–—]\s*(\d{1,3}))?)?$/;
export function parseRef(input: string): ParsedRef | null {
const m = input.trim().match(REF_RE);
if (!m) return null;
const book = resolveBook(m[1]);
if (!book) return null;
const shape = bookShape(book.key);
if (!shape) return null;
const chapter = parseInt(m[2], 10);
if (chapter < 1 || chapter > shape.length) return null;
const maxV = shape[chapter - 1];
if (!m[3]) {
// capítulo completo
return { book, chapter, verseStart: 1, verseEnd: maxV, wholeChapter: true };
}
const v1 = parseInt(m[3], 10);
const v2 = m[4] ? parseInt(m[4], 10) : v1;
if (v1 < 1 || v1 > maxV || v2 < v1) return null;
return { book, chapter, verseStart: v1, verseEnd: Math.min(v2, maxV), wholeChapter: false };
}
export function formatRefLabel(ref: ParsedRef, lang: "es" | "en"): string {
const name = lang === "es" ? ref.book.es : ref.book.en;
if (ref.wholeChapter) return `${name} ${ref.chapter}`;
const range = ref.verseStart === ref.verseEnd ? `${ref.verseStart}` : `${ref.verseStart}-${ref.verseEnd}`;
return `${name} ${ref.chapter}:${range}`;
}

69
src/search.ts Normal file
View file

@ -0,0 +1,69 @@
// Búsqueda de texto en todo el Tanaj (hebreo directo o transliteración).
import { BOOKS, BookInfo } from "./books";
import { loadBook } from "./corpus";
import { consonantal, stripNikud, stripTeamim } from "./hebrew";
import { isLatin, translitToRegex } from "./translit";
export interface SearchHit {
book: BookInfo;
chapter: number; // 1-based
verse: number; // 1-based
text: string; // texto original (con nikud + te'amim)
}
// Índice consonantal en memoria (se construye una vez por sesión, bajo demanda)
let consIndex: Map<string, string[][]> | null = null;
async function buildIndex(): Promise<Map<string, string[][]>> {
if (consIndex) return consIndex;
const idx = new Map<string, string[][]>();
for (const b of BOOKS) {
const chapters = await loadBook(b.key);
idx.set(
b.key,
chapters.map((ch) => ch.map((v) => consonantal(v)))
);
}
consIndex = idx;
return idx;
}
/**
* Busca `query` en todo el Tanaj. Acepta hebreo (con o sin nikud)
* o transliteración latina. Devuelve hasta `limit` resultados.
*/
export async function searchText(query: string, limit: number): Promise<SearchHit[]> {
const q = query.trim();
if (!q) return [];
let matcher: (cons: string) => boolean;
if (isLatin(q)) {
const re = translitToRegex(q);
if (!re) return [];
matcher = (cons) => re.test(cons);
} else {
// hebreo directo: normalizar a consonantal y buscar como substring
const needle = consonantal(stripNikud(stripTeamim(q)));
if (!needle) return [];
matcher = (cons) => cons.includes(needle);
}
const idx = await buildIndex();
const hits: SearchHit[] = [];
for (const b of BOOKS) {
const chapters = idx.get(b.key);
if (!chapters) continue;
const original = await loadBook(b.key);
for (let c = 0; c < chapters.length; c++) {
const verses = chapters[c];
for (let v = 0; v < verses.length; v++) {
if (matcher(verses[v])) {
hits.push({ book: b, chapter: c + 1, verse: v + 1, text: original[c][v] });
if (hits.length >= limit) return hits;
}
}
}
}
return hits;
}

107
src/translit.ts Normal file
View file

@ -0,0 +1,107 @@
// Transliteración latina → patrón de búsqueda en hebreo consonantal.
//
// Idea: las consonantes mapean a clases de letras hebreas (incluyendo finales);
// las vocales latinas (a,e,i,o,u) son flexibles — pueden corresponder a nada
// (solo nikud) o a una mater lectionis (א ה ו י). Así "bereshit" encuentra
// בראשית y "mayim" encuentra מים.
interface Token {
pattern: string; // fragmento de regex
isVowel: boolean;
}
// Dígrafos primero (orden importa)
const DIGRAPHS: Array<[string, string]> = [
["sh", "ש"],
["tz", "[צץ]"],
["ts", "[צץ]"],
["ch", "[חכך]"],
["kh", "[כך]"],
["th", "ת"],
["ph", "[פף]"],
];
const CONSONANTS: Record<string, string> = {
b: "ב",
v: "[בו]",
g: "ג",
d: "ד",
h: "ה",
w: "ו",
z: "ז",
j: "[חכך]", // español: jet
t: "[תט]",
y: "י",
k: "[כךק]",
c: "[כךקס]", // comodín latino
l: "ל",
m: "[מם]",
n: "[נן]",
s: "[סש]",
x: "[סש]",
p: "[פף]",
f: "[פף]",
q: "ק",
r: "ר",
};
// Vocales: opcionalmente una mater lectionis. La ' (apóstrofe) fuerza א/ע.
const VOWELS: Record<string, string> = {
a: "[אהע]?",
e: "[אהעי]?",
i: "י?",
o: "[וא]?",
u: "ו?",
};
/** ¿El texto parece transliteración latina (y no hebreo directo)? */
export function isLatin(s: string): boolean {
return /^[a-z'\s]+$/i.test(s.trim());
}
/**
* Convierte una transliteración a RegExp sobre texto hebreo consonantal.
* Devuelve null si no hay consonantes utilizables.
*/
export function translitToRegex(input: string): RegExp | null {
const words = input.trim().toLowerCase().split(/\s+/);
const wordPatterns: string[] = [];
for (const word of words) {
const tokens: Token[] = [];
let i = 0;
while (i < word.length) {
const two = word.slice(i, i + 2);
const dig = DIGRAPHS.find(([d]) => d === two);
if (dig) {
tokens.push({ pattern: dig[1], isVowel: false });
i += 2;
continue;
}
const ch = word[i];
if (ch === "'") {
tokens.push({ pattern: "[אע]", isVowel: false });
} else if (CONSONANTS[ch]) {
tokens.push({ pattern: CONSONANTS[ch], isVowel: false });
} else if (VOWELS[ch]) {
tokens.push({ pattern: VOWELS[ch], isVowel: true });
}
// caracteres desconocidos se ignoran
i += 1;
}
// vocal inicial: suele ser א/ע presente (ej. "adam" → אדם)
if (tokens.length && tokens[0].isVowel) {
tokens[0] = { pattern: "[אהעיו]", isVowel: false };
}
const consonantCount = tokens.filter((t) => !t.isVowel).length;
if (consonantCount === 0) continue;
wordPatterns.push(tokens.map((t) => t.pattern).join(""));
}
if (!wordPatterns.length) return null;
try {
return new RegExp(wordPatterns.join("[ ־]"), "u");
} catch {
return null;
}
}

85
styles.css Normal file
View file

@ -0,0 +1,85 @@
/* ===== Pasuk modal ===== */
.pasuk-modal {
padding: 12px;
}
.pasuk-input {
width: 100%;
padding: 8px 10px;
font-size: 15px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-primary);
color: var(--text-normal);
}
.pasuk-input:focus {
border-color: var(--interactive-accent);
outline: none;
}
.pasuk-hint {
margin: 6px 2px 10px;
font-size: 12px;
color: var(--text-muted);
}
.pasuk-results {
max-height: 50vh;
overflow-y: auto;
}
.pasuk-result {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
}
.pasuk-result.is-selected {
background: var(--background-modifier-hover);
}
.pasuk-result-ref {
font-size: 12px;
font-weight: 600;
color: var(--text-accent);
margin-bottom: 2px;
}
.pasuk-result-text {
direction: rtl;
text-align: right;
font-size: 17px;
line-height: 1.7;
}
.pasuk-empty {
padding: 12px;
color: var(--text-muted);
text-align: center;
}
/* ===== Settings ===== */
.pasuk-kofi-btn.pasuk-kofi-btn {
background-color: #ff5e5b;
color: #ffffff;
border: none;
font-weight: 600;
}
.pasuk-kofi-btn.pasuk-kofi-btn:hover {
background-color: #e8514e;
}
.pasuk-about {
margin-top: 1.5em;
padding-top: 0.75em;
border-top: 1px solid var(--background-modifier-border);
color: var(--text-muted);
font-size: 0.85em;
text-align: center;
}
.pasuk-about a {
color: var(--text-accent);
}

63
tools/build-corpus.mjs Normal file
View file

@ -0,0 +1,63 @@
// Empaqueta el corpus MAM del Tanaj (39 libros, nikud + te'amim) desde el
// proyecto codigos-torah hacia src/data/corpus.ts como gzip+base64 por libro.
// El plugin lo descomprime bajo demanda con DecompressionStream (lazy, por libro).
//
// Run: npm run build-corpus
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { join, basename } from "node:path";
const CORPUS_DIR =
"/Users/salehpenhos/Documents/Claude/Codigo/codigos-torah/public/data/corpus";
const OUT_DIR = new URL("../src/data/", import.meta.url).pathname;
mkdirSync(OUT_DIR, { recursive: true });
const files = readdirSync(CORPUS_DIR).filter((f) => f.endsWith(".json"));
if (files.length !== 39) {
throw new Error(`Se esperaban 39 libros, hay ${files.length}`);
}
let totalRaw = 0;
let totalGz = 0;
const entries = [];
const meta = [];
for (const f of files.sort()) {
const book = basename(f, ".json");
const raw = readFileSync(join(CORPUS_DIR, f));
const data = JSON.parse(raw.toString("utf-8"));
const chapters = data.he;
if (!Array.isArray(chapters)) throw new Error(`${book}: formato inesperado`);
const verseCounts = chapters.map((c) => c.length);
// Re-serializar solo los capítulos (sin el wrapper {he:...})
const payload = JSON.stringify(chapters);
const gz = gzipSync(Buffer.from(payload, "utf-8"), { level: 9 });
totalRaw += payload.length;
totalGz += gz.length;
entries.push(`\t${JSON.stringify(book)}: ${JSON.stringify(gz.toString("base64"))},`);
meta.push({ book, chapters: chapters.length, verseCounts });
}
const ts = `// GENERATED FILE — do not edit. Run: npm run build-corpus
// Fuente: corpus MAM (Miqra according to the Masorah) del proyecto codigos-torah,
// versión fijada de Sefaria, con parches Koren en la Torá. 39 libros del Tanaj.
// Formato por libro: gzip+base64 de JSON string[][] (capítulos -> versículos).
export const CORPUS_GZ: Record<string, string> = {
${entries.join("\n")}
};
/** Capítulos y versículos por libro (para validar referencias sin descomprimir). */
export const BOOK_SHAPE: Record<string, number[]> = {
${meta.map((m) => `\t${JSON.stringify(m.book)}: ${JSON.stringify(m.verseCounts)},`).join("\n")}
};
`;
writeFileSync(join(OUT_DIR, "corpus.ts"), ts);
console.log(
`${files.length} libros → src/data/corpus.ts ` +
`(raw ${(totalRaw / 1048576).toFixed(1)} MB → gz ${(totalGz / 1048576).toFixed(1)} MB)`
);

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts"]
}