feat: macros for posts to extend page functionality

This commit is contained in:
2026-02-12 16:02:34 +01:00
parent 5ed0371456
commit 5c6fcb46ef
10 changed files with 1266 additions and 0 deletions

View File

@@ -330,3 +330,180 @@
.milkdown-content .ProseMirror:focus-visible {
outline: none;
}
/* =================================
Macro Styles
================================= */
/* Base macro inline element in editor */
.milkdown-macro {
display: inline-flex;
align-items: center;
padding: 2px 8px;
margin: 0 2px;
border-radius: 4px;
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.9em;
line-height: 1.4;
vertical-align: baseline;
cursor: default;
user-select: none;
}
/* Known (registered) macro - teal/cyan shade */
.milkdown-macro.macro-known {
background-color: rgba(0, 150, 136, 0.15);
border: 1px solid rgba(0, 150, 136, 0.3);
color: var(--vscode-textLink-foreground, #3794ff);
}
/* Unknown macro - warning shade */
.milkdown-macro.macro-unknown {
background-color: rgba(255, 152, 0, 0.15);
border: 1px solid rgba(255, 152, 0, 0.3);
color: var(--vscode-editorWarning-foreground, #cca700);
}
/* Hover effect */
.milkdown-macro:hover {
filter: brightness(1.1);
}
/* Selected macro */
.milkdown-macro.ProseMirror-selectednode {
outline: 2px solid var(--vscode-focusBorder, #007acc);
outline-offset: 1px;
}
/* Macro icon prefix */
.milkdown-macro::before {
margin-right: 4px;
}
.milkdown-macro.macro-known::before {
content: "⬡";
}
.milkdown-macro.macro-unknown::before {
content: "⚠";
}
/* =================================
Rendered Macro Styles (Preview)
================================= */
/* Macro error display */
.macro-error {
display: inline-block;
padding: 2px 6px;
background-color: rgba(255, 0, 0, 0.1);
border: 1px dashed rgba(255, 0, 0, 0.5);
border-radius: 3px;
color: var(--vscode-errorForeground, #f44);
font-family: monospace;
font-size: 0.85em;
}
/* Gallery macro preview */
.macro-gallery {
margin: 1em 0;
border-radius: 8px;
overflow: hidden;
}
.macro-gallery.gallery-preview {
background-color: rgba(0, 150, 136, 0.08);
border: 1px dashed rgba(0, 150, 136, 0.3);
padding: 16px;
}
.macro-gallery .gallery-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--vscode-descriptionForeground);
}
.macro-gallery .gallery-icon {
font-size: 2em;
}
.macro-gallery .gallery-info {
font-size: 0.9em;
font-weight: 500;
}
.macro-gallery .gallery-caption {
font-size: 0.85em;
font-style: italic;
opacity: 0.8;
}
/* YouTube macro preview */
.macro-youtube {
margin: 1em 0;
border-radius: 8px;
overflow: hidden;
}
.macro-youtube.youtube-preview .youtube-thumbnail {
position: relative;
width: 100%;
max-width: 480px;
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
background-color: #000;
border-radius: 8px;
overflow: hidden;
}
.macro-youtube .youtube-play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.3);
}
.macro-youtube .youtube-play-button {
width: 60px;
height: 42px;
background-color: rgba(255, 0, 0, 0.85);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5em;
}
.macro-youtube .youtube-title {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
font-size: 0.85em;
border-radius: 4px;
}
/* YouTube iframe container */
.macro-youtube .youtube-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}
.macro-youtube .youtube-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}

View File

@@ -16,6 +16,9 @@ import type { RemarkPlugin } from '@milkdown/kit/transformer';
import type { Root, List, ListItem } from 'mdast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { macroPlugin } from '../../plugins/macroPlugin';
// Import macros module to register all macro definitions
import '../../macros';
import './MilkdownEditor.css';
// Remark plugin to force tight lists (no blank lines between list items)
@@ -217,6 +220,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
})
.use(commonmark)
.use(gfm)
.use(macroPlugin)
.use(history)
.use(listener)
.use(clipboard)

View File

