cascade-cards-source-markdown
Version:
Cascade Cards Markdown data source adapter
174 lines • 5.49 kB
JavaScript
// src/markdown-source.ts
import fs from "fs/promises";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import remarkHtml from "remark-html";
import fg from "fast-glob";
var MarkdownSource = class {
name = "markdown";
options;
cache = /* @__PURE__ */ new Map();
fileMap = /* @__PURE__ */ new Map();
// term -> filePath
initialized = false;
constructor(options) {
this.options = {
baseDir: process.cwd(),
cache: true,
termResolver: (term) => this.defaultTermResolver(term),
extractLinks: true,
...options
};
}
async resolve(term) {
await this.ensureInitialized();
const filePath = this.options.termResolver(term);
if (!filePath) {
return null;
}
if (this.options.cache && this.cache.has(filePath)) {
const cached = this.cache.get(filePath);
return this.toDataSourceContent(cached);
}
try {
const parsed = await this.parseMarkdownFile(filePath);
if (this.options.cache) {
this.cache.set(filePath, parsed);
}
return this.toDataSourceContent(parsed);
} catch (error) {
console.warn(`Failed to load markdown file for term "${term}":`, error);
return null;
}
}
async ensureInitialized() {
if (this.initialized) return;
try {
const files = await fg(this.options.glob, {
cwd: this.options.baseDir,
absolute: true
});
for (const filePath of files) {
const relativePath = path.relative(this.options.baseDir, filePath);
const slug = this.pathToSlug(relativePath);
this.fileMap.set(slug, filePath);
const basename = path.basename(filePath, path.extname(filePath));
this.fileMap.set(basename.toLowerCase(), filePath);
}
this.initialized = true;
} catch (error) {
console.error("Failed to initialize markdown source:", error);
throw error;
}
}
defaultTermResolver(term) {
const normalizedTerm = term.toLowerCase();
if (this.fileMap.has(normalizedTerm)) {
return this.fileMap.get(normalizedTerm);
}
const variations = [
normalizedTerm.replace(/\s+/g, "-"),
normalizedTerm.replace(/\s+/g, "_"),
normalizedTerm.replace(/[^a-z0-9]/g, ""),
normalizedTerm.replace(/[^a-z0-9]/g, "-")
];
for (const variation of variations) {
if (this.fileMap.has(variation)) {
return this.fileMap.get(variation);
}
}
return null;
}
pathToSlug(relativePath) {
return relativePath.replace(/\.md$/, "").replace(/\\/g, "/").toLowerCase();
}
async parseMarkdownFile(filePath) {
const content = await fs.readFile(filePath, "utf-8");
const { data: frontmatter, content: markdownContent } = matter(content);
const processor = remark().use(remarkHtml, { sanitize: false });
const result = await processor.process(markdownContent);
const html = result.toString();
const title = frontmatter.title || this.extractTitleFromMarkdown(markdownContent) || path.basename(filePath, path.extname(filePath));
const links = [];
if (this.options.extractLinks) {
links.push(...this.extractLinksFromContent(markdownContent, frontmatter));
}
const slug = this.pathToSlug(path.relative(this.options.baseDir, filePath));
return {
title,
content: markdownContent,
html,
frontmatter,
filePath,
slug,
links
};
}
extractTitleFromMarkdown(content) {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : null;
}
extractLinksFromContent(content, frontmatter) {
const links = [];
if (frontmatter.related && Array.isArray(frontmatter.related)) {
links.push(...frontmatter.related.map(
(item) => typeof item === "string" ? { term: item } : { term: item.term, label: item.label }
));
}
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
links.push(...frontmatter.tags.map((tag) => ({ term: tag })));
}
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = linkRegex.exec(content)) !== null) {
const [, label, href] = match;
if (!href.startsWith("http") && !href.startsWith("mailto:")) {
const term = href.replace(/\.md$/, "").replace(/^\//, "");
links.push({ term, label });
}
}
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g;
while ((match = wikiLinkRegex.exec(content)) !== null) {
const reference = match[1];
const [term, label] = reference.includes("|") ? reference.split("|", 2) : [reference, void 0];
links.push({ term: term.trim(), label: label?.trim() });
}
return links;
}
toDataSourceContent(parsed) {
return {
title: parsed.title,
html: parsed.html,
markdown: parsed.content,
links: parsed.links,
meta: {
...parsed.frontmatter,
filePath: parsed.filePath,
slug: parsed.slug
}
};
}
// Utility methods for external use
async getAllTerms() {
await this.ensureInitialized();
return Array.from(this.fileMap.keys());
}
async clearCache() {
this.cache.clear();
}
async reloadFiles() {
this.initialized = false;
this.cache.clear();
this.fileMap.clear();
await this.ensureInitialized();
}
};
function markdownSource(options) {
return new MarkdownSource(options);
}
export {
MarkdownSource,
markdownSource
};
//# sourceMappingURL=index.js.map