notion-astro-loader
Version:
Notion loader for the Astro Content Layer API. It allows you to load pages from a Notion database then render them as pages in a collection.
199 lines (198 loc) • 7.03 kB
JavaScript
import { toc as rehypeToc } from "@jsdevtools/rehype-toc";
import { iteratePaginatedAPI, isFullBlock, } from "@notionhq/client";
import * as transformedPropertySchema from "./schemas/transformed-properties.js";
import { fileToImageAsset, fileToUrl } from "./format.js";
// #region Processor
import notionRehype from "notion-rehype-k";
import rehypeKatex from "rehype-katex";
import rehypeSlug from "rehype-slug";
import rehypeStringify from "rehype-stringify";
import { unified } from "unified";
const baseProcessor = unified()
.use(notionRehype, {}) // Parse Notion blocks to rehype AST
.use(rehypeSlug)
.use(
// @ts-ignore
rehypeKatex) // Then you can use any rehype plugins to enrich the AST
.use(rehypeStringify); // Turn AST to HTML string
export function buildProcessor(rehypePlugins) {
let headings = [];
const processorWithToc = baseProcessor().use(rehypeToc, {
customizeTOC(toc) {
headings = extractTocHeadings(toc);
return false;
},
});
const processorPromise = rehypePlugins.then((plugins) => {
let processor = processorWithToc;
for (const [plugin, options] of plugins) {
processor = processor.use(plugin, options);
}
return processor;
});
return async function process(blocks) {
const processor = await processorPromise;
const vFile = await processor.process({ data: blocks });
return { vFile, headings };
};
}
// #endregion
async function awaitAll(iterable) {
const result = [];
for await (const item of iterable) {
result.push(item);
}
return result;
}
/**
* Return a generator that yields all blocks in a Notion page, recursively.
* @param blockId ID of block to get children for.
* @param fetchImage Function that fetches an image and returns a local path.
*/
async function* listBlocks(client, blockId, fetchImage) {
for await (const block of iteratePaginatedAPI(client.blocks.children.list, {
block_id: blockId,
})) {
if (!isFullBlock(block)) {
continue;
}
if (block.has_children) {
const children = await awaitAll(listBlocks(client, block.id, fetchImage));
// @ts-ignore -- TODO: Make TypeScript happy here
block[block.type].children = children;
}
// Specialized handling for image blocks
if (block.type === "image") {
// Fetch remote image and store it locally
const url = await fetchImage(block.image);
// notion-rehype-k incorrectly expects "file" to be a string instead of an object
yield {
...block,
image: {
type: block.image.type,
[block.image.type]: url,
caption: block.image.caption,
},
};
}
else {
yield block;
}
}
}
function extractTocHeadings(toc) {
if (toc.tagName !== "nav") {
throw new Error(`Expected nav, got ${toc.tagName}`);
}
function listElementToTree(ol, depth) {
return ol.children.flatMap((li) => {
const [_link, subList] = li.children;
const link = _link;
const currentHeading = {
depth,
text: link.children[0].value,
slug: link.properties.href.slice(1),
};
let headings = [currentHeading];
if (subList) {
headings = headings.concat(listElementToTree(subList, depth + 1));
}
return headings;
});
}
return listElementToTree(toc.children[0], 0);
}
export class NotionPageRenderer {
client;
page;
#imagePaths = [];
#logger;
/**
* @param client Notion API client.
* @param page Notion page object including page ID and properties. Does not include blocks.
* @param parentLogger Logger to use for logging messages.
*/
constructor(client, page, parentLogger) {
this.client = client;
this.page = page;
// Create a sub-logger labelled with the page name
const pageTitle = transformedPropertySchema.title.safeParse(page.properties.Name);
this.#logger = parentLogger.fork(`page ${page.id} (Name ${pageTitle.success ? pageTitle.data : "unknown"})`);
if (!pageTitle.success) {
this.#logger.warn(`Failed to parse property Name as title: ${pageTitle.error.toString()}`);
}
}
/**
* Return page properties for Astro to use.
*/
getPageData() {
const { page } = this;
return {
id: page.id,
data: {
icon: page.icon,
cover: page.cover,
archived: page.archived,
in_trash: page.in_trash,
url: page.url,
public_url: page.public_url,
properties: page.properties,
},
};
}
/**
* Return rendered HTML for the page.
* @param process Processor function to transform Notion blocks into HTML.
* This is created once for all pages then shared.
*/
async render(process) {
this.#logger.debug("Rendering");
try {
const blocks = await awaitAll(listBlocks(this.client, this.page.id, this.#fetchImage));
const { vFile, headings } = await process(blocks);
this.#logger.debug("Rendered");
return {
html: vFile.toString(),
metadata: {
headings,
imagePaths: this.#imagePaths,
},
};
}
catch (error) {
this.#logger.error(`Failed to render: ${getErrorMessage(error)}`);
return undefined;
}
}
/**
* Helper function to convert remote Notion images into local images in Astro.
* Additionally saves the path in `this.#imagePaths`.
* @param imageFileObject Notion file object representing an image.
* @returns Local path to the image, or undefined if the image could not be fetched.
*/
#fetchImage = async (imageFileObject) => {
try {
const fetchedImageData = await fileToImageAsset(imageFileObject);
this.#imagePaths.push(fetchedImageData.src);
return fetchedImageData.src;
}
catch (error) {
this.#logger.error(`Failed to fetch image when rendering page.
Have you added \`image: { remotePatterns: [{ protocol: "https", hostname: "*.amazonaws.com" }] }\` to your Astro config file?\n
Error: ${getErrorMessage(error)}`);
// Fall back to using the remote URL directly.
return fileToUrl(imageFileObject);
}
};
}
function getErrorMessage(error) {
if (error instanceof Error) {
return error.message;
}
else if (typeof error === "string") {
return error;
}
else {
return "Unknown error";
}
}