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
2,785 changes: 1,441 additions & 1,344 deletions package-lock.json

Large diffs are not rendered by default.

59 changes: 28 additions & 31 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,50 +42,47 @@
"test": "vitest"
},
"dependencies": {
"@types/hast": "^2.0.0",
"@types/mdast": "^3.0.0",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"debug": "^4.4.3",
"doctype": "^3.0.1",
"github-slugger": "^2.0.0",
"hast-util-find-and-replace": "^3.2.1",
"hast-util-is-element": "^2.1.3",
"hast-util-select": "^5.0.5",
"hastscript": "^7.2.0",
"hast-util-find-and-replace": "^5.0.1",
"hast-util-is-element": "^3.0.0",
"hast-util-select": "^6.0.4",
"hastscript": "^9.0.1",
"js-yaml": "^4.1.0",
"md-attr-parser": "^1.3.0",
"mdast-util-find-and-replace": "^2.2.2",
"mdast-util-to-hast": "^11.3.0",
"mdast-util-to-string": "^3.2.0",
"mdast-util-find-and-replace": "^3.0.2",
"mdast-util-to-hast": "^13.2.1",
"mdast-util-to-string": "^4.0.0",
"meow": "^13.2.0",
"refractor": "^3.6.0",
"rehype-format": "^3.1.0",
"rehype-raw": "^5.1.0",
"rehype-stringify": "^8.0.0",
"remark-attr": "^0.11.1",
"remark-breaks": "^1.0.5",
"remark-footnotes": "^2.0.0",
"remark-frontmatter": "^2.0.0",
"remark-parse": "^8.0.2",
"remark-rehype": "^8.1.0",
"remark-shortcodes": "^0.3.1",
"to-vfile": "^6.1.0",
"unified": "^9.2.0",
"unist-builder": "^3.0.1",
"unist-util-filter": "^4.0.1",
"unist-util-find-after": "^4.0.1",
"unist-util-inspect": "^7.0.2",
"unist-util-remove": "^3.1.1",
"unist-util-select": "^4.0.3",
"unist-util-visit": "^4.1.2",
"unist-util-visit-parents": "^5.1.3"
"refractor": "^4.0.0",
"rehype-format": "^5.0.1",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"to-vfile": "^8.0.0",
"unified": "^11.0.5",
"unist-builder": "^4.0.0",
"unist-util-filter": "^5.0.1",
"unist-util-find-after": "^5.0.0",
"unist-util-inspect": "^8.1.0",
"unist-util-remove": "^4.0.0",
"unist-util-select": "^5.1.0",
"unist-util-visit": "^5.0.0",
"unist-util-visit-parents": "^6.0.2"
},
"devDependencies": {
"@release-it/conventional-changelog": "^10.0.1",
"@types/common-tags": "^1.8.4",
"@types/debug": "^4.1.12",
"@types/js-yaml": "^4.0.9",
"@types/node": "^18.7.21",
"@types/refractor": "^3.0.2",
"@typescript-eslint/eslint-plugin": "^5.38.0",
"@typescript-eslint/parser": "^5.38.0",
"common-tags": "^1.8.2",
Expand Down
11 changes: 5 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import rehypeFormat from 'rehype-format';
import rehypeStringify from 'rehype-stringify';
import unified, { Processor } from 'unified';
import { unified, type Processor } from 'unified';
import { mdast as doc } from './plugins/document.js';
import { hast as hastMath } from './plugins/math.js';
import { FootnoteFactory } from './plugins/footnotes.js';
import { Properties } from 'hast';
import { Metadata, readMetadata } from './plugins/metadata.js';
import { replace as handleReplace, ReplaceRule } from './plugins/replace.js';
import type { FootnoteFactory } from './plugins/footnotes.js';
import type { Properties } from 'hast';
import { type Metadata, readMetadata } from './plugins/metadata.js';
import { replace as handleReplace, type ReplaceRule } from './plugins/replace.js';
import { reviveParse as markdown } from './revive-parse.js';
import { reviveRehype as html } from './revive-rehype.js';
import { debug } from './utils.js';
Expand Down Expand Up @@ -136,7 +136,6 @@ export function VFM(

const processor = unified()
.use(markdown(hardLineBreaks, math))
.data('settings', { position: true })
.use(
html({ imgFigcaptionOrder, assignIdToFigcaption, endnotesAsFootnotes }),
);
Expand Down
158 changes: 137 additions & 21 deletions src/plugins/attr.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,137 @@
import attr from 'remark-attr';

export const mdast = [
attr,
{
enableAtxHeaderInline: true,
scope: 'permissive',
elements: [
'link',
'atxHeading',
'strong',
'emphasis',
'code',
'deletion',
'reference',
'footnoteCall',
'autoLink',
'fencedCode',
],
},
];
import type { Code, Heading, Image, Root, Paragraph, PhrasingContent } from 'mdast';
import parseAttr from 'md-attr-parser';
import type { Node, Parent } from 'unist';
import { visit } from 'unist-util-visit';

/**
* Parse `{...}` attribute block from the end of a text value.
* Returns parsed props and the cleaned text, or null if no valid block found.
*/
function parseTrailingAttr(value: string): {
prop: Record<string, any>;
cleaned: string;
} | null {
const match = /\{([^}]+)\}\s*$/.exec(value);
if (!match) return null;

const parsed = parseAttr(match[0]);
if (!parsed.eaten) return null;

const prop: Record<string, any> = {};
for (const [k, v] of Object.entries(parsed.prop)) {
if (v !== undefined) prop[k] = v;
}

// If md-attr-parser found no valid props, check for standalone boolean attributes
// e.g., {hidden} → hidden: true
if (Object.keys(prop).length === 0) {
const inner = match[1].trim();
// Only accept simple word-like attribute names (no spaces, no special chars except hyphens)
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(inner)) {
prop[inner] = true;
} else {
return null;
}
}

return { prop, cleaned: value.slice(0, match.index).trimEnd() };
}