@@ -0,0 +1,80 @@
/**
* Gallery Macro
*
* Renders an image gallery from a linked media file or folder.
*
* Usage: [[gallery link="media/photos" columns="3" caption="My Photos"]]
*
* Parameters:
* - link (required): Path to media file or folder
* - columns: Number of columns (default: 3)
* - caption: Gallery caption
*/
import { registerMacro } from '../registry';
import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types';
const galleryMacro: MacroDefinition = {
name: 'gallery',
description: 'Renders an image gallery from linked media',
validate(params: MacroParams): string | undefined {
if (!params.link) {
return 'Gallery macro requires a "link" parameter';
}
if (params.columns) {
const cols = parseInt(params.columns, 10);
if (isNaN(cols) || cols < 1 || cols > 6) {
return 'Gallery columns must be a number between 1 and 6';
}
}
return undefined;
},
editorPreview(params: MacroParams): string {
const link = params.link || '?';
return `📷 Gallery: ${link}`;
},
render(params: MacroParams, context: MacroRenderContext): string {
const { link, columns = '3', caption } = params;
const colCount = parseInt(columns, 10) || 3;
// Build the gallery HTML
const classes = ['macro-gallery', `gallery-cols-${colCount}`];
if (context.isPreview) {
classes.push('gallery-preview');
}
let html = `<div class="${classes.join(' ')}" data-link="${link}">`;
// In preview mode, show a placeholder
// In production, this would load actual images
if (context.isPreview) {
html += `<div class="gallery-placeholder">`;
html += `<span class="gallery-icon">🖼️</span>`;
html += `<span class="gallery-info">Gallery: ${link}</span>`;
if (caption) {
html += `<span class="gallery-caption">${caption}</span>`;
}
html += `</div>`;
} else {
// Production render would load images here
// For now, create a placeholder that frontend JS can hydrate
html += `<div class="gallery-container" data-columns="${colCount}">`;
html += `<!-- Gallery images loaded dynamically from: ${link} -->`;
html += `</div>`;
if (caption) {
html += `<figcaption class="gallery-caption">${caption}</figcaption>`;
}
}
html += `</div>`;
return html;
},
};
// Self-register
registerMacro(galleryMacro);
export default galleryMacro;

View File

@@ -0,0 +1,17 @@
/**
* Macro Definitions Index
*
* This file imports all macro definitions so they self-register.
* To add a new macro:
* 1. Create a new file in this folder (e.g., myMacro.ts)
* 2. Implement MacroDefinition interface
* 3. Call registerMacro() at the end
* 4. Import it here
*/
// Import all macro definitions - they self-register on import
import './gallery';
import './youtube';
// Add new macro imports here:
// import './myNewMacro';

View File

@@ -0,0 +1,89 @@
/**
* YouTube Macro
*
* Embeds a YouTube video player.
*
* Usage: [[youtube id="dQw4w9WgXcQ" title="Video Title"]]
*
* Parameters:
* - id (required): YouTube video ID
* - title: Accessible title for the iframe
* - start: Start time in seconds
* - autoplay: Whether to autoplay (default: false)
*/
import { registerMacro } from '../registry';
import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types';
const youtubeMacro: MacroDefinition = {
name: 'youtube',
description: 'Embeds a YouTube video player',
validate(params: MacroParams): string | undefined {
if (!params.id) {
return 'YouTube macro requires an "id" parameter (the video ID)';
}
// Basic validation of YouTube ID format
if (!/^[\w-]{11}$/.test(params.id)) {
return 'Invalid YouTube video ID format';
}
return undefined;
},
editorPreview(params: MacroParams): string {
const id = params.id || '?';
const title = params.title;
return title ? `▶ YouTube: ${title}` : `▶ YouTube: ${id}`;
},
render(params: MacroParams, context: MacroRenderContext): string {
const { id, title = 'YouTube video', start, autoplay } = params;
// Build embed URL with parameters
const embedParams = new URLSearchParams();
if (start) {
embedParams.set('start', start);
}
if (autoplay === 'true') {
embedParams.set('autoplay', '1');
}
embedParams.set('rel', '0'); // Don't show related videos
const queryString = embedParams.toString();
const embedUrl = `https://www.youtube.com/embed/${id}${queryString ? '?' + queryString : ''}`;
if (context.isPreview) {
// In preview, show thumbnail with play button overlay
return `
<div class="macro-youtube youtube-preview">
<div class="youtube-thumbnail" style="background-image: url('https://img.youtube.com/vi/${id}/maxresdefault.jpg')">
<div class="youtube-play-overlay">
<span class="youtube-play-button">▶</span>
</div>
<span class="youtube-title">${title}</span>
</div>
</div>
`;
}
// Production render - full iframe embed
return `
<div class="macro-youtube">
<div class="youtube-container">
<iframe
src="${embedUrl}"
title="${title}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</div>
`;
},
};
// Self-register
registerMacro(youtubeMacro);
export default youtubeMacro;

