Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ListPagesResponseSchema,
PublishedPageSchema,
} from "../core/contract.js";
import { parseMarkdownDocument } from "../core/markdown.js";
import { prepareMarkdownBodyForPublish } from "../core/publish-markdown.js";
import {
loadConfig,
loadMapping,
Expand Down Expand Up @@ -117,6 +119,10 @@ async function runPublish(context: CommandContext): Promise<void> {
filePath === undefined
? await readStdin()
: await readFile(path.resolve(filePath), "utf8");
const renderMarkdown =
filePath === undefined
? undefined
: await buildRenderMarkdown(markdown, path.resolve(filePath));
const response = await fetch(
`${apiBase}/api/namespaces/${encodeURIComponent(namespace)}/pages/publish`,
{
Expand All @@ -127,6 +133,7 @@ async function runPublish(context: CommandContext): Promise<void> {
},
body: JSON.stringify({
markdown,
...(renderMarkdown === undefined ? {} : { renderMarkdown }),
...(options.slug === undefined ? {} : { slug: options.slug }),
...(existingMapping?.pageId === undefined
? {}
Expand Down Expand Up @@ -154,6 +161,18 @@ async function runPublish(context: CommandContext): Promise<void> {
console.log(published.url);
}

async function buildRenderMarkdown(
markdown: string,
sourcePath: string,
): Promise<string | undefined> {
const parsed = parseMarkdownDocument(markdown);
const renderMarkdown = await prepareMarkdownBodyForPublish(parsed.body, {
sourcePath,
});

return renderMarkdown === parsed.body ? undefined : renderMarkdown;
}

async function runList(context: CommandContext): Promise<void> {
const all = context.args.includes("--all");
const filtered = context.args.filter((a) => a !== "--all");
Expand Down
1 change: 1 addition & 0 deletions src/core/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ClaimNamespaceResponseSchema = z.object({

export const PublishPageRequestSchema = z.object({
markdown: z.string().min(1),
renderMarkdown: z.string().min(1).optional(),
slug: NameSchema.optional(),
pageId: z.string().uuid().optional(),
});
Expand Down
11 changes: 9 additions & 2 deletions src/core/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import matter from "gray-matter";
import rehypeHighlight from "rehype-highlight";
import rehypeSanitize from "rehype-sanitize";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
Expand Down Expand Up @@ -52,12 +52,19 @@ export async function renderMarkdownToHtml(
markdown: string,
): Promise<RenderedPageDocument> {
const renderedMarkdown = autolinkBareUrls(stripWikilinks(markdown.trim()));
const sanitizeSchema = {
...defaultSchema,
protocols: {
...defaultSchema.protocols,
src: [...(defaultSchema.protocols?.["src"] ?? []), "data"],
},
};
const rawHtml = String(
await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeSanitize, sanitizeSchema)
.use(rehypeHighlight)
.use(rehypeStringify)
.process(renderedMarkdown),
Expand Down
208 changes: 208 additions & 0 deletions src/core/publish-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { readFile } from "node:fs/promises";
import path from "node:path";

export interface PrepareMarkdownBodyOptions {
sourcePath?: string;
}

const LOCAL_IMAGE_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".avif",
]);

const OBSIDIAN_IMAGE_EMBED_RE = /!\[\[([^\]\n]+)\]\]/g;
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\n]+)\)/g;

export async function prepareMarkdownBodyForPublish(
markdownBody: string,
options: PrepareMarkdownBodyOptions = {},
): Promise<string> {
if (options.sourcePath === undefined) {
return markdownBody;
}

const baseDir = path.dirname(options.sourcePath);

return transformOutsideCodeBlocks(markdownBody, async (segment) => {
const withObsidianEmbeds = await replaceAsync(
segment,
OBSIDIAN_IMAGE_EMBED_RE,
async (match, innerTarget) => {
const [rawTarget] = innerTarget.split("|");
const target = rawTarget?.trim();

if (target === undefined || target.length === 0) {
return match;
}

const asset = await resolveLocalImageAsset(baseDir, target);

if (asset === null) {
return match;
}

return `![${asset.alt}](${asset.dataUrl})`;
},
);

return replaceAsync(
withObsidianEmbeds,
MARKDOWN_IMAGE_RE,
async (match, alt, rawTarget) => {
const target = normalizeMarkdownImageTarget(rawTarget);

if (target === null || isExternalAssetTarget(target)) {
return match;
}

const asset = await resolveLocalImageAsset(baseDir, target);

if (asset === null) {
return match;
}

const label = alt.trim().length > 0 ? alt.trim() : asset.alt;
return `![${escapeMarkdownLabel(label)}](${asset.dataUrl})`;
},
);
});
}

async function transformOutsideCodeBlocks(
markdown: string,
transform: (segment: string) => Promise<string>,
): Promise<string> {
const lines = markdown.split("\n");
const result: string[] = [];
const buffer: string[] = [];
let inCodeBlock = false;

async function flushBuffer(): Promise<void> {
if (buffer.length === 0) {
return;
}

result.push(await transform(buffer.join("\n")));
buffer.length = 0;
}

for (const line of lines) {
if (line.trimStart().startsWith("```")) {
await flushBuffer();
inCodeBlock = !inCodeBlock;
result.push(line);
continue;
}

if (inCodeBlock) {
result.push(line);
continue;
}

buffer.push(line);
}

await flushBuffer();

return result.join("\n");
}

async function replaceAsync(
text: string,
pattern: RegExp,
replacer: (...args: string[]) => Promise<string>,
): Promise<string> {
const matches = [...text.matchAll(pattern)];

if (matches.length === 0) {
return text;
}

let result = "";
let lastIndex = 0;

for (const match of matches) {
const fullMatch = match[0];
const matchIndex = match.index ?? 0;
result += text.slice(lastIndex, matchIndex);
result += await replacer(...match);
lastIndex = matchIndex + fullMatch.length;
}

result += text.slice(lastIndex);
return result;
}

function normalizeMarkdownImageTarget(rawTarget: string): string | null {
const trimmed = rawTarget.trim();

if (trimmed.length === 0) {
return null;
}

if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
return trimmed.slice(1, -1).trim();
}

const withOptionalTitle = trimmed.match(/^(\S+)(?:\s+["'][^"']*["'])?$/);
return withOptionalTitle?.[1] ?? trimmed;
}

function isExternalAssetTarget(target: string): boolean {
return /^(?:[a-z]+:)?\/\//i.test(target) || target.startsWith("data:");
}

async function resolveLocalImageAsset(
baseDir: string,
rawTarget: string,
): Promise<{ alt: string; dataUrl: string } | null> {
const resolvedPath = path.resolve(baseDir, rawTarget);
const extension = path.extname(resolvedPath).toLowerCase();

if (!LOCAL_IMAGE_EXTENSIONS.has(extension)) {
return null;
}

try {
const content = await readFile(resolvedPath);
return {
alt: escapeMarkdownLabel(path.basename(rawTarget)),
dataUrl: buildDataUrl(content, extension),
};
} catch {
return null;
}
}

function buildDataUrl(content: Buffer, extension: string): string {
return `data:${mimeTypeForExtension(extension)};base64,${content.toString("base64")}`;
}

function mimeTypeForExtension(extension: string): string {
switch (extension) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".svg":
return "image/svg+xml";
case ".avif":
return "image/avif";
default:
return "application/octet-stream";
}
}

function escapeMarkdownLabel(value: string): string {
return value.replaceAll("[", "\\[").replaceAll("]", "\\]");
}
5 changes: 4 additions & 1 deletion src/core/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ensureName, slugify } from "./slug.js";

export interface PublishPageInput {
markdown: string;
renderMarkdown?: string;
namespace: string;
pageId?: string;
requestedSlug?: string;
Expand Down Expand Up @@ -89,6 +90,7 @@ export function createPublishService(
await authenticate(safeNamespace, input.token);

const parsed = parseMarkdownDocument(input.markdown);
const renderMarkdown = input.renderMarkdown ?? parsed.body;
const requestedSlug =
input.requestedSlug ?? parsed.frontmatter.slug ?? slugify(parsed.title);
const safeSlug = ensureName(slugify(requestedSlug));
Expand All @@ -102,7 +104,7 @@ export function createPublishService(
const now = new Date().toISOString();
const markdownBlobKey = `${pageId}.md`;
const htmlBlobKey = `${pageId}.html`;
const rendered = await renderMarkdownToHtml(parsed.body);
const rendered = await renderMarkdownToHtml(renderMarkdown);
const htmlDocument = buildHtmlDocument({
title: parsed.title,
description: parsed.description,
Expand All @@ -112,6 +114,7 @@ export function createPublishService(
const contentHash = sha256(
JSON.stringify({
markdown: input.markdown,
renderMarkdown,
slug,
title: parsed.title,
description: parsed.description,
Expand Down
3 changes: 3 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,14 @@ Open source — [github.com/Restuta/pubmd](https://github.com/Restuta/pubmd)`;

try {
let markdown: string;
let renderMarkdown: string | undefined;
let slug: string | undefined;
let pageId: string | undefined;

if (isJson) {
const body = PublishPageRequestSchema.parse(await context.req.json());
markdown = body.markdown;
renderMarkdown = body.renderMarkdown;
slug = body.slug;
pageId = body.pageId;
} else {
Expand All @@ -119,6 +121,7 @@ Open source — [github.com/Restuta/pubmd](https://github.com/Restuta/pubmd)`;
namespace: context.req.param("namespace"),
token,
markdown,
...(renderMarkdown === undefined ? {} : { renderMarkdown }),
origin: requestOrigin(context.req.url),
...(pageId === undefined ? {} : { pageId }),
...(slug === undefined ? {} : { requestedSlug: slug }),
Expand Down
Loading