2026-04-28 20:03:58 -07:00
|
|
|
/*
|
|
|
|
|
* macros.ts — macro parsing and Tag generation for ribbit.
|
|
|
|
|
*
|
2026-04-28 22:16:28 -07:00
|
|
|
* Macros use @name(...) syntax. Ribbit automatically wraps macro output
|
|
|
|
|
* in an element with data- attributes that preserve the original source.
|
|
|
|
|
* Round-tripping is handled generically — consumers only write toHTML.
|
2026-04-28 20:03:58 -07:00
|
|
|
*
|
|
|
|
|
* Syntax:
|
|
|
|
|
* @user — bare, no args
|
|
|
|
|
* @user() — empty parens, same as bare
|
|
|
|
|
* @npc(Goblin King) — self-closing with keywords
|
|
|
|
|
* @toc(depth="3") — self-closing with params
|
|
|
|
|
* @style(box center — block: newline after args = content
|
|
|
|
|
* **Bold** content here.
|
|
|
|
|
* )
|
|
|
|
|
* @style(box verbatim — verbatim block
|
|
|
|
|
* Literal <b>content</b>.
|
|
|
|
|
* )
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-28 23:08:20 -07:00
|
|
|
import type { Tag, Converter, ToolbarButton } from './types';
|
2026-04-28 20:03:58 -07:00
|
|
|
import { escapeHtml } from './tags';
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/* ── Constants ─────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
const VERBATIM_KEYWORD = 'verbatim';
|
|
|
|
|
const VERBATIM_DATA_VALUE = 'true';
|
|
|
|
|
const DATASET_PARAM_PREFIX = 'param';
|
|
|
|
|
const DATASET_PARAM_PREFIX_LENGTH = 5;
|
|
|
|
|
const PLACEHOLDER_SENTINEL = '\x00P';
|
|
|
|
|
const PLACEHOLDER_TERMINATOR = '\x00';
|
|
|
|
|
|
|
|
|
|
/* Named regex for key="value" pairs inside macro argument strings */
|
|
|
|
|
const PARAM_PATTERN = /(?<paramKey>\w+)="(?<paramValue>[^"]*)"/g;
|
|
|
|
|
|
|
|
|
|
/* Matches the opening line of a block macro: @name(args with no closing paren */
|
|
|
|
|
const BLOCK_MACRO_OPEN = /^@(?<macroName>\w+)\((?<macroArgs>[^)]*)\s*$/;
|
|
|
|
|
|
|
|
|
|
/* Matches a line that closes a block macro body */
|
|
|
|
|
const BLOCK_CLOSE_LINE = /^\)\s*$/;
|
|
|
|
|
|
|
|
|
|
/* Matches a nested block macro opening inside a body */
|
|
|
|
|
const NESTED_BLOCK_OPEN = /^@\w+\([^)]*\s*$/;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Matches inline macros: `@name` or `@name(args)`.
|
|
|
|
|
* The lookbehind ensures macros only start after whitespace or
|
|
|
|
|
* markdown punctuation, preventing false matches mid-word.
|
|
|
|
|
*
|
|
|
|
|
* Named groups:
|
|
|
|
|
* inlineName — the macro name after @
|
|
|
|
|
* inlineArgs — optional parenthesized arguments
|
|
|
|
|
*/
|
|
|
|
|
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(?<inlineName>\w+)(?:\((?<inlineArgs>[^)]*)\))?/g;
|
|
|
|
|
|
|
|
|
|
/* ── Public interfaces ─────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Definition for a macro that can be registered with ribbit.
|
|
|
|
|
*
|
|
|
|
|
* Each macro provides a name and a `toHTML` renderer. Ribbit handles
|
|
|
|
|
* wrapping, round-tripping, and toolbar integration automatically.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* ```ts
|
|
|
|
|
* const userMacro: MacroDef = {
|
|
|
|
|
* name: 'user',
|
|
|
|
|
* toHTML: () => '<a href="/User/gsb">gsb</a>',
|
|
|
|
|
* };
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* ```ts
|
|
|
|
|
* const styleMacro: MacroDef = {
|
|
|
|
|
* name: 'style',
|
|
|
|
|
* toHTML: ({ keywords, content }) =>
|
|
|
|
|
* `<div class="${keywords.join(' ')}">${content}</div>`,
|
|
|
|
|
* };
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2026-04-28 20:03:58 -07:00
|
|
|
export interface MacroDef {
|
|
|
|
|
name: string;
|
|
|
|
|
/**
|
2026-04-28 22:16:28 -07:00
|
|
|
* Render the macro's inner HTML. Ribbit wraps the result in an
|
|
|
|
|
* element with data- attributes for round-tripping.
|
2026-04-28 20:03:58 -07:00
|
|
|
*
|
2026-04-28 22:16:28 -07:00
|
|
|
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' }
|
|
|
|
|
* { name: 'style', toHTML: ({ keywords, content }) =>
|
|
|
|
|
* `<div class="${keywords.join(' ')}">${content}</div>` }
|
2026-04-28 20:03:58 -07:00
|
|
|
*/
|
|
|
|
|
toHTML: (context: {
|
|
|
|
|
keywords: string[];
|
|
|
|
|
params: Record<string, string>;
|
|
|
|
|
content?: string;
|
|
|
|
|
convert: Converter;
|
|
|
|
|
}) => string;
|
2026-04-28 23:08:20 -07:00
|
|
|
/**
|
|
|
|
|
* Toolbar button. Set to false to hide from the macros dropdown.
|
|
|
|
|
* Default: auto-generated from the macro name.
|
|
|
|
|
*/
|
|
|
|
|
button?: ToolbarButton | false;
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Internal representation of a fully parsed macro invocation. */
|
2026-04-28 20:03:58 -07:00
|
|
|
interface ParsedMacro {
|
|
|
|
|
name: string;
|
|
|
|
|
keywords: string[];
|
|
|
|
|
params: Record<string, string>;
|
|
|
|
|
verbatim: boolean;
|
|
|
|
|
content?: string;
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Number of source lines consumed by this macro (for block advancement). */
|
2026-04-28 20:03:58 -07:00
|
|
|
consumed: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/* ── Module-level helpers ──────────────────────────────────────── */
|
2026-04-28 20:03:58 -07:00
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Parse the argument string from a macro invocation into keywords,
|
|
|
|
|
* key="value" params, and a verbatim flag.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* ```ts
|
|
|
|
|
* parseArgs('box center depth="3"')
|
|
|
|
|
* // { keywords: ['box', 'center'], params: { depth: '3' }, verbatim: false }
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
function parseArgs(argumentString: string | undefined): {
|
2026-04-28 20:03:58 -07:00
|
|
|
keywords: string[];
|
|
|
|
|
params: Record<string, string>;
|
|
|
|
|
verbatim: boolean;
|
|
|
|
|
} {
|
2026-04-29 15:48:36 -07:00
|
|
|
if (!argumentString || !argumentString.trim()) {
|
|
|
|
|
return {
|
|
|
|
|
keywords: [],
|
|
|
|
|
params: {},
|
|
|
|
|
verbatim: false,
|
|
|
|
|
};
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|
|
|
|
|
const params: Record<string, string> = {};
|
2026-04-29 15:48:36 -07:00
|
|
|
/* Strip key="value" pairs, collecting them into params */
|
|
|
|
|
const withoutParams = argumentString.replace(
|
|
|
|
|
new RegExp(PARAM_PATTERN.source, 'g'),
|
|
|
|
|
(_match, paramKey, paramValue) => {
|
|
|
|
|
params[paramKey] = paramValue;
|
|
|
|
|
return '';
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-28 20:03:58 -07:00
|
|
|
const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean);
|
2026-04-29 15:48:36 -07:00
|
|
|
const verbatim = allKeywords.includes(VERBATIM_KEYWORD);
|
|
|
|
|
const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD);
|
|
|
|
|
return {
|
|
|
|
|
keywords,
|
|
|
|
|
params,
|
|
|
|
|
verbatim,
|
|
|
|
|
};
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function macroError(name: string): string {
|
|
|
|
|
return `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 22:16:28 -07:00
|
|
|
/**
|
|
|
|
|
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
|
2026-04-29 15:48:36 -07:00
|
|
|
* Block macros (with content) use `<div>`, inline macros use `<span>`.
|
2026-04-28 22:16:28 -07:00
|
|
|
*/
|
|
|
|
|
function wrapMacro(
|
|
|
|
|
name: string,
|
|
|
|
|
keywords: string[],
|
|
|
|
|
params: Record<string, string>,
|
|
|
|
|
verbatim: boolean,
|
|
|
|
|
hasContent: boolean,
|
|
|
|
|
innerHtml: string,
|
|
|
|
|
): string {
|
|
|
|
|
const tag = hasContent ? 'div' : 'span';
|
|
|
|
|
let attrs = ` data-macro="${escapeHtml(name)}"`;
|
|
|
|
|
if (keywords.length) {
|
|
|
|
|
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
|
|
|
|
attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`;
|
2026-04-28 22:16:28 -07:00
|
|
|
}
|
|
|
|
|
if (verbatim) {
|
2026-04-29 15:48:36 -07:00
|
|
|
attrs += ` data-verbatim="${VERBATIM_DATA_VALUE}"`;
|
2026-04-28 22:16:28 -07:00
|
|
|
}
|
|
|
|
|
return `<${tag}${attrs}>${innerHtml}</${tag}>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reconstruct macro source from a DOM element's data- attributes.
|
2026-04-29 15:48:36 -07:00
|
|
|
* This is the generic toMarkdown for all macros — it reads the
|
|
|
|
|
* data- attributes that wrapMacro wrote and rebuilds the @name(...)
|
|
|
|
|
* syntax so the document can round-trip without per-macro logic.
|
2026-04-28 22:16:28 -07:00
|
|
|
*/
|
|
|
|
|
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
|
|
|
|
|
const name = element.dataset.macro || '';
|
|
|
|
|
const keywords = element.dataset.keywords || '';
|
2026-04-29 15:48:36 -07:00
|
|
|
const verbatim = element.dataset.verbatim === VERBATIM_DATA_VALUE;
|
2026-04-28 22:16:28 -07:00
|
|
|
|
|
|
|
|
const paramParts: string[] = [];
|
2026-04-29 15:48:36 -07:00
|
|
|
for (const [datasetKey, datasetValue] of Object.entries(element.dataset)) {
|
|
|
|
|
if (datasetKey.startsWith(DATASET_PARAM_PREFIX) && datasetKey.length > DATASET_PARAM_PREFIX_LENGTH) {
|
|
|
|
|
const paramName = datasetKey.slice(DATASET_PARAM_PREFIX_LENGTH).toLowerCase();
|
|
|
|
|
paramParts.push(`${paramName}="${datasetValue}"`);
|
2026-04-28 22:16:28 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allKeywords = verbatim
|
2026-04-29 15:48:36 -07:00
|
|
|
? [keywords, VERBATIM_KEYWORD].filter(Boolean).join(' ')
|
2026-04-28 22:16:28 -07:00
|
|
|
: keywords;
|
|
|
|
|
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
|
|
|
|
|
|
|
|
|
|
const isBlock = element.tagName === 'DIV';
|
|
|
|
|
if (isBlock) {
|
|
|
|
|
const content = convert.children(element);
|
|
|
|
|
return `\n\n@${name}(${args}\n${content}\n)\n\n`;
|
|
|
|
|
}
|
|
|
|
|
return args ? `@${name}(${args})` : `@${name}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 20:03:58 -07:00
|
|
|
/**
|
|
|
|
|
* Try to parse a block macro starting at the given line index.
|
2026-04-29 15:48:36 -07:00
|
|
|
* Returns null if the line doesn't start a block macro or the
|
|
|
|
|
* closing paren is never found (unclosed macro).
|
2026-04-28 20:03:58 -07:00
|
|
|
*/
|
2026-04-29 15:48:36 -07:00
|
|
|
function parseBlockMacro(lines: string[], lineIndex: number): ParsedMacro | null {
|
|
|
|
|
const line = lines[lineIndex];
|
|
|
|
|
const openMatch = BLOCK_MACRO_OPEN.exec(line);
|
|
|
|
|
if (!openMatch || !openMatch.groups) {
|
2026-04-28 20:03:58 -07:00
|
|
|
return null;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
const name = openMatch.groups.macroName;
|
|
|
|
|
const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs);
|
|
|
|
|
|
2026-04-28 20:03:58 -07:00
|
|
|
const contentLines: string[] = [];
|
2026-04-29 15:48:36 -07:00
|
|
|
let scanIndex = lineIndex + 1;
|
|
|
|
|
let nestingDepth = 1;
|
|
|
|
|
while (scanIndex < lines.length && nestingDepth > 0) {
|
|
|
|
|
if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) {
|
|
|
|
|
nestingDepth--;
|
|
|
|
|
if (nestingDepth === 0) {
|
2026-04-28 20:03:58 -07:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) {
|
|
|
|
|
nestingDepth++;
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
contentLines.push(lines[scanIndex]);
|
|
|
|
|
scanIndex++;
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
/* Unclosed macro — treat as plain text */
|
|
|
|
|
if (nestingDepth !== 0) {
|
2026-04-28 20:03:58 -07:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
keywords,
|
|
|
|
|
params,
|
|
|
|
|
verbatim,
|
|
|
|
|
content: contentLines.join('\n'),
|
2026-04-29 15:48:36 -07:00
|
|
|
consumed: scanIndex + 1 - lineIndex,
|
2026-04-28 20:03:58 -07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/* ── Public API ────────────────────────────────────────────────── */
|
2026-04-28 20:03:58 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build Tags from an array of macro definitions.
|
2026-04-29 15:48:36 -07:00
|
|
|
*
|
|
|
|
|
* Returns a block-level Tag for parsing `@name(args\ncontent\n)` syntax,
|
|
|
|
|
* a selector Tag for HTML→markdown round-tripping, and a lookup map
|
|
|
|
|
* for inline macro processing.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* ```ts
|
|
|
|
|
* const { blockTag, selectorTag, macroMap } = buildMacroTags([
|
|
|
|
|
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' },
|
|
|
|
|
* ]);
|
|
|
|
|
* ```
|
2026-04-28 20:03:58 -07:00
|
|
|
*/
|
|
|
|
|
export function buildMacroTags(
|
|
|
|
|
macros: MacroDef[],
|
2026-04-28 22:16:28 -07:00
|
|
|
): { blockTag: Tag; selectorTag: Tag; macroMap: Map<string, MacroDef> } {
|
2026-04-28 20:03:58 -07:00
|
|
|
const macroMap = new Map<string, MacroDef>();
|
|
|
|
|
for (const macro of macros) {
|
|
|
|
|
macroMap.set(macro.name, macro);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blockTag: Tag = {
|
|
|
|
|
name: 'macro',
|
|
|
|
|
match: (context) => {
|
|
|
|
|
const parsed = parseBlockMacro(context.lines, context.index);
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
content: parsed.content || '',
|
|
|
|
|
raw: JSON.stringify(parsed),
|
|
|
|
|
consumed: parsed.consumed,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
toHTML: (token, convert) => {
|
|
|
|
|
const parsed: ParsedMacro = JSON.parse(token.raw);
|
|
|
|
|
const macro = macroMap.get(parsed.name);
|
|
|
|
|
if (!macro) {
|
|
|
|
|
return macroError(parsed.name);
|
|
|
|
|
}
|
|
|
|
|
let content = parsed.content;
|
|
|
|
|
if (content !== undefined) {
|
|
|
|
|
if (parsed.verbatim) {
|
|
|
|
|
content = escapeHtml(content.trim()).replace(/\n/g, '<br>\n');
|
|
|
|
|
} else {
|
|
|
|
|
content = convert.block(content);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-28 22:16:28 -07:00
|
|
|
const innerHtml = macro.toHTML({
|
2026-04-28 20:03:58 -07:00
|
|
|
keywords: parsed.keywords,
|
|
|
|
|
params: parsed.params,
|
|
|
|
|
content,
|
|
|
|
|
convert,
|
|
|
|
|
});
|
2026-04-28 22:16:28 -07:00
|
|
|
return wrapMacro(
|
|
|
|
|
parsed.name, parsed.keywords, parsed.params,
|
|
|
|
|
parsed.verbatim, true, innerHtml,
|
|
|
|
|
);
|
2026-04-28 20:03:58 -07:00
|
|
|
},
|
|
|
|
|
selector: '[data-macro]',
|
|
|
|
|
toMarkdown: () => '',
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 22:16:28 -07:00
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Generic selector tag — matches any element with data-macro
|
2026-04-28 22:16:28 -07:00
|
|
|
* and reconstructs the macro source from data- attributes.
|
2026-04-29 15:48:36 -07:00
|
|
|
* Separate from blockTag so the selector-based HTML→markdown
|
|
|
|
|
* path can find macro elements independently.
|
2026-04-28 22:16:28 -07:00
|
|
|
*/
|
|
|
|
|
const selectorTag: Tag = {
|
|
|
|
|
name: 'macro:generic',
|
|
|
|
|
match: () => null,
|
|
|
|
|
toHTML: () => '',
|
|
|
|
|
selector: '[data-macro]',
|
|
|
|
|
toMarkdown: macroToMarkdown,
|
|
|
|
|
};
|
2026-04-28 20:03:58 -07:00
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
return {
|
|
|
|
|
blockTag,
|
|
|
|
|
selectorTag,
|
|
|
|
|
macroMap,
|
|
|
|
|
};
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process inline macros in a text string, replacing them with rendered HTML.
|
2026-04-29 15:48:36 -07:00
|
|
|
*
|
|
|
|
|
* Inline macros are replaced with placeholder tokens so that subsequent
|
|
|
|
|
* inline parsing (bold, italic, etc.) doesn't mangle the HTML output.
|
|
|
|
|
* The caller restores placeholders after all inline processing is done.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* ```ts
|
|
|
|
|
* const placeholders: string[] = [];
|
|
|
|
|
* const result = processInlineMacros(
|
|
|
|
|
* 'Hello @user!',
|
|
|
|
|
* macroMap,
|
|
|
|
|
* convert,
|
|
|
|
|
* placeholders,
|
|
|
|
|
* );
|
|
|
|
|
* ```
|
2026-04-28 20:03:58 -07:00
|
|
|
*/
|
|
|
|
|
export function processInlineMacros(
|
|
|
|
|
text: string,
|
|
|
|
|
macroMap: Map<string, MacroDef>,
|
|
|
|
|
convert: Converter,
|
|
|
|
|
placeholders: string[],
|
|
|
|
|
): string {
|
2026-04-29 15:48:36 -07:00
|
|
|
return text.replace(
|
|
|
|
|
INLINE_MACRO_GLOBAL,
|
|
|
|
|
(match, ...args) => {
|
|
|
|
|
/* Named groups are the last non-offset argument from replace() */
|
|
|
|
|
const groups = args[args.length - 1] as { inlineName: string; inlineArgs?: string };
|
|
|
|
|
const macroName = groups.inlineName;
|
|
|
|
|
const macro = macroMap.get(macroName);
|
|
|
|
|
if (!macro) {
|
|
|
|
|
placeholders.push(macroError(macroName));
|
|
|
|
|
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
|
|
|
|
|
}
|
|
|
|
|
const { keywords, params } = parseArgs(groups.inlineArgs);
|
|
|
|
|
const innerHtml = macro.toHTML({
|
|
|
|
|
keywords,
|
|
|
|
|
params,
|
|
|
|
|
convert,
|
|
|
|
|
});
|
|
|
|
|
const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml);
|
|
|
|
|
placeholders.push(wrapped);
|
|
|
|
|
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-28 20:03:58 -07:00
|
|
|
}
|