wip: parsing & rendering of markdown ulists

This commit is contained in:
Benjamin Singleton 2026-03-01 22:56:29 -06:00
parent 17e1c34a3b
commit 00d7e1afc7
3 changed files with 142 additions and 68 deletions

View file

@ -73,10 +73,10 @@ onMounted(() => {
<template> <template>
<OptionalParent :is="tag"> <OptionalParent :is="tag">
<template v-for="(line, index) in markdown.lines" :key="index"> <template v-for="(line, index) in markdown.elements" :key="index">
<p v-if="line.type === 'paragraph'" :class="lineClass"> <p v-if="line.type === 'paragraph'" :class="lineClass">
<MarkdownParts <MarkdownParts
:elements="line.elements" :elements="line.paragraph.spans"
:slots="markdown.slots"> :slots="markdown.slots">
<template <template
v-for="(slot, name) in providedSlots" v-for="(slot, name) in providedSlots"
@ -88,7 +88,7 @@ onMounted(() => {
</p> </p>
<h3 v-else-if="line.type === 'header'" :class="lineClass"> <h3 v-else-if="line.type === 'header'" :class="lineClass">
<MarkdownParts <MarkdownParts
:elements="line.elements" :elements="line.header.spans"
:slots="markdown.slots"> :slots="markdown.slots">
<template <template
v-for="(slot, name) in providedSlots" v-for="(slot, name) in providedSlots"
@ -98,11 +98,18 @@ onMounted(() => {
</template> </template>
</MarkdownParts> </MarkdownParts>
</h3> </h3>
<!-- <ul v-else-if="line.type === 'ulist'"> <ul v-else-if="line.type === 'ulist'" :class="lineClass">
<li v-for="(li, index) in line.ulist" :key="index"> <li v-for="item in line.ulist.items">
<RichTemplate :template="li" /> <MarkdownParts :elements="item" :slots="markdown.slots">
<template
v-for="(slot, name) in providedSlots"
:key="name"
#[name]>
<component :is="slot" />
</template>
</MarkdownParts>
</li> </li>
</ul> --> </ul>
</template> </template>
</OptionalParent> </OptionalParent>
</template> </template>

View file

@ -1,11 +1,11 @@
<script setup lang="ts" generic="Slot extends string"> <script setup lang="ts" generic="Slot extends string">
import { type DeepReadonly, type VNode } from "vue"; import { type DeepReadonly, type VNode } from "vue";
import SmartLink from "../atoms/SmartLink.vue"; import SmartLink from "../atoms/SmartLink.vue";
import type { MarkdownLineElement } from "@/new-i18n-lib/markdown"; import type { MarkdownSpan } from "@/new-i18n-lib/markdown";
defineProps<{ defineProps<{
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
elements: DeepReadonly<MarkdownLineElement<Slot>[]>; elements: DeepReadonly<MarkdownSpan<Slot>[]>;
slots: DeepReadonly<Slot[]>; slots: DeepReadonly<Slot[]>;
}>(); }>();

View file

@ -8,30 +8,29 @@ import type { RouteNamedMap } from "vue-router/auto-routes";
import { routes } from "vue-router/auto-routes"; import { routes } from "vue-router/auto-routes";
export interface Markdown<Slot extends string = string> { export interface Markdown<Slot extends string = string> {
lines: MarkdownLine<Slot>[]; elements: MarkdownElement<Slot>[];
slots: Slot[]; slots: Slot[];
} }
export type MarkdownLine<Slot extends string = string> = { export type MarkdownElement<Slot extends string = string> =
type: "paragraph" | "header"; | { type: "paragraph"; paragraph: { spans: MarkdownSpan<Slot>[] } }
elements: MarkdownLineElement<Slot>[]; | { type: "header"; header: { spans: MarkdownSpan<Slot>[] } }
| { type: "ulist"; ulist: { items: MarkdownSpan<Slot>[][] } };
type MarkdownLine<Slot extends string = string> = {
type: "paragraph" | "header" | "ulistItem";
spans: MarkdownSpan<Slot>[];
}; };
export type MarkdownFeature = export type MarkdownFeature = "header" | "ulist" | "italic" | "bold" | "link";
| Exclude<MarkdownLine["type"], "paragraph">
| Exclude<MarkdownLineElement["type"], "plain">;
export type MarkdownLineElement<Slot extends string = string> = export type MarkdownSpan<Slot extends string = string> =
| { type: "plain"; plain: string } | { type: "plain"; plain: string }
| { type: "italic"; italic: MarkdownLineElement[] } | { type: "italic"; italic: MarkdownSpan[] }
| { type: "bold"; bold: MarkdownLineElement[] } | { type: "bold"; bold: MarkdownSpan[] }
| { | {
type: "link"; type: "link";
link: { link: { label: MarkdownSpan[]; to: SmartDest; newTab: boolean };
label: MarkdownLineElement[];
to: SmartDest;
newTab: boolean;
};
} }
| { type: "slot"; slot: Slot }; | { type: "slot"; slot: Slot };
@ -39,8 +38,62 @@ export function parseMarkdown<Slot extends string>(
markdownString: string, markdownString: string,
slots: readonly Slot[], slots: readonly Slot[],
): Result<Markdown<Slot>, string> { ): Result<Markdown<Slot>, string> {
const linesRes = parseMarkdownLines(markdownString, slots);
if (linesRes.type === "err") {
return linesRes;
}
const lines = linesRes.ok;
const elements: MarkdownElement<Slot>[] = [];
while (true) {
const line = lines.shift();
if (line === undefined) {
break;
}
const element = ((): MarkdownElement<Slot> => {
switch (line.type) {
case "paragraph": {
return {
type: "paragraph",
paragraph: { spans: line.spans },
};
}
case "header": {
return { type: "header", header: { spans: line.spans } };
}
case "ulistItem": {
const items: MarkdownSpan<Slot>[][] = [line.spans];
while (true) {
const peekLine = lines[0];
if (
peekLine === undefined
|| peekLine.type !== "ulistItem"
) {
break;
}
items.push(peekLine.spans);
lines.shift();
}
return { type: "ulist", ulist: { items } };
}
}
})();
elements.push(element);
}
return { type: "ok", ok: { elements, slots: [...slots] } };
}
function parseMarkdownLines<Slot extends string>(
markdownString: string,
slots: readonly Slot[],
): Result<MarkdownLine<Slot>[], string> {
if (markdownString.trim() === "--") { if (markdownString.trim() === "--") {
return { type: "ok", ok: { lines: [], slots: [] } }; return { type: "ok", ok: [] };
} }
const lines = markdownString.split("\n"); const lines = markdownString.split("\n");
@ -84,7 +137,7 @@ export function parseMarkdown<Slot extends string>(
markdownLines.push(markdownLine); markdownLines.push(markdownLine);
} }
return { type: "ok", ok: { lines: markdownLines, slots: [...slots] } }; return { type: "ok", ok: markdownLines };
} }
function parseMarkdownLine<Slot extends string>( function parseMarkdownLine<Slot extends string>(
@ -92,20 +145,34 @@ function parseMarkdownLine<Slot extends string>(
slots: readonly Slot[], slots: readonly Slot[],
): Result<MarkdownLine<Slot>, string> { ): Result<MarkdownLine<Slot>, string> {
if (line.startsWith("#")) { if (line.startsWith("#")) {
const elementsRes = parseMarkdownLineElements(line.substring(1), slots); const elementsRes = parseMarkdownSpans(line.substring(1), slots);
if (elementsRes.type === "err") { if (elementsRes.type === "err") {
return { return {
type: "err", type: "err",
err: `While parsing elements:\n${elementsRes.err}`, err: `While parsing header spans:\n${elementsRes.err}`,
}; };
} }
const elements = elementsRes.ok; const elements = elementsRes.ok;
return { type: "ok", ok: { type: "header", elements } }; return { type: "ok", ok: { type: "header", spans: elements } };
} }
const elementsRes = parseMarkdownLineElements(line, slots); if (line.startsWith("-")) {
const elementsRes = parseMarkdownSpans(line.substring(1), slots);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing ulist item spans:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "ulistItem", spans: elements } };
}
const elementsRes = parseMarkdownSpans(line, slots);
if (elementsRes.type === "err") { if (elementsRes.type === "err") {
return { return {
type: "err", type: "err",
@ -114,13 +181,13 @@ function parseMarkdownLine<Slot extends string>(
} }
const elements = elementsRes.ok; const elements = elementsRes.ok;
return { type: "ok", ok: { type: "paragraph", elements } }; return { type: "ok", ok: { type: "paragraph", spans: elements } };
} }
function parseMarkdownLineElements<Slot extends string>( function parseMarkdownSpans<Slot extends string>(
line: string, line: string,
slots: readonly Slot[], slots: readonly Slot[],
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
if (line.startsWith("#")) { if (line.startsWith("#")) {
// subheaders may be supported in the future, // subheaders may be supported in the future,
// so ignoring them or treating them as h1 headers now would be a breaking change when // so ignoring them or treating them as h1 headers now would be a breaking change when
@ -132,7 +199,7 @@ function parseMarkdownLineElements<Slot extends string>(
const trimmedLine = line.trim(); const trimmedLine = line.trim();
const chars = trimmedLine.split(""); const chars = trimmedLine.split("");
const elementsRes = readMarkdownLineElements(chars, slots, { const elementsRes = readMarkdownSpans(chars, slots, {
inItalic: false, inItalic: false,
inBold: false, inBold: false,
}); });
@ -144,19 +211,19 @@ function parseMarkdownLineElements<Slot extends string>(
return { type: "ok", ok: elements }; return { type: "ok", ok: elements };
} }
interface ReadMarkdownLineElementCtx { interface ReadMarkdownSpanCtx {
inItalic: boolean; inItalic: boolean;
inBold: boolean; inBold: boolean;
} }
function readMarkdownLineElements<Slot extends string>( function readMarkdownSpans<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownLineElementCtx, ctx: ReadMarkdownSpanCtx,
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const elements: MarkdownLineElement<Slot>[] = []; const elements: MarkdownSpan<Slot>[] = [];
while (true) { while (true) {
const elementRes = readMarkdownLineElement(chars, slots, ctx); const elementRes = readMarkdownSpan(chars, slots, ctx);
if (elementRes.type === "err") { if (elementRes.type === "err") {
return elementRes; return elementRes;
} }
@ -172,24 +239,24 @@ function readMarkdownLineElements<Slot extends string>(
return { type: "ok", ok: elements }; return { type: "ok", ok: elements };
} }
function readMarkdownLineElement<Slot extends string>( function readMarkdownSpan<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownLineElementCtx, ctx: ReadMarkdownSpanCtx,
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const [firstChar, secondChar] = chars; const [firstChar, secondChar] = chars;
if (firstChar === undefined) { if (firstChar === undefined) {
return { type: "ok", ok: [] }; return { type: "ok", ok: [] };
} else if (firstChar === "<") { } else if (firstChar === "<") {
return readMarkdownLineElementSlot(chars, slots); return readMarkdownSpanSlot(chars, slots);
} else if (firstChar === "[") { } else if (firstChar === "[") {
return readMarkdownLineElementLink(chars, slots, ctx); return readMarkdownSpanLink(chars, slots, ctx);
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) { } else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) {
return readMarkdownLineElementBold(chars, slots, ctx); return readMarkdownSpanBold(chars, slots, ctx);
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) { } else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) {
return readMarkdownLineElementItalic(chars, slots, ctx); return readMarkdownSpanItalic(chars, slots, ctx);
} else { } else {
return readMarkdownLineElementPlain(chars); return readMarkdownSpanPlain(chars);
} }
} }
@ -198,10 +265,10 @@ function isInArray<T, U extends T>(value: T, array: readonly U[]): value is U {
return array.includes(value as U); return array.includes(value as U);
} }
function readMarkdownLineElementSlot<Slot extends string>( function readMarkdownSpanSlot<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const openAngleRes = expectReadChar(chars, "<"); const openAngleRes = expectReadChar(chars, "<");
if (openAngleRes.type === "err") { if (openAngleRes.type === "err") {
return openAngleRes; return openAngleRes;
@ -225,17 +292,17 @@ function readMarkdownLineElementSlot<Slot extends string>(
return { type: "ok", ok: [{ type: "slot", slot: slotName }] }; return { type: "ok", ok: [{ type: "slot", slot: slotName }] };
} }
function readMarkdownLineElementLink<Slot extends string>( function readMarkdownSpanLink<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownLineElementCtx, ctx: ReadMarkdownSpanCtx,
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const openSquareRes = expectReadChar(chars, "["); const openSquareRes = expectReadChar(chars, "[");
if (openSquareRes.type === "err") { if (openSquareRes.type === "err") {
return openSquareRes; return openSquareRes;
} }
const labelElementsRes = readMarkdownLineElements(chars, slots, ctx); const labelElementsRes = readMarkdownSpans(chars, slots, ctx);
if (labelElementsRes.type === "err") { if (labelElementsRes.type === "err") {
return labelElementsRes; return labelElementsRes;
} }
@ -411,7 +478,7 @@ function readMarkdownLineElementLink<Slot extends string>(
const { dest, newTab } = linkPropsRes.ok; const { dest, newTab } = linkPropsRes.ok;
const resolvedLabel: MarkdownLineElement[] = const resolvedLabel: MarkdownSpan[] =
labelElements.length === 0 ? labelElements.length === 0 ?
[ [
{ {
@ -500,11 +567,11 @@ function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
} }
} }
function readMarkdownLineElementBold<Slot extends string>( function readMarkdownSpanBold<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownLineElementCtx, ctx: ReadMarkdownSpanCtx,
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const firstStarRes = expectReadChar(chars, "*"); const firstStarRes = expectReadChar(chars, "*");
if (firstStarRes.type === "err") { if (firstStarRes.type === "err") {
return firstStarRes; return firstStarRes;
@ -516,10 +583,10 @@ function readMarkdownLineElementBold<Slot extends string>(
} }
ctx.inBold = true; ctx.inBold = true;
const elements: MarkdownLineElement<Slot>[] = []; const elements: MarkdownSpan<Slot>[] = [];
let closed = false; let closed = false;
while (true) { while (true) {
const elementRes = readMarkdownLineElement(chars, slots, ctx); const elementRes = readMarkdownSpan(chars, slots, ctx);
if (elementRes.type === "err") { if (elementRes.type === "err") {
return elementRes; return elementRes;
} }
@ -553,21 +620,21 @@ function readMarkdownLineElementBold<Slot extends string>(
} }
} }
function readMarkdownLineElementItalic<Slot extends string>( function readMarkdownSpanItalic<Slot extends string>(
chars: string[], chars: string[],
slots: readonly Slot[], slots: readonly Slot[],
ctx: ReadMarkdownLineElementCtx, ctx: ReadMarkdownSpanCtx,
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
const starRes = expectReadChar(chars, "*"); const starRes = expectReadChar(chars, "*");
if (starRes.type === "err") { if (starRes.type === "err") {
return starRes; return starRes;
} }
ctx.inItalic = true; ctx.inItalic = true;
const elements: MarkdownLineElement<Slot>[] = []; const elements: MarkdownSpan<Slot>[] = [];
let closed = false; let closed = false;
while (true) { while (true) {
const elementRes = readMarkdownLineElement(chars, slots, ctx); const elementRes = readMarkdownSpan(chars, slots, ctx);
if (elementRes.type === "err") { if (elementRes.type === "err") {
return elementRes; return elementRes;
} }
@ -597,9 +664,9 @@ function readMarkdownLineElementItalic<Slot extends string>(
} }
} }
function readMarkdownLineElementPlain<Slot extends string>( function readMarkdownSpanPlain<Slot extends string>(
chars: string[], chars: string[],
): Result<MarkdownLineElement<Slot>[], string> { ): Result<MarkdownSpan<Slot>[], string> {
let plain = ""; let plain = "";
let escaped = false; let escaped = false;
while (true) { while (true) {
@ -739,5 +806,5 @@ export function isEmptyMarkdown(markdown: Markdown): boolean {
// return false; // return false;
return markdown.lines.length === 0; return markdown.elements.length === 0;
} }