View File

@@ -0,0 +1,42 @@
/**
* Macros Module
*
* Provides a simple extension system for rendering custom content blocks
* in markdown using [[macro param="value"]] syntax.
*
* Usage:
* 1. Import this module to register all macros
* 2. Use the registry functions to render macros
*
* Adding new macros:
* 1. Create a file in ./definitions/ (e.g., myMacro.ts)
* 2. Implement MacroDefinition interface
* 3. Call registerMacro() to self-register
* 4. Import the file in ./definitions/index.ts
*/
// Import all macro definitions so they register
import './definitions';
// Re-export types
export type {
MacroDefinition,
MacroParams,
MacroRenderContext,
ParsedMacro,
} from './types';
// Re-export registry functions
export {
registerMacro,
getMacro,
hasMacro,
getMacroNames,
getAllMacros,
clearMacros,
parseParams,
parseMacros,
renderMacro,
renderAllMacros,
getEditorPreview,
} from './registry';

View File

@@ -0,0 +1,207 @@
/**
* Macro Registry
*
* Central registry for all macro definitions.
* Macros self-register using registerMacro() function.
*/
import type { MacroDefinition, MacroParams, MacroRenderContext, ParsedMacro } from './types';
// Internal registry storage
const macroRegistry = new Map<string, MacroDefinition>();
/**
* Register a macro definition.
* Call this from each macro definition file.
*
* @param macro - The macro definition to register
* @throws Error if a macro with the same name is already registered
*/
export function registerMacro(macro: MacroDefinition): void {
const name = macro.name.toLowerCase();
if (macroRegistry.has(name)) {
console.warn(`Macro "${name}" is already registered. Overwriting.`);
}
macroRegistry.set(name, macro);
}
/**
* Get a macro definition by name.
*
* @param name - The macro name (case-insensitive)
* @returns The macro definition or undefined if not found
*/
export function getMacro(name: string): MacroDefinition | undefined {
return macroRegistry.get(name.toLowerCase());
}
/**
* Check if a macro is registered.
*
* @param name - The macro name (case-insensitive)
*/
export function hasMacro(name: string): boolean {
return macroRegistry.has(name.toLowerCase());
}
/**
* Get all registered macro names.
*/
export function getMacroNames(): string[] {
return Array.from(macroRegistry.keys());
}
/**
* Get all registered macro definitions.
*/
export function getAllMacros(): MacroDefinition[] {
return Array.from(macroRegistry.values());
}
/**
* Clear all registered macros (useful for testing).
*/
export function clearMacros(): void {
macroRegistry.clear();
}
// Regex to match [[macroName param1="value1" param2='value2']]
// Supports both single and double quotes for values
const MACRO_REGEX = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
// Regex to extract individual parameters
const PARAM_REGEX = /(\w+)=["']([^"']*?)["']/g;
/**
* Parse parameters from a macro parameter string.
*
* @param paramString - The parameter string (e.g., 'link="file.jpg" caption="Hello"')
* @returns Parsed key-value pairs
*/
export function parseParams(paramString: string | undefined): MacroParams {
if (!paramString) return {};
const params: MacroParams = {};
let match;
while ((match = PARAM_REGEX.exec(paramString)) !== null) {
params[match[1]] = match[2];
}
// Reset regex lastIndex for next use
PARAM_REGEX.lastIndex = 0;
return params;
}
/**
* Parse all macros from a markdown string.
*
* @param markdown - The markdown content to parse
* @returns Array of parsed macros with their positions
*/
export function parseMacros(markdown: string): ParsedMacro[] {
const macros: ParsedMacro[] = [];
let match;
while ((match = MACRO_REGEX.exec(markdown)) !== null) {
macros.push({
name: match[1].toLowerCase(),
params: parseParams(match[2]),
rawText: match[0],
start: match.index,
end: match.index + match[0].length,
});
}
// Reset regex lastIndex for next use
MACRO_REGEX.lastIndex = 0;
return macros;
}
/**
* Render a single macro to HTML.
*
* @param macro - The parsed macro
* @param context - Render context
* @returns The rendered HTML or an error placeholder
*/
export async function renderMacro(
macro: ParsedMacro,
context: MacroRenderContext
): Promise<string> {
const definition = getMacro(macro.name);
if (!definition) {
return `<span class="macro-error" title="Unknown macro: ${macro.name}">${macro.rawText}</span>`;
}
// Validate if validator exists
if (definition.validate) {
const error = definition.validate(macro.params);
if (error) {
return `<span class="macro-error" title="${error}">${macro.rawText}</span>`;
}
}
try {
const result = definition.render(macro.params, context);
return result instanceof Promise ? await result : result;
} catch (error) {
const message = error instanceof Error ? error.message : 'Render error';
return `<span class="macro-error" title="${message}">${macro.rawText}</span>`;
}
}
/**
* Render all macros in a markdown string to HTML.
* Returns the markdown with macros replaced by their rendered HTML.
*
* @param markdown - The markdown content
* @param context - Render context
* @returns Markdown with macros replaced by rendered HTML
*/
export async function renderAllMacros(
markdown: string,
context: MacroRenderContext
): Promise<string> {
const macros = parseMacros(markdown);
if (macros.length === 0) return markdown;
// Render all macros in parallel
const rendered = await Promise.all(
macros.map(macro => renderMacro(macro, context))
);
// Replace macros from end to start to preserve positions
let result = markdown;
for (let i = macros.length - 1; i >= 0; i--) {
const macro = macros[i];
result = result.slice(0, macro.start) + rendered[i] + result.slice(macro.end);
}
return result;
}
/**
* Get the editor preview text for a macro.
*
* @param name - The macro name
* @param params - The macro parameters
* @returns Preview text for the editor
*/
export function getEditorPreview(name: string, params: MacroParams): string {
const definition = getMacro(name);
if (!definition) {
return `${name}`;
}
if (definition.editorPreview) {
return definition.editorPreview(params);
}
return `${definition.name}`;
}

