diff --git a/assets/examples/mastodon-feed.jpg b/assets/examples/mastodon-feed.jpg
new file mode 100644
index 0000000000..9dd96e7239
Binary files /dev/null and b/assets/examples/mastodon-feed.jpg differ
diff --git a/examples/mastodon-feed/README.md b/examples/mastodon-feed/README.md
new file mode 100644
index 0000000000..1882266265
--- /dev/null
+++ b/examples/mastodon-feed/README.md
@@ -0,0 +1,15 @@
+# mastodon-feed
+
+![screenshot](https://raw.githubusercontent.com/thi-ng/umbrella/develop/assets/examples/mastodon-feed.png)
+
+[Live demo](http://demo.thi.ng/umbrella/mastodon-feed/)
+
+Please refer to the [example build instructions](https://github.com/thi-ng/umbrella/wiki/Example-build-instructions) on the wiki.
+
+## Authors
+
+- Karsten Schmidt
+
+## License
+
+© 2023 Karsten Schmidt // Apache Software License 2.0
diff --git a/examples/mastodon-feed/index.html b/examples/mastodon-feed/index.html
new file mode 100644
index 0000000000..6570e8875c
--- /dev/null
+++ b/examples/mastodon-feed/index.html
@@ -0,0 +1,43 @@
+
+
+
+ ⛱️'
+ />
+
+
+
+ mastodon-feed · @thi.ng/umbrella
+
+
+
+
+
+ Source code
+
+
+
diff --git a/examples/mastodon-feed/package.json b/examples/mastodon-feed/package.json
new file mode 100644
index 0000000000..90a10bf12e
--- /dev/null
+++ b/examples/mastodon-feed/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@example/mastodon-feed",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Mastodon API feed reader with support for different media types, fullscreen media modal, HTML rewriting",
+ "repository": "https://github.com/thi-ng/umbrella",
+ "author": "Karsten Schmidt ",
+ "license": "Apache-2.0",
+ "scripts": {
+ "start": "vite --host --open",
+ "build": "tsc && vite build --base='./'",
+ "preview": "vite preview --host --open"
+ },
+ "devDependencies": {
+ "typescript": "^5.2.2",
+ "vite": "^4.4.9"
+ },
+ "dependencies": {
+ "@thi.ng/api": "workspace:^",
+ "@thi.ng/bench": "workspace:^",
+ "@thi.ng/checks": "workspace:^",
+ "@thi.ng/date": "workspace:^",
+ "@thi.ng/defmulti": "workspace:^",
+ "@thi.ng/hiccup-html": "workspace:^",
+ "@thi.ng/parse": "workspace:^",
+ "@thi.ng/rdom": "workspace:^",
+ "@thi.ng/rstream": "workspace:^",
+ "@thi.ng/strings": "workspace:^",
+ "@thi.ng/transducers": "workspace:^"
+ },
+ "browser": {
+ "process": false
+ },
+ "thi.ng": {
+ "readme": [
+ "date",
+ "defmulti",
+ "hiccup-html",
+ "parse",
+ "rdom",
+ "rstream",
+ "strings",
+ "transducers"
+ ],
+ "screenshot": "examples/mastodon-feed.jpg"
+ }
+}
diff --git a/examples/mastodon-feed/src/api.ts b/examples/mastodon-feed/src/api.ts
new file mode 100644
index 0000000000..f3646ae98f
--- /dev/null
+++ b/examples/mastodon-feed/src/api.ts
@@ -0,0 +1,39 @@
+import type { ComponentLike } from "@thi.ng/rdom";
+
+export interface Account {
+ id: string;
+ username: string;
+ name: string;
+ url: string;
+ bio: ComponentLike[];
+ avatar: string;
+ header: string;
+}
+
+export interface Message {
+ id: string;
+ url: string;
+ date: number;
+ tags: string[];
+ content: ComponentLike[];
+ media: MediaItem[];
+ sensitive: boolean;
+ reblog: boolean;
+ likes: number;
+ boosts: number;
+ replies: number;
+}
+
+export interface MediaItem {
+ id: string;
+ type: string;
+ url: string;
+ preview: string;
+ description: string;
+ width: number;
+ height: number;
+}
+
+export const LINK_COLOR = "white";
+export const TAG_COLOR = "gold";
+export const BUTTON_COLOR = "bg-gold.black";
diff --git a/examples/mastodon-feed/src/components/account.ts b/examples/mastodon-feed/src/components/account.ts
new file mode 100644
index 0000000000..cbf8857b89
--- /dev/null
+++ b/examples/mastodon-feed/src/components/account.ts
@@ -0,0 +1,73 @@
+import { anchor, div, h1, img, inputText, span } from "@thi.ng/hiccup-html";
+import { $input, $inputTrigger } from "@thi.ng/rdom";
+import type { ISubscriber, ISubscription } from "@thi.ng/rstream";
+import { BUTTON_COLOR, type Account } from "../api.js";
+
+/**
+ * Form component to allow user entering a Mastodon user ID for loading &
+ * displaying their messages.
+ *
+ * @param value
+ * @param trigger
+ */
+export const accountChooser = (
+ value: ISubscription,
+ trigger: ISubscriber
+) =>
+ div(
+ ".pa3",
+ {},
+ inputText(".db.w-100.pa2.br2.ba.b--white-10.bg-near-black.white", {
+ value,
+ onchange: $input(value),
+ onkeydown: (e) => {
+ if (e.key === "Enter") {
+ $input(value)(e);
+ trigger.next(true);
+ }
+ },
+ placeholder: "User ID",
+ }),
+ anchor(
+ `.db.w-100.mt2.pa2.br2.${BUTTON_COLOR}.link.tc.pointer`,
+ { onclick: $inputTrigger(trigger) },
+ "Load messages"
+ )
+ );
+
+/**
+ * User account header/info component, displaying various details & images from
+ * the provided user account.
+ *
+ * @param account
+ */
+export const accountInfo = ({ username, name, bio, avatar, header }: Account) =>
+ div(
+ {},
+ div(
+ ".relative",
+ { style: { "padding-bottom": "33.33%" } },
+ div(".aspect-ratio--object.cover", {
+ style: { background: `url(${header}) center` },
+ }),
+ div(".absolute.z-999.bottom-0.w-100.h4", {
+ style: {
+ background:
+ "linear-gradient(to bottom, #0000 0%, #000e 100%)",
+ },
+ }),
+ img(".absolute.left-2.w-25.br-100.ba.bw3.b--black.z-999", {
+ src: avatar,
+ style: {
+ bottom: "-25%",
+ },
+ })
+ ),
+ div(
+ ".mt5.ml4.mt4-ns.ml7-ns.lh-solid",
+ {},
+ h1(".mv0", {}, name),
+ span(".f3", {}, `@${username}`)
+ ),
+ div(".mt0.mb4.mh3.ph3", {}, ...bio)
+ );
diff --git a/examples/mastodon-feed/src/components/media.ts b/examples/mastodon-feed/src/components/media.ts
new file mode 100644
index 0000000000..9a3e2a389b
--- /dev/null
+++ b/examples/mastodon-feed/src/components/media.ts
@@ -0,0 +1,124 @@
+import type { Nullable } from "@thi.ng/api";
+import { DEFAULT, defmulti } from "@thi.ng/defmulti";
+import { div, img, video } from "@thi.ng/hiccup-html";
+import { $replace } from "@thi.ng/rdom";
+import type { ISubscriber, ISubscription } from "@thi.ng/rstream";
+import type { MediaItem } from "../api.js";
+
+// CSS attribs for media preview wrapper
+const GRID_LAYOUT = {
+ style: {
+ display: "grid",
+ "grid-template-columns": "1fr 1fr",
+ gap: "1rem",
+ },
+};
+
+/**
+ * Wrapper component for all media previews. Creates a {@link mediaItem}
+ * component for each item.
+ *
+ * @param items
+ * @param selection
+ */
+export const mediaAttachments = (
+ items: MediaItem[],
+ selection: ISubscriber>
+) =>
+ div(
+ ".mb3",
+ items[0]?.type === "video" ? {} : GRID_LAYOUT,
+ ...items.map((x) => mediaItem(x, selection))
+ );
+
+/**
+ * Polymorphic component function, dynamically choosing an implementation based
+ * on media type.
+ *
+ * @remarks
+ * The 2nd argument is a target stream of media items to trigger the
+ * {@link mediaModal} overlay (currently only used for images).
+ */
+const mediaItem = defmulti>, any>(
+ (x) => x.type,
+ {},
+ {
+ [DEFAULT]: ({ type }: MediaItem) =>
+ div(".w-100", {}, `Unsupported media type: ${type}`),
+
+ image: (item, selection) =>
+ div(
+ ".relative.aspect-ratio--1x1",
+ {},
+ div(".aspect-ratio--object.cover.pointer", {
+ title: item.description,
+ style: { background: `url(${item.preview}) center` },
+ onclick: () => selection.next(item),
+ })
+ ),
+
+ gifv: ({ description, url }) =>
+ div(
+ ".relative.aspect-ratio--1x1",
+ {},
+ div(
+ ".aspect-ratio--object.overflow-hidden",
+ {},
+ video(".w-100", {
+ src: url,
+ title: description,
+ preload: "auto",
+ playsinline: true,
+ autoplay: true,
+ muted: true,
+ loop: true,
+ })
+ )
+ ),
+
+ video: ({ description, url, preview }) =>
+ video(".w-100", {
+ src: url,
+ title: description,
+ controls: true,
+ poster: preview,
+ }),
+ }
+);
+
+/**
+ * Fullscreen media modal overlay which subscribes to given stream of media
+ * items. Only enables the modal for incoming non-null values (currently only
+ * supports images).
+ *
+ * @param sel
+ */
+export const mediaModal = (
+ sel: ISubscription, Nullable>
+) =>
+ $replace(
+ sel.map((item) =>
+ item
+ ? div(
+ ".fadein.fixed.top-0.left-0.z-9999.w-100.vh-100.bg-black-90.flex.items-center.pointer",
+ {
+ // disable modal on click
+ onclick: () => sel.next(null),
+ },
+ img(".center", {
+ src: item.url,
+ style: {
+ "max-width": "100%",
+ "max-height": "100%",
+ },
+ }),
+ div(
+ ".fixed.bottom-1.left-1.pv2.ph3.mw7-l.br2.bg-black-80",
+ {},
+ item.description || "😢 No alt text provided..."
+ )
+ )
+ : // invisible div if no item
+ div(".dn", {})
+ )
+ );
diff --git a/examples/mastodon-feed/src/components/message.ts b/examples/mastodon-feed/src/components/message.ts
new file mode 100644
index 0000000000..714de424fd
--- /dev/null
+++ b/examples/mastodon-feed/src/components/message.ts
@@ -0,0 +1,32 @@
+import type { Nullable } from "@thi.ng/api";
+import { defFormat } from "@thi.ng/date";
+import { anchor, div } from "@thi.ng/hiccup-html";
+import type { ISubscriber } from "@thi.ng/rstream";
+import { unitless } from "@thi.ng/strings";
+import type { MediaItem, Message } from "../api.js";
+import { mediaAttachments } from "./media.js";
+
+const fmt = defFormat(["dd", " ", "MMM", " ", "yyyy", " ", "HH", ":", "mm"]);
+
+export const message = (
+ { url, content, media, replies, likes, boosts, date }: Message,
+ mediaSel: ISubscriber>
+) =>
+ div(
+ ".ma3.pv3.ph4.bg-near-black.ba.br3.bw1.b--white-10.overflow-x-hidden",
+ {},
+ // message body is a collection of hiccup elements (parsed & cleaned from HTML)
+ // see: transformContent() & transformMessage() functions
+ ...content,
+ // media previews
+ mediaAttachments(media, mediaSel),
+ // message metadata
+ div(
+ ".f7.gray",
+ {},
+ anchor(".link.gray", {}, `↺ ${unitless(replies)}`),
+ anchor(".link.gray.ml3", {}, `❤️ ${unitless(likes)}`),
+ anchor(".link.gray.ml3", {}, `🚀 ${unitless(boosts)}`),
+ anchor(".link.gray.ml3", { href: url }, fmt(date))
+ )
+ );
diff --git a/examples/mastodon-feed/src/html.ts b/examples/mastodon-feed/src/html.ts
new file mode 100644
index 0000000000..e3f8e86a04
--- /dev/null
+++ b/examples/mastodon-feed/src/html.ts
@@ -0,0 +1,130 @@
+import { type Fn } from "@thi.ng/api";
+import { DEFAULT, defmulti } from "@thi.ng/defmulti";
+import {
+ defContext,
+ defGrammar,
+ type ContextOpts,
+ type ParseScope,
+} from "@thi.ng/parse";
+import { unescapeEntities } from "@thi.ng/strings";
+
+export interface TransformOpts {
+ /**
+ * List of attribute names to ignore.
+ */
+ ignoreAttribs: string[];
+ /**
+ * Element transform/filter. Receives an hiccup element before its being
+ * added to its parent. The function has full freedom to replace the element
+ * with any value. If the function returns a nullish result the element will
+ * be skipped/omitted entirely.
+ */
+ tx: Fn;
+}
+
+// HTML parse grammar rules (see: thi.ng/parse readme for details)
+// playground URL:
+// https://demo.thi.ng/umbrella/parse-playground/#l9oBVGVsOiAnPCchIDxuYW1lPiA8YXR0cmliPiogKDxlbGJvZHk-IHwgPGVsdm9pZD4hICkgOwplbGJvZHk6ICc-JyEgKDxib2R5PiB8IDxlbD4pKiAiPC8iISA8bmFtZT4hICc-JyEgPT4gaG9pc3QgOwplbHZvaWQ6IDxXUzA-ICIvPiIhIDsKbmFtZTogW2EtejAtOV9cLV0rID0-IGpvaW4gOwphdHRyaWI6IDxXUzE-IDxuYW1lPiA8YXR0dmFsPj8gOwphdHR2YWw6ICc9JyEgKDx2YWw-IHwgPGVtcHR5PikgOwp2YWw6ICciJyEgLig_KyciJyEpID0-IGpvaW4gOwplbXB0eTogJyInISAnIichIDsKYm9keTogLig_LSc8JyEpID0-IGpvaW4gOwptYWluOiA8U1RBUlQ-IDxlbD4rIDxFTkQ-ID0-IGhvaXN0IDukbWFpbtk_PGRpdiBpZD0iZm9vIiBhYmMgZGF0YS14eXo9IiI-PGEgaHJlZj0iI2JhciI-YmF6PC9hPjxici8-PC9kaXY-oKCgoA
+const lang = defGrammar(`
+el: '<'! * ( | ! ) ;
+elbody: '>'! ( | )* ""! ! '>'! => hoist ;
+elvoid: "/>"! ;
+name: [a-z0-9_\\-]+ => join ;
+attrib: ? ;
+attval: '='! ( | ) ;
+val: '"'! .(?+'"'!) => join ;
+empty: '"'! '"'! ;
+body: .(?-'<'!) => join ;
+main: + => hoist ;
+`);
+
+/**
+ * Creates a parser context for given source string and calls main parser rule.
+ * Returns result object, incl. the context for further inspection.
+ *
+ * @param src
+ * @param opts
+ */
+const parseHTML = (src: string, opts?: Partial) => {
+ const ctx = defContext(src, opts);
+ return { result: lang!.rules.main(ctx), ctx };
+};
+
+/**
+ * Parses given HTML source string into a collection of elements in
+ * thi.ng/hiccup format, using provided options to transform, clean or filter
+ * elements.
+ *
+ * @param src
+ * @param opts
+ */
+export const htmlToHiccup = (
+ src: string,
+ opts: Partial = {}
+) => {
+ if (!src) return [];
+ const { result, ctx } = parseHTML(src);
+ if (!result) return [["div", {}, "Parse error @ ", ctx.state.l]];
+ const acc: any[] = [];
+ transformScope(ctx.root, opts, acc);
+ return acc;
+};
+
+/**
+ * Recursive depth-first transformation function to process the parse tree (this
+ * is where the actual conversion to hiccup format happens).
+ *
+ * @remarks
+ * The dispatch values for the various implementations here correspond to the
+ * above grammar rules.
+ *
+ * @internal
+ */
+const transformScope = defmulti<
+ ParseScope,
+ Partial,
+ any[],
+ void
+>(
+ (x) => x.id,
+ {},
+ {
+ [DEFAULT]: (scope: ParseScope) => {
+ throw new Error(`missing impl for scope ID: ${scope.id}`);
+ },
+
+ // root node of the parse tree
+ root: ({ children }, opts, acc) => {
+ if (!children) return;
+ for (let x of children[0].children!) transformScope(x, opts, acc);
+ },
+
+ // element node transformer, collects & filters attributes/children
+ // adds resulting hiccup element to accumulator array
+ el: ({ children }, opts, acc) => {
+ const [name, { children: $attribs }, body] = children!;
+ const attribs: any = {};
+ const el: any[] = [name.result, attribs];
+ if ($attribs) {
+ for (let a of $attribs) {
+ const name = a.children![0].result;
+ if (opts.ignoreAttribs?.includes(name)) continue;
+ if (a.children![1].children) {
+ const val = a.children![1].children[0].result;
+ if (val != null) attribs[name] = unescapeEntities(val);
+ } else {
+ attribs[name] = true;
+ }
+ }
+ }
+ if (body?.children) {
+ for (let x of body.children!) transformScope(x, opts, el);
+ }
+ const res = opts.tx ? opts.tx(el) : el;
+ if (res != null) acc.push(res);
+ },
+
+ // plain text transform (only resolves HTML entities)
+ body: ({ result }, _, acc) => acc.push(unescapeEntities(result)),
+ }
+);
diff --git a/examples/mastodon-feed/src/index.ts b/examples/mastodon-feed/src/index.ts
new file mode 100644
index 0000000000..f8e37257ac
--- /dev/null
+++ b/examples/mastodon-feed/src/index.ts
@@ -0,0 +1,97 @@
+import type { Nullable } from "@thi.ng/api";
+import { timed } from "@thi.ng/bench";
+import { div } from "@thi.ng/hiccup-html";
+import { $compile, $klist, $refresh } from "@thi.ng/rdom";
+import { reactive, stream } from "@thi.ng/rstream";
+import { push, transduce } from "@thi.ng/transducers";
+import type { Account, MediaItem, Message } from "./api.js";
+import { accountChooser, accountInfo } from "./components/account.js";
+import { mediaModal } from "./components/media.js";
+import { message } from "./components/message.js";
+import { transformAccount, transformMessage } from "./transforms.js";
+
+// reactive state values (various parts of the UI will subscribe to these)
+const userID = reactive("@toxi@mastodon.thi.ng");
+const loadTrigger = reactive(true);
+const messages = stream();
+const mediaSelection = reactive>(null);
+
+/**
+ * Attempts to load JSON from given URL.
+ *
+ * @param url
+ */
+const loadJSON = async (url: string) => {
+ const response = await fetch(url);
+ if (response.status < 400) {
+ return await response.json();
+ } else {
+ throw new Error(`HTTP error: ${response.status}`);
+ }
+};
+
+/**
+ * Performs Mastodon API requests for user ID/instance currently in
+ * {@link userID}. If successful, transforms received data, updates state
+ * ({@link messages}) and returns a new account info/header component.
+ */
+const loadAccount = async () => {
+ const [_, username, instance] = userID.deref()!.split("@");
+ if (!(username && instance)) return;
+ // clear existing messages in UI
+ messages.next([]);
+ // lookup account info
+ const baseURL = `https://${instance}/api/v1/accounts`;
+ const account = await loadJSON(`${baseURL}/lookup?acct=${username}`);
+ // load recent messages
+ const rawMessages = await loadJSON(
+ `${baseURL}/${account.id}/statuses?limit=50&exclude_replies=true&exclude_reblogs=true`
+ );
+ // transform raw data & place results into stream to trigger UI update
+ messages.next(
+ timed(() => transduce(transformMessage, push(), rawMessages))
+ );
+ // return account info component
+ return accountInfo(transformAccount(account));
+};
+
+// create & mount UI components
+$compile(
+ div(
+ ".w-100.w-50-l.center.lh-copy",
+ {},
+ // input form component
+ accountChooser(userID, loadTrigger),
+ // the account header component is managed by a wrapper component
+ // subscribed to the `loadTrigger` stream which will then call the above
+ // async function `loadAccount()` to retrieve the new account info &
+ // messages and then return the new contents for this header
+ // component...
+ $refresh(
+ loadTrigger,
+ loadAccount,
+ // error component ctor (if there're network or HTTP errors)
+ async (e) => div(".mv0.mh3.pa3.bg-dark-red.white", {}, e.message),
+ // preloader component ctor
+ async () => div(".mv0.mh3", {}, "loading data...")
+ ),
+ // keyed list component which subscribes to `messages` stream and
+ // transforms each item via the `message` component function. the last arg
+ // is the key function which MUST (and here does) produce a unique ID
+ // for each list item.
+ $klist(
+ messages,
+ "div",
+ {},
+ (x) => message(x, mediaSelection),
+ (x) => x.id
+ ),
+ // fullscreen media overlay component
+ mediaModal(mediaSelection)
+ )
+).mount(document.getElementById("app")!);
+
+// global event listener for stopping the modal media overlay
+window.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") mediaSelection.next(null);
+});
diff --git a/examples/mastodon-feed/src/transforms.ts b/examples/mastodon-feed/src/transforms.ts
new file mode 100644
index 0000000000..4ab2e15241
--- /dev/null
+++ b/examples/mastodon-feed/src/transforms.ts
@@ -0,0 +1,155 @@
+import { identity } from "@thi.ng/api";
+import { isString } from "@thi.ng/checks";
+import { DEFAULT, defmulti } from "@thi.ng/defmulti";
+import { truncate } from "@thi.ng/strings";
+import { comp, mapKeys, pluck, rename, step } from "@thi.ng/transducers";
+import {
+ LINK_COLOR,
+ TAG_COLOR,
+ type Account,
+ type MediaItem,
+ type Message,
+} from "./api.js";
+import { htmlToHiccup } from "./html.js";
+
+/**
+ * Polymorphic function to transform selected HTML elements (given in
+ * thi.ng/hiccup format). Dynamically chooses implementation based on element
+ * type.
+ *
+ * @remarks
+ * Some of these transformation steps here are only needed/desired because the
+ * original Mastodon HTML is quite noisy, but it also shows how easy it is to
+ * manipulate these elements once they're available in hiccup format (i.e. as
+ * plain JS arrays & objects)...
+ */
+const cleanupElement = defmulti(
+ (x) => x[0],
+ {},
+ {
+ // by default keep each element as is
+ [DEFAULT]: identity,
+
+ // transform/augment elements: `["a", {href: "...", ...}, body...]`
+ a: (el) => {
+ const isHashTag = el[1].rel === "tag";
+ let col: string;
+ if (isHashTag) {
+ const url = el[1].href;
+ el[1].onclick = (e: MouseEvent) => {
+ e.preventDefault();
+ alert(`Hashtag: ${url}`);
+ };
+ el[1].href = "#";
+ col = `${TAG_COLOR} hover-${LINK_COLOR}`;
+ } else {
+ col = LINK_COLOR;
+ el[1].target = "_blank";
+ }
+ el[1].class = `link b ${col}`;
+ // merge string children
+ for (let i = 3; i < el.length; ) {
+ if (isString(el[i - 1]) && isString(el[i])) {
+ el[i - 1] += el[i];
+ el.splice(i, 1);
+ } else {
+ i++;
+ }
+ }
+ // truncate link body (usually long URLs in this use case)
+ el[2] = truncate(32, "…")(el[2]);
+ return el;
+ },
+
+ // skip spans with `http...` body, hoist any single string bodies
+ span: (el) => {
+ if (
+ el.length < 3 ||
+ (el.length === 3 && /^https?:\/\/$/.test(el[2]))
+ )
+ return;
+ return el.length === 3 ? el[2] : el;
+ },
+ }
+);
+
+/**
+ * Transforms an HTML source string into thi.ng/hiccup format and applies above
+ * element cleanups.
+ *
+ * @param src
+ */
+const transformContent = (src: string) =>
+ htmlToHiccup(src, {
+ ignoreAttribs: ["target", "translate"],
+ tx: cleanupElement,
+ });
+
+/**
+ * Transforms & filters raw Mastodon API account info into an {@link Account}
+ * object.
+ */
+export const transformAccount = step(
+ comp(
+ mapKeys({
+ note: transformContent,
+ }),
+ rename({
+ id: true,
+ username: true,
+ url: true,
+ avatar: true,
+ header: true,
+ name: "display_name",
+ bio: "note",
+ })
+ )
+);
+
+/**
+ * A transducer which transforms raw Mastodon API data of a single message/toot
+ * into a {@link Message} object.
+ */
+export const transformMessage = comp(
+ mapKeys({
+ created_at: Date.parse,
+ tags: (tags) => [...pluck("name", tags)],
+ media_attachments: (attachments) => attachments.map(transformMedia),
+ content: transformContent,
+ reblog: (x) => !!x,
+ }),
+ rename({
+ id: true,
+ url: true,
+ content: true,
+ tags: true,
+ sensitive: true,
+ reblog: true,
+ date: "created_at",
+ media: "media_attachments",
+ likes: "favourites_count",
+ boosts: "reblogs_count",
+ replies: "replies_count",
+ })
+);
+
+/**
+ * Transforms a single media item description from raw Mastodon API data into a
+ * {@link MediaItem} object.
+ */
+const transformMedia = ({
+ id,
+ type,
+ url,
+ preview_url,
+ description,
+ meta: { small },
+}: any): MediaItem => ({
+ id,
+ type,
+ url,
+ preview: preview_url,
+ description,
+ width: small.width,
+ height: small.height,
+});
diff --git a/examples/mastodon-feed/src/vite-env.d.ts b/examples/mastodon-feed/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/mastodon-feed/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/mastodon-feed/tsconfig.json b/examples/mastodon-feed/tsconfig.json
new file mode 100644
index 0000000000..8ccc361721
--- /dev/null
+++ b/examples/mastodon-feed/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../tsconfig.json",
+ "include": ["src/**/*"],
+ "compilerOptions": {
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index a0607b3ec1..ba8305c505 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1230,6 +1230,26 @@ __metadata:
languageName: unknown
linkType: soft
+"@example/mastodon-feed@workspace:examples/mastodon-feed":
+ version: 0.0.0-use.local
+ resolution: "@example/mastodon-feed@workspace:examples/mastodon-feed"
+ dependencies:
+ "@thi.ng/api": "workspace:^"
+ "@thi.ng/bench": "workspace:^"
+ "@thi.ng/checks": "workspace:^"
+ "@thi.ng/date": "workspace:^"
+ "@thi.ng/defmulti": "workspace:^"
+ "@thi.ng/hiccup-html": "workspace:^"
+ "@thi.ng/parse": "workspace:^"
+ "@thi.ng/rdom": "workspace:^"
+ "@thi.ng/rstream": "workspace:^"
+ "@thi.ng/strings": "workspace:^"
+ "@thi.ng/transducers": "workspace:^"
+ typescript: ^5.2.2
+ vite: ^4.4.9
+ languageName: unknown
+ linkType: soft
+
"@example/multitouch@workspace:examples/multitouch":
version: 0.0.0-use.local
resolution: "@example/multitouch@workspace:examples/multitouch"