Skip to content
Merged
4 changes: 2 additions & 2 deletions packages/annotate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
"comark": "catalog:"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0"
"@comark/html": "^0.8.0",
"comark": "^0.8.0"
},
"engines": {
"node": ">=22.12.0"
Expand Down
39 changes: 1 addition & 38 deletions packages/annotate/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,6 @@ export function start(): () => void {
return overlay;
}
let armed: HTMLImageElement | null = null;
let forwarded = false;
let disarming = 0;
const disarm = () => {
clearTimeout(disarming);
Expand All @@ -541,56 +540,20 @@ export function start(): () => void {
"pointerdown",
(event) => {
disarm();
// A right-click, or a Control-click on a Mac.
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return;
const target = event.target instanceof Element ? event.target : null;
const frame = target?.closest(".ca-frame");
const figure = figureOf(frame ?? null);
const ready = figure && made.get(figure);
if (!frame || !ready || target?.closest(SKIP) || ready.key !== keyOf(figure)) return;
armed = overlayOf(frame);
forwarded = false;
if (armed.getAttribute("src") !== ready.url) armed.src = ready.url;
armed.setAttribute("data-menu", "");
// The menu opens on press on some systems and on release on others; either way it has its target by then.
disarming = window.setTimeout(disarm, 1500);
},
{ capture: true, signal },
);
document.addEventListener(
"contextmenu",
(event) => {
// Chromium can resolve the native menu target before the armed overlay starts
// receiving pointer events. Hand the event to the prepared copy as well, so
// browser image actions and listeners see the marked screenshot consistently.
if (armed && event.target !== armed && !forwarded) {
forwarded = true;
// Keep the browser's native default action (Copy Image, Save Image, etc.)
// on the original image while replacing the JavaScript notification.
event.stopImmediatePropagation();
armed.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
view: event.view,
detail: event.detail,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
button: event.button,
buttons: event.buttons,
}),
);
}
setTimeout(disarm);
},
{ capture: true, signal },
);
document.addEventListener("contextmenu", () => setTimeout(disarm), { signal });

