Skip to content

Chapter 9 of 19

Filter by Tag ​

Read tags from each note's YAML front matter and filter the list by tag.

Parse front matter ​

Tags live in the note file itself, in a block of YAML between two --- lines at the top, so the notes folder stays plain Markdown that any editor can open. Install yaml to parse the block; it ships its own types:

sh
npm install yaml

src/notes/note.ts splits each file into its front matter and its body:

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

type Note = {
    name: string;
    text: string;
    modified: number;
    body: string;
    tags: string[];
    excerpt: string;
};

const FRONT_MATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;

const parseYaml = (source: string): unknown => {
    try {
        return parse(source);
    } catch {
        return null;
    }
};

const toTags = (value: unknown): string[] => {
    const values: unknown[] = Array.isArray(value) ? value : [value];
    const tags = values
        .filter((tag) => typeof tag === "string")
        .map((tag) => tag.trim().replace(/^#/, ""))
        .filter((tag) => tag !== "");

    return [...new Set(tags)];
};

const splitFrontMatter = (text: string): { tags: string[]; body: string } => {
    const match = FRONT_MATTER.exec(text);
    if (match === null) {
        return { tags: [], body: text };
    }
    const data = parseYaml(match[1] ?? "");
    const tags =
        typeof data === "object" && data !== null && "tags" in data
            ? toTags(data.tags)
            : [];

    return { tags, body: text.slice(match[0].length) };
};

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 => {
    const { tags, body } = splitFrontMatter(text);

    return {
        name,
        text,
        modified,
        body,
        tags,
        excerpt: firstParagraph(fromMarkdown(body)) ?? "",
    };
};

export { type Note, toNote };
  • FRONT_MATTER matches only at the very start of the file: a --- line, the YAML, and a closing --- line. A file without the block has no tags, and its body is the whole text.
  • parse throws on YAML it can't read. parseYaml turns the error into null, so a note with a broken block still loads, with no tags.
  • toTags accepts a list (tags: [home, reading]) or a single tag (tags: home), removes duplicates, and skips values that aren't strings, such as 2026, which YAML reads as a number. It also drops a leading #, so home and #home are the same tag. A # after a space starts a YAML comment, so a tag written with one must be quoted: tags: "#home".
  • body is the text after the block. The excerpt and the preview read it, while the editor, the saver and the search keep using the whole text.

Filter the list ​

src/components/notes-screen.tsx adds a drop-down of tags under the search entry:

src/components/notes-screen.tsx
tsx
import { DropDown, ListView } from "@gtkx/components";
import * as Gtk from "@gtkx/gi/gtk";
import * as Pango from "@gtkx/gi/pango";
import { useTranslation } from "@gtkx/i18n";
import { AdwSpinner, AdwStatusPage } from "@gtkx/jsx/adw";
import {
    GtkBox,
    GtkCallbackAction,
    GtkLabel,
    GtkScrolledWindow,
    GtkSearchEntry,
    GtkShortcut,
    GtkShortcutController,
    GtkShortcutTrigger,
} from "@gtkx/jsx/gtk";
import type { SplitViewScreenProps } from "@gtkx/navigation";
import { useState } from "react";
import { type RootParamList, useOpenNoteName } from "../navigation.js";
import type { Note } from "../notes/note.js";
import { useNotes } from "../notes/notes-provider.js";
import { useNoteSearch } from "../search/use-note-search.js";

const ALL_NOTES = "";

const allTags = (notes: Iterable<Note>): string[] =>
    [...new Set([...notes].flatMap((note) => note.tags))].sort((a, b) =>
        a.localeCompare(b),
    );

const TagFilter = ({
    tags,
    tag,
    onChange,
}: {
    tags: string[];
    tag: string;
    onChange: (tag: string) => void;
}) => {
    const { t } = useTranslation();

    return (
        <DropDown
            tooltipText={t("Filter by Tag")}
            items={[
                { id: ALL_NOTES, value: t("All Notes") },
                ...tags.map((name) => ({ id: name, value: `#${name}` })),
            ]}
            selectedId={tag}
            onSelectionChanged={(id) => onChange(id ?? ALL_NOTES)}
        />
    );
};

const NoteRow = ({ note }: { note: Note }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={2}
        marginTop={8}
        marginBottom={8}
    >
        <GtkLabel
            label={note.name}
            xalign={0}
            ellipsize={Pango.EllipsizeMode.END}
            cssClasses={["heading"]}
        />
        <GtkLabel
            label={note.excerpt}
            xalign={0}
            ellipsize={Pango.EllipsizeMode.END}
            singleLineMode
            cssClasses={["dimmed", "caption"]}
        />
    </GtkBox>
);

const FocusShortcut = ({ target }: { target: Gtk.Widget | null }) => (
    <GtkShortcutController
        scope={Gtk.ShortcutScope.GLOBAL}
        shortcuts={
            <GtkShortcut
                trigger={<GtkShortcutTrigger accelerator="<Control>f" />}
                action={
                    <GtkCallbackAction
                        callback={() => target?.grabFocus() ?? false}
                    />
                }
            />
        }
    />
);

const NotesScreen = ({
    navigation,
}: SplitViewScreenProps<RootParamList, "Notes">) => {
    const { t } = useTranslation();
    const { notes } = useNotes();
    const openName = useOpenNoteName();
    const [query, setQuery] = useState("");
    const [selectedTag, setSelectedTag] = useState(ALL_NOTES);
    const [searchEntry, setSearchEntry] = useState<Gtk.SearchEntry | null>(
        null,
    );
    const matches = useNoteSearch(notes, query.trim());

    if (notes === null) {
        return <AdwSpinner />;
    }

    if (notes.size === 0) {
        return (
            <AdwStatusPage
                iconName="document-new-symbolic"
                title={t("No Notes")}
                description={t("Notes are Markdown files in your notes folder")}
                cssClasses={["compact"]}
            />
        );
    }

    const tags = allTags(notes.values());
    const tag = tags.includes(selectedTag) ? selectedTag : ALL_NOTES;
    const shown = [...notes.values()]
        .filter((note) => tag === ALL_NOTES || note.tags.includes(tag))
        .filter((note) => matches === null || matches.has(note.name))
        .sort((a, b) => b.modified - a.modified);

    return (
        <GtkBox
            orientation={Gtk.Orientation.VERTICAL}
            controllers={<FocusShortcut target={searchEntry} />}
        >
            <GtkBox
                orientation={Gtk.Orientation.VERTICAL}
                spacing={6}
                marginTop={6}
                marginBottom={6}
                marginStart={6}
                marginEnd={6}
            >
                <GtkSearchEntry
                    ref={setSearchEntry}
                    accessibleLabel={t("Search Notes")}
                    placeholderText={t("Search notes")}
                    text={query}
                    onSearchChanged={(entry) => setQuery(entry.getText())}
                    onStopSearch={() => setQuery("")}
                />
                {tags.length > 0 && (
                    <TagFilter
                        tags={tags}
                        tag={tag}
                        onChange={setSelectedTag}
                    />
                )}
            </GtkBox>
            {shown.length === 0 ? (
                <AdwStatusPage
                    iconName="edit-find-symbolic"
                    title={t("No Results Found")}
                    vexpand
                    cssClasses={["compact"]}
                />
            ) : (
                <GtkScrolledWindow
                    vexpand
                    hscrollbarPolicy={Gtk.PolicyType.NEVER}
                >
                    <ListView<Note>
                        items={shown.map((note) => ({
                            id: note.name,
                            value: note,
                        }))}
                        isFlat
                        cssClasses={["navigation-sidebar"]}
                        selectionMode={Gtk.SelectionMode.SINGLE}
                        selectedIds={openName === null ? [] : [openName]}
                        onSelectionChanged={([name]) => {
                            if (name !== undefined) {
                                navigation.navigate("Note", { name });
                            }
                        }}
                        rowProps={({ item }) => ({
                            accessibleLabel: item.name,
                        })}
                        renderItem={({ item }) => <NoteRow note={item} />}
                    />
                </GtkScrolledWindow>
            )}
        </GtkBox>
    );
};

export { NotesScreen };
  • allTags collects the tags of every note once, sorted alphabetically.
  • TagFilter renders a DropDown from @gtkx/components. Its items are "All Notes" followed by each tag. The values are strings, so the drop-down shows them as labels without a renderItem. Selection works by item ID, and "All Notes" takes the empty string, which no tag can be because toTags drops empty ones. The tooltip says what the drop-down is for, since it shows only the current choice.
  • selectedTag is what the user chose, and tag is what the list uses. When the chosen tag no longer exists, for example after you edit it out of the last note that had it, tag falls back to all notes, and passing it as selectedId moves the drop-down back to "All Notes".
  • The tag and the search apply together: a note shows when it has the tag and matches the query. The search indexes each note's whole text, front matter included, so searching for a tag name also finds its notes.
  • The drop-down renders only when some note has a tag, so a folder without front matter looks as before. The search entry and the drop-down share a box, which takes over the entry's margins.

Show tags in the preview ​

Until now the preview rendered the whole file, so a front matter block showed as a horizontal rule above a heading made of the YAML. The note screen now passes the preview the note's body and its tags:

src/components/note-screen.tsx
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.body}
                    tags={note.tags}
                    onActivateLink={openLinkedNote}
                />
            </GtkStackPage>
        </GtkStack>
    );
};

