From ffd424e617ce7db6b199972d28adbc1f28658ff9 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 14 Sep 2026 23:27:32 +0500 Subject: [PATCH 1/9] build: skip comment stripping for dependency modules --- rollup.config.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rollup.config.js b/rollup.config.js index 68b6be5..43d743c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -91,7 +91,11 @@ function create_configuration({ plugin_name, input, format, optimize }) { function remove_comments() { return { name: "remove_comments", - transform(source) { + transform(source, id) { + if (id.includes("node_modules")) { + return; + } + return { code: strip_comments(source, {}) }; From 35915c476ad9530065041f6381fc5ece6d92c697 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 14 Sep 2026 23:39:18 +0500 Subject: [PATCH 2/9] plugin/markdown: share the JSON options parser --- src/plugins/safehtml/index.js | 27 ++++++--------------------- src/utilities/options.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 src/utilities/options.js diff --git a/src/plugins/safehtml/index.js b/src/plugins/safehtml/index.js index 8b0d932..05ee148 100644 --- a/src/plugins/safehtml/index.js +++ b/src/plugins/safehtml/index.js @@ -1,21 +1,23 @@ import DOMPurify from "dompurify"; -import { is_array, warn } from "@/utilities/utils"; +import { parse_options } from "@/utilities/options"; +const directive_name = "safehtml"; const meta_options_selectors = "meta[name='alpinegear-safehtml-options']"; const data_options_attribute = "data-safehtml-options"; function plugin({ bind, directive, mutateDom: mutate_dom, prefixed }) { let global_options; - directive("safehtml", (el, { expression }, { effect, evaluateLater: evaluate_later }) => { + directive(directive_name, (el, { expression }, { effect, evaluateLater: evaluate_later }) => { global_options ??= parse_options( document.querySelector(meta_options_selectors)?.content, - meta_options_selectors); + meta_options_selectors, + directive_name); const evaluate = evaluate_later(expression); const options = { ...global_options, - ...parse_options(el.getAttribute(data_options_attribute), data_options_attribute), + ...parse_options(el.getAttribute(data_options_attribute), data_options_attribute, directive_name), RETURN_DOM: false, RETURN_DOM_FRAGMENT: false, IN_PLACE: false @@ -37,23 +39,6 @@ function plugin({ bind, directive, mutateDom: mutate_dom, prefixed }) { }); } -function parse_options(value, source) { - if (value) { - try { - let options = JSON.parse(value); - if (options && typeof options === "object" && !is_array(options)) { - return options; - } - } - catch { - // Report the same error for malformed JSON and unsupported JSON values - } - } - - value && warn(`x-safehtml options in '${source}' must be a valid JSON object`); - return {}; -} - export default plugin; export { plugin as safehtml diff --git a/src/utilities/options.js b/src/utilities/options.js new file mode 100644 index 0000000..e236af8 --- /dev/null +++ b/src/utilities/options.js @@ -0,0 +1,18 @@ +import { is_array, warn } from "./utils"; + +export function parse_options(value, source, directive) { + if (value) { + try { + const options = JSON.parse(value); + if (options && typeof options === "object" && !is_array(options)) { + return options; + } + } + catch { + // Report the same error for malformed JSON and unsupported JSON values + } + } + + value && warn(`x-${directive} options in '${source}' must be a valid JSON object`); + return {}; +} From 062694e6c640f9934698b31d7ffca7aefe47b60e Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 14 Sep 2026 23:44:16 +0500 Subject: [PATCH 3/9] plugin/markdown: add x-markdown directive backed by Tanstack Markdown --- package.json | 1 + pnpm-lock.yaml | 16 ++++++ src/plugins/markdown/README.md | 1 + src/plugins/markdown/index.js | 81 +++++++++++++++++++++++++++++++ src/plugins/markdown/package.json | 28 +++++++++++ 5 files changed, 127 insertions(+) create mode 100644 src/plugins/markdown/README.md create mode 100644 src/plugins/markdown/index.js create mode 100644 src/plugins/markdown/package.json diff --git a/package.json b/package.json index 8118baf..3d90baf 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-virtual": "^3.0.2", + "@tanstack/markdown": "^0.0.14", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.1", "@vitest/coverage-v8": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b57a718..4a735f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@rollup/plugin-virtual': specifier: ^3.0.2 version: 3.0.2(rollup@4.63.1) + '@tanstack/markdown': + specifier: ^0.0.14 + version: 0.0.14 '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -544,6 +547,17 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@tanstack/markdown@0.0.14': + resolution: {integrity: sha512-NNjOoA7Zbmh3LG6MTxTBX3zM8sGe5bMfH3xxX5oCt1zh75TiCiaQFoQ4FKSNNhVKqpD0zzNd+qNdmA+sXR5XTw==} + peerDependencies: + octane: '>=0.1.12' + react: '>=18' + peerDependenciesMeta: + octane: + optional: true + react: + optional: true + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2189,6 +2203,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@tanstack/markdown@0.0.14': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 diff --git a/src/plugins/markdown/README.md b/src/plugins/markdown/README.md new file mode 100644 index 0000000..b5bb305 --- /dev/null +++ b/src/plugins/markdown/README.md @@ -0,0 +1 @@ +# @ramstack/alpinegear-markdown diff --git a/src/plugins/markdown/index.js b/src/plugins/markdown/index.js new file mode 100644 index 0000000..93f986e --- /dev/null +++ b/src/plugins/markdown/index.js @@ -0,0 +1,81 @@ +import { renderHtml as render_html } from "@tanstack/markdown/html"; +import { parse_options } from "@/utilities/options"; +import { has_modifier, is_template, warn } from "@/utilities/utils"; + +const directive_name = "markdown"; +const meta_options_selectors = "meta[name='alpinegear-markdown-options']"; +const data_options_attribute = "data-markdown-options"; +const content_modifiers = ["content", "static"]; +const supported_options = ["allowHtml", "frontmatter", "headingIds", "headingAnchors", "codeLineNumbers"]; + +function plugin({ bind, directive, mutateDom: mutate_dom, prefixed }) { + let global_options; + + directive(directive_name, (el, { expression, modifiers }, { effect, evaluateLater: evaluate_later }) => { + if (is_template(el)) { + warn("x-markdown cannot be used on a 'template' tag"); + return; + } + + const has_content_modifier = content_modifiers.some(mod => has_modifier(modifiers, mod)); + + if (expression && has_content_modifier) { + warn("x-markdown cannot combine an expression with the '.content' or '.static' modifier"); + return; + } + + if (!expression && !has_content_modifier) { + warn("x-markdown requires an expression or the '.content' or '.static' modifier"); + return; + } + + global_options ??= pick_options(parse_options( + document.querySelector(meta_options_selectors)?.content, + meta_options_selectors, + directive_name)); + + const options = { + ...global_options, + ...pick_options(parse_options( + el.getAttribute(data_options_attribute), + data_options_attribute, + directive_name)) + }; + + const render = value => { + const html = render_html(value, options); + + mutate_dom(() => { + el.innerHTML = html; + + + const ignore = prefixed("ignore"); + for (let child of el.children) { + child.setAttribute(ignore, ""); + bind(child, { [ignore]: "" }); + } + }); + }; + + if (expression) { + const evaluate = evaluate_later(expression); + effect(() => evaluate(value => render(String(value || "")))); + } + else { + render(el.textContent.trim()); + } + }); +} + +function pick_options(options) { + return Object.fromEntries( + supported_options + .filter(k => k in options) + .map(k => [k, options[k]]) + ); +} + +export default plugin; +export { + plugin as markdown +} diff --git a/src/plugins/markdown/package.json b/src/plugins/markdown/package.json new file mode 100644 index 0000000..a574fc9 --- /dev/null +++ b/src/plugins/markdown/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ramstack/alpinegear-markdown", + "version": "0.0.0", + "description": "@ramstack/alpinegear-markdown provides the 'x-markdown' Alpine.js directive", + "author": "Rameel Burhan", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rameel/ramstack.alpinegear.js.git", + "directory": "src/plugins/markdown" + }, + "keywords": [ + "alpine.js", + "alpinejs", + "markdown", + "tanstack-markdown", + "alpinejs-directive", + "alpinejs-plugin" + ], + "exports": { + ".": { + "import": { + "production": "./alpinegear-markdown.esm.min.js", + "default": "./alpinegear-markdown.esm.js" + } + } + } +} From 57c7ff792726cbd2ed45cfdd43b104d934c4f0c4 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 15 Sep 2026 14:35:31 +0500 Subject: [PATCH 4/9] plugin/markdown: add tests --- tests/playwright/assets/page.html | 1 + tests/playwright/x-markdown.spec.js | 177 ++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 tests/playwright/x-markdown.spec.js diff --git a/tests/playwright/assets/page.html b/tests/playwright/assets/page.html index 233bf43..c7e44f5 100644 --- a/tests/playwright/assets/page.html +++ b/tests/playwright/assets/page.html @@ -17,6 +17,7 @@ + diff --git a/tests/playwright/x-markdown.spec.js b/tests/playwright/x-markdown.spec.js new file mode 100644 index 0000000..1f214f0 --- /dev/null +++ b/tests/playwright/x-markdown.spec.js @@ -0,0 +1,177 @@ +import { expect, test } from "@playwright/test"; +import { set_html } from "./assets/utils"; + +test("x-markdown renders reactive Markdown", async ({ page }) => { + await set_html(page, ` +
+ +
+
`); + + const content = page.locator("#content"); + + await expect(content.locator("h1")).toHaveText("Hello world"); + await expect(content.locator("h1")).toHaveAttribute("id", "hello-world"); + await expect(content.locator("strong")).toHaveText("world"); + + await page.locator("button").click(); + + await expect(content.locator("h2")).toHaveText("Updated text"); + await expect(content.locator("em")).toHaveText("text"); +}); + +test("x-markdown escapes raw HTML and removes unsafe URLs by default", async ({ page }) => { + await set_html(page, ` +
+
+
`); + + const content = page.locator("#content"); + + await expect(content.locator("img")).not.toBeAttached(); + await expect(content.locator("script")).not.toBeAttached(); + await expect(content.locator("a")).not.toBeAttached(); + await expect(content).toContainText(""); + await expect(content).toContainText("link"); + expect(await page.evaluate(() => window.injected)).toBeUndefined(); +}); + +test("x-markdown renders raw HTML with the allowHtml option", async ({ page }) => { + await set_html(page, ` +
+
+
`); + + const content = page.locator("#content"); + + await expect(content.locator("br")).toBeAttached(); + await expect(content.locator("strong")).toHaveText("world"); +}); + +test("x-markdown.content and x-markdown.static render the element content", async ({ page }) => { + await set_html(page, ` +
+ # Hello + + Some **bold** text +
+ +
## Alias
`); + + const content = page.locator("#content"); + + await expect(content.locator("h1")).toHaveText("Hello"); + await expect(content.locator("strong")).toHaveText("bold"); + + await expect(page.locator("#static h2")).toHaveText("Alias"); +}); + +test("x-markdown forwards supported render options and ignores unknown ones", async ({ page }) => { + await set_html(page, ` +
+
+
`); + + const content = page.locator("#content"); + + await expect(content.locator("h1 a")).toHaveAttribute("href", "#title"); + await expect(content.locator("h1 a")).toHaveClass(/anchor-heading/); +}); + +test("x-markdown merges global and local options", async ({ page }) => { + await set_html(page, ` + + +
+
+
+
`); + + await expect(page.locator("#global em")).toHaveText("raw"); + await expect(page.locator("#global strong")).toHaveText("markdown"); + + await expect(page.locator("#local em")).not.toBeAttached(); + await expect(page.locator("#local")).toContainText("raw"); + await expect(page.locator("#local strong")).toHaveText("markdown"); +}); + +test("x-markdown ignores invalid local options", async ({ page }) => { + const warnings = []; + page.on("console", message => message.type() === "warning" && warnings.push(message.text())); + + await set_html(page, ` +
+
+
`); + + await expect(page.locator("#content em")).not.toBeAttached(); + await expect(page.locator("#content")).toContainText("escaped"); + + expect(warnings).toEqual([ + "alpinegear.js: x-markdown options in 'data-markdown-options' must be a valid JSON object" + ]); +}); + +test("x-markdown warns without an expression or content modifier", async ({ page }) => { + const warnings = []; + page.on("console", message => message.type() === "warning" && warnings.push(message.text())); + + await set_html(page, ` +
# Hello
`); + + await expect(page.locator("#content")).toHaveText("# Hello"); + + expect(warnings).toEqual([ + "alpinegear.js: x-markdown requires an expression or the '.content' or '.static' modifier" + ]); +}); + +test("x-markdown warns when an expression is combined with a content modifier", async ({ page }) => { + const warnings = []; + page.on("console", message => message.type() === "warning" && warnings.push(message.text())); + + await set_html(page, ` +
+
+
`); + + await expect(page.locator("#content")).toBeEmpty(); + + expect(warnings).toEqual([ + "alpinegear.js: x-markdown cannot combine an expression with the '.content' or '.static' modifier" + ]); +}); + +test("x-markdown cannot be used on a 'template' tag", async ({ page }) => { + const warnings = []; + page.on("console", message => message.type() === "warning" && warnings.push(message.text())); + + await set_html(page, ` + `); + + expect(await page.evaluate(() => document.getElementById("content").innerHTML)).toBe("# Hello"); + expect(page.locator("h1")).not.toBeAttached(); + + expect(warnings).toEqual([ + "alpinegear.js: x-markdown cannot be used on a 'template' tag" + ]); +}); + +test("x-markdown ignores Alpine directives in rendered HTML", async ({ page }) => { + await set_html(page, ` +
+
+
`); + + const button = page.locator("#content button"); + + await expect(button).toHaveText("Safe"); + await expect(button).toHaveAttribute("x-init", "window.injected=true"); + await expect(button).toHaveAttribute("x-ignore", ""); + + await page.evaluate(() => Alpine.initTree(document.querySelector("#content button"))); + + expect(await page.evaluate(() => window.injected)).toBeUndefined(); +}); From 56d98260bbb59b011c32af5b9627943861e4aa29 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 15 Sep 2026 15:49:48 +0500 Subject: [PATCH 5/9] Add README --- README.md | 14 ++++ src/plugins/markdown/README.md | 105 ++++++++++++++++++++++++++++++ src/plugins/markdown/package.json | 2 +- 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b59d08..0c039d3 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,20 @@ Provides the `x-safehtml` directive, which sanitizes reactive HTML with DOMPurif ``` +**[@ramstack/alpinegear-markdown](https://www.npmjs.com/package/@ramstack/alpinegear-markdown)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/markdown))
+Provides the `x-markdown` directive, which renders reactive Markdown with TanStack Markdown. + +```html +
+
+ +
+ ## Rendered from the element content +
+
+``` + + **[@ramstack/alpinegear-hotkey](https://www.npmjs.com/package/@ramstack/alpinegear-hotkey)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/hotkey))
Provides the `x-hotkey` directive, allowing easily handle keyboard shortcuts. diff --git a/src/plugins/markdown/README.md b/src/plugins/markdown/README.md index b5bb305..9829117 100644 --- a/src/plugins/markdown/README.md +++ b/src/plugins/markdown/README.md @@ -1 +1,106 @@ # @ramstack/alpinegear-markdown + +`@ramstack/alpinegear-markdown` provides the `x-markdown` Alpine.js directive. +It renders Markdown with [TanStack Markdown](https://github.com/TanStack/markdown), which is bundled into the plugin. + +## Installation + +### Using CDN + +Include the plugin before Alpine.js: + +```html + + +``` + +### Using NPM + +```bash +npm install --save @ramstack/alpinegear-markdown +``` + +```js +import Alpine from "alpinejs"; +import markdown from "@ramstack/alpinegear-markdown"; + +Alpine.plugin(markdown); +Alpine.start(); +``` + +## Usage + +### Expression + +```html +
+
+
+``` + +Whenever `content` changes, the directive renders the new value and replaces the element's contents. + +### Element content + +Use the `.content` modifier to render the element's own text as Markdown. +The `.static` modifier is an alias: + +```html +
+ # Hello + + Some **bold** text +
+``` + +The source is taken from `textContent` once, when the directive is initialized. + +The directive must not be used on a `