View File

@@ -0,0 +1,77 @@
/**
* Interface for macro definitions.
*
* Macros are custom content blocks that can be inserted into markdown
* using the syntax: [[macroName param1="value1" param2="value2"]]
*
* They are rendered in the editor with a shaded background showing the raw syntax,
* and can be transformed to custom HTML during preview/export.
*/
export interface MacroParams {
[key: string]: string;
}
export interface MacroRenderContext {
/** The post ID if available */
postId?: string;
/** Base path for resolving relative URLs */
basePath?: string;
/** Whether rendering for preview (true) or final export (false) */
isPreview: boolean;
}
export interface MacroDefinition {
/**
* Unique name for the macro (lowercase, no spaces).
* Used in the syntax: [[name ...]]
*/
name: string;
/**
* Human-readable description of what this macro does.
*/
description: string;
/**
* Render the macro to HTML for preview/export.
*
* @param params - Key-value pairs from the macro syntax
* @param context - Additional context for rendering
* @returns HTML string to replace the macro with
*/
render(params: MacroParams, context: MacroRenderContext): string | Promise<string>;
/**
* Optional: Short preview text shown in the editor.
* If not provided, shows the macro name.
*
* @param params - Key-value pairs from the macro syntax
* @returns Preview text (not HTML, just plain text)
*/
editorPreview?(params: MacroParams): string;
/**
* Optional: Validate macro parameters.
*
* @param params - Key-value pairs to validate
* @returns Error message if invalid, undefined if valid
*/
validate?(params: MacroParams): string | undefined;
}
/**
* Parsed macro from markdown content
*/
export interface ParsedMacro {
/** The macro name */
name: string;
/** Parsed parameters */
params: MacroParams;
/** Original raw text including [[ and ]] */
rawText: string;
/** Start position in the source text */
start: number;
/** End position in the source text */
end: number;
}

View File