export { NoteScreen };

The editor still gets note.text, so the front matter stays editable.

MarkdownPreview, in src/preview/markdown-preview.tsx, shows the tags above the body:

src/preview/markdown-preview.tsx
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 }) => (
    <GtkBox cssClasses={["card"]}>
        <GtkLabel
            label={node.value}
            selectable
            wrap
            wrapMode={Pango.WrapMode.WORD_CHAR}
            xalign={0}
            hexpand
            marginTop={12}
            marginBottom={12}
            marginStart={12}
            marginEnd={12}
            cssClasses={["monospace"]}
        />
    </GtkBox>
);

const QuoteBlock = ({ node }: { node: Blockquote }) => (
    <GtkBox spacing={12}>
        <GtkSeparator orientation={Gtk.Orientation.VERTICAL} />
        <GtkBox
            orientation={Gtk.Orientation.VERTICAL}
            spacing={12}
            hexpand
            cssClasses={["dimmed"]}
        >
            <Blocks nodes={node.children} />
        </GtkBox>
    </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} />);

const Tags = ({ tags }: { tags: string[] }) => (
    <GtkBox spacing={12}>
        {tags.map((tag) => (
            <GtkLabel
                key={tag}
                label={`#${tag}`}
                cssClasses={["caption-heading", "accent"]}
            />
        ))}
    </GtkBox>
);

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

