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/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/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, {})
};
diff --git a/src/plugins/markdown/README.md b/src/plugins/markdown/README.md
new file mode 100644
index 0000000..9829117
--- /dev/null
+++ b/src/plugins/markdown/README.md
@@ -0,0 +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 `` tag. It also cannot combine an expression with `.content` or `.static` modifier.
+In both cases the directive logs a warning and does nothing.
+
+The rendered content is treated as static HTML. Each top-level element is marked with the current Alpine `x-ignore`
+attribute and ignored immediately, so Alpine does not initialize its subtree even if `Alpine.initTree()` is called later.
+
+## Configuration
+
+Global TanStack Markdown options can be declared in a `meta` element:
+
+```html
+
+```
+
+Options for an individual element can be provided with `data-markdown-options`:
+
+```html
+
+```
+
+Both settings are static JSON objects read when the directive is initialized.
+Element options override global options using a shallow merge.
+
+The following options are supported:
+
+| Option | Type | Default | Description |
+| --- | --- | --- | --- |
+| `allowHtml` | `boolean` | `false` | Emit raw HTML instead of escaping it |
+| `frontmatter` | `boolean` | `true` | Extract a leading `---` frontmatter block |
+| `headingIds` | `boolean` | `true` | Generate stable IDs for headings |
+| `headingAnchors` | `boolean \| object` | `false` | Append anchor links to headings with IDs |
+| `codeLineNumbers` | `boolean` | `false` | Forward the line numbers preference to code blocks |
+
+Any other keys, including non-serializable TanStack Markdown options such as `urlTransform`, `highlighter`, and `extensions`, are ignored.
+
+> [!WARNING]
+> `allowHtml: true` is a trusted-content boundary.
+> Rendered HTML can contain native event handlers, and the directive only prevents Alpine from initializing the subtree.
+> Do not enable it for untrusted user content.
+
+## License
+
+This package is released under the MIT License.
diff --git a/src/plugins/markdown/index.js b/src/plugins/markdown/index.js
new file mode 100644
index 0000000..baaf18b
--- /dev/null
+++ b/src/plugins/markdown/index.js
@@ -0,0 +1,100 @@
+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",
+ "codeLineNumbers",
+ "frontmatter",
+ "headingAnchors",
+ "headingIds"
+];
+
+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(strip_indent(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);
+ }
+ });
+}
+
+function pick_options(options) {
+ return Object.fromEntries(
+ supported_options
+ .filter(k => k in options)
+ .map(k => [k, options[k]])
+ );
+}
+
+function strip_indent(text) {
+ let indent = min_indent(text);
+ if (indent) {
+ const regex = new RegExp(`^[ \\t\\r\\f\\v]{${indent}}`, "gm");
+ text = text.replace(regex, "");
+ }
+
+ return text.trim();
+}
+
+function min_indent(v) {
+ return v.match(/^[ \t\r\f\v]*(?=\S)/gm)?.reduce((r, s) => Math.min(r, s.length), Number.MAX_SAFE_INTEGER) ?? 0;
+}
+
+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..4389d89
--- /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 for rendering Markdown with TanStack Markdown.",
+ "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"
+ }
+ }
+ }
+}
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 {};
+}
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 @@
+