---
description: "Parse Markdown with an npm package and render the syntax tree as native GTK widgets."
---

# Render the Preview

Parse each note with `mdast-util-from-markdown` and render its syntax tree as GTK widgets, so the preview is native text that follows the system style.

## Parse Markdown

Install the parser and the types of the tree it returns:

```bash
npm install mdast-util-from-markdown
npm install -D @types/mdast
```

`fromMarkdown` turns a note's text into an [mdast](https://github.com/syntax-tree/mdast) syntax tree: a root node whose children are blocks, such as headings, paragraphs and lists, which hold inline nodes, such as text, emphasis and links. It is an ordinary npm package, running in the app's Node.js process like the file APIs.

The list's excerpt has been the first line that isn't a heading, as written, so Book club reads `Next up: *Piranesi*` with its asterisks. Removing the syntax with regular expressions would miss links, escapes and nested formatting, while the tree holds the text without it. `src/notes/note.ts` now takes the excerpt from the tree:

```ts [src/notes/note.ts]
import type { Nodes, PhrasingContent } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";

type Note = {
    name: string;
    text: string;
    modified: number;
    excerpt: string;
};

const plainText = (nodes: PhrasingContent[]): string =>
    nodes
        .map((node) => {
            switch (node.type) {
                case "text":
                case "inlineCode":
                case "html":
                    return node.value;
                case "image":
                case "imageReference":
                    return node.alt ?? "";
                case "break":
                    return " ";
                case "footnoteReference":
                    return "";
                default:
                    return plainText(node.children);
            }
        })
        .join("");

const firstParagraph = (node: Nodes): string | null => {
    if (node.type === "paragraph") {
        return plainText(node.children);
    }
    if (!("children" in node) || node.type === "heading") {
        return null;
    }
    for (const child of node.children) {
        const text = firstParagraph(child);
        if (text !== null) {
            return text;
        }
    }

    return null;
};

const toNote = (name: string, text: string, modified: number): Note => ({
    name,
    text,
    modified,
    excerpt: firstParagraph(fromMarkdown(text)) ?? "",
});

export { type Note, toNote };
```

- `firstParagraph` walks the tree depth first and returns the first paragraph's text, including one inside a list item or a block quote. It skips headings, which usually repeat the note's name, and code blocks, which aren't paragraphs.
- `plainText` joins a paragraph's inline nodes: emphasis and links give their children's text, inline code its code, an image its description, and a line break a space.

## Map nodes to widgets