const MarkdownPreview = ({
    text,
    tags = [],
    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}
                >
                    {tags.length > 0 && <Tags tags={tags} />}
                    <LinkContext value={onActivateLink}>
                        <Blocks nodes={tree.children} />
                    </LinkContext>
                </GtkBox>
            </AdwClamp>
        </GtkScrolledWindow>
    );
};

export { MarkdownPreview };

Tags renders a row of labels with libadwaita's caption-heading and accent style classes. The tags prop is optional and defaults to an empty list, which renders no row.

Run it ​

Add front matter to a few notes. Give Book club.md a list:

md
---
tags: [home, reading]
---
# Book club

Next up: *Piranesi* by Susanna Clarke.

and put a block with a single tag at the top of Recipes.md:

md
---
tags: home
---

The drop-down appears under the search entry. Choose #home, and the list narrows to Book club and Recipes. Open Book club and switch to the preview: its tags show above the heading, and the front matter doesn't.

The Notes window with #home chosen in the tag drop-down, the sidebar listing Book club and Recipes, and Book club's preview showing #home and #reading above its heading.The Notes window with #home chosen in the tag drop-down, the sidebar listing Book club and Recipes, and Book club's preview showing #home and #reading above its heading.
Captured from src/components/notes-screen.tsx and src/preview/markdown-preview.tsx above, with #home chosen and Book club open in the preview.