/**
* Apply parsed attributes to a node's hProperties.
*/
function applyHProperties(node: { data?: any }, prop: Record<string, any>) {
if (!node.data) node.data = {};
node.data.hProperties = {
...(node.data.hProperties as Record<string, unknown>),
...prop,
};
}

/**
* Custom attribute parser transformer (replaces remark-attr).
* Parses `{#id .class key=value}` syntax on headings and images.
*/
export const mdast = () => (tree: Node) => {
// Process headings
visit(tree as Root, 'heading', (node: Heading, index, parent) => {
// First, check if the last text child has trailing {attrs}
const lastChild = node.children[node.children.length - 1];
if (lastChild && lastChild.type === 'text') {
const result = parseTrailingAttr(lastChild.value);
if (result) {
lastChild.value = result.cleaned;
if (lastChild.value === '') {
node.children.pop();
}
applyHProperties(node, result.prop);
return;
}
}

// Check next sibling paragraph for line-break attribute specification:
// # Heading
// {#foo}
if (parent && typeof index === 'number') {
const nextSibling = (parent as Parent).children[index + 1];
if (
nextSibling &&
nextSibling.type === 'paragraph' &&
(nextSibling as Paragraph).children.length === 1 &&
(nextSibling as Paragraph).children[0].type === 'text'
) {
const textNode = (nextSibling as Paragraph).children[0] as { value: string };
const fullMatch = /^\{([^}]+)\}\s*$/.exec(textNode.value);
if (fullMatch) {
const result = parseTrailingAttr(textNode.value);
if (result) {
// Remove the attribute paragraph
(parent as Parent).children.splice(index + 1, 1);
applyHProperties(node, result.prop);
return;
}
}
}
}
});

