kempo-ui 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/components/Markdown.js +1 -0
  2. package/docs/components/accordion.html +4 -0
  3. package/docs/components/aside.html +4 -0
  4. package/docs/components/calendar.html +4 -0
  5. package/docs/components/card.html +4 -0
  6. package/docs/components/chat.html +4 -0
  7. package/docs/components/code-editor.html +4 -0
  8. package/docs/components/color-picker.html +4 -0
  9. package/docs/components/combobox.html +4 -0
  10. package/docs/components/content-slider.html +4 -0
  11. package/docs/components/context.html +4 -0
  12. package/docs/components/dialog.html +4 -0
  13. package/docs/components/dropdown.html +4 -0
  14. package/docs/components/filter-list.html +4 -0
  15. package/docs/components/focus-capture.html +4 -0
  16. package/docs/components/html-editor.html +4 -0
  17. package/docs/components/hybrid-component.html +4 -0
  18. package/docs/components/icon.html +4 -0
  19. package/docs/components/import.html +4 -0
  20. package/docs/components/light-component.html +4 -0
  21. package/docs/components/markdown-editor.html +4 -0
  22. package/docs/components/markdown.html +650 -0
  23. package/docs/components/nav-spacer.html +4 -0
  24. package/docs/components/nav.html +4 -0
  25. package/docs/components/photo-viewer.html +4 -0
  26. package/docs/components/progress.html +4 -0
  27. package/docs/components/resize.html +4 -0
  28. package/docs/components/shadow-component.html +4 -0
  29. package/docs/components/show-more.html +4 -0
  30. package/docs/components/slider.html +4 -0
  31. package/docs/components/sortable.html +4 -0
  32. package/docs/components/speech-to-text.html +4 -0
  33. package/docs/components/spinner.html +4 -0
  34. package/docs/components/split.html +4 -0
  35. package/docs/components/table.html +4 -0
  36. package/docs/components/tableControls.html +4 -0
  37. package/docs/components/tableCustomFields.html +4 -0
  38. package/docs/components/tableFetchRecords.html +4 -0
  39. package/docs/components/tableFieldSortHide.html +4 -0
  40. package/docs/components/tablePagination.html +4 -0
  41. package/docs/components/tablePlaceholder.html +4 -0
  42. package/docs/components/tableRecordEditing.html +4 -0
  43. package/docs/components/tableRecordFiltering.html +4 -0
  44. package/docs/components/tableRecordHiding.html +4 -0
  45. package/docs/components/tableRecordSearching.html +4 -0
  46. package/docs/components/tableRecordSelection.html +4 -0
  47. package/docs/components/tableRowControls.html +4 -0
  48. package/docs/components/tableServerSync.html +4 -0
  49. package/docs/components/tableSorting.html +4 -0
  50. package/docs/components/tabs.html +4 -0
  51. package/docs/components/tags.html +4 -0
  52. package/docs/components/text-to-speech.html +4 -0
  53. package/docs/components/theme-select.html +4 -0
  54. package/docs/components/theme-switcher.html +4 -0
  55. package/docs/components/time.html +4 -0
  56. package/docs/components/timestamp.html +4 -0
  57. package/docs/components/toast.html +4 -0
  58. package/docs/components/toggle.html +4 -0
  59. package/docs/components/tree.html +4 -0
  60. package/docs/components/voice-selector.html +4 -0
  61. package/docs/index.html +10 -0
  62. package/docs/src/components/Markdown.js +1 -0
  63. package/docs/utils/context.html +4 -0
  64. package/docs/utils/cookie.html +4 -0
  65. package/docs/utils/debounce.html +4 -0
  66. package/docs/utils/drag.html +4 -0
  67. package/docs/utils/elevation.html +4 -0
  68. package/docs/utils/formatTimestamp.html +4 -0
  69. package/docs/utils/object.html +4 -0
  70. package/docs/utils/propConverters.html +4 -0
  71. package/docs/utils/sanitizeHtml.html +4 -0
  72. package/docs/utils/string.html +4 -0
  73. package/docs/utils/theme.html +4 -0
  74. package/docs/utils/toTitleCase.html +4 -0
  75. package/docs/utils/type.html +4 -0
  76. package/docs/utils/voice.html +4 -0
  77. package/docs/utils/wait.html +4 -0
  78. package/docs-src/components/markdown.page.html +161 -0
  79. package/docs-src/index.page.html +6 -0
  80. package/docs-src/nav.fragment.html +4 -0
  81. package/llms.txt +1 -0
  82. package/package.json +1 -1
  83. package/src/components/Markdown.js +125 -0
  84. package/tests/components/Markdown.browser-test.js +292 -0