Inline formatting becomes [Pango markup](https://docs.gtk.org/Pango/pango_markup.html), the tags a `GtkLabel` renders when `useMarkup` is set. A new folder, `src/preview/`, holds the conversion in `markup.ts`:

```ts [src/preview/markup.ts]
import * as GLib from "@gtkx/gi/glib";
import type { PhrasingContent } from "mdast";

const escape = (text: string): string => GLib.markupEscapeText(text, -1);

const toMarkup = (nodes: PhrasingContent[]): string =>
    nodes.map(nodeMarkup).join("");

const nodeMarkup = (node: PhrasingContent): string => {
    switch (node.type) {
        case "text":
        case "html":
            return escape(node.value);
        case "inlineCode":
            return `<tt>${escape(node.value)}</tt>`;
        case "emphasis":
            return `<i>${toMarkup(node.children)}</i>`;
        case "strong":
            return `<b>${toMarkup(node.children)}</b>`;
        case "delete":
            return `<s>${toMarkup(node.children)}</s>`;
        case "link":
            return `<a href="${escape(node.url)}">${toMarkup(node.children)}</a>`;
        case "linkReference":
            return toMarkup(node.children);
        case "image":
        case "imageReference":
            return escape(node.alt ?? "");
        case "break":
            return "\n";
        case "footnoteReference":
            return `<sup>${escape(node.label ?? node.identifier)}</sup>`;
    }
};

export { toMarkup };
```

- [`GLib.markupEscapeText`](https://docs.gtk.org/glib/func.markup_escape_text.html) escapes the note's own text, so a `<` or `&` in a note shows as written instead of breaking the markup. Inline HTML shows as text too.
- A link becomes an `<a href>` tag, which the label underlines and makes clickable.
- `fromMarkdown` follows [CommonMark](https://spec.commonmark.org/), so GitHub's strikethrough, tables and task lists stay as text. `@types/mdast` also types the nodes of those extensions, such as `delete` and `footnoteReference`. The `switch` handles every inline type `@types/mdast` lists, and the `string` return type makes TypeScript report any it misses.

`src/preview/markdown-preview.tsx` renders the blocks:

```tsx [src/preview/markdown-preview.tsx]
import * as Gtk from "@gtkx/gi/gtk";
import * as Pango from "@gtkx/gi/pango";
import { useTranslation } from "@gtkx/i18n";
import { AdwClamp } from "@gtkx/jsx/adw";
import {
    GtkBox,
    GtkLabel,
    GtkScrolledWindow,
    GtkSeparator,
} from "@gtkx/jsx/gtk";
import type {
    Blockquote,
    Code,
    Heading,
    List,
    Paragraph,
    RootContent,
} from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { createContext, use, useMemo } from "react";
import { toMarkup } from "./markup.js";

const LinkContext = createContext<(uri: string) => boolean>(() => false);

const HEADING_CLASSES = [
    "title-1",
    "title-2",
    "title-3",
    "title-4",
    "heading",
    "heading",
];

type TextBlockProps = {
    markup: string;
    cssClasses?: string[];
    accessibleRole?: Gtk.AccessibleRole;
};

const TextBlock = ({
    markup,
    cssClasses = [],
    accessibleRole = Gtk.AccessibleRole.LABEL,
}: TextBlockProps) => {
    const onLink = use(LinkContext);

    return (
        <GtkLabel
            accessibleRole={accessibleRole}
            label={markup}
            useMarkup
            wrap
            wrapMode={Pango.WrapMode.WORD_CHAR}
            xalign={0}
            cssClasses={cssClasses}
            onActivateLink={(uri) => onLink(uri)}
        />
    );
};

const HeadingBlock = ({ node }: { node: Heading }) => (
    <TextBlock
        accessibleRole={Gtk.AccessibleRole.HEADING}
        markup={toMarkup(node.children)}
        cssClasses={[HEADING_CLASSES[node.depth - 1] ?? "heading"]}
    />
);

const ParagraphBlock = ({ node }: { node: Paragraph }) => (
    <TextBlock markup={toMarkup(node.children)} />
);

const CodeBlock = ({ node }: { node: Code }) => (
    <GtkLabel
        label={node.value}
        selectable
        wrap
        wrapMode={Pango.WrapMode.WORD_CHAR}
        xalign={0}
        cssClasses={["monospace"]}
    />
);

const QuoteBlock = ({ node }: { node: Blockquote }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={12}
        cssClasses={["dimmed"]}
    >
        <Blocks nodes={node.children} />
    </GtkBox>
);

const ListBlock = ({ node }: { node: List }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={node.spread ? 12 : 6}
    >
        {node.children.map((item, index) => (
            <GtkBox key={index} spacing={8}>
                <GtkLabel
                    label={node.ordered ? `${(node.start ?? 1) + index}.` : "•"}
                    valign={Gtk.Align.START}
                    widthChars={2}
                    xalign={1}
                />
                <GtkBox
                    orientation={Gtk.Orientation.VERTICAL}
                    spacing={6}
                    hexpand
                >
                    <Blocks nodes={item.children} />
                </GtkBox>
            </GtkBox>
        ))}
    </GtkBox>
);

const Block = ({ node }: { node: RootContent }) => {
    switch (node.type) {
        case "heading":
            return <HeadingBlock node={node} />;
        case "paragraph":
            return <ParagraphBlock node={node} />;
        case "code":
            return <CodeBlock node={node} />;
        case "blockquote":
            return <QuoteBlock node={node} />;
        case "list":
            return <ListBlock node={node} />;
        case "thematicBreak":
            return <GtkSeparator />;
        case "html":
            return (
                <TextBlock
                    markup={toMarkup([node])}
                    cssClasses={["monospace", "dimmed"]}
                />
            );
        default:
            return null;
    }
};

const Blocks = ({ nodes }: { nodes: RootContent[] }) =>
    nodes.map((node, index) => <Block key={index} node={node} />);

type MarkdownPreviewProps = {
    text: string;
    onActivateLink?: (uri: string) => boolean;
};

const MarkdownPreview = ({
    text,
    onActivateLink = () => false,
}: MarkdownPreviewProps) => {
    const { t } = useTranslation();
    const tree = useMemo(() => fromMarkdown(text), [text]);

    return (
        <GtkScrolledWindow vexpand hscrollbarPolicy={Gtk.PolicyType.NEVER}>
            <AdwClamp maximumSize={720}>
                <GtkBox
                    accessibleRole={Gtk.AccessibleRole.DOCUMENT}
                    accessibleLabel={t("Preview")}
                    orientation={Gtk.Orientation.VERTICAL}
                    spacing={12}
                    marginTop={18}
                    marginBottom={18}
                    marginStart={18}
                    marginEnd={18}
                >
                    <LinkContext value={onActivateLink}>
                        <Blocks nodes={tree.children} />
                    </LinkContext>
                </GtkBox>
            </AdwClamp>
        </GtkScrolledWindow>
    );
};

export { MarkdownPreview };
```

- `MarkdownPreview` parses the text in `useMemo`, so it parses again only when the text changes, and renders the root's children.
- `Block` picks a widget for each block node. Headings, paragraphs and HTML blocks are labels with markup, and a code block is a monospace label the user can select. A block quote and each list item render their own children, so lists and quotes nest. Other nodes, such as link definitions, render nothing.
- The labels take their look from libadwaita [style classes](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/style-classes.html): `title-1` to `title-4` and `heading` size the headings, `monospace` sets code in a fixed-width font, and `dimmed` lowers a quote's contrast. They follow the system's light and dark styles without any CSS.
- `AdwClamp` keeps lines a readable length in a wide window, and the scrolled window scrolls only vertically, since the labels wrap.
- `accessibleRole` tells screen readers what each widget is: the preview is a document named Preview, and headings are headings rather than plain labels. [Test the App](/v2/tutorial/testing) finds them by these roles.
- Every label reports an activated link through `onActivateLink`. Blocks nest to any depth, so `LinkContext` hands the handler down rather than passing it through every block's props; see React's [`createContext`](https://react.dev/reference/react/createContext).

## Switch between editor and preview

The note screen, `src/components/note-screen.tsx`, puts the editor and the preview in a [`GtkStack`](https://docs.gtk.org/gtk4/class.Stack.html), which shows one child at a time:

```tsx [src/components/note-screen.tsx]
import * as Gtk from "@gtkx/gi/gtk";
import { useTranslation } from "@gtkx/i18n";
import { AdwStatusPage } from "@gtkx/jsx/adw";
import { GtkStack, GtkStackPage } from "@gtkx/jsx/gtk";
import type { SplitViewScreenProps } from "@gtkx/navigation";
import { NoteEditor } from "../editor/note-editor.js";
import type { RootParamList } from "../navigation.js";
import { linkedNoteName } from "../notes/files.js";
import { useNotes } from "../notes/notes-provider.js";
import { MarkdownPreview } from "../preview/markdown-preview.js";

type NoteScreenProps = SplitViewScreenProps<RootParamList, "Note"> & {
    preview: boolean;
};

const NoteScreen = ({ route, navigation, preview }: NoteScreenProps) => {
    const { t } = useTranslation();
    const { notes, edit } = useNotes();
    const { name } = route.params;
    const note = notes?.get(name);

    const openLinkedNote = (uri: string): boolean => {
        const linked = linkedNoteName(uri);
        if (linked === null) {
            return false;
        }
        navigation.navigate("Note", { name: linked });

        return true;
    };

    if (note === undefined) {
        return (
            <AdwStatusPage
                iconName="edit-find-symbolic"
                title={t("Note Not Found")}
            />
        );
    }

    return (
        <GtkStack
            visibleChildName={preview ? "preview" : "editor"}
            transitionType={Gtk.StackTransitionType.CROSSFADE}
        >
            <GtkStackPage name="editor">
                <NoteEditor
                    key={name}
                    text={note.text}
                    onChange={(text) => edit(name, text)}
                />
            </GtkStackPage>
            <GtkStackPage name="preview">
                <MarkdownPreview
                    text={note.text}
                    onActivateLink={openLinkedNote}
                />
            </GtkStackPage>
        </GtkStack>
    );
};

export { NoteScreen };
```

- Each child sits in a [`GtkStackPage`](/v2/guide/components#stack-pages), the object the stack keeps for it, and `visibleChildName` shows the page with that `name`. The crossfade animates the switch.
- Both pages stay mounted, so the editor keeps its cursor and undo history while the preview shows, and the preview follows every edit.
- `preview` arrives as a prop, next to the navigator's `route` and `navigation`.

The toggle button that sets `preview` belongs in the note's header bar, which the navigator builds from the screen's `options`, so the state lives in `src/app.tsx`:

```tsx [src/app.tsx]
import { ToastProvider } from "@gtkx/components";
import type * as Adw from "@gtkx/gi/adw";
import { useTranslation } from "@gtkx/i18n";
import {
    AdwApplication,
    AdwApplicationWindow,
    AdwStatusPage,
    AdwToastOverlay,
} from "@gtkx/jsx/adw";
import { GtkToggleButton } from "@gtkx/jsx/gtk";
import { NavigationContainer } from "@gtkx/navigation";
import { quit } from "@gtkx/react";
import { useRef, useState } from "react";
import { NoteScreen } from "./components/note-screen.js";
import { NotesScreen } from "./components/notes-screen.js";
import { Split } from "./navigation.js";
import { defaultFolder } from "./notes/files.js";
import { NotesProvider } from "./notes/notes-provider.js";

function App() {
    const { t } = useTranslation();
    const toastOverlayRef = useRef<Adw.ToastOverlay | null>(null);
    const [preview, setPreview] = useState(false);

    return (
        <AdwApplication>
            <AdwApplicationWindow
                title={t("Notes")}
                defaultWidth={720}
                defaultHeight={480}
                widthRequest={360}
                heightRequest={294}
                onCloseRequest={() => quit()}
            >
                <ToastProvider overlayRef={toastOverlayRef}>
                    <NotesProvider folder={defaultFolder()}>
                        <AdwToastOverlay ref={toastOverlayRef}>
                            <NavigationContainer>
                                <Split.Navigator
                                    contentPlaceholder={
                                        <AdwStatusPage
                                            iconName="document-edit-symbolic"
                                            title={t("No Note Selected")}
                                        />
                                    }
                                >
                                    <Split.Screen
                                        name="Notes"
                                        component={NotesScreen}
                                        options={{ title: t("Notes") }}
                                    />
                                    <Split.Screen
                                        name="Note"
                                        options={({ route }) => ({
                                            title: route.params.name,
                                            headerEnd: (
                                                <GtkToggleButton
                                                    iconName="view-reveal-symbolic"
                                                    tooltipText={t("Preview")}
                                                    active={preview}
                                                    onNotifyActive={setPreview}
                                                />
                                            ),
                                        })}
                                    >
                                        {(props) => (
                                            <NoteScreen
                                                {...props}
                                                preview={preview}
                                            />
                                        )}
                                    </Split.Screen>
                                </Split.Navigator>
                            </NavigationContainer>
                        </AdwToastOverlay>
                    </NotesProvider>
                </ToastProvider>
            </AdwApplicationWindow>
        </AdwApplication>
    );
}

export { App };
```

- `headerEnd` puts the button at the end of the content pane's header bar; [Headers](/v2/guide/navigation#headers) lists the other header options. `active` and `onNotifyActive` keep the button and the state in step, as in [The JSX prop model](/v2/guide/jsx-elements#the-jsx-prop-model).
- The `Note` screen takes a function as its child instead of `component`, so it can pass `preview` along with the navigator's props; see React Navigation's [Passing additional data](https://reactnavigation.org/docs/hello-react-navigation#passing-additional-data).
- The state outlives each note, so the preview stays on when another note opens. [Add Menus and Shortcuts](/v2/tutorial/actions-menus-shortcuts) turns the button into a window action with a keyboard shortcut.

## Follow links

Website relaunch links to another note with `[kickoff](<Meeting notes.md>)`. A link like that should open the note in the app. `src/notes/files.ts` gains `linkedNoteName`, which turns a link into a note name:

```ts [src/notes/files.ts]
import * as GLib from "@gtkx/gi/glib";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type Note, toNote } from "./note.js";

const EXTENSION = ".md";

const defaultFolder = (): string =>
    join(
        GLib.getUserSpecialDir(GLib.UserDirectory.DIRECTORY_DOCUMENTS) ??
            GLib.getHomeDir(),
        "Notes",
    );

const noteName = (fileName: string): string | null =>
    fileName.endsWith(EXTENSION) && !fileName.startsWith(".")
        ? fileName.slice(0, -EXTENSION.length)
        : null;

const linkedNoteName = (uri: string): string | null => {
    const fileName =
        GLib.Uri.peekScheme(uri) === null
            ? GLib.Uri.unescapeString(uri, "/")
            : null;

    return fileName === null || fileName.includes("/")
        ? null
        : noteName(fileName);
};

const notePath = (folder: string, name: string): string =>
    join(folder, `${name}${EXTENSION}`);

const isMissing = (error: unknown): boolean =>
    error instanceof Error && "code" in error && error.code === "ENOENT";

const readNote = async (folder: string, name: string): Promise<Note | null> => {
    const path = notePath(folder, name);
    try {
        const info = await stat(path);
        if (!info.isFile()) {
            return null;
        }

        return toNote(name, await readFile(path, "utf8"), info.mtimeMs);
    } catch (error) {
        if (isMissing(error)) {
            return null;
        }

        throw error;
    }
};

const readNotes = async (folder: string): Promise<Note[]> => {
    await mkdir(folder, { recursive: true });
    const names = (await readdir(folder))
        .map(noteName)
        .filter((name) => name !== null);
    const notes = await Promise.all(
        names.map((name) => readNote(folder, name)),
    );

    return notes.filter((note) => note !== null);
};

const writeNote = (folder: string, name: string, text: string): Promise<void> =>
    writeFile(notePath(folder, name), text);

export {
    defaultFolder,
    linkedNoteName,
    noteName,
    readNote,
    readNotes,
    writeNote,
};
```

- [`GLib.Uri.peekScheme`](https://docs.gtk.org/glib/type_func.Uri.peek_scheme.html) returns `null` for a relative link, one without a scheme such as `https:`.
- [`GLib.Uri.unescapeString`](https://docs.gtk.org/glib/type_func.Uri.unescape_string.html) decodes escapes such as `%20`, and returns `null` when the link escapes a `/`.
- A link into another folder gives `null`, and `noteName`, which the folder listing uses, accepts only a visible `.md` file.

`openLinkedNote` in the note screen above navigates to the linked note and returns `true`, which tells the label the link is handled. For any other link it returns `false`, and the label's default handler for [`activate-link`](https://docs.gtk.org/gtk4/signal.Label.activate-link.html) opens it in the app the system uses for it, such as the web browser. A link to a note the folder doesn't have shows Note Not Found.

## Run it

Save the files. The new imports restart the app. The excerpts in the list now show text without Markdown syntax.

Open a note and press the eye button in its header bar: the note shows as headings, formatted paragraphs and lists. Activate a link to another note to open it.

<figure class="result">
    <img class="result-light" src="/figures/tutorial-the-markdown-preview-light.png" width="720" height="480" loading="lazy" alt="The Notes window with Website relaunch shown as a rendered preview: a large title, a paragraph with bold text and a kickoff link, a Milestones heading and a numbered list." />
    <img class="result-dark" src="/figures/tutorial-the-markdown-preview-dark.png" width="720" height="480" loading="lazy" alt="The Notes window with Website relaunch shown as a rendered preview: a large title, a paragraph with bold text and a kickoff link, a Milestones heading and a numbered list." />
    <figcaption>Captured from <a href="#switch-between-editor-and-preview"><code>src/app.tsx</code></a> above, after opening Website relaunch and turning on the preview.</figcaption>
</figure>
