// Glue for `type: prosekit` requirement fields (see AlternativeCard in // src/app.rs) - mounts a headless ProseKit (ProseMirror) rich-text // editor into a container element, then mirrors its rendered HTML into // a paired hidden on every change, firing a real `input` event // on it. That lets the Rust side reuse the exact same RwSignal/on:input // wiring every other requirement kind already goes through, instead of // needing its own submit-time collection path. // // Loaded straight from a CDN as an ES module (no bundler in this // project, same pattern as yes.js) - prosekit ships pre-bundled ESM, // and re-exports the ProseMirror packages it's built on under // `prosekit/pm/*`, so importing primitives (InputRule, etc.) from there // instead of a separate prosemirror-* package guarantees they're the // exact same module instance the editor itself uses. // // Least-verified part of this file (worth an actual hwatu pass after // deploy, not just trusting the docs): the exact shape `createEditor` // wants for `defaultContent` when seeding from an HTML string, and the // precise `definePasteHandler` callback signature. Everything else here // mirrors prosekit's own documented examples closely. import 'https://esm.sh/prosekit/basic/style.css' import 'https://esm.sh/prosekit/basic/typography.css' import { defineBasicExtension } from 'https://esm.sh/prosekit/basic' import { createEditor, defineNodeSpec, definePasteHandler, defineUpdateHandler, union, } from 'https://esm.sh/prosekit/core' // project.uhhm.no//, no trailing path - a bare repo link, // not a file/issue/PR link within one (those aren't "the repo", and // trying to card-ify them too isn't what was asked for). const GITEA_REPO_URL_RE = /^https:\/\/project\.uhhm\.no\/([\w.-]+)\/([\w.-]+)\/?$/ // Loads each stylesheet once regardless of how many editors this page // mounts - tags, not a JS import, since plain `import 'x.css'` // isn't valid outside a bundler and esm.sh's CSS-as-JS support isn't // worth depending on here. function ensureStylesheet(href) { if (document.querySelector(`link[href="${href}"]`)) return const link = document.createElement('link') link.rel = 'stylesheet' link.href = href document.head.appendChild(link) } function defineGiteaRepoEmbed() { return defineNodeSpec({ name: 'giteaRepoEmbed', group: 'block', atom: true, selectable: true, attrs: { owner: { default: '' }, repo: { default: '' }, description: { default: '' }, url: { default: '' }, }, parseDOM: [ { tag: 'div[data-gitea-repo]', getAttrs: (dom) => ({ owner: dom.getAttribute('data-owner') || '', repo: dom.getAttribute('data-repo') || '', description: dom.getAttribute('data-description') || '', url: dom.getAttribute('data-url') || '', }), }, ], // Plain inline styles, not a class - this same markup is what ends // up as the newsletter's HTML body, read by an email client with no // access to this app's stylesheet. toDOM: (node) => { const { owner, repo, description, url } = node.attrs return [ 'div', { 'data-gitea-repo': 'true', 'data-owner': owner, 'data-repo': repo, 'data-description': description, 'data-url': url, style: 'display:block;border:1px solid #ddd;border-radius:8px;' + 'padding:12px 16px;margin:8px 0;font-family:inherit;', }, [ 'a', { href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' }, `${owner}/${repo}`, ], ['div', { style: 'opacity:0.7;font-size:0.9em;margin-top:4px;' }, description], ] }, }) } // Resolves owner/repo via portal's own /gitea-repo handler // (content::gitea_repo_handler in src/content.rs), not Gitea's API // directly - keeps this editor from needing a CORS allowance on // Gitea's side, and matches every other backing store in this app // being server-resolved only. async function resolveGiteaRepo(owner, repo) { const res = await fetch( `/gitea-repo?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`, ) if (!res.ok) { throw new Error(`resolve_gitea_repo failed with status ${res.status}`) } return await res.json() } function defineGiteaRepoEmbedPasteRule() { return definePasteHandler((view, event) => { const text = event.clipboardData?.getData('text/plain')?.trim() const match = text && text.match(GITEA_REPO_URL_RE) if (!match) return false event.preventDefault() const [, owner, repo] = match const pos = view.state.selection.from resolveGiteaRepo(owner, repo) .then((info) => { const nodeType = view.state.schema.nodes.giteaRepoEmbed if (!nodeType) return const node = nodeType.create(info) const tr = view.state.tr.replaceRangeWith(pos, pos, node) view.dispatch(tr) }) .catch((err) => { console.error('gitea repo embed failed', err) }) return true }) } export function mountEditor(container, hiddenInput, initial) { ensureStylesheet('https://esm.sh/prosekit/basic/style.css') ensureStylesheet('https://esm.sh/prosekit/basic/typography.css') const extension = union( defineBasicExtension(), defineGiteaRepoEmbed(), defineGiteaRepoEmbedPasteRule(), ) const editor = createEditor({ extension, // Seeds from the hidden input's current value (only ever non-empty // when re-mounting content that already went through this same // editor, since that's the only place this HTML shape comes from). defaultContent: initial || undefined, }) const sync = () => { const html = editor.view.dom.innerHTML if (hiddenInput.value === html) return hiddenInput.value = html hiddenInput.dispatchEvent(new Event('input', { bubbles: true })) } editor.use(defineUpdateHandler(sync)) editor.mount(container) sync() }