diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 9bdb898..52a026a 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -80,6 +80,7 @@ jobs:
# (no target/ prefix) - override so the running binary looks
# in the right place for /pkg/*.
LEPTOS_SITE_ROOT=site
+ AUTOMATION_READ_TOKEN=${{ secrets.PORTAL_AUTOMATION_READ_TOKEN }}
EOF
# No sudo: the runner's own unit sets NoNewPrivileges=yes, which
@@ -93,10 +94,22 @@ jobs:
# No sudo here either - the runner is already in the `docker` group,
# so it can talk to the Docker socket directly.
+ # Apex and www are separate cookie scopes (no shared Domain
+ # attribute on the session cookie), but Kanidm's redirect_uri is
+ # fixed to PUBLIC_URL - a login started on the other host set its
+ # session cookie there, then landed on PUBLIC_URL's callback with
+ # an empty session ("no login in progress"). Redirecting www to
+ # the naked domain keeps every visit on one canonical host
+ # instead - PUBLIC_URL (repo variable) is set to https://{$DOMAIN}
+ # to match.
- name: Update Caddy routing
run: |
cat > /etc/caddy/services.d/uhhm-portal.caddy <<'EOF'
- www.{$DOMAIN}, {$DOMAIN} {
+ www.{$DOMAIN} {
+ redir https://{$DOMAIN}{uri} permanent
+ }
+
+ {$DOMAIN} {
reverse_proxy host.docker.internal:3010
log {
output file /var/log/caddy/www.log
diff --git a/prosekit-editor.js b/prosekit-editor.js
new file mode 100644
index 0000000..60d2561
--- /dev/null
+++ b/prosekit-editor.js
@@ -0,0 +1,169 @@
+// 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()
+}
diff --git a/src/app.rs b/src/app.rs
index 37d4c3d..293534e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -198,6 +198,31 @@ mod yes {
}
}
+// Same idiomatic-typed-module approach as `yes` above, for the
+// prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
+// project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative
+// to the crate root, not the `public/` static-asset dir, same as
+// `yes.js` below). `mount_editor` hands the editor a container node
+// plus the paired
+// hidden `` it mirrors its HTML into and fires real `input`
+// events on - see `mount_editor`'s call site in `AlternativeCard`, and
+// the JS module itself for why a hidden-input bridge rather than a
+// bespoke Rust<->JS value channel.
+#[cfg(feature = "hydrate")]
+mod prosekit {
+ use wasm_bindgen::prelude::*;
+
+ #[wasm_bindgen(module = "/prosekit-editor.js")]
+ extern "C" {
+ #[wasm_bindgen(js_name = mountEditor)]
+ pub fn mount_editor(
+ container: &web_sys::HtmlDivElement,
+ hidden: &web_sys::HtmlInputElement,
+ initial: &str,
+ );
+ }
+}
+
#[component]
fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the
@@ -427,6 +452,8 @@ fn AlternativeCard(
};
+ let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
+
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
return view! {
@@ -445,7 +472,38 @@ fn AlternativeCard(
.into_any();
}
- let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
+ if req.kind == "prosekit" {
+ let container_ref: NodeRef = NodeRef::new();
+ let hidden_ref: NodeRef = NodeRef::new();
+
+ #[cfg(feature = "hydrate")]
+ {
+ Effect::new(move |_| {
+ let (Some(container), Some(hidden)) =
+ (container_ref.get(), hidden_ref.get())
+ else {
+ return;
+ };
+ prosekit::mount_editor(&container, &hidden, &sig.get_untracked());
+ });
+ }
+
+ return view! {
+
+ }
+ .into_any();
+ }
+
view! {