// Process images: ![caption](url){#id .class key=value}
visit(tree as Root, 'paragraph', (node: Paragraph) => {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (child.type !== 'image') continue;

// Check if next sibling is a text node starting with {attrs}
const nextChild = node.children[i + 1];
if (!nextChild || nextChild.type !== 'text') continue;

const textValue = (nextChild as { value: string }).value;
const match = /^\{([^}]+)\}/.exec(textValue);
if (!match) continue;

const parsed = parseAttr(match[0]);
if (!parsed.eaten) continue;

const hasValidAttrs =
parsed.prop.id !== undefined ||
parsed.prop.class !== undefined ||
Object.values(parsed.prop).some((v) => v !== undefined);
if (!hasValidAttrs) continue;

const prop: Record<string, any> = {};
for (const [k, v] of Object.entries(parsed.prop)) {
if (v !== undefined) prop[k] = v;
}

applyHProperties(child, prop);

// Remove the consumed attribute text
const remaining = textValue.slice(match[0].length);
if (remaining === '' || /^\s*$/.test(remaining)) {
node.children.splice(i + 1, 1);
} else {
(nextChild as { value: string }).value = remaining;
}
}
});
};
65 changes: 32 additions & 33 deletions src/plugins/code.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ElementContent as HastElementContent } from 'hast';
import { Code, Root } from 'mdast';
import { Handler } from 'mdast-util-to-hast';
import type { ElementContent as HastElementContent } from 'hast';
import type { Code, Root } from 'mdast';
import type { Handler, State } from 'mdast-util-to-hast';
import parseAttr from 'md-attr-parser';
import refractor from 'refractor';
import { Node } from 'unist';
import { refractor } from 'refractor';
import type { Node } from 'unist';
import { u } from 'unist-builder';
import { visit } from 'unist-util-visit';

Expand All @@ -13,19 +13,12 @@ interface HProperties {
title?: string;
[key: string]: unknown;
}
declare module 'mdast' {
interface Code {
data?: {
hProperties?: HProperties;
hChildren?: HastElementContent[] | ReturnType<typeof refractor.highlight>;
};
}
}

function getHProperties(node: Code): HProperties {
return (node.data?.hProperties as HProperties) ?? {};
return ((node.data as any)?.hProperties as HProperties) ?? {};
}
function setHProperties(node: Code, props: HProperties): void {
node.data = { ...(node.data ?? {}), hProperties: props };
(node as any).data = { ...((node as any).data ?? {}), hProperties: props };
}

/**
Expand Down Expand Up @@ -141,34 +134,27 @@ function processMeta(node: Code): void {
export function mdast() {
return (tree: Node) => {
visit(tree as Root, 'code', (node) => {
/**
* Workaround for remark-attr's "bad hack".
* When meta is null, remark-attr parses code content as attributes.
* @see https://github.com/arobase-che/remark-attr/blob/325f0ef730eafa601c9b631ea175b26c18c85a4a/src/index.js#L260-L263
*/
if (!node.meta && node.data?.hProperties) {
delete node.data.hProperties;
}

extractLangTitle(node);
processMeta(node);

// syntax highlight
if (node.lang && refractor.registered(node.lang)) {
if (!node.data) node.data = {};
node.data.hChildren = refractor.highlight(node.value, node.lang);
if (!(node as any).data) (node as any).data = {};
const highlighted = refractor.highlight(node.value, node.lang);
// refractor v4 returns a Root node; extract its children
(node as any).data.hChildren = highlighted.children;
}
});
};
}

export function handler(h: any, node: any): Handler {
export const handler: Handler = (state, node) => {
const value = node.value || '';
const lang = node.lang ? node.lang.match(/^[^ \t]+(?=[ \t]|$)/) : 'text';
const langClass = 'language-' + lang;

// Merge language-* class with hProperties.class if present
const hProps = node.data?.hProperties ?? {};
const hProps = (node as any).data?.hProperties ?? {};
const hClass = hProps.class;
const className = hClass
? [langClass, ...(Array.isArray(hClass) ? hClass : [hClass])]
Expand All @@ -177,9 +163,22 @@ export function handler(h: any, node: any): Handler {
const preProps = { className: [langClass] };
const codeProps = { ...hProps, className };
// Use hChildren for syntax highlighting if available, otherwise plain text
const children = node.data?.hChildren ?? [u('text', value)];
const children: HastElementContent[] = (node as any).data?.hChildren ?? [u('text', value)];

return h(node.position, 'pre', preProps, [
h(node.position, 'code', codeProps, children),
]);
}
const codeEl: import('hast').Element = {
type: 'element',
tagName: 'code',
properties: codeProps,
children,
};

const preEl: import('hast').Element = {
type: 'element',
tagName: 'pre',
properties: preProps,
children: [codeEl],
};

state.patch(node, preEl);
return preEl;
};
Loading