@@ -0,0 +1,161 @@
1
+ <page pageName="Markdown" title="Markdown - Components - Kempo Docs - A Web Components Solution">
2
+ <content>
3
+ <k-accordion persistent-id="toc" class="b r mb">
4
+ <k-accordion-header for-panel="toc-panel">Table of Contents</k-accordion-header>
5
+ <k-accordion-panel name="toc-panel">
6
+ <div class="m pl">
7
+ <h6>Examples</h6>
8
+ <a href="#basicUsage">Basic Usage</a><br />
9
+ <a href="#valueAttr">Value Attribute</a><br />
10
+ <a href="#breaks">Line Breaks</a><br />
11
+ <a href="#tagFiltering">Tag Filtering</a><br />
12
+ <a href="#dynamic">Updating At Runtime</a><br />
13
+
14
+ <h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
15
+ <a href="#constructor">Constructor</a><br />
16
+ <a href="#requirements">Requirements</a><br />
17
+ <a href="#properties">Properties</a><br />
18
+ <a href="#methods">Methods</a><br />
19
+ </div>
20
+ </k-accordion-panel>
21
+ </k-accordion>
22
+
23
+ <p>The <code>k-markdown</code> component takes its child text content (or a <code>value</code> attribute), parses it as markdown using <a href="https://marked.js.org" target="_blank">marked</a> (full CommonMark + GFM &mdash; tables, task lists, fenced code, autolinks, inline HTML), runs the result through <a href="{{pathToRoot}}utils/sanitizeHtml.html"><code>sanitizeHtml</code></a>, and renders the HTML in place. It renders to the light DOM so page-level typography styles cascade naturally into headings, paragraphs, tables, and so on.</p>
24
+ <p>Common leading whitespace is automatically stripped from the source so you can indent the markdown to match the surrounding HTML without breaking parsing.</p>
25
+
26
+ <h3 id="basicUsage"><a href="#basicUsage" class="no-link">Basic Usage</a></h3>
27
+ <p>Drop markdown text inside the tag and it will be replaced with the rendered HTML when the element connects.</p>
28
+ <div class="row -mx mb">
29
+ <div class="col d-span-6 m-span-12 px">
30
+ <k-card label="HTML">
31
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-markdown</span>&gt;</span><br> # Hello World<br><br> This is **bold** and *italic* text.<br><br> - list item 1<br> - list item 2<br><span class="hljs-tag">&lt;/<span class="hljs-name">k-markdown</span>&gt;</span></code></pre>
32
+ </k-card>
33
+ </div>
34
+ <div class="col d-span-6 m-span-12 px">
35
+ <k-card label="Output">
36
+ <k-markdown>
37
+ # Hello World
38
+
39
+ This is **bold** and *italic* text.
40
+
41
+ - list item 1
42
+ - list item 2
43
+ </k-markdown>
44
+ </k-card>
45
+ </div>
46
+ </div>
47
+
48
+ <h3 id="valueAttr"><a href="#valueAttr" class="no-link">Value Attribute</a></h3>
49
+ <p>Skip the children and pass markdown directly via the <code>value</code> attribute when you only need a single line. The attribute also takes precedence over child content if both are provided.</p>
50
+ <div class="row -mx mb">
51
+ <div class="col d-span-6 m-span-12 px">
52
+ <k-card label="HTML">
53
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-markdown</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;Visit [Kempo UI](https://github.com/dustinpoissant/kempo-ui) for more.&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">k-markdown</span>&gt;</span></code></pre>
54
+ </k-card>
55
+ </div>
56
+ <div class="col d-span-6 m-span-12 px">
57
+ <k-card label="Output">
58
+ <k-markdown value="Visit [Kempo UI](https://github.com/dustinpoissant/kempo-ui) for more."></k-markdown>
59
+ </k-card>
60
+ </div>
61
+ </div>
62
+
63
+ <h3 id="breaks"><a href="#breaks" class="no-link">Line Breaks</a></h3>
64
+ <p>By default, single newlines are joined into a paragraph (CommonMark behavior). Add the <code>breaks</code> attribute to convert single newlines to <code>&lt;br&gt;</code> &mdash; the Slack/iMessage convention &mdash; useful for chat-style content.</p>
65
+ <div class="row -mx mb">
66
+ <div class="col d-span-6 m-span-12 px">
67
+ <k-card label="HTML">
68
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-markdown</span> <span class="hljs-attr">breaks</span>&gt;</span><br> Line one<br> Line two<br> Line three<br><span class="hljs-tag">&lt;/<span class="hljs-name">k-markdown</span>&gt;</span></code></pre>
69
+ </k-card>
70
+ </div>
71
+ <div class="col d-span-6 m-span-12 px">
72
+ <k-card label="Output">
73
+ <k-markdown breaks>
74
+ Line one
75
+ Line two
76
+ Line three
77
+ </k-markdown>
78
+ </k-card>
79
+ </div>
80
+ </div>
81
+
82
+ <h3 id="tagFiltering"><a href="#tagFiltering" class="no-link">Tag Filtering</a></h3>
83
+ <p>The rendered HTML is sanitized with the same <code>allowed-tags</code> / <code>disallowed-tags</code> controls used by <a href="{{pathToRoot}}components/markdown-editor.html"><code>k-markdown-editor</code></a>. Pass a comma-separated allowlist (or <code>*</code> for everything), or a comma-separated denylist &mdash; the two are mutually exclusive. <code>&lt;script&gt;</code>, <code>&lt;iframe&gt;</code>, <code>&lt;style&gt;</code>, and similar tags are always stripped unless you opt in with <code>scripts-enabled</code> (which only restores <code>&lt;script&gt;</code>).</p>
84
+ <div class="row -mx mb">
85
+ <div class="col d-span-6 m-span-12 px">
86
+ <k-card label="HTML">
87
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-markdown</span> <span class="hljs-attr">allowed-tags</span>=<span class="hljs-string">&quot;p,strong,em,a&quot;</span>&gt;</span><br> Only **bold**, *italic*, [links](https://example.com), and paragraphs are kept.<br><br> # This heading will be unwrapped<br><span class="hljs-tag">&lt;/<span class="hljs-name">k-markdown</span>&gt;</span></code></pre>
88
+ </k-card>
89
+ </div>
90
+ <div class="col d-span-6 m-span-12 px">
91
+ <k-card label="Output">
92
+ <k-markdown allowed-tags="p,strong,em,a">
93
+ Only **bold**, *italic*, [links](https://example.com), and paragraphs are kept.
94
+
95
+ # This heading will be unwrapped
96
+ </k-markdown>
97
+ </k-card>
98
+ </div>
99
+ </div>
100
+
101
+ <h3 id="dynamic"><a href="#dynamic" class="no-link">Updating At Runtime</a></h3>
102
+ <p>Set the <code>value</code> property in JavaScript to re-render with new markdown.</p>
103
+ <div class="row -mx mb">
104
+ <div class="col d-span-6 m-span-12 px">
105
+ <k-card label="HTML">
106
+ <pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">k-markdown</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;dynamicMd&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;Click the button.&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">k-markdown</span>&gt;</span><br><span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;dynamicMdBtn&quot;</span>&gt;</span>Update<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;module&quot;</span>&gt;</span><span class="language-javascript"><br> <span class="hljs-keyword">const</span> md = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;dynamicMd&#x27;</span>);<br> <span class="hljs-keyword">const</span> btn = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;dynamicMdBtn&#x27;</span>);<br> <span class="hljs-keyword">let</span> toggled = <span class="hljs-literal">false</span>;<br> btn.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;click&#x27;</span>, <span class="hljs-function">() =&gt;</span> {<br> toggled = !toggled;<br> md.<span class="hljs-property">value</span> = toggled<br> ? <span class="hljs-string">&#x27;## Updated!\n\nThis was set via JavaScript with **markdown** and a [link](https://example.com).&#x27;</span><br> : <span class="hljs-string">&#x27;Click the button.&#x27;</span>;<br> });<br></span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></code></pre>
107
+ </k-card>
108
+ </div>
109
+ <div class="col d-span-6 m-span-12 px">
110
+ <k-card label="Output">
111
+ <k-markdown id="dynamicMd" value="Click the button."></k-markdown>
112
+ <button id="dynamicMdBtn" class="btn primary mt">Update</button>
113
+ </k-card>
114
+ </div>
115
+ </div>
116
+
117
+ <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
118
+
119
+ <h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
120
+ <h6>Extends LightComponent</h6>
121
+
122
+ <h3 id="requirements"><a href="#requirements" class="no-link">Requirements</a></h3>
123
+ <ul>
124
+ <li><a href="{{pathToRoot}}components/light-component.html">LightComponent</a></li>
125
+ <li><code>marked</code> (vendored at <code>src/utils/marked.esm.js</code>) via <a href="{{pathToRoot}}utils/renderMarkdown.js"><code>renderMarkdown</code></a></li>
126
+ <li><a href="{{pathToRoot}}utils/sanitizeHtml.html"><code>sanitizeHtml</code></a></li>
127
+ </ul>
128
+
129
+ <h3 id="properties"><a href="#properties" class="no-link">Properties</a></h3>
130
+ <h5><code>value<i>: string</i></code></h5>
131
+ <p>The markdown source. If empty when the element connects, the component falls back to the dedented child text content. Setting this property at runtime re-renders the output.</p>
132
+ <h5><code>breaks<i>: boolean</i></code></h5>
133
+ <p>When <code>true</code>, single newlines convert to <code>&lt;br&gt;</code> instead of being joined into a paragraph. Defaults to <code>false</code>. Reflects to the <code>breaks</code> attribute.</p>
134
+ <h5><code>allowedTags<i>: string</i></code></h5>
135
+ <p>Comma-separated allowlist for the rendered HTML. Pass <code>*</code> to allow every tag. Mutually exclusive with <code>disallowedTags</code> &mdash; if both are set, <code>allowedTags</code> wins and a console warning is logged. Reflects to the <code>allowed-tags</code> attribute.</p>
136
+ <h5><code>disallowedTags<i>: string</i></code></h5>
137
+ <p>Comma-separated denylist &mdash; everything is allowed except the listed tags. Reflects to the <code>disallowed-tags</code> attribute.</p>
138
+ <h5><code>scriptsEnabled<i>: boolean</i></code></h5>
139
+ <p>When present, <code>&lt;script&gt;</code> tags survive sanitization (provided they also pass the allow/deny tag check). Defaults to off so script content can never reach the DOM. Reflects to the <code>scripts-enabled</code> attribute.</p>
140
+
141
+ <h3 id="methods"><a href="#methods" class="no-link">Methods</a></h3>
142
+ <h5><code>renderedHtml<i>: string</i></code> (getter)</h5>
143
+ <p>Returns the sanitized HTML string that was rendered &mdash; useful for copying the output or feeding it into another component.</p>
144
+ </content>
145
+ <content location="scripts">
146
+ <script type="module" src="{{pathToRoot}}src/components/Markdown.js"></script>
147
+ <script type="module" src="{{pathToRoot}}src/components/Accordion.js"></script>
148
+ <script type="module" src="{{pathToRoot}}src/components/Card.js"></script>
149
+ <script type="module">
150
+ const md = document.getElementById('dynamicMd');
151
+ const btn = document.getElementById('dynamicMdBtn');
152
+ let toggled = false;
153
+ btn.addEventListener('click', () => {
154
+ toggled = !toggled;
155
+ md.value = toggled
156
+ ? '## Updated!\n\nThis was set via JavaScript with **markdown** and a [link](https://example.com).'
157
+ : 'Click the button.';
158
+ });
159
+ </script>
160
+ </content>
161
+ </page>
@@ -125,6 +125,12 @@
125
125
  <p class="tc-muted">WYSIWYG editor with formatting tools and dual editing modes.</p>
126
126
  </a>
127
127
  </div>
128
+ <div class="span-12 t-span-6 d-span-4 px">
129
+ <a href="{{pathToRoot}}components/markdown.html" class="card mb no-link d-b">
130
+ <h3 class="tc-primary">Markdown</h3>
131
+ <p class="tc-muted">Renders the markdown text inside the element as sanitized HTML in place.</p>
132
+ </a>
133
+ </div>
128
134
  <div class="span-12 t-span-6 d-span-4 px">
129
135
  <a href="{{pathToRoot}}components/markdown-editor.html" class="card mb no-link d-b">
130
136
  <h3 class="tc-primary">Markdown Editor</h3>
@@ -82,6 +82,9 @@
82
82
  <k-filter-item filter-keywords="html editor components"><a
83
83
  href="{{pathToRoot}}components/html-editor.html">HTML
84
84
  Editor<br><small>Component</small></a></k-filter-item>
85
+ <k-filter-item filter-keywords="markdown render parser marked gfm components"><a
86
+ href="{{pathToRoot}}components/markdown.html"
87
+ >Markdown<br><small>Component</small></a></k-filter-item>
85
88
  <k-filter-item filter-keywords="markdown editor markdowneditor write preview marked gfm tables components"><a
86
89
  href="{{pathToRoot}}components/markdown-editor.html"
87
90
  >Markdown Editor<br><small>Component</small></a></k-filter-item>
@@ -292,6 +295,7 @@
292
295
  <a href="{{pathToRoot}}components/filter-list.html">Filter List</a>
293
296
  <a href="{{pathToRoot}}components/focus-capture.html">FocusCapture</a>
294
297
  <a href="{{pathToRoot}}components/html-editor.html">HTML Editor</a>
298
+ <a href="{{pathToRoot}}components/markdown.html">Markdown</a>
295
299
  <a href="{{pathToRoot}}components/markdown-editor.html">Markdown Editor</a>
296
300
  <a href="{{pathToRoot}}components/icon.html">Icon</a>
297
301
  <a href="{{pathToRoot}}components/import.html">Import</a>
package/llms.txt CHANGED
@@ -67,6 +67,7 @@ import { html, css, LitElement } from 'kempo-ui/src/lit-all.min.js';
67
67
  | `<k-filter-list>` `<k-filter-item>` | `FilterList.js` `FilterItem.js` | Filter/search list; items have `filter` attribute with keywords | [filter-list.html](https://dustinpoissant.github.io/kempo-ui/components/filter-list.html) |
68
68
  | `<k-focus-capture>` | `FocusCapture.js` | Traps keyboard focus within children | [focus-capture.html](https://dustinpoissant.github.io/kempo-ui/components/focus-capture.html) |
69
69
  | `<k-html-editor>` | `HtmlEditor.js` | WYSIWYG rich text editor; `value` property; `controls` attribute (`full`/`normal`/`minimal`/`none`) for built-in toolbars; `disabled`/`readonly`/`required` (Lexical's `setEditable()` + Monaco's `readOnly`); form-associated | [html-editor.html](https://dustinpoissant.github.io/kempo-ui/components/html-editor.html) |
70
+ | `<k-markdown>` | `Markdown.js` | Renders child markdown text as sanitized HTML in place; `value` attribute (overrides children); `breaks` (single newline → `<br>`); `allowed-tags`/`disallowed-tags`/`scripts-enabled` for sanitization; light DOM render; `renderedHtml` getter | [markdown.html](https://dustinpoissant.github.io/kempo-ui/components/markdown.html) |
70
71
  | `<k-markdown-editor>` | `MarkdownEditor.js` | Markdown editor with Write/Preview tabs; raw markdown is the form value; rendered with vendored `marked` (full CommonMark + GFM — tables, task lists, fenced code, autolinks, inline HTML) via `renderMarkdown.js`; then sanitized via `sanitizeHtml` using either `allowed-tags` (comma-separated allowlist; pass `*` for everything) OR `disallowed-tags` (denylist) — mutually exclusive, allowlist wins if both set; defaults cover standard markdown output incl. img/hr/table family; `<script>` is always stripped unless `scripts-enabled` attribute is set; iframe/style/etc. are always stripped; `value`, `name`, `placeholder`, `disabled`/`readonly`/`required`, `mode` (`write`/`preview`), `allowed-tags`, `controls` (`'minimal'`/`'normal'`/`'full'`/`'none'` — pre-built toolbar; auto-imports the relevant control modules; user-slotted children override the preset); methods `focus()`, `blur()`, `clear()`, `setMode()`, `togglePreview()`, plus selection helpers `getSelection()`, `replaceSelection()`, `wrapSelection()`, `insertAtCursor()`, `insertLinePrefix()`; `controls-top`/`controls-bottom` slots for toolbar buttons; built-in controls under `markdownEditorControls/` (`k-md-bold`, `k-md-italic`, `k-md-strikethrough` (`~~...~~`), `k-md-heading` w/ `level`, `k-md-code`, `k-md-link`, `k-md-image` w/ `popup` (opens a Dropdown by default — `popup="dialog"` to switch to modal Dialog — for URL + alt text), `k-md-table` (small Dropdown with cols × rows inputs + Create button, inserts a GFM markdown table), `k-md-menu` w/ `label` (toolbar button that opens a Dropdown of slotted child controls; `trigger` slot fully customizes the button content — defaults to a `menu` icon), `k-md-format-block` w/ `tag` (heading picker designed to live inside `k-md-menu` — `h1`-`h6`), `k-md-speech-to-text` w/ `language`/`continuous` (wraps `<k-speech-to-text>`; final transcript is inserted at the cursor when the user stops speaking), `k-md-bullet-list`, `k-md-numbered-list`, `k-md-quote`); extend `MarkdownEditorControl` for custom buttons (incl. ones that insert raw HTML — adjust `allowed-tags` accordingly); fires `input`, `change`, `mode-change` | [markdown-editor.html](https://dustinpoissant.github.io/kempo-ui/components/markdown-editor.html) |
71
72
  | `<k-icon>` | `Icon.js` | SVG icon; `name` (looked up in `pathsToIcons`) or `src` (direct URL); `rotation`, `direction`, `animation` attributes | [icon.html](https://dustinpoissant.github.io/kempo-ui/components/icon.html) |
72
73
  | `<k-import>` | `Import.js` | Includes external HTML fragments; `src` attribute | [import.html](https://dustinpoissant.github.io/kempo-ui/components/import.html) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -0,0 +1,125 @@
1
+ import { html, unsafeHTML } from '../lit-all.min.js';
2
+ import LightComponent from './LightComponent.js';
3
+ import renderMarkdown from '../utils/renderMarkdown.js';
4
+ import sanitizeHtml, { STRIP_COMPLETELY } from '../utils/sanitizeHtml.js';
5
+
6
+ /*
7
+ k-markdown
8
+
9
+ Reads markdown from its child text content (or the `value` attribute) and
10
+ renders the HTML equivalent in-place. Markdown is parsed with the vendored
11
+ `marked` (CommonMark + GFM) and the resulting HTML is run through
12
+ `sanitizeHtml` before being inserted, with the same allowed-tags /
13
+ disallowed-tags / scripts-enabled controls as MarkdownEditor's preview.
14
+
15
+ Renders to the light DOM so page-level typography and styles cascade into
16
+ headings, paragraphs, tables, etc. without extra wiring.
17
+ */
18
+
19
+ export default class Markdown extends LightComponent {
20
+ static properties = {
21
+ value: { type: String },
22
+ breaks: { type: Boolean, reflect: true },
23
+ allowedTags: { type: String, reflect: true, attribute: 'allowed-tags' },
24
+ disallowedTags: { type: String, reflect: true, attribute: 'disallowed-tags' },
25
+ scriptsEnabled: { type: Boolean, reflect: true, attribute: 'scripts-enabled' }
26
+ };
27
+
28
+ #captured = false;
29
+
30
+ /*
31
+ Lifecycle Callbacks
32
+ */
33
+ constructor() {
34
+ super();
35
+ this.value = '';
36
+ this.breaks = false;
37
+ this.allowedTags = '';
38
+ this.disallowedTags = '';
39
+ this.scriptsEnabled = false;
40
+ }
41
+
42
+ connectedCallback() {
43
+ // Capture and clear the original markdown children BEFORE Lit appends
44
+ // its lightRoot div — once super runs, lightRoot lives among the
45
+ // siblings we'd otherwise scan. After capture, the markdown lives on
46
+ // the `value` property; the source nodes are dropped so the parsed
47
+ // HTML doesn't render alongside its raw source.
48
+ if(!this.#captured){
49
+ this.#captured = true;
50
+ const text = this.textContent || '';
51
+ const dedented = dedent(text);
52
+ while(this.firstChild) this.removeChild(this.firstChild);
53
+ if(!this.hasAttribute('value') && !this.value && dedented){
54
+ this.value = dedented;
55
+ }
56
+ }
57
+ super.connectedCallback();
58
+ }
59
+
60
+ /*
61
+ Public Methods
62
+ */
63
+ get renderedHtml() {
64
+ const opts = {};
65
+ const resolved = this.#resolvedAllowedTags;
66
+ if(resolved) opts.allowedTags = resolved;
67
+ if(this.scriptsEnabled){
68
+ const stripCompletely = new Set(STRIP_COMPLETELY);
69
+ stripCompletely.delete('SCRIPT');
70
+ opts.stripCompletely = stripCompletely;
71
+ }
72
+ return sanitizeHtml(renderMarkdown(this.value || '', { breaks: this.breaks }), opts);
73
+ }
74
+
75
+ /*
76
+ Utility
77
+ */
78
+ get #resolvedAllowedTags() {
79
+ const allow = (this.allowedTags || '').trim();
80
+ const deny = (this.disallowedTags || '').trim();
81
+ if(allow && deny){
82
+ console.warn('[k-markdown] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`.');
83
+ }
84
+ if(allow){
85
+ if(allow === '*') return { has: () => true };
86
+ return new Set(allow.split(',').map(t => t.trim().toUpperCase()).filter(Boolean));
87
+ }
88
+ if(deny){
89
+ const denySet = new Set(deny.split(',').map(t => t.trim().toUpperCase()).filter(Boolean));
90
+ return { has: (tag) => !denySet.has(tag) };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /*
96
+ Rendering
97
+ */
98
+ renderLightDom() {
99
+ if(!this.value) return html``;
100
+ return html`${unsafeHTML(this.renderedHtml)}`;
101
+ }
102
+ }
103
+
104
+ /*
105
+ Strip a common leading indent from every line so users can indent the
106
+ markdown source to match the surrounding HTML without breaking parsing.
107
+ Also trims fully-blank leading/trailing lines.
108
+ */
109
+ const dedent = (text) => {
110
+ const lines = text.split('\n');
111
+ while(lines.length && lines[0].trim() === '') lines.shift();
112
+ while(lines.length && lines[lines.length - 1].trim() === '') lines.pop();
113
+ if(!lines.length) return '';
114
+ let minIndent = Infinity;
115
+ for(const line of lines){
116
+ if(line.trim().length === 0) continue;
117
+ const match = line.match(/^(\s*)/);
118
+ const indent = match ? match[1].length : 0;
119
+ if(indent < minIndent) minIndent = indent;
120
+ }
121
+ if(minIndent === Infinity) minIndent = 0;
122
+ return lines.map(l => l.slice(minIndent)).join('\n');
123
+ };
124
+
125
+ customElements.define('k-markdown', Markdown);
@@ -0,0 +1,292 @@
1
+ import Markdown from '../../src/components/Markdown.js';
2
+ import LightComponent from '../../src/components/LightComponent.js';
3
+
4
+ const createMarkdown = async (innerHTML = '', attrs = {}) => {
5
+ const container = document.createElement('div');
6
+ const el = document.createElement('k-markdown');
7
+ for(const [key, value] of Object.entries(attrs)){
8
+ el.setAttribute(key, value);
9
+ }
10
+ if(innerHTML) el.innerHTML = innerHTML;
11
+ container.appendChild(el);
12
+ document.body.appendChild(container);
13
+ await el.updateComplete;
14
+ return { container, el };
15
+ };
16
+
17
+ const cleanup = (container) => {
18
+ if(container && container.parentNode){
19
+ container.parentNode.removeChild(container);
20
+ }
21
+ };
22
+
23
+ export default {
24
+ /*
25
+ Element Creation
26
+ */
27
+ 'should create k-markdown element': async ({pass, fail}) => {
28
+ const { container, el } = await createMarkdown();
29
+ if(!(el instanceof Markdown)){
30
+ cleanup(container);
31
+ return fail('Element should be instance of Markdown');
32
+ }
33
+ cleanup(container);
34
+ pass('k-markdown element created correctly');
35
+ },
36
+
37
+ 'should extend LightComponent': async ({pass, fail}) => {
38
+ const { container, el } = await createMarkdown();
39
+ if(!(el instanceof LightComponent)){
40
+ cleanup(container);
41
+ return fail('Markdown should extend LightComponent');
42
+ }
43
+ cleanup(container);
44
+ pass('Markdown extends LightComponent');
45
+ },
46
+
47
+ /*
48
+ Default Properties
49
+ */
50
+ 'should have empty default value': async ({pass, fail}) => {
51
+ const { container, el } = await createMarkdown();
52
+ if(el.value !== ''){
53
+ cleanup(container);
54
+ return fail(`Expected value to be '', got '${el.value}'`);
55
+ }
56
+ cleanup(container);
57
+ pass('Default value is empty string');
58
+ },
59
+
60
+ 'should have breaks default false': async ({pass, fail}) => {
61
+ const { container, el } = await createMarkdown();
62
+ if(el.breaks !== false){
63
+ cleanup(container);
64
+ return fail(`Expected breaks to be false, got ${el.breaks}`);
65
+ }
66
+ cleanup(container);
67
+ pass('Default breaks is false');
68
+ },
69
+
70
+ /*
71
+ Children → value capture
72
+ */
73
+ 'should capture markdown from child text content': async ({pass, fail}) => {
74
+ const { container, el } = await createMarkdown('# Hello');
75
+ if(el.value !== '# Hello'){
76
+ cleanup(container);
77
+ return fail(`Expected value '# Hello', got '${el.value}'`);
78
+ }
79
+ cleanup(container);
80
+ pass('Captures markdown from children');
81
+ },
82
+
83
+ 'should dedent indented child markdown': async ({pass, fail}) => {
84
+ const container = document.createElement('div');
85
+ container.innerHTML = `<k-markdown>
86
+ # Title
87
+
88
+ Some text
89
+ </k-markdown>`;
90
+ document.body.appendChild(container);
91
+ const el = container.querySelector('k-markdown');
92
+ await el.updateComplete;
93
+ const expected = '# Title\n\nSome text';
94
+ if(el.value !== expected){
95
+ cleanup(container);
96
+ return fail(`Expected dedented value '${expected}', got '${el.value}'`);
97
+ }
98
+ cleanup(container);
99
+ pass('Indented child markdown is dedented');
100
+ },
101
+
102
+ 'value attribute takes precedence over children': async ({pass, fail}) => {
103
+ const container = document.createElement('div');
104
+ container.innerHTML = `<k-markdown value="from attr"># From children</k-markdown>`;
105
+ document.body.appendChild(container);
106
+ const el = container.querySelector('k-markdown');
107
+ await el.updateComplete;
108
+ if(el.value !== 'from attr'){
109
+ cleanup(container);
110
+ return fail(`Expected value 'from attr', got '${el.value}'`);
111
+ }
112
+ cleanup(container);
113
+ pass('value attribute beats children');
114
+ },
115
+
116
+ /*
117
+ Rendering
118
+ */
119
+ 'should render markdown to HTML in light DOM': async ({pass, fail}) => {
120
+ const { container, el } = await createMarkdown('# Heading');
121
+ const heading = el.querySelector('h1');
122
+ if(!heading){
123
+ cleanup(container);
124
+ return fail(`Expected h1 in rendered output, got: ${el.innerHTML}`);
125
+ }
126
+ if(heading.textContent !== 'Heading'){
127
+ cleanup(container);
128
+ return fail(`Expected h1 text 'Heading', got '${heading.textContent}'`);
129
+ }
130
+ cleanup(container);
131
+ pass('Renders markdown heading as h1 in light DOM');
132
+ },
133
+
134
+ 'should render bold and italic': async ({pass, fail}) => {
135
+ const { container, el } = await createMarkdown('This is **bold** and *italic*.');
136
+ const strong = el.querySelector('strong');
137
+ const em = el.querySelector('em');
138
+ if(!strong || strong.textContent !== 'bold'){
139
+ cleanup(container);
140
+ return fail(`Expected <strong>bold</strong>, got: ${el.innerHTML}`);
141
+ }
142
+ if(!em || em.textContent !== 'italic'){
143
+ cleanup(container);
144
+ return fail(`Expected <em>italic</em>, got: ${el.innerHTML}`);
145
+ }
146
+ cleanup(container);
147
+ pass('Bold and italic render correctly');
148
+ },
149
+
150
+ 'should render lists': async ({pass, fail}) => {
151
+ const container = document.createElement('div');
152
+ container.innerHTML = `<k-markdown>
153
+ - one
154
+ - two
155
+ - three
156
+ </k-markdown>`;
157
+ document.body.appendChild(container);
158
+ const el = container.querySelector('k-markdown');
159
+ await el.updateComplete;
160
+ const items = el.querySelectorAll('ul > li');
161
+ if(items.length !== 3){
162
+ cleanup(container);
163
+ return fail(`Expected 3 li elements, got ${items.length}: ${el.innerHTML}`);
164
+ }
165
+ cleanup(container);
166
+ pass('Lists render with three items');
167
+ },
168
+
169
+ 'should clear original child markdown source': async ({pass, fail}) => {
170
+ const { container, el } = await createMarkdown('# Hello');
171
+ if(el.textContent.includes('# Hello')){
172
+ cleanup(container);
173
+ return fail(`Original markdown source should be removed, got textContent: '${el.textContent}'`);
174
+ }
175
+ cleanup(container);
176
+ pass('Original child markdown is cleared');
177
+ },
178
+
179
+ /*
180
+ Re-rendering on value change
181
+ */
182
+ 'should re-render when value property changes': async ({pass, fail}) => {
183
+ const { container, el } = await createMarkdown('first');
184
+ el.value = '## Updated';
185
+ await el.updateComplete;
186
+ const h2 = el.querySelector('h2');
187
+ if(!h2 || h2.textContent !== 'Updated'){
188
+ cleanup(container);
189
+ return fail(`Expected h2 'Updated' after value change, got: ${el.innerHTML}`);
190
+ }
191
+ cleanup(container);
192
+ pass('Re-renders on value change');
193
+ },
194
+
195
+ /*
196
+ breaks attribute
197
+ */
198
+ 'breaks=false joins single newlines (CommonMark)': async ({pass, fail}) => {
199
+ const { container, el } = await createMarkdown();
200
+ el.value = 'one\ntwo';
201
+ await el.updateComplete;
202
+ const br = el.querySelector('br');
203
+ if(br){
204
+ cleanup(container);
205
+ return fail(`Expected no <br> when breaks=false, got: ${el.innerHTML}`);
206
+ }
207
+ cleanup(container);
208
+ pass('breaks=false joins single newlines');
209
+ },
210
+
211
+ 'breaks=true converts single newlines to <br>': async ({pass, fail}) => {
212
+ const { container, el } = await createMarkdown('', { breaks: '' });
213
+ el.value = 'one\ntwo';
214
+ await el.updateComplete;
215
+ const br = el.querySelector('br');
216
+ if(!br){
217
+ cleanup(container);
218
+ return fail(`Expected <br> when breaks=true, got: ${el.innerHTML}`);
219
+ }
220
+ cleanup(container);
221
+ pass('breaks=true converts single newlines to <br>');
222
+ },
223
+
224
+ /*
225
+ Sanitization
226
+ */
227
+ 'should strip <script> tags by default': async ({pass, fail}) => {
228
+ const { container, el } = await createMarkdown();
229
+ el.value = 'safe <script>alert(1)</script> text';
230
+ await el.updateComplete;
231
+ if(el.querySelector('script')){
232
+ cleanup(container);
233
+ return fail(`Expected no <script>, got: ${el.innerHTML}`);
234
+ }
235
+ if(el.textContent.includes('alert(1)')){
236
+ cleanup(container);
237
+ return fail(`Script content should be stripped completely, got textContent: '${el.textContent}'`);
238
+ }
239
+ cleanup(container);
240
+ pass('Script tags stripped by default');
241
+ },
242
+
243
+ 'allowed-tags="*" allows everything': async ({pass, fail}) => {
244
+ const { container, el } = await createMarkdown('', { 'allowed-tags': '*' });
245
+ el.value = '<details><summary>open</summary>hi</details>';
246
+ await el.updateComplete;
247
+ if(!el.querySelector('details') || !el.querySelector('summary')){
248
+ cleanup(container);
249
+ return fail(`Expected details+summary preserved, got: ${el.innerHTML}`);
250
+ }
251
+ cleanup(container);
252
+ pass('allowed-tags="*" allows custom tags');
253
+ },
254
+
255
+ 'disallowed-tags drops listed tags but keeps text': async ({pass, fail}) => {
256
+ const { container, el } = await createMarkdown('', { 'disallowed-tags': 'h1' });
257
+ el.value = '# Heading\n\nA paragraph.';
258
+ await el.updateComplete;
259
+ if(el.querySelector('h1')){
260
+ cleanup(container);
261
+ return fail(`Expected no <h1>, got: ${el.innerHTML}`);
262
+ }
263
+ if(!el.textContent.includes('Heading')){
264
+ cleanup(container);
265
+ return fail(`Expected heading text preserved, got textContent: '${el.textContent}'`);
266
+ }
267
+ if(!el.querySelector('p')){
268
+ cleanup(container);
269
+ return fail(`Expected paragraph preserved, got: ${el.innerHTML}`);
270
+ }
271
+ cleanup(container);
272
+ pass('disallowed-tags drops listed tags but keeps text');
273
+ },
274
+
275
+ /*
276
+ renderedHtml getter
277
+ */
278
+ 'renderedHtml getter returns sanitized HTML string': async ({pass, fail}) => {
279
+ const { container, el } = await createMarkdown('# Title');
280
+ const out = el.renderedHtml;
281
+ if(typeof out !== 'string'){
282
+ cleanup(container);
283
+ return fail(`Expected string, got ${typeof out}`);
284
+ }
285
+ if(!out.includes('<h1') || !out.includes('Title')){
286
+ cleanup(container);
287
+ return fail(`Expected '<h1...>Title' in renderedHtml, got '${out}'`);
288
+ }
289
+ cleanup(container);
290
+ pass('renderedHtml returns expected HTML string');
291
+ }
292
+ };