refactor: replace command link interception with :cmd[] directive

Replace the previous mechanism of intercepting specific command-prefixed
anchor tags with a dedicated `:cmd[]` markdown directive.

- Implement `cmdDirective` for `marked-directive` support.
- Update `CommandLinkManager` to listen for clicks on `[data-cmd]` spans
  instead of parsing `href` attributes.
- Add CSS styles for the new `.cmd-link` class.
This commit is contained in:
2026-07-12 14:05:07 +08:00
parent 182d7ff28d
commit 42e8971ff7
4 changed files with 69 additions and 50 deletions
+16 -50
View File
@@ -1,10 +1,9 @@
/**
* CommandLinkManager — mounts as a child of <Article> and intercepts
* clicks on command-like links (>roll, >spark, >link, >stat) to send
* them as journal stream messages instead of navigating.
* clicks on :cmd[] directive spans to send them as journal stream
* messages.
*
* Uses capture-phase event delegation so it fires before the Article
* component's own link interception (which would otherwise navigate).
* Uses capture-phase event delegation on [data-cmd] spans.
*/
import { Component, onCleanup } from "solid-js";
@@ -22,34 +21,9 @@ import {
findStatDef,
} from "./journal/stat-helpers";
/** Commands that get intercepted as stream messages. */
const COMMAND_PREFIXES = [">roll", ">spark", ">link", ">stat"];
/**
* Extract the command text from an href.
* Percent-encoded spaces (%20) are decoded automatically by decodeURIComponent.
*/
function hrefToCommand(href: string): string | null {
// Exclude external URLs and pure anchors
if (/^(https?:|\/\/)/i.test(href)) return null;
if (href === "#" || href.startsWith("#!")) return null;
// Decode percent-encoding (%20 → space, etc.)
let cleaned = decodeURIComponent(href);
// Strip trailing .md extension (markdown links may include it)
if (cleaned.endsWith(".md")) {
cleaned = cleaned.slice(0, -3);
}
// Only consider links starting with a command prefix
if (!COMMAND_PREFIXES.some((p) => cleaned.startsWith(p))) return null;
return cleaned.trim();
}
// We need unwrap helper for sendMessage results
function unwrap<R>(r: { success: true; msg: R } | { success: false; error: string }) {
function unwrap<R>(
r: { success: true; msg: R } | { success: false; error: string },
) {
return r.success
? ({ ok: true, err: undefined } as const)
: ({ ok: false, err: r.error } as const);
@@ -66,26 +40,20 @@ export const CommandLinkManager: Component = () => {
// ---- Click interceptor (capture phase) ----
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a[href]");
if (!anchor) return;
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
if (!cmdSpan) return;
const href = anchor.getAttribute("href");
if (!href) return;
const command = hrefToCommand(href);
const command = cmdSpan.getAttribute("data-cmd");
if (!command) return;
// This is a command link — intercept it
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
dispatchCommand(command);
};
const dom = contentDom();
if (dom) {
// Use capture phase to beat the Article component's bubble-phase handler
dom.addEventListener("click", onClick, true);
}
@@ -96,10 +64,7 @@ export const CommandLinkManager: Component = () => {
// ---- Command dispatch ----
function dispatchCommand(raw: string) {
// Convert > prefix to / so parseInput understands it
const command = raw.startsWith(">") ? "/" + raw.slice(1) : raw;
function dispatchCommand(command: string) {
// Observers: everything goes as chat
if (isObserver()) {
sendMessage("chat", { text: command });
@@ -112,7 +77,7 @@ export const CommandLinkManager: Component = () => {
// Players: chat + stat commands only
if (isPlayer()) {
if (parsed.type === "chat") {
sendMessage("chat", { text: raw });
sendMessage("chat", { text: command });
return;
}
if (parsed.type === "stat") {
@@ -138,7 +103,7 @@ export const CommandLinkManager: Component = () => {
const p = await resolveSparkPayload({ key, csvPath, remix });
sendMessage("spark", p);
} catch {
// silently ignore spark table errors from links
// silently ignore spark table errors from directive clicks
}
})();
} else if (parsed.type === "stat") {
@@ -148,13 +113,14 @@ export const CommandLinkManager: Component = () => {
}
}
/** Resolve bare key -> full key (copied from JournalInput for standalone use). */
/** Resolve bare key -> full key. */
function resolveKey(
inputKey: string,
statDefs: typeof comp.data.stats,
playerName: string,
): string {
if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey;
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
return inputKey;
const def = statDefs.find((d) => d.key === inputKey);
if (def) return fullKey(def, playerName);
return inputKey;
@@ -218,7 +184,7 @@ export const CommandLinkManager: Component = () => {
}
}
return null; // renders nothing — purely event-based
return null;
};
export default CommandLinkManager;