feat: macros for posts to extend page functionality
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
80
src/renderer/macros/definitions/gallery.ts
Normal file
80
src/renderer/macros/definitions/gallery.ts
Normal 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;
|
||||
17
src/renderer/macros/definitions/index.ts
Normal file
17
src/renderer/macros/definitions/index.ts
Normal 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';
|
||||
89
src/renderer/macros/definitions/youtube.ts
Normal file
89
src/renderer/macros/definitions/youtube.ts
Normal 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;
|
||||
42
src/renderer/macros/index.ts
Normal file
42
src/renderer/macros/index.ts
Normal 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';
|
||||
207
src/renderer/macros/registry.ts
Normal file
207
src/renderer/macros/registry.ts
Normal 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}`;
|
||||
}
|
||||
77
src/renderer/macros/types.ts
Normal file
77
src/renderer/macros/types.ts
Normal 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;
|
||||
}
|
||||
189
src/renderer/plugins/macroPlugin.ts
Normal file
189
src/renderer/plugins/macroPlugin.ts
Normal 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;
|
||||
384
tests/renderer/macros/registry.test.ts
Normal file
384
tests/renderer/macros/registry.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Tests for the Macro Registry
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
registerMacro,
|
||||
getMacro,
|
||||
hasMacro,
|
||||
getMacroNames,
|
||||
getAllMacros,
|
||||
clearMacros,
|
||||
parseParams,
|
||||
parseMacros,
|
||||
renderMacro,
|
||||
renderAllMacros,
|
||||
getEditorPreview,
|
||||
} from '../../../src/renderer/macros/registry';
|
||||
import type { MacroDefinition, MacroParams, MacroRenderContext, ParsedMacro } from '../../../src/renderer/macros/types';
|
||||
|
||||
// Helper to create a test macro
|
||||
function createTestMacro(overrides: Partial<MacroDefinition> = {}): MacroDefinition {
|
||||
return {
|
||||
name: 'testmacro',
|
||||
description: 'A test macro',
|
||||
render: (params: MacroParams) => `<div>Test: ${params.value || 'no value'}</div>`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Macro Registry', () => {
|
||||
beforeEach(() => {
|
||||
clearMacros();
|
||||
});
|
||||
|
||||
describe('registerMacro', () => {
|
||||
it('should register a macro successfully', () => {
|
||||
const macro = createTestMacro({ name: 'mymacro' });
|
||||
registerMacro(macro);
|
||||
|
||||
expect(hasMacro('mymacro')).toBe(true);
|
||||
expect(getMacro('mymacro')).toBe(macro);
|
||||
});
|
||||
|
||||
it('should store macro names as lowercase', () => {
|
||||
const macro = createTestMacro({ name: 'MyMacro' });
|
||||
registerMacro(macro);
|
||||
|
||||
expect(hasMacro('mymacro')).toBe(true);
|
||||
expect(hasMacro('MyMacro')).toBe(true);
|
||||
expect(hasMacro('MYMACRO')).toBe(true);
|
||||
});
|
||||
|
||||
it('should overwrite existing macro with warning', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const macro1 = createTestMacro({ name: 'dupe', description: 'First' });
|
||||
const macro2 = createTestMacro({ name: 'dupe', description: 'Second' });
|
||||
|
||||
registerMacro(macro1);
|
||||
registerMacro(macro2);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already registered'));
|
||||
expect(getMacro('dupe')?.description).toBe('Second');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMacro', () => {
|
||||
it('should return undefined for unregistered macro', () => {
|
||||
expect(getMacro('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return macro case-insensitively', () => {
|
||||
const macro = createTestMacro({ name: 'gallery' });
|
||||
registerMacro(macro);
|
||||
|
||||
expect(getMacro('gallery')).toBe(macro);
|
||||
expect(getMacro('Gallery')).toBe(macro);
|
||||
expect(getMacro('GALLERY')).toBe(macro);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMacroNames', () => {
|
||||
it('should return empty array when no macros registered', () => {
|
||||
expect(getMacroNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all registered macro names', () => {
|
||||
registerMacro(createTestMacro({ name: 'alpha' }));
|
||||
registerMacro(createTestMacro({ name: 'beta' }));
|
||||
registerMacro(createTestMacro({ name: 'gamma' }));
|
||||
|
||||
const names = getMacroNames();
|
||||
expect(names).toHaveLength(3);
|
||||
expect(names).toContain('alpha');
|
||||
expect(names).toContain('beta');
|
||||
expect(names).toContain('gamma');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllMacros', () => {
|
||||
it('should return all registered macro definitions', () => {
|
||||
const macro1 = createTestMacro({ name: 'one' });
|
||||
const macro2 = createTestMacro({ name: 'two' });
|
||||
|
||||
registerMacro(macro1);
|
||||
registerMacro(macro2);
|
||||
|
||||
const macros = getAllMacros();
|
||||
expect(macros).toHaveLength(2);
|
||||
expect(macros).toContain(macro1);
|
||||
expect(macros).toContain(macro2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseParams', () => {
|
||||
it('should parse double-quoted parameters', () => {
|
||||
const result = parseParams('foo="bar" baz="qux"');
|
||||
expect(result).toEqual({ foo: 'bar', baz: 'qux' });
|
||||
});
|
||||
|
||||
it('should parse single-quoted parameters', () => {
|
||||
const result = parseParams("foo='bar' baz='qux'");
|
||||
expect(result).toEqual({ foo: 'bar', baz: 'qux' });
|
||||
});
|
||||
|
||||
it('should parse mixed quotes', () => {
|
||||
const result = parseParams('foo="bar" baz=\'qux\'');
|
||||
expect(result).toEqual({ foo: 'bar', baz: 'qux' });
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(parseParams('')).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle undefined', () => {
|
||||
expect(parseParams(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle parameters with spaces in values', () => {
|
||||
const result = parseParams('title="Hello World" caption="Nice photo"');
|
||||
expect(result).toEqual({ title: 'Hello World', caption: 'Nice photo' });
|
||||
});
|
||||
|
||||
it('should handle parameters with special characters', () => {
|
||||
const result = parseParams('url="https://example.com/path?a=1&b=2"');
|
||||
expect(result).toEqual({ url: 'https://example.com/path?a=1&b=2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMacros', () => {
|
||||
it('should parse a single macro without params', () => {
|
||||
const result = parseMacros('Hello [[gallery]] world');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('gallery');
|
||||
expect(result[0].params).toEqual({});
|
||||
expect(result[0].rawText).toBe('[[gallery]]');
|
||||
});
|
||||
|
||||
it('should parse a macro with parameters', () => {
|
||||
const result = parseMacros('[[gallery link="photos" columns="3"]]');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('gallery');
|
||||
expect(result[0].params).toEqual({ link: 'photos', columns: '3' });
|
||||
});
|
||||
|
||||
it('should parse multiple macros', () => {
|
||||
const result = parseMacros('Intro [[gallery]] middle [[youtube id="abc"]] end');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('gallery');
|
||||
expect(result[1].name).toBe('youtube');
|
||||
expect(result[1].params).toEqual({ id: 'abc' });
|
||||
});
|
||||
|
||||
it('should capture start and end positions', () => {
|
||||
const text = 'Hello [[test]] world';
|
||||
const result = parseMacros(text);
|
||||
|
||||
expect(result[0].start).toBe(6);
|
||||
expect(result[0].end).toBe(14);
|
||||
expect(text.slice(result[0].start, result[0].end)).toBe('[[test]]');
|
||||
});
|
||||
|
||||
it('should return empty array for text without macros', () => {
|
||||
expect(parseMacros('Just regular text')).toEqual([]);
|
||||
expect(parseMacros('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle macro names case-insensitively', () => {
|
||||
const result = parseMacros('[[MyMacro]]');
|
||||
expect(result[0].name).toBe('mymacro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMacro', () => {
|
||||
const context: MacroRenderContext = { isPreview: true };
|
||||
|
||||
beforeEach(() => {
|
||||
clearMacros();
|
||||
});
|
||||
|
||||
it('should render a registered macro', async () => {
|
||||
registerMacro({
|
||||
name: 'hello',
|
||||
description: 'Says hello',
|
||||
render: (params) => `<span>Hello, ${params.name || 'world'}!</span>`,
|
||||
});
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'hello',
|
||||
params: { name: 'Alice' },
|
||||
rawText: '[[hello name="Alice"]]',
|
||||
start: 0,
|
||||
end: 22,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toBe('<span>Hello, Alice!</span>');
|
||||
});
|
||||
|
||||
it('should return error span for unknown macro', async () => {
|
||||
const macro: ParsedMacro = {
|
||||
name: 'unknown',
|
||||
params: {},
|
||||
rawText: '[[unknown]]',
|
||||
start: 0,
|
||||
end: 11,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toContain('macro-error');
|
||||
expect(result).toContain('Unknown macro');
|
||||
expect(result).toContain('[[unknown]]');
|
||||
});
|
||||
|
||||
it('should handle async render functions', async () => {
|
||||
registerMacro({
|
||||
name: 'async',
|
||||
description: 'Async macro',
|
||||
render: async (params) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return `<div>Async: ${params.val}</div>`;
|
||||
},
|
||||
});
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'async',
|
||||
params: { val: 'test' },
|
||||
rawText: '[[async val="test"]]',
|
||||
start: 0,
|
||||
end: 20,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toBe('<div>Async: test</div>');
|
||||
});
|
||||
|
||||
it('should return error span when validation fails', async () => {
|
||||
registerMacro({
|
||||
name: 'validated',
|
||||
description: 'Validated macro',
|
||||
validate: (params) => params.required ? undefined : 'Missing required param',
|
||||
render: () => '<div>OK</div>',
|
||||
});
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'validated',
|
||||
params: {},
|
||||
rawText: '[[validated]]',
|
||||
start: 0,
|
||||
end: 13,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toContain('macro-error');
|
||||
expect(result).toContain('Missing required param');
|
||||
});
|
||||
|
||||
it('should return error span when render throws', async () => {
|
||||
registerMacro({
|
||||
name: 'broken',
|
||||
description: 'Broken macro',
|
||||
render: () => { throw new Error('Render failed'); },
|
||||
});
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'broken',
|
||||
params: {},
|
||||
rawText: '[[broken]]',
|
||||
start: 0,
|
||||
end: 10,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toContain('macro-error');
|
||||
expect(result).toContain('Render failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderAllMacros', () => {
|
||||
const context: MacroRenderContext = { isPreview: true };
|
||||
|
||||
beforeEach(() => {
|
||||
clearMacros();
|
||||
});
|
||||
|
||||
it('should replace all macros in text', async () => {
|
||||
registerMacro({
|
||||
name: 'bold',
|
||||
description: 'Makes text bold',
|
||||
render: (params) => `<strong>${params.text}</strong>`,
|
||||
});
|
||||
|
||||
const input = 'Hello [[bold text="world"]]!';
|
||||
const result = await renderAllMacros(input, context);
|
||||
|
||||
expect(result).toBe('Hello <strong>world</strong>!');
|
||||
});
|
||||
|
||||
it('should handle multiple macros', async () => {
|
||||
registerMacro({
|
||||
name: 'a',
|
||||
description: 'Macro A',
|
||||
render: () => 'AAA',
|
||||
});
|
||||
registerMacro({
|
||||
name: 'b',
|
||||
description: 'Macro B',
|
||||
render: () => 'BBB',
|
||||
});
|
||||
|
||||
const input = '[[a]] and [[b]]';
|
||||
const result = await renderAllMacros(input, context);
|
||||
|
||||
expect(result).toBe('AAA and BBB');
|
||||
});
|
||||
|
||||
it('should return unchanged text when no macros', async () => {
|
||||
const input = 'Just plain text';
|
||||
const result = await renderAllMacros(input, context);
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEditorPreview', () => {
|
||||
beforeEach(() => {
|
||||
clearMacros();
|
||||
});
|
||||
|
||||
it('should return warning for unknown macro', () => {
|
||||
const result = getEditorPreview('unknown', {});
|
||||
expect(result).toBe('⚠ unknown');
|
||||
});
|
||||
|
||||
it('should return custom preview from macro definition', () => {
|
||||
registerMacro({
|
||||
name: 'gallery',
|
||||
description: 'Gallery',
|
||||
render: () => '',
|
||||
editorPreview: (params) => `📷 ${params.title || 'Gallery'}`,
|
||||
});
|
||||
|
||||
const result = getEditorPreview('gallery', { title: 'My Photos' });
|
||||
expect(result).toBe('📷 My Photos');
|
||||
});
|
||||
|
||||
it('should return default preview when editorPreview not defined', () => {
|
||||
registerMacro({
|
||||
name: 'simple',
|
||||
description: 'Simple macro',
|
||||
render: () => '',
|
||||
});
|
||||
|
||||
const result = getEditorPreview('simple', {});
|
||||
expect(result).toBe('⬡ simple');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user