const say = (frame: Element | null, message: string, state: "done" | "failed") => {
const button = frame?.querySelector(".ca-copy");
Expand Down
2 changes: 1 addition & 1 deletion packages/annotate/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const css = `@layer comark{${[
".ca-copy-status:empty{display:none}",
// The copy behind the browser's menu: invisible, and in the way only for a right-click.
".ca-frame .ca-overlay{position:absolute;inset:0;width:100%;height:100%;opacity:0;outline:none;pointer-events:none}",
".ca-frame .ca-overlay[data-menu]{pointer-events:auto}",
".ca-frame .ca-overlay[data-menu]{pointer-events:auto;opacity:1}",
// A copy paints the marks at rest, and nothing animates while it reads them.
"[data-ca-capture] *{transition:none!important}",
"[data-ca-capture=still] *{--ca-on:0!important}",
Expand Down
15 changes: 12 additions & 3 deletions packages/annotate/test/annotate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,24 @@ describe("comark-annotate", () => {
expect(html).toMatch(/\.ca-label\{[^}]*font-family:var\(--ca-note-font\);/);
});

it("lets Comark's security plugin clean notes", async () => {
const cleaned = async () => {
const source = `::screenshot{src="a.png" alt="A"}\n:::annotation{x=0 y=0 w=10 h=10}\nNote <img src="x" onerror="alert(1)"> <script>alert(2)</script>\n:::\n::`;
const document = await parseMarkdown(source, {
plugins: [annotate(), security({ blockedTags: ["script"] })],
});
const html = await renderHtmlFromDocument(document, { components });
return renderHtmlFromDocument(document, { components });
};

it("lets Comark's security plugin clean notes", async () => {
const html = await cleaned();
expect(html).toContain('<img src="x">');
expect(html).not.toContain("onerror");
expect(html).not.toContain("alert(2)");
});

// Comark 0.8 moves inline HTML's text out of the element inside a block, so the blocked script's code is left as
// escaped text: https://github.com/comarkdown/comark/issues/505. This starts passing, and so failing, once it's fixed.
it.fails("removes a blocked script's code with it", async () => {
expect(await cleaned()).not.toContain("alert(2)");
});

it("moves side notes beside the image with an arrow and keeps the rest in the list", async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/carousel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@
"vue": "catalog:"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0",
"@comark/html": "^0.8.0",
"comark": "^0.8.0",
"react": ">=18",
"svelte": ">=5",
"vue": ">=3.5"
Expand Down
4 changes: 2 additions & 2 deletions packages/charts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@
"comark-zoom": "workspace:*"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0"
"@comark/html": "^0.8.0",
"comark": "^0.8.0"
},
"peerDependenciesMeta": {
"@comark/html": {
Expand Down
4 changes: 2 additions & 2 deletions packages/chips/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@
"vue": "catalog:"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0",
"@comark/html": "^0.8.0",
"comark": "^0.8.0",
"react": ">=18",
"svelte": ">=5",
"vue": ">=3.5"
Expand Down
4 changes: 2 additions & 2 deletions packages/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
"vue": "catalog:"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0",
"@comark/html": "^0.8.0",
"comark": "^0.8.0",
"react": ">=18",
"svelte": ">=5.20",
"vue": ">=3.5"
Expand Down
14 changes: 13 additions & 1 deletion packages/code/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { toNativeAttributes } from "comark/utils";

/** One of your actions as the parse plugin writes it on a code block. */
export type ActionButton = { id: string; label: string };

Expand All @@ -20,6 +22,16 @@ declare global {
}
}

/**
* The attributes a component's `pre` gets, written as Comark writes them on a `pre` of its own: a fence's `language`
* and `filename` become `data-language` and `data-filename`.
*/
export function preAttributes<T extends Record<string, unknown>>(attributes: T): T {
// Components get the attributes without their node, so an HTML `<pre language>`, which Comark leaves alone, is
// treated as a fence too. Authors don't write those.
return toNativeAttributes("pre", ["pre", {}], attributes);
}

/**
* Starts the buttons of every `.ck` code block on the page and returns a function that stops them. Every caller shares
* one set of listeners, which stop with the last caller. The function is self-contained: the HTML renderer inlines its
Expand Down Expand Up @@ -97,7 +109,7 @@ export function start(css: string): () => void {
const detail: CodeActionDetail = {
action: button.getAttribute("data-code-action") ?? "",
code: code.textContent ?? "",
language: pre.getAttribute("language") ?? "",
language: pre.getAttribute("data-language") ?? "",
};
root.dispatchEvent(new CustomEvent("comark-code-action", { bubbles: true, detail }));
}
Expand Down
4 changes: 2 additions & 2 deletions packages/code/src/react.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createElement, type ReactNode, useEffect } from "react";
import { buttons, css, start } from "./client.ts";
import { buttons, css, preAttributes, start } from "./client.ts";

export { default } from "./index.ts";

Expand All @@ -18,7 +18,7 @@ export function Pre({ actions, children, ...attrs }: PreProps) {
"div",
{ className: "ck" },
// Everything but `actions` belongs to the `pre` itself, such as Shiki's class and the block's language.
createElement("pre", attrs, children),
createElement("pre", preAttributes(attrs), children),
// The bar is static markup built from escaped text, the same string every renderer uses.
createElement("div", {
className: "ck-bar",
Expand Down
4 changes: 2 additions & 2 deletions packages/code/src/svelte/Pre.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<!-- Code blocks for `@comark/svelte`. Everything but `actions` belongs to the `pre` itself. -->
<script>
import { onMount } from "svelte";
import { buttons, css, start } from "comark-code/client";
import { buttons, css, preAttributes, start } from "comark-code/client";

let { actions = [], children, ...attrs } = $props();

onMount(() => start(css));
</script>

<div class="ck">
<pre {...attrs}>{@render children?.()}</pre>
<pre {...preAttributes(attrs)}>{@render children?.()}</pre>
<!-- Static markup built from escaped text, the same string every renderer uses. -->
<div class="ck-bar">{@html buttons(actions)}</div>
</div>
4 changes: 2 additions & 2 deletions packages/code/src/vue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineComponent, h, onBeforeUnmount, onMounted } from "vue";
import { buttons, css, start } from "./client.ts";
import { buttons, css, preAttributes, start } from "./client.ts";

export { default } from "./index.ts";

Expand All @@ -17,7 +17,7 @@ export const Pre = defineComponent({
onBeforeUnmount(() => stop());
return () =>
h("div", { class: "ck" }, [
h("pre", attrs, slots.default?.()),
h("pre", preAttributes(attrs), slots.default?.()),
h("div", { class: "ck-bar", innerHTML: buttons(props.actions) }),
]);
},
Expand Down
8 changes: 5 additions & 3 deletions packages/code/test/code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("comark-code", () => {
it("wraps a code block with Wrap and Copy buttons, and leaves the block as it was", async () => {
const html = await markup(sh);
expect(html).toMatch(
/^<div class="ck"><pre language="sh"><code class="language-sh">pnpm add comark-code<\/code><\/pre><div class="ck-bar">/,
/^<div class="ck"><pre data-language="sh"><code class="language-sh">pnpm add comark-code<\/code><\/pre><div class="ck-bar">/,
);
expect(html).toContain(
'<button type="button" class="ck-wrap" aria-label="Wrap lines" aria-pressed="false" title="Wrap lines">',
Expand All @@ -42,7 +42,9 @@ describe("comark-code", () => {
const html = await createHtmlRenderer({ plugins: [code(), shiki()], components })(
"```ts [config.ts]\nconst a = 1\n```",
);
expect(html).toMatch(/<div class="ck"><pre language="ts" filename="config.ts" class="shiki /);
expect(html).toMatch(
/<div class="ck"><pre data-language="ts" data-filename="config.ts" class="shiki /,
);
expect(html).toContain("--shiki-dark:");
});

Expand Down Expand Up @@ -74,7 +76,7 @@ describe("comark-code", () => {
components: { ...tabsComponents, ...components },
})(group);
expect(html).toContain('<div class="ct" data-tabs-code>');
expect(html.match(/<div class="ck"><pre language="sh">/g)).toHaveLength(2);
expect(html.match(/<div class="ck"><pre data-language="sh">/g)).toHaveLength(2);
expect(html.match(/data-code-action="run"/g)).toHaveLength(2);
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/code/test/frameworks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("comark-code renderers", () => {
);
const expected = normalize(await renderers.html(value));
expect(expected).toContain(
'<div class="ck"><pre language="sh" filename="install.sh"><code class="language-sh">pnpm add comark-code</code></pre><div class="ck-bar"><button type="button" class="ck-action" data-code-action="run">Run</button>',
'<div class="ck"><pre data-language="sh" data-filename="install.sh"><code class="language-sh">pnpm add comark-code</code></pre><div class="ck-bar"><button type="button" class="ck-action" data-code-action="run">Run</button>',
);
expect(expected.match(/class="ck-copy"/g)).toHaveLength(2);
for (const name of ["vue", "react"] as const) {
Expand Down
4 changes: 2 additions & 2 deletions packages/embed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
"vue": "catalog:"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0",
"@comark/html": "^0.8.0",
"comark": "^0.8.0",
"react": ">=18",
"svelte": ">=5",
"vue": ">=3.5"
Expand Down
4 changes: 2 additions & 2 deletions packages/icon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@
"vue": "catalog:"
},
"peerDependencies": {
"@comark/html": "^0.7.0",
"comark": "^0.7.0",
"@comark/html": "^0.8.0",
"comark": "^0.8.0",
"react": ">=18",
"svelte": ">=5",
"vue": ">=3.5"
Expand Down
2 changes: 1 addition & 1 deletion packages/include/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"comark": "catalog:"
},
"peerDependencies": {
"comark": "^0.7.0"
"comark": "^0.8.0"
},
"engines": {
"node": ">=22.12.0"
Expand Down
10 changes: 6 additions & 4 deletions packages/include/test/include.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ describe("comark-include", () => {
],
]);
expect(await render(':include{src="src/index.ts" region="setup"}', { read })).toBe(
'<pre language="ts"><code class="language-ts">const a = 1\nconst b = 2</code></pre>',
'<pre data-language="ts"><code class="language-ts">const a = 1\nconst b = 2</code></pre>',
);
expect(await render(':include{src="src/index.ts" lines="1:1" lang="js"}', { read })).toBe(
`<pre language="js"><code class="language-js">import { x } from 'x'</code></pre>`,
`<pre data-language="js"><code class="language-js">import { x } from 'x'</code></pre>`,
);
});

Expand Down Expand Up @@ -124,7 +124,7 @@ describe("comark-include", () => {
it("indents pasted lines like the include, so a partial stays in its list item", async () => {
const read = files({ "step.md": "Run it:\n\n```sh\npnpm i\n```" });
expect(await render('- One\n\n :include{src="step.md"}\n- Two', { read })).toBe(
'<ul>\n <li>\n <p>One</p>\n <p>Run it:</p>\n <pre language="sh"><code class="language-sh">pnpm i</code></pre>\n </li>\n <li>Two</li>\n</ul>',
'<ul>\n <li>\n <p>One</p>\n <p>Run it:</p>\n <pre data-language="sh"><code class="language-sh">pnpm i</code></pre>\n </li>\n <li>Two</li>\n</ul>',
);
});

Expand Down Expand Up @@ -166,7 +166,9 @@ describe("comark-include", () => {
const source = ["# #region setup", 'end = "#endregion setup"', "# #endregion setup"].join("\n");
expect(
await render(':include{src="a.py" region="setup"}', { read: files({ "a.py": source }) }),
).toBe('<pre language="py"><code class="language-py">end = "#endregion setup"</code></pre>');
).toBe(
'<pre data-language="py"><code class="language-py">end = "#endregion setup"</code></pre>',
);
});

it("drops a failing include and names it with the fix", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"vite-doctor": "catalog:"
},
"peerDependencies": {
"comark": "^0.7.0",
"comark": "^0.8.0",
"comark-annotate": "workspace:^",
"comark-carousel": "workspace:^",
"comark-charts": "workspace:^",
Expand Down
Loading
Loading