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"