diff --git a/api-goldens/element-ng/markdown/directives/tabs/index.api.md b/api-goldens/element-ng/markdown/directives/tabs/index.api.md new file mode 100644 index 0000000000..366fd8a176 --- /dev/null +++ b/api-goldens/element-ng/markdown/directives/tabs/index.api.md @@ -0,0 +1,17 @@ +## Public API Report File for "@siemens/element-ng_markdown_directives_tabs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { InputSignal } from '@angular/core'; +import { Node as Node_2 } from 'mdast'; +import { Parent } from 'mdast'; +import { Type } from '@angular/core'; + +// @public +export const siMarkdownTabsetDirective: () => TypeHandler; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/api-goldens/element-ng/markdown/index.api.md b/api-goldens/element-ng/markdown/index.api.md index dbf7b3f850..02a91915a7 100644 --- a/api-goldens/element-ng/markdown/index.api.md +++ b/api-goldens/element-ng/markdown/index.api.md @@ -23,6 +23,7 @@ import { RootContent } from 'mdast'; import { Signal } from '@angular/core'; import { TemplateRef } from '@angular/core'; import { Transformer as Transformer_2 } from 'unified'; +import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; import { Type } from '@angular/core'; // @public @@ -58,8 +59,16 @@ export interface ComputedAsyncOptions { params: () => R; } +// @public +export interface DirectiveNode extends Parent { + attributes: Record; + name: string; + // (undocumented) + type: 'containerDirective' | 'leafDirective' | 'textDirective'; +} + // @public (undocumented) -export type ExtendedRootContent = RootContent | Footnotes | Citations; +export type ExtendedRootContent = RootContent | Footnotes | Citations | DirectiveNode; // @public export interface Footnotes extends Parent { @@ -81,6 +90,20 @@ export interface PluginWithOptions { // @public export const SI_MARKDOWN_CONTROL: InjectionToken; +// @public (undocumented) +export class SiMarkdownCalloutComponent implements SiMarkdownExtensionComponent { + // (undocumented) + readonly callouts: Record; + // (undocumented) + readonly icons: Record<"elementLightOn", string>; + // (undocumented) + readonly node: _angular_core.InputSignal; + // (undocumented) + readonly options: _angular_core.InputSignal; + // (undocumented) + readonly parent: _angular_core.InputSignal; +} + // @public export interface SiMarkdownCitation extends SourceReference { identifier?: string; @@ -118,9 +141,13 @@ export interface SiMarkdownControl { templates: Signal>>; } +// @public +export const siMarkdownDirective: (directives: ReadonlyMap) => SiMarkdownExtension; + // @public export interface SiMarkdownExtension { codeTypes?: TypeHandler[]; + directives?: TypeHandler[]; plugins?: PluginWithOptions[]; types?: TypeHandler[]; } @@ -177,6 +204,7 @@ export class SiMarkdownOptions { installExtension(extension: SiMarkdownExtension): SiMarkdownOptions; installUnifiedPlugin(plugin: UnifiedPlugin, options?: UnifiedPluginOptions): SiMarkdownOptions; makeProcessor(meta: SiMarkdownMetadata): Processor; + registerDirective(directives: TypeHandler | TypeHandler[]): SiMarkdownOptions; setCodeHighlighter(highlighter?: SiMarkdownHighlighter): SiMarkdownOptions; } diff --git a/docs/components/chat-messages/markdown.md b/docs/components/chat-messages/markdown.md index d624a658b6..bda5d0f51f 100644 --- a/docs/components/chat-messages/markdown.md +++ b/docs/components/chat-messages/markdown.md @@ -111,6 +111,94 @@ by the Element integrations, such as emoji shortcodes. > **Bundle size:** KaTeX, Mermaid, Highlight.js, and additional `unified` plugins increase the > application bundle size. Import and configure only the integrations your Markdown content requires. +### Directives + +Directives add named, component-backed elements to Markdown. The renderer supports the directive +syntax provided by [`remark-directive`](https://github.com/remarkjs/remark-directive): inline +directives use `:name[label]{attributes}`, leaf directives use `::name{attributes}`, and container +directives wrap Markdown content with matching colon fences, at least three colons. + +```md +:::name{attribute=value} +Normal **Markdown** content. +::: +``` + +Register a directive handler with `registerDirective()`. A handler maps a directive name to an +Angular component that implements `SiMarkdownExtensionComponent`; that component receives the +parsed directive node, its parent, and any registered options as signal inputs. + +Example: + +```md +::notice{message="Scheduled maintenance starts at 22:00."} +``` + +```ts +import { Component, input } from '@angular/core'; +import { + makeSiMarkdownOptions, + type SiMarkdownDirectiveNode, + type SiMarkdownExtensionComponent +} from '@siemens/element-ng/markdown'; +import { type Node, type Parent } from 'mdast'; + +@Component({ + selector: 'si-markdown-notice', + template: '
{{ node().attributes.message }}
' +}) +class MarkdownNoticeComponent implements SiMarkdownExtensionComponent { + readonly node = input.required(); + readonly parent = input.required(); + readonly options = input(); +} + +protected readonly markdownOptions = makeSiMarkdownOptions().registerDirective({ + type: 'notice', + component: MarkdownNoticeComponent +}); +``` + +#### Callout + +The `callout` container directive is integrated by default. Use its optional `type` attribute to +choose a visual severity: `success`, `info`, `warning`, `danger`, `caution`, `critical`, `note`, +or `tip`. `note` is the default. + +```md +:::callout{type=warning heading="Optional heading"} +This action cannot be undone. +::: +``` + +#### Tabs + +The tabset directive is an opt-in directive that adds Element tabset. Register +`siMarkdownTabsetDirective()` to enable the `tabset` container directive. Its child `tab` +directives map to `si-tab`; only their `heading` attribute is used. Use a longer fence for the +outer `tabset` when nesting `tab` directives. + +```ts +import { makeSiMarkdownOptions } from '@siemens/element-ng/markdown'; +import { siMarkdownTabsetDirective } from '@siemens/element-ng/markdown/directives/tabs'; + +protected readonly markdownOptions = makeSiMarkdownOptions().registerDirective( + siMarkdownTabsetDirective() +); +``` + +```md +::::tabset{height=12rem} +:::tab{heading="First tab"} +First tab content. +::: + +:::tab{heading="Second tab"} +Second tab content. +::: +:::: +``` + ### Custom extension An extension can install `unified` plugin(s) and associate the AST node types produced by that diff --git a/package.json b/package.json index 9ee812e5b7..7526dd17dc 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "ol": "10.10.0", "ol-ext": "4.0.38", "ol-mapbox-style": "13.4.2", + "remark-directive": "^4.0.0", "remark-gemoji": "^8.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", diff --git a/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-dark-linux.png index fdd5ca3b0f..7985303181 100644 --- a/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c2d0e44841bde89ed9ec28b805adf79a60c3298fa6bbbe7ff5129d179e39b50 -size 1435134 +oid sha256:8bffd451a693bca453c06b46cfbbe8b86ec3178d1582ba2f89fb64055c0de0cc +size 1498399 diff --git a/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-light-linux.png b/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-light-linux.png index ae145e5254..62171c9d06 100644 --- a/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/si-markdown.spec.ts-snapshots/si-markdown--si-markdown-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efb42e537c040c55a2166dc95c2da66af84e8ab0cb15696762a6255326060d82 -size 1401587 +oid sha256:dcdb40ed6b1db2a700cb8efa3ce15701695ecd319ca1d2691ec32010897b9078 +size 1462250 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f5271530a..4cdd7ffe4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: ol-mapbox-style: specifier: 13.4.2 version: 13.4.2(ol@10.10.0) + remark-directive: + specifier: ^4.0.0 + version: 4.0.0(supports-color@10.2.2) remark-gemoji: specifier: ^8.0.0 version: 8.0.0 @@ -3763,6 +3766,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==, tarball: https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, tarball: https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz} @@ -4411,9 +4417,18 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==, tarball: https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==, tarball: https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==, tarball: https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==, tarball: https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==, tarball: https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==, tarball: https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz} @@ -5828,6 +5843,12 @@ packages: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz} engines: {node: '>= 10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==, tarball: https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==, tarball: https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, tarball: https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz} @@ -5839,6 +5860,9 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==, tarball: https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==, tarball: https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==, tarball: https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5860,6 +5884,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, tarball: https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==, tarball: https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==, tarball: https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz} engines: {node: '>=20'} @@ -6367,6 +6394,9 @@ packages: mathml-tag-names@4.0.0: resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==, tarball: https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz} + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==, tarball: https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==, tarball: https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz} @@ -6454,6 +6484,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==, tarball: https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz} + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==, tarball: https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==, tarball: https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz} @@ -7044,6 +7077,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, tarball: https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==, tarball: https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz} + parse-headers@2.0.6: resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==, tarball: https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz} @@ -7468,6 +7504,9 @@ packages: resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==, tarball: https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz} hasBin: true + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==, tarball: https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz} + remark-gemoji@8.0.0: resolution: {integrity: sha512-/fL9rc72FYwFGtOKcT+QeQdx9Q9t5v4N6KLXSDOTEgaedzK85I9judBqB2eqz+g4b0ERMejlwSOuPK+wket6aA==, tarball: https://registry.npmjs.org/remark-gemoji/-/remark-gemoji-8.0.0.tgz} @@ -7886,6 +7925,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, tarball: https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==, tarball: https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz} engines: {node: '>=8'} @@ -12440,6 +12482,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/ws@8.18.1': @@ -13255,8 +13299,14 @@ snapshots: char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + chardet@2.2.0: {} child_process@1.0.2: @@ -14810,6 +14860,13 @@ snapshots: ipaddr.js@2.4.0: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -14820,6 +14877,8 @@ snapshots: dependencies: hasown: 2.0.4 + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -14834,6 +14893,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -15321,6 +15382,20 @@ snapshots: mathml-tag-names@4.0.0: {} + mdast-util-directive@3.1.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -15518,6 +15593,16 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -16209,6 +16294,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-headers@2.0.6: {} parse-json@4.0.0: @@ -16609,6 +16704,15 @@ snapshots: dependencies: jsesc: 3.1.0 + remark-directive@4.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0(supports-color@10.2.2) + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-gemoji@8.0.0: dependencies: '@types/mdast': 4.0.4 @@ -17219,6 +17323,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 diff --git a/projects/element-ng/markdown/directives/callout/index.ts b/projects/element-ng/markdown/directives/callout/index.ts new file mode 100644 index 0000000000..1977399f97 --- /dev/null +++ b/projects/element-ng/markdown/directives/callout/index.ts @@ -0,0 +1,5 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +export * from './si-markdown-callout.register'; diff --git a/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.html b/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.html new file mode 100644 index 0000000000..8d57867f4f --- /dev/null +++ b/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.html @@ -0,0 +1,28 @@ +@let conf = config(); +
+
+ @if (conf.callout.icon) { +
+
+
+
+
diff --git a/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.spec.ts b/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.spec.ts new file mode 100644 index 0000000000..8abdcabf43 --- /dev/null +++ b/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.spec.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SiMarkdownComponent } from '../../si-markdown.component'; + +describe('SiMarkdownCalloutComponent', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SiMarkdownComponent); + element = fixture.nativeElement; + }); + + it('renders note, tip, and warning callouts', async () => { + fixture.componentRef.setInput( + 'markdown', + `:::callout{type=note} +Note content. +::: + +:::callout{type=tip} +Tip content. +::: + +:::callout{type=warning} +Warning content. +:::` + ); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + + const callouts = element.querySelectorAll('si-markdown-callout'); + + expect(callouts).toHaveLength(3); + expect(callouts[0]).toHaveTextContent('Note content.'); + expect(callouts[0].querySelector('.card')).toHaveClass('accent-info'); + expect(callouts[0].querySelector('si-status-icon')).toBeInTheDocument(); + expect(callouts[1].querySelector('.card')).toHaveClass('accent-success'); + expect(callouts[2].querySelector('.card')).toHaveClass('accent-warning'); + }); +}); diff --git a/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.ts b/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.ts new file mode 100644 index 0000000000..b8716d7d63 --- /dev/null +++ b/projects/element-ng/markdown/directives/callout/si-markdown-callout.component.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { Component, computed, inject, input } from '@angular/core'; +import { elementLightOn } from '@siemens/element-icons'; +import { StatusType } from '@siemens/element-ng/common'; +import { + addIcons, + SiIconComponent, + SiStatusIconComponent, + STATUS_ICON_CONFIG +} from '@siemens/element-ng/icon'; +import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; +import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; +import { type Node, type Parent } from 'mdast'; + +import { SiMarkdownFragmentComponent } from '../../si-markdown-fragment.component'; +import { type DirectiveNode, type SiMarkdownExtensionComponent } from '../../si-markdown.types'; + +type CalloutType = + 'success' | 'info' | 'warning' | 'danger' | 'caution' | 'critical' | 'note' | 'tip'; + +interface Callout { + label?: TranslatableString; + severity: StatusType; + icon?: string; + color?: string; +} + +@Component({ + selector: 'si-markdown-callout', + imports: [SiIconComponent, SiStatusIconComponent, SiMarkdownFragmentComponent, SiTranslatePipe], + templateUrl: './si-markdown-callout.component.html', + host: { + class: 'd-block mb-4 border rounded' + } +}) +export class SiMarkdownCalloutComponent implements SiMarkdownExtensionComponent { + private readonly statusIcons = inject(STATUS_ICON_CONFIG); + + readonly node = input.required(); + readonly parent = input.required(); + readonly options = input(); + + readonly icons = addIcons({ elementLightOn }); + + readonly callouts: Record = { + info: { label: this.statusIcons.info.ariaLabel, severity: 'info' }, + success: { label: this.statusIcons.success.ariaLabel, severity: 'success' }, + warning: { label: this.statusIcons.warning.ariaLabel, severity: 'warning' }, + danger: { label: this.statusIcons.danger.ariaLabel, severity: 'danger' }, + caution: { label: this.statusIcons.caution.ariaLabel, severity: 'caution' }, + critical: { label: this.statusIcons.critical.ariaLabel, severity: 'critical' }, + note: { + label: t(() => $localize`:@@SI_MARKDOWN_CALLOUT.NOTE:Note`), + severity: 'info' + }, + tip: { + label: t(() => $localize`:@@SI_MARKDOWN_CALLOUT.TIP:Tip`), + severity: 'success', + icon: this.icons.elementLightOn, + color: this.statusIcons.success.color + } + }; + + protected readonly config = computed(() => { + const directive = this.node() as DirectiveNode; + const type = directive.attributes.type?.toLocaleLowerCase() as CalloutType; + return { + callout: this.callouts[type] ?? this.callouts.note, + heading: directive.attributes.heading + }; + }); +} diff --git a/projects/element-ng/markdown/directives/callout/si-markdown-callout.register.ts b/projects/element-ng/markdown/directives/callout/si-markdown-callout.register.ts new file mode 100644 index 0000000000..45e509674d --- /dev/null +++ b/projects/element-ng/markdown/directives/callout/si-markdown-callout.register.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { TypeHandler } from '../../si-markdown.types'; +import { SiMarkdownCalloutComponent } from './si-markdown-callout.component'; + +export const siMarkdownCalloutDirective = (): TypeHandler => ({ + type: 'callout', + component: SiMarkdownCalloutComponent +}); diff --git a/projects/element-ng/markdown/directives/tabs/index.ts b/projects/element-ng/markdown/directives/tabs/index.ts new file mode 100644 index 0000000000..f5f52b9946 --- /dev/null +++ b/projects/element-ng/markdown/directives/tabs/index.ts @@ -0,0 +1,5 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +export * from './si-markdown-tabset.register'; diff --git a/projects/element-ng/markdown/directives/tabs/ng-package.json b/projects/element-ng/markdown/directives/tabs/ng-package.json new file mode 100644 index 0000000000..f7136414d1 --- /dev/null +++ b/projects/element-ng/markdown/directives/tabs/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.component.spec.ts b/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.component.spec.ts new file mode 100644 index 0000000000..163712ea49 --- /dev/null +++ b/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.component.spec.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { makeSiMarkdownOptions } from '../../si-markdown-options'; +import { SiMarkdownComponent } from '../../si-markdown.component'; +import { siMarkdownTabsetDirective } from './si-markdown-tabset.register'; + +describe('Markdown tabset/tab directives', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SiMarkdownComponent); + fixture.componentRef.setInput( + 'options', + makeSiMarkdownOptions().registerDirective(siMarkdownTabsetDirective()) + ); + element = fixture.nativeElement; + }); + + it('renders tabs with their heading and content', async () => { + fixture.componentRef.setInput( + 'markdown', + `::::tabset +:::tab{heading="First tab" disabled=true} +First tab content. +::: + +:::tab{heading="Second tab"} +Second tab content. +::: +::::` + ); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + + const tabs = element.querySelectorAll('si-tab'); + + expect(element.querySelector('si-tabset')).toBeInTheDocument(); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('First tab'); + expect(tabs[0]).not.toHaveClass('disabled'); + + tabs[0].click(); + await fixture.whenStable(); + + expect(element.querySelector('.tab-content')).toHaveTextContent('First tab content.'); + }); +}); diff --git a/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.component.ts b/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.component.ts new file mode 100644 index 0000000000..2bff4df4a4 --- /dev/null +++ b/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.component.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { Component, computed, input } from '@angular/core'; +import { + type DirectiveNode, + type SiMarkdownExtensionComponent, + SiMarkdownFragmentComponent +} from '@siemens/element-ng/markdown'; +import { SiTabComponent, SiTabsetComponent } from '@siemens/element-ng/tabs'; +import { type Node, type Parent } from 'mdast'; + +@Component({ + selector: 'si-markdown-tabset', + imports: [SiMarkdownFragmentComponent, SiTabComponent, SiTabsetComponent], + template: ` + + @for (tab of tabs(); track $index) { + +
+
+ } +
+ `, + styles: ` + si-tabset { + block-size: var(--si-md-tabset-height, 250px); + } + `, + host: { + // note: not setting style directly on si-tabset for CSP reasons + '[style.--si-md-tabset-height]': 'height()' + } +}) +export class SiMarkdownTabsetComponent implements SiMarkdownExtensionComponent { + readonly node = input.required(); + readonly parent = input.required(); + readonly options = input(); + + readonly height = computed(() => { + const directive = this.node() as DirectiveNode; + return directive.attributes.height; + }); + + protected readonly tabs = computed(() => { + const directive = this.node() as DirectiveNode; + const children = directive.children as unknown as DirectiveNode[]; + return children.filter(child => child.type === 'containerDirective' && child.name === 'tab'); + }); +} diff --git a/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.register.ts b/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.register.ts new file mode 100644 index 0000000000..2cbc40fa5f --- /dev/null +++ b/projects/element-ng/markdown/directives/tabs/si-markdown-tabset.register.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { type TypeHandler } from '@siemens/element-ng/markdown'; + +import { SiMarkdownTabsetComponent } from './si-markdown-tabset.component'; + +export const siMarkdownTabsetDirective = (): TypeHandler => ({ + type: 'tabset', + component: SiMarkdownTabsetComponent +}); diff --git a/projects/element-ng/markdown/extensions/citations/si-markdown-citations.spec.ts b/projects/element-ng/markdown/extensions/citations/si-markdown-citations.spec.ts index 8b5f10ddc8..2a1460be38 100644 --- a/projects/element-ng/markdown/extensions/citations/si-markdown-citations.spec.ts +++ b/projects/element-ng/markdown/extensions/citations/si-markdown-citations.spec.ts @@ -34,6 +34,25 @@ describe('siMarkdownCitationsTransformer', () => { ]); }); + it('replaces inline cite directives with identifier citations', () => { + const processor = makeOptions().makeProcessor({ + citations: [ + { + identifier: 'doc1', + name: 'Annual Report', + url: 'https://example.com/report' + } + ] + }); + const tree = processor.runSync(processor.parse('Revenue increased by 12% :cite[doc1].')); + + expect((tree.children[0] as Parent).children).toMatchObject([ + { type: 'text', value: 'Revenue increased by 12% ' }, + { type: 'citations', children: [{ type: 'citation', citationIndex: 0 }] }, + { type: 'text', value: '.' } + ]); + }); + it('replaces nodes covered by position citations', () => { const markdown = 'Revenue increased in 2025.[[1]](https://example.com/report)'; const processor = makeOptions().makeProcessor({ diff --git a/projects/element-ng/markdown/extensions/citations/si-markdown-citations.ts b/projects/element-ng/markdown/extensions/citations/si-markdown-citations.ts index a805dba98a..1252b682b8 100644 --- a/projects/element-ng/markdown/extensions/citations/si-markdown-citations.ts +++ b/projects/element-ng/markdown/extensions/citations/si-markdown-citations.ts @@ -150,6 +150,14 @@ export const siMarkdownCitationsTransformer = ( continue; } + if (child.type === 'textDirective' && child.name === 'cite') { + const directiveCitationIndex = citationsByIdentifier.get(getText(child)); + if (directiveCitationIndex !== undefined) { + children.splice(index, 1, createCitation(directiveCitationIndex)); + continue; + } + } + if ('children' in child) { replaceCitations(child as Parent); } diff --git a/projects/element-ng/markdown/extensions/directive/index.ts b/projects/element-ng/markdown/extensions/directive/index.ts new file mode 100644 index 0000000000..749b43014b --- /dev/null +++ b/projects/element-ng/markdown/extensions/directive/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +export * from '../../directives/callout/si-markdown-callout.component'; +export * from './si-markdown-directive.extension'; diff --git a/projects/element-ng/markdown/extensions/directive/si-markdown-directive.component.spec.ts b/projects/element-ng/markdown/extensions/directive/si-markdown-directive.component.spec.ts new file mode 100644 index 0000000000..db59d8fb25 --- /dev/null +++ b/projects/element-ng/markdown/extensions/directive/si-markdown-directive.component.spec.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { Component, input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { type Node, type Parent } from 'mdast'; + +import { SiMarkdownFragmentComponent } from '../../si-markdown-fragment.component'; +import { SiMarkdownOptions } from '../../si-markdown-options'; +import { SiMarkdownComponent } from '../../si-markdown.component'; +import { type SiMarkdownExtensionComponent } from '../../si-markdown.types'; + +@Component({ + selector: 'si-markdown-custom-directive', + imports: [SiMarkdownFragmentComponent], + template: '
' +}) +class CustomDirectiveComponent implements SiMarkdownExtensionComponent { + readonly node = input.required(); + readonly parent = input.required(); + readonly options = input(); +} + +describe('SiMarkdownDirectiveComponent', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SiMarkdownComponent); + element = fixture.nativeElement; + }); + + it('renders components registered for custom directives', async () => { + fixture.componentRef.setInput( + 'options', + new SiMarkdownOptions().registerDirective({ + type: 'custom', + component: CustomDirectiveComponent + }) + ); + fixture.componentRef.setInput('markdown', ':::custom\nCustom content.\n:::'); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + + expect(element.querySelector('.custom-directive')).toHaveTextContent('Custom content.'); + }); + + it('renders the content of unhandled directives as Markdown', async () => { + fixture.componentRef.setInput('markdown', ':::unknown\n**Fallback content.**\n:::'); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + + expect(element.querySelector('strong')).toHaveTextContent('Fallback content.'); + }); +}); diff --git a/projects/element-ng/markdown/extensions/directive/si-markdown-directive.component.ts b/projects/element-ng/markdown/extensions/directive/si-markdown-directive.component.ts new file mode 100644 index 0000000000..e75887c583 --- /dev/null +++ b/projects/element-ng/markdown/extensions/directive/si-markdown-directive.component.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { NgComponentOutlet } from '@angular/common'; +import { Component, computed, input } from '@angular/core'; +import { type Node, type Parent } from 'mdast'; + +import { SiMarkdownFragmentComponent } from '../../si-markdown-fragment.component'; +import { + type TypeHandler, + type DirectiveNode, + type SiMarkdownExtensionComponent +} from '../../si-markdown.types'; + +@Component({ + selector: 'si-markdown-directive', + imports: [NgComponentOutlet, SiMarkdownFragmentComponent], + template: ` + @let component = directiveComponent(); + @if (component) { + + } @else { +
+ } + ` +}) +export class SiMarkdownDirectiveComponent implements SiMarkdownExtensionComponent { + readonly node = input.required(); + readonly parent = input.required(); + readonly options = input>(); + + protected readonly directiveComponent = computed(() => { + const directive = this.node() as DirectiveNode; + return this.options()?.get(directive.name)?.component; + }); +} diff --git a/projects/element-ng/markdown/extensions/directive/si-markdown-directive.extension.ts b/projects/element-ng/markdown/extensions/directive/si-markdown-directive.extension.ts new file mode 100644 index 0000000000..6d32d6a8da --- /dev/null +++ b/projects/element-ng/markdown/extensions/directive/si-markdown-directive.extension.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { type TypeHandler, type SiMarkdownExtension } from '../../si-markdown.types'; +import { SiMarkdownDirectiveComponent } from './si-markdown-directive.component'; + +const directiveNodeTypes = ['containerDirective', 'leafDirective', 'textDirective'] as const; + +/** Installs component rendering for directives parsed by `remark-directive`. */ +export const siMarkdownDirective = ( + directives: ReadonlyMap +): SiMarkdownExtension => ({ + types: directiveNodeTypes.map(type => ({ + type, + component: SiMarkdownDirectiveComponent, + options: directives + })) +}); diff --git a/projects/element-ng/markdown/index.ts b/projects/element-ng/markdown/index.ts index c819b9da3a..3e208fb650 100644 --- a/projects/element-ng/markdown/index.ts +++ b/projects/element-ng/markdown/index.ts @@ -5,6 +5,7 @@ export * from './si-markdown.types'; export * from './si-markdown.component'; export * from './si-markdown-fragment.component'; +export * from './extensions/directive'; export * from './extensions/inline-html/si-markdown-inline-html.component'; export * from './si-markdown-template.directive'; export * from './si-markdown-options'; diff --git a/projects/element-ng/markdown/internal/si-markdown-postprocess.spec.ts b/projects/element-ng/markdown/internal/si-markdown-postprocess.spec.ts index ee0ee0bf30..7bf6b1917d 100644 --- a/projects/element-ng/markdown/internal/si-markdown-postprocess.spec.ts +++ b/projects/element-ng/markdown/internal/si-markdown-postprocess.spec.ts @@ -8,6 +8,26 @@ import { SiMarkdownOptions } from '../si-markdown-options'; import { type SiMarkdownRoot } from '../si-markdown.types'; describe('siMarkdownPostprocess', () => { + it('converts GitHub-style admonition blockquotes to callout directives', () => { + const processor = new SiMarkdownOptions().makeProcessor({}); + const tree = processor.runSync( + processor.parse(`> [!NOTE] +> Note content. + +> An ordinary blockquote.`) + ); + + expect(tree.children).toMatchObject([ + { + type: 'containerDirective', + name: 'callout', + attributes: { type: 'note' }, + children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Note content.' }] }] + }, + { type: 'blockquote' } + ]); + }); + it('collects definitions used by link and image references', () => { const processor = new SiMarkdownOptions().makeProcessor({}); const tree = processor.runSync( diff --git a/projects/element-ng/markdown/internal/si-markdown-postprocess.ts b/projects/element-ng/markdown/internal/si-markdown-postprocess.ts index f0bd371b64..9def3ff539 100644 --- a/projects/element-ng/markdown/internal/si-markdown-postprocess.ts +++ b/projects/element-ng/markdown/internal/si-markdown-postprocess.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ import { + type Blockquote, type Definition, type FootnoteReference, type FootnoteDefinition, @@ -13,6 +14,40 @@ import { type Transformer } from 'unified'; import { type SiMarkdownRoot } from '../si-markdown.types'; +const githubAdmonitionPattern = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/; + +/** Turns GitHub style admonitions into a callout directive */ +const toCalloutDirective = (node: Blockquote): void => { + const firstChild = node.children[0]; + if (firstChild?.type !== 'paragraph') { + return; + } + + const firstText = firstChild.children[0]; + if (firstText?.type !== 'text') { + return; + } + + const match = githubAdmonitionPattern.exec(firstText.value); + if (!match) { + return; + } + + firstText.value = firstText.value.slice(match[0].length); + if (!firstText.value) { + firstChild.children.shift(); + } + if (!firstChild.children.length) { + node.children.shift(); + } + + Object.assign(node, { + type: 'containerDirective', + name: 'callout', + attributes: { type: match[1].toLowerCase() } + }); +}; + /** Moves footnote definitions into a single root-level container. */ export const siMarkdownPostprocess = (): Transformer => tree => { const footnoteReferences: FootnoteReference[] = []; @@ -23,6 +58,9 @@ export const siMarkdownPostprocess = (): Transformer => tr const collect = (parent: Parent): void => { for (let index = 0; index < parent.children.length; index++) { const child = parent.children[index]; + if (child.type === 'blockquote') { + toCalloutDirective(child); + } if (child.type === 'footnoteReference') { footnoteReferences.push(child); diff --git a/projects/element-ng/markdown/si-markdown-options.ts b/projects/element-ng/markdown/si-markdown-options.ts index 979cc11d85..68180f8d7f 100644 --- a/projects/element-ng/markdown/si-markdown-options.ts +++ b/projects/element-ng/markdown/si-markdown-options.ts @@ -3,11 +3,14 @@ * SPDX-License-Identifier: MIT */ import { type Root } from 'mdast'; +import remarkDirective from 'remark-directive'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import { Processor, unified, type Plugin } from 'unified'; +import { siMarkdownCalloutDirective } from './directives/callout'; import { siMarkdownCode } from './extensions/code'; +import { siMarkdownDirective } from './extensions/directive/si-markdown-directive.extension'; import { siMarkdownInlineHtml } from './extensions/inline-html'; import { siMarkdownLink } from './extensions/link'; import { siMarkdownTable } from './extensions/table'; @@ -45,13 +48,16 @@ export class SiMarkdownOptions { private readonly plugins: PluginWithOptions[] = []; private types: TypeHandler[] = []; private codeTypes = new Map(); + private directives = new Map(); private highlighter?: SiMarkdownHighlighter; constructor() { this.installExtension(siMarkdownInlineHtml()) .installExtension(siMarkdownLink()) .installExtension(siMarkdownTable()) - .installExtension(siMarkdownCode({ getHighlighter: () => this.getHighlighter() })); + .installExtension(siMarkdownCode({ getHighlighter: () => this.getHighlighter() })) + .installExtension(siMarkdownDirective(this.directives)) + .registerDirective(siMarkdownCalloutDirective()); } /** @@ -65,6 +71,21 @@ export class SiMarkdownOptions { return this; } + /** + * Registers a component used to render a named Markdown directive. + * @param directive - The directive with name and handling component + * @returns self for chaining + */ + registerDirective(directives: TypeHandler | TypeHandler[]): SiMarkdownOptions { + if (!Array.isArray(directives)) { + directives = [directives]; + } + for (const directive of directives) { + this.directives.set(directive.type, directive); + } + return this; + } + /** * Installs a extension which can contain plugins, type handlers * @param extension - The extension definition @@ -82,6 +103,9 @@ export class SiMarkdownOptions { this.codeTypes.set(ct.type, ct); } } + if (extension.directives) { + this.registerDirective(extension.directives); + } return this; } @@ -101,7 +125,7 @@ export class SiMarkdownOptions { makeProcessor( meta: SiMarkdownMetadata ): Processor { - const processor = unified().use(remarkParse).use(remarkGfm); + const processor = unified().use(remarkParse).use(remarkGfm).use(remarkDirective); for (const p of this.plugins) { const options = typeof p.options === 'function' ? p.options(meta) : p.options; diff --git a/projects/element-ng/markdown/si-markdown.types.ts b/projects/element-ng/markdown/si-markdown.types.ts index 3d5949d9d8..5e53d03041 100644 --- a/projects/element-ng/markdown/si-markdown.types.ts +++ b/projects/element-ng/markdown/si-markdown.types.ts @@ -44,12 +44,21 @@ export interface ComponentWithOptions { options?: any; } -/** AST node type handler */ +/** Type handler for a named type. This can be an AST node type, a code type, a directive name */ export interface TypeHandler extends ComponentWithOptions { - /** type of the AST node */ + /** Type name */ type: string; } +/** AST node created by `remark-directive`. */ +export interface DirectiveNode extends Parent { + type: 'containerDirective' | 'leafDirective' | 'textDirective'; + /** The directive name without its leading colons. */ + name: string; + /** Attributes declared on the directive. */ + attributes: Record; +} + export type SiMarkdownHighlighter = ComponentWithOptions; export type UnifiedPlugin = Plugin | Transformer | Preset | PluggableList; @@ -72,6 +81,8 @@ export interface SiMarkdownExtension { types?: TypeHandler[]; /** Special code type handlers to install */ codeTypes?: TypeHandler[]; + /** Directives */ + directives?: TypeHandler[]; } /** Citation metadata associated with a markdown response. */ @@ -110,7 +121,7 @@ export interface Footnotes extends Parent { children: FootnoteDefinition[]; } -export type ExtendedRootContent = RootContent | Footnotes | Citations; +export type ExtendedRootContent = RootContent | Footnotes | Citations | DirectiveNode; /** Extended root with references */ export type SiMarkdownRoot = Omit & { diff --git a/projects/element-ng/package.json b/projects/element-ng/package.json index 7b1ab87223..f898741505 100644 --- a/projects/element-ng/package.json +++ b/projects/element-ng/package.json @@ -58,6 +58,7 @@ "mermaid": "^11.16.0", "katex": "^0.17.0", "ngx-image-cropper": "^9.0.0", + "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", @@ -103,6 +104,9 @@ "katex": { "optional": true }, + "remark-directive": { + "optional": true + }, "remark-gfm": { "optional": true }, diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index be2c7a83fa..2612a071e5 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -213,6 +213,8 @@ export interface SiTranslatableKeys { 'SI_MARKDOWN.COPY_CODE'?: string; 'SI_MARKDOWN.COPY_TABLE_AS_CSV'?: string; 'SI_MARKDOWN.DOWNLOAD_TABLE_AS_CSV'?: string; + 'SI_MARKDOWN_CALLOUT.NOTE'?: string; + 'SI_MARKDOWN_CALLOUT.TIP'?: string; 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; 'SI_NAVBAR_VERTICAL.COLLAPSE'?: string; diff --git a/src/app/examples/si-markdown/si-markdown.html b/src/app/examples/si-markdown/si-markdown.html index 854f9e4e16..e6d0ea4a1a 100644 --- a/src/app/examples/si-markdown/si-markdown.html +++ b/src/app/examples/si-markdown/si-markdown.html @@ -27,6 +27,9 @@ + + + diff --git a/src/app/examples/si-markdown/si-markdown.ts b/src/app/examples/si-markdown/si-markdown.ts index e025e963e2..7a81085a8c 100644 --- a/src/app/examples/si-markdown/si-markdown.ts +++ b/src/app/examples/si-markdown/si-markdown.ts @@ -13,6 +13,7 @@ import { SiMarkdownCitation, SiMarkdownComponent } from '@siemens/element-ng/markdown'; +import { siMarkdownTabsetDirective } from '@siemens/element-ng/markdown/directives/tabs'; import { siMarkdownCitations } from '@siemens/element-ng/markdown/extensions/citations'; import { siMarkdownMathKaTeX } from '@siemens/element-ng/markdown/extensions/katex'; import { siMarkdownMermaid } from '@siemens/element-ng/markdown/extensions/mermaid'; @@ -65,6 +66,9 @@ export class SampleComponent implements OnInit { if (this.useGemojis()) { opts.installUnifiedPlugin(remarkGemoji); } + if (this.useTabsetDirective()) { + opts.registerDirective(siMarkdownTabsetDirective()); + } return opts; }); @@ -129,6 +133,7 @@ export class SampleComponent implements OnInit { protected readonly useKatex = signal(true); protected readonly useMermaid = signal(true); protected readonly useGemojis = signal(true); + protected readonly useTabsetDirective = signal(true); protected readonly debug = signal(false); private readonly textarea = viewChild.required>('textarea'); diff --git a/src/assets/sample-markdown-ext.md b/src/assets/sample-markdown-ext.md index a5d9aacf15..693e6f4eb4 100644 --- a/src/assets/sample-markdown-ext.md +++ b/src/assets/sample-markdown-ext.md @@ -215,3 +215,70 @@ Here's a cat :cat2: meow! Слава Україні! :ukraine: This is awesome :+1: :100: + +--- + +### Directives + +Directives are a generic way to extend markdown syntax. There's tree types of directives + +- Inline directives in the form `:name[label]{attributes}` +- Block leaf directives, i.e. `::name{attributes}` +- Container directives. Those start with at least three colons and are terminated by the same amount + of colons: + + ```md + :::name{attributes} + normal **markdown** content + ::: + ``` + + Container directives can also be nested. The outer directive needs more colons, e.g. + + ```md + Outer directives have more colons + + ::::outer{attributes} + normal markdown content + + :::inner{attributes} + ::: + :::: + ``` + +:::callout{type=warning} +This is a warning callout +::: + +:::callout{type=tip heading="Did you know?"} +Directives can be nested. When nesting, the parent directive needs more colons. +::: + +:::callout +This is cool. **Any** markdown works here too. + +```js +console.log('Hello world'); +``` + +::: + +> [!NOTE] +> This is a GitHub style callout + +And with the custom tab set directives + +::::tabset{height=12rem} +:::tab{heading="First"} +First content with **any** markdown inside + +```js +console.log('Hello world'); +``` + +::: + +:::tab{heading="Second"} +Second content +::: +::::