Compare commits

...

2 Commits

Author SHA1 Message Date
Dmitriy Pleshevskiy 1d708bc95f
par(md): add link 2022-06-12 22:57:56 +03:00
Dmitriy Pleshevskiy c9598e7997
ren(html): fix encoding soft break 2022-06-12 14:18:45 +03:00
4 changed files with 160 additions and 20 deletions

View File

@ -113,3 +113,81 @@ world`;
assertEquals(ren.render(par.parse(input)), "<p>hello world</p>");
},
});
// Link
Deno.test({
name: "should parse link",
fn: () => {
const par = new MarkdownParser();
assertEquals(
ren.render(par.parse("[]()")),
'<p><a href="#"></a></p>',
);
assertEquals(
ren.render(par.parse("[hello]()")),
'<p><a href="#">hello</a></p>',
);
assertEquals(
ren.render(par.parse("[hello]()")),
'<p><a href="#">hello</a></p>',
);
},
});
Deno.test({
name: "should parse link destination",
fn: () => {
const par = new MarkdownParser();
assertEquals(
ren.render(par.parse("[](/hello)")),
'<p><a href="/hello"></a></p>',
);
assertEquals(
ren.render(par.parse("[](/hello?key=value&key2=value2)")),
'<p><a href="/hello?key=value&key2=value2"></a></p>',
);
assertEquals(
ren.render(par.parse("[hello](https://example.com)")),
'<p><a href="https://example.com">hello</a></p>',
);
assertEquals(
ren.render(par.parse("[hello](mailto:john@example.com)")),
'<p><a href="mailto:john@example.com">hello</a></p>',
);
assertEquals(
ren.render(par.parse("[](/привет)")),
'<p><a href="/%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82"></a></p>',
);
assertEquals(
ren.render(par.parse("[](</hello world>)")),
'<p><a href="/hello%20world"></a></p>',
);
assertEquals(
ren.render(par.parse("[](</hello world?key=value value2&key2=value3>)")),
'<p><a href="/hello%20world?key=value%20value2&key2=value3"></a></p>',
);
},
});
Deno.test({
name: "should parse link title",
fn: () => {
const par = new MarkdownParser();
assertEquals(
ren.render(par.parse("[](/hello 'hello')")),
'<p><a href="/hello" title="hello"></a></p>',
);
assertEquals(
ren.render(par.parse('[hello](/hello "world")')),
'<p><a href="/hello" title="world">hello</a></p>',
);
assertEquals(
ren.render(par.parse('[hello](</hello world> "hello world")')),
'<p><a href="/hello%20world" title="hello world">hello</a></p>',
);
},
});

View File

@ -1,5 +1,5 @@
import { AnyNode, Elem, Fragment, TextNode } from "../core/node.ts";
import { isNil } from "../core/utils.ts";
import { isNil, Nilable } from "../core/utils.ts";
import { Parser } from "./types.ts";
const RE_EMPTY_LINE = /^\s*$/;
@ -7,6 +7,9 @@ const RE_EMPTY_LINE = /^\s*$/;
const RE_OPEN_ATX_HEADING = /^\s{0,3}(#{1,6})(\s|$)/;
const RE_CLOSE_ATX_HEADING = /(^|\s+)#*\s*$/;
// TODO: make better regex for destination
const RE_LINK = /\[([\s\S]*?)]\((?:([^\s]*)|<(.+?)>)(?: ('|")(.+?)\4)?\)/;
export class MarkdownParser implements Parser {
parse(input: string): AnyNode {
const astDoc: AstDocument = { kind: AstKind.Document, content: [] };
@ -41,8 +44,15 @@ function Paragraph(ast: AstParagraph): Elem {
return new Elem("p", {}, ast.content.map(InlineContent));
}
function InlineContent(ast: AstInlineContent): TextNode {
return Text(ast);
function InlineContent(ast: AstInlineContent): AnyNode {
return ast.kind === AstKind.Link ? Link(ast) : Text(ast);
}
function Link(ast: AstLink): Elem {
const attrs: Record<string, string> = { href: ast.destination || "#" };
if (ast.title) attrs.title = ast.title;
return new Elem("a", attrs, ast.content.map(Text));
}
function Text(ast: AstText): TextNode {
@ -105,8 +115,6 @@ function parseParagraph(ast: AstDocument, readStr: string): string | null {
readStr = readStr.slice(paragraphInlineContent.length);
}
console.log({ paragraphInlineContent, readStr });
if (paragraphInlineContent.length) {
parseInlineContent(paragraph, paragraphInlineContent);
}
@ -120,13 +128,45 @@ function parseInlineContent(
): string | null {
if (!readStr.length) return null;
const linkMatch = RE_LINK.exec(readStr);
if (!isNil(linkMatch)) {
const astLink: AstLink = {
kind: AstKind.Link,
destination: encodeURI(linkMatch[3] ?? linkMatch[2]),
title: linkMatch[5],
content: [],
};
// 1. parse before link
parseText(ast, readStr.slice(0, linkMatch.index));
// 2. create link and parse inner content for link
ast.content.push(astLink);
parseText(astLink, linkMatch[1]);
// 3. parse rest text
return parseInlineContent(
ast,
readStr.slice(linkMatch.index + linkMatch[0].length),
);
} else {
return parseText(ast, readStr);
}
}
function parseText(
ast: AstAtxHeading | AstParagraph | AstLink,
readStr: string,
): string | null {
if (!readStr.length) return null;
const parts = readStr.split("\n").filter(Boolean).map(
(textPart): AstText => ({ kind: AstKind.Text, content: textPart }),
);
ast.content = parts;
ast.content.push(...parts);
return readStr;
return "";
}
// AST
@ -143,7 +183,12 @@ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
type AstParagraph = BaseAstItem<AstKind.Paragraph, AstInlineContent[]>;
type AstInlineContent = AstText;
type AstInlineContent = AstText | AstLink;
interface AstLink extends BaseAstItem<AstKind.Link, AstText[]> {
destination: string;
title: Nilable<string>;
}
type AstText = BaseAstItem<AstKind.Text, string>;
@ -156,5 +201,6 @@ enum AstKind {
Document,
AtxHeading,
Paragraph,
Link,
Text,
}

View File

@ -19,24 +19,34 @@ Deno.test({
Deno.test({
name: "should render element",
fn: () => {
const el = E("p", [], "hello world");
const ren = new HtmlStrRenderer();
const res = ren.render(el);
assertEquals(res, "<p>hello world</p>");
assertEquals(ren.render(E("p", [])), "<p></p>");
assertEquals(ren.render(E("p", [], "hello world")), "<p>hello world</p>");
assertEquals(
ren.render(E("p", [], ["hello", "world"])),
"<p>hello world</p>",
);
assertEquals(
ren.render(E("p", [], [E("span", [], "hello"), E("span", [], "world")])),
"<p><span>hello</span><span>world</span></p>",
);
assertEquals(
ren.render(E("p", [], ["hello", E("span", [], "world")])),
"<p>hello <span>world</span></p>",
);
assertEquals(
ren.render(E("p", [], [E("span", [], "hello"), "world"])),
"<p><span>hello</span> world</p>",
);
},
});
Deno.test({
name: "should render empty fragment as empty string",
fn: () => {
const frag = F([]);
const ren = new HtmlStrRenderer();
const res = ren.render(frag);
assertEquals(res, "");
assertEquals(ren.render(F([])), "");
},
});
@ -52,7 +62,7 @@ Deno.test({
const ren = new HtmlStrRenderer();
const res = ren.render(frag);
assertEquals(res, 'hello world<div class="hello"></div><p>world</p>');
assertEquals(res, 'hello world <div class="hello"></div><p>world</p>');
},
});

View File

@ -79,7 +79,9 @@ function encodeHtmlFragment(
node: Fragment,
hooks: HtmlStrRendererHooks,
): string {
return concat(node.children.map((ch) => encodeAnyNode(ch, hooks)));
return concatEncodedNodes(
node.children.map((ch) => encodeAnyNode(ch, hooks)),
);
}
function encodeHtmlElement(
@ -90,7 +92,11 @@ function encodeHtmlElement(
if (isSelfClosedTagName(tagName)) return open;
const encodedChildren = children.map((ch) => encodeAnyNode(ch, hooks));
return `${open}${concat(encodedChildren)}</${tagName}>`;
return `${open}${concatEncodedNodes(encodedChildren)}</${tagName}>`;
}
function concatEncodedNodes(encodedChildren: string[]): string {
return join(" ", encodedChildren).replace(/>\s+?</g, "><");
}
function encodeAttrs(