@@ -0,0 +1,189 @@
/**
* Milkdown Macro Plugin
*
* Provides parsing and rendering of [[macro param="value"]] syntax in Milkdown.
* Macros appear with a shaded background in the editor.
*/
import { $node, $inputRule, $remark } from '@milkdown/kit/utils';
import { InputRule } from '@milkdown/kit/prose/inputrules';
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
import type { Plugin } from 'unified';
import type { Root, Text, Parent } from 'mdast';
import { visit } from 'unist-util-visit';
import { getEditorPreview, parseParams, hasMacro } from '../macros/registry';
// Regex to match [[macroName param1="value1" param2='value2']]
const MACRO_REGEX = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
/**
* Custom MDAST node type for macros
*/
interface MacroMdastNode {
type: 'macro';
name: string;
params: Record<string, string>;
raw: string;
}
/**
* Remark plugin to parse [[macro]] syntax into macro nodes
*/
const remarkMacroParser: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => {
if (index === undefined || parent === undefined) return;
const matches = [...node.value.matchAll(MACRO_REGEX)];
if (matches.length === 0) return;
// Build new nodes to replace this text node
const newNodes: (Text | MacroMdastNode)[] = [];
let lastIndex = 0;
for (const match of matches) {
const [fullMatch, name, paramStr] = match;
const startIdx = match.index!;
// Text before macro
if (startIdx > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, startIdx),
});
}
// Macro node
newNodes.push({
type: 'macro',
name: name.toLowerCase(),
params: parseParams(paramStr),
raw: fullMatch,
});
lastIndex = startIdx + fullMatch.length;
}
// Text after last macro
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// Replace the text node with our new nodes
parent.children.splice(index, 1, ...(newNodes as typeof parent.children));
// Return the index to skip newly inserted nodes
return index + newNodes.length;
});
};
};
/**
* Remark plugin registration for Milkdown
*/
export const remarkMacro = $remark('remarkMacro', () => remarkMacroParser);
/**
* ProseMirror node schema for macros
*/
export const macroNode = $node('macro', () => ({
group: 'inline',
inline: true,
atom: true, // Treated as a single unit, not editable as text
attrs: {
name: { default: '' },
params: { default: {} },
raw: { default: '' },
},
parseDOM: [
{
tag: 'span[data-macro]',
getAttrs: (dom): Record<string, unknown> => {
const element = dom as HTMLElement;
return {
name: element.dataset.macroName || '',
params: JSON.parse(element.dataset.macroParams || '{}'),
raw: element.dataset.macroRaw || '',
};
},
},
],
toDOM: (node: ProseNode) => {
const { name, params, raw } = node.attrs;
const preview = getEditorPreview(name, params);
const isKnown = hasMacro(name);
return [
'span',
{
'data-macro': 'true',
'data-macro-name': name,
'data-macro-params': JSON.stringify(params),
'data-macro-raw': raw,
class: `milkdown-macro ${isKnown ? 'macro-known' : 'macro-unknown'}`,
title: raw,
contenteditable: 'false',
},
preview,
];
},
parseMarkdown: {
match: (node) => node.type === 'macro',
runner: (state, node, type) => {
const macroNode = node as unknown as MacroMdastNode;
state.addNode(type, {
name: macroNode.name,
params: macroNode.params,
raw: macroNode.raw,
});
},
},
toMarkdown: {
match: (node) => node.type.name === 'macro',
runner: (state, node) => {
// Output the original raw macro syntax
state.addNode('text', undefined, node.attrs.raw);
},
},
}));
/**
* Input rule to convert typed [[macro...]] to macro node
* Triggers when user types ]] to close a macro
*/
export const macroInputRule = $inputRule(() => {
// Match [[macroName param="value"]] when user types the closing ]]
return new InputRule(
/\[\[(\w+)(?:\s+([^\]]+))?\]\]$/,
(state, match, start, end) => {
const [fullMatch, name, paramStr] = match;
const macroType = state.schema.nodes.macro;
if (!macroType) return null;
const params = parseParams(paramStr);
const macroNodeInstance = macroType.create({
name: name.toLowerCase(),
params,
raw: fullMatch,
});
return state.tr.replaceWith(start, end, macroNodeInstance);
}
);
});
/**
* Export all macro plugin components
* Use with: editor.use(macroPlugin)
*/
export const macroPlugin = [
remarkMacro,
macroNode,
macroInputRule,
].flat();
export default macroPlugin;