Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))<br>
Provides the `x-markdown` directive, which renders reactive Markdown with TanStack Markdown.

```html
<div x-data="{ content: '# Hello **world**' }">
<div x-markdown="content"></div>

<div x-markdown.content>
## Rendered from the element content
</div>
</div>
```


**[@ramstack/alpinegear-hotkey](https://www.npmjs.com/package/@ramstack/alpinegear-hotkey)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/hotkey))<br>
Provides the `x-hotkey` directive, allowing easily handle keyboard shortcuts.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {})
};
Expand Down
106 changes: 106 additions & 0 deletions src/plugins/markdown/README.md
Original file line number Diff line number Diff line change
@@ -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
<script src="https://cdn.jsdelivr.net/npm/@ramstack/alpinegear-markdown@1/alpinegear-markdown.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
```

### 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
<div x-data="{ content: '# Hello **world**' }">
<div x-markdown="content"></div>
</div>
```

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
<div x-markdown.content>
# Hello

Some **bold** text
</div>
```

The source is taken from `textContent` once, when the directive is initialized.

The directive must not be used on a `<template>` 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
<meta
name="alpinegear-markdown-options"
content='{ "allowHtml": true }'
>
```

Options for an individual element can be provided with `data-markdown-options`:

```html
<div
x-markdown="content"
data-markdown-options='{ "allowHtml": false }'
></div>
```

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.
100 changes: 100 additions & 0 deletions src/plugins/markdown/index.js
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions src/plugins/markdown/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
27 changes: 6 additions & 21 deletions src/plugins/safehtml/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/utilities/options.js
Original file line number Diff line number Diff line change
@@ -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 {};
}
1 change: 1 addition & 0 deletions tests/playwright/assets/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<script src="../../../dist/destroy/alpinegear-destroy.js"></script>
<script src="../../../dist/hotkey/alpinegear-hotkey.js"></script>
<script src="../../../dist/safehtml/alpinegear-safehtml.js"></script>
<script src="../../../dist/markdown/alpinegear-markdown.js"></script>
<script src="../../../dist/typegrab/alpinegear-typegrab.js"></script>
<script src="alpine.3.14.1.js"></script>
</body>
Expand Down
Loading
Loading