Browse Source

feat(iot-hub): expand markdown gallery, sanitised captions, shared photoswipe

- ${images.gallery(...)} accepts JS-style image objects
  ({src, alt?, caption?}) that may span multiple lines and contain
  whitespace inside the gallery brackets. Captions go through a
  whitelist sanitiser (b/strong/i/em/u/s/mark/small/sub/sup/br/code/
  span with class+style only — style values containing
  expression()/javascript:/url() are dropped) so authors can use
  inline markup without exposing XSS.
- Promote escapeHtml / escapeHtmlAttr to exported helpers in
  iot-hub-markdown.utils, add the new sanitizeInlineHtml. Drop the
  duplicate copies (and the now-unused DocLinks helpers + buildDocLinkButton)
  from shared/models/iot-hub/device-package.models.
- Move the PhotoSwipe wiring out of TbIotHubMarkdownComponent into a
  reusable tbPhotoSwipeGallery shared directive, declared/exported
  by SharedModule. The component now just renders the
  data-attributed wrapper and the directive lazy-binds PhotoSwipe.
- Tighten the gallery layout: 200px-min auto-fill grid, 8px vertical
  margins, hover border swapped to the shared #2a7dec accent token.
pull/15615/head
Igor Kulikov 5 months ago
parent
commit
af9b5822a6
  1. 33
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss
  2. 129
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts
  3. 57
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts
  4. 219
      ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts
  5. 48
      ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts
  6. 3
      ui-ngx/src/app/shared/shared.module.ts

33
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss

@ -28,6 +28,8 @@ $table-header-background-color: #f9fbff;
$table-header-text-color: rgba(33, 37, 41, .6); $table-header-text-color: rgba(33, 37, 41, .6);
$table-text-color: rgba(15, 22, 29, 0.8); $table-text-color: rgba(15, 22, 29, 0.8);
$gallery-border-hover-color: #2a7dec;
:host { :host {
display: block; display: block;
font-size: 14px; font-size: 14px;
@ -392,8 +394,10 @@ $table-text-color: rgba(15, 22, 29, 0.8);
.tb-gallery-images { .tb-gallery-images {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px; gap: 12px;
margin-top: 8px;
margin-bottom: 8px;
.tb-gallery-image { .tb-gallery-image {
position: relative; position: relative;
@ -407,7 +411,7 @@ $table-text-color: rgba(15, 22, 29, 0.8);
transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
display: block; display: block;
&:hover { &:hover {
border-color: #3d50f5; border-color: $gallery-border-hover-color;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 18px #0000002e; box-shadow: 0 6px 18px #0000002e;
outline: none; outline: none;
@ -453,30 +457,5 @@ $table-text-color: rgba(15, 22, 29, 0.8);
} }
} }
} }
.tb-gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
}
.tb-gallery-img {
max-height: 160px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: max-height 0.2s ease;
object-fit: contain;
&:hover {
border-color: rgba(0, 0, 0, 0.24);
}
&.tb-gallery-img-expanded {
max-height: none;
max-width: 100%;
}
}
} }
} }

129
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts

@ -30,12 +30,13 @@ import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link
import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { import {
escapeHtmlAttr,
replaceItemLinkPlaceholders, replaceItemLinkPlaceholders,
resolveDocLinkPlaceholders resolveDocLinkPlaceholders,
sanitizeInlineHtml
} from '@home/components/iot-hub/iot-hub-markdown.utils'; } from '@home/components/iot-hub/iot-hub-markdown.utils';
import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models'; import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models';
import PhotoSwipeLightbox from 'photoswipe/lightbox'; import { isNotEmptyStr } from '@core/utils';
import PhotoSwipe from 'photoswipe';
@Component({ @Component({
selector: 'tb-iot-hub-markdown', selector: 'tb-iot-hub-markdown',
@ -99,82 +100,6 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges {
onReady() { onReady() {
const container = this.elementRef.nativeElement; const container = this.elementRef.nativeElement;
const galleryImages = container.querySelectorAll<HTMLElement>('.tb-gallery-images');
const lightbox = new PhotoSwipeLightbox({
gallery: galleryImages,
children: '.tb-gallery-image',
pswpModule: PhotoSwipe,
counter: false,
bgOpacity: 0
});
lightbox.addFilter('domItemData', (itemData, element) => {
const image = element.querySelector('img');
itemData.src = image.src;
itemData.width = image.naturalWidth;
itemData.height = image.naturalHeight;
itemData.thumbCropped = true;
return itemData;
});
lightbox.on('change', () => {
const item = lightbox.pswp.currSlide.content.element;// element.querySelector('img');
item.style.display = 'block';
item.style.maxWidth = '90vw';
item.style.maxHeight = '78vh';
item.style.objectFit = 'contain';
item.style.borderRadius = '4px';
item.style.boxShadow = '0 20px 60px #00000080';
});
lightbox.on('uiRegister', () => {
lightbox.pswp.element.style.background = '#0a0a148c';
lightbox.pswp.element.style.backdropFilter = 'blur(18px)';
lightbox.pswp.element.style.setProperty('-webkit-backdrop-filter', 'blur(18px)');
lightbox.pswp.ui.registerElement({
name: 'custom-caption',
order: 9,
isButton: false,
appendTo: 'root',
html: '<span class="tb-gallery-caption"></span><span class="tb-gallery-counter"></span>',
onInit: (el, pswp) => {
el.style.position = 'fixed';
el.style.bottom = '1.5rem';
el.style.left = '50%';
el.style.transform = 'translate(-50%)';
el.style.zIndex = '10000';
el.style.display = 'flex';
el.style.flexDirection = 'column';
el.style.alignItems = 'center';
el.style.gap = '.25rem';
el.style.maxWidth = '80vw';
el.style.textAlign = 'center';
el.style.pointerEvents = 'none';
const caption = el.querySelector<HTMLElement>('.tb-gallery-caption');
caption.style.color = '#fff';
caption.style.fontSize = '1.125rem';
caption.style.lineHeight = '1.5';
caption.style.background = '#000000a6';
caption.style.padding = '.5rem 1.25rem';
caption.style.borderRadius = '8px';
caption.style.backdropFilter = 'blur(8px)';
caption.style.setProperty('-webkit-backdrop-filter', 'blur(8px)');
const counter = el.querySelector<HTMLElement>('.tb-gallery-counter');
counter.style.color = '#fff9';
counter.style.fontSize = '.8rem';
lightbox.pswp.on('change', () => {
counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems();
const currSlideElement = lightbox.pswp.currSlide.data.element;
let captionHTML = '';
if (currSlideElement) {
const imageTooltip = currSlideElement.querySelector('.tb-image-tooltip');
if (imageTooltip) {
captionHTML = imageTooltip.innerHTML;
}
}
caption.innerHTML = captionHTML || '';
});
}
});
});
lightbox.init();
this.ready.emit(container); this.ready.emit(container);
} }
@ -233,6 +158,39 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges {
} }
private resolveVariables(content: string): string { private resolveVariables(content: string): string {
// Image gallery is handled first because its inner ${...} contents
// may include nested braces and span multiple lines, which the
// generic ${key} regex below cannot parse.
//
// Format: ${images.gallery({src: 'p1', alt: 'a1', caption: 'c1'}, {src: 'p2'}, ...)}
// Each image entry is a JS object literal with a required `src`
// and optional `alt` / `caption` string fields.
content = content.replace(/\$\{\s*images\.gallery\(([\s\S]*?)\)\s*}/g, (_match, inner: string) => {
const objects: string[] = inner.match(/\{[\s\S]*?}/g) || [];
const items = objects
.map((obj: string) => {
const src = (obj.match(/src\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || '';
const alt = (obj.match(/alt\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || '';
const caption = (obj.match(/caption\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || '';
return { src: this.resolveImage(src), alt, caption };
})
.filter((item: { src?: string }) => !!item.src);
const images = items
.map(item => {
let galleryImageHtml = `<button class="tb-gallery-image">
<span class="tb-image-container">
<img src="${item.src}" alt="${escapeHtmlAttr(item.alt)}"/>
</span>`;
if (isNotEmptyStr(item.caption)) {
galleryImageHtml += `<span class="tb-image-tooltip">${sanitizeInlineHtml(item.caption)}</span>`;
}
galleryImageHtml += `</button>`;
return galleryImageHtml;
})
.join('');
return `<div class="tb-gallery-images" tbPhotoSwipeGallery galleryChildrenSelector=".tb-gallery-image" imageCaptionSelector=".tb-image-tooltip">${images}</div>`;
});
return content.replace(/\$\{([^}]+)}/g, (_match, key) => { return content.replace(/\$\{([^}]+)}/g, (_match, key) => {
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)}
const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s);
@ -242,19 +200,6 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges {
const icons: Record<string, string> = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' }; const icons: Record<string, string> = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' };
return `<div class="tb-callout tb-callout-${type}"><i class="material-icons tb-callout-icon">${icons[type]}</i><span class="tb-callout-text">${text}</span></div>`; return `<div class="tb-callout tb-callout-${type}"><i class="material-icons tb-callout-icon">${icons[type]}</i><span class="tb-callout-text">${text}</span></div>`;
} }
// Image gallery: ${images.gallery(path1,path2,path3)}
const galleryMatch = key.match(/^images\.gallery\((.+)\)$/);
if (galleryMatch) {
const paths = galleryMatch[1].split(',').map((p: string) => p.trim());
const images = paths
.map((p: string) => this.resolveImage(p))
.filter((src: string | undefined) => !!src)
.map((src: string) => `<button class="tb-gallery-image"><span class="tb-image-container">
<img src="${src}" alt=""/>
</span><span class="tb-image-tooltip">Placeholder!</span></button>`)
.join('');
return `<div class="tb-gallery-images">${images}</div>`;
}
// Special variables // Special variables
const res = this.onResolveVariable(key); const res = this.onResolveVariable(key);

57
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts

@ -15,10 +15,7 @@
/// ///
export const ITEM_LINK_PLACEHOLDER_REGEX = export const ITEM_LINK_PLACEHOLDER_REGEX =
/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})}/g;
export const ITEM_LINK_KEY_REGEX =
/^item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
export function itemLinkCardTag(itemId: string): string { export function itemLinkCardTag(itemId: string): string {
return `<tb-iot-hub-item-link-card itemId="${itemId}"></tb-iot-hub-item-link-card>`; return `<tb-iot-hub-item-link-card itemId="${itemId}"></tb-iot-hub-item-link-card>`;
@ -61,11 +58,11 @@ function buildDocLinkButton(url: string, text: string, icon: string): string {
`<tb-icon matButtonIcon>${icon}</tb-icon><span>${safeText}</span></a>`; `<tb-icon matButtonIcon>${icon}</tb-icon><span>${safeText}</span></a>`;
} }
function escapeHtml(value: string): string { export function escapeHtml(value: string): string {
return value.replace(/[&<>]/g, ch => ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : '&gt;'); return value.replace(/[&<>]/g, ch => ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : '&gt;');
} }
function escapeHtmlAttr(value: string): string { export function escapeHtmlAttr(value: string): string {
return value.replace(/[&<>"']/g, ch => { return value.replace(/[&<>"']/g, ch => {
switch (ch) { switch (ch) {
case '&': return '&amp;'; case '&': return '&amp;';
@ -77,3 +74,51 @@ function escapeHtmlAttr(value: string): string {
}); });
} }
// Inline-only tags + a small attribute whitelist that are safe to keep
// in caption strings (or other small bits of user-authored HTML inside
// generated markdown). Anything else is dropped — disallowed tags are
// replaced by their text content and disallowed attributes are removed.
const SAFE_INLINE_TAGS: ReadonlySet<string> = new Set([
'B', 'STRONG', 'I', 'EM', 'U', 'S', 'MARK',
'SMALL', 'SUB', 'SUP', 'BR', 'CODE', 'SPAN'
]);
const SAFE_INLINE_ATTRS: ReadonlySet<string> = new Set(['class', 'style']);
export function sanitizeInlineHtml(value: string): string {
if (!value) {
return '';
}
const doc = new DOMParser().parseFromString(`<div>${value}</div>`, 'text/html');
const root = doc.body.firstElementChild as HTMLElement | null;
if (!root) {
return '';
}
const sanitize = (parent: Element): void => {
const children = Array.from(parent.childNodes);
for (const node of children) {
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const el = node as Element;
if (!SAFE_INLINE_TAGS.has(el.tagName)) {
// Replace disallowed elements with their plain text content.
parent.replaceChild(doc.createTextNode(el.textContent || ''), el);
continue;
}
for (const attr of Array.from(el.attributes)) {
if (!SAFE_INLINE_ATTRS.has(attr.name)) {
el.removeAttribute(attr.name);
continue;
}
if (attr.name === 'style' && /(expression\s*\(|javascript:|url\s*\()/i.test(attr.value)) {
el.removeAttribute(attr.name);
}
}
sanitize(el);
}
};
sanitize(root);
return root.innerHTML;
}

219
ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts

@ -0,0 +1,219 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
import cssjs from '@core/css/css';
const PHOTO_GALLERY_STYLE_ID = 'photoswipe-gallery-style';
const PHOTO_GALLERY_CLASS = 'tb-photoswipe-gallery';
const PHOTO_GALLERY_STYLE =
'{\n'+
' background: rgba(10, 10, 20, 0.55);\n' +
' backdrop-filter: blur(18px);\n' +
' opacity: 1;\n' +
'}\n' +
'\n' +
'.pswp__tb-photoswipe-caption {\n' +
' position: fixed;\n' +
' bottom: 1.5rem;\n' +
' left: 50%;\n' +
' transform: translate(-50%);\n' +
' z-index: 10000;\n' +
' display: flex;\n' +
' flex-direction: column;\n' +
' align-items: center;\n' +
' gap: .25rem;\n' +
' max-width: 80vw;\n' +
' text-align: center;\n' +
' pointer-events: none;\n' +
' line-height: 1.75;\n' +
'}\n' +
'\n' +
'.pswp__tb-photoswipe-caption .tb-gallery-caption {\n' +
' color: #fff;\n' +
' font-size: 1.125rem;\n' +
' line-height: 1.5;\n' +
' background: #000000a6;\n' +
' padding: .5rem 1.25rem;\n' +
' border-radius: 8px;\n' +
' backdrop-filter: blur(8px);\n' +
' -webkit-backdrop-filter: blur(8px);\n' +
'}\n' +
'\n' +
'.pswp__tb-photoswipe-caption .tb-gallery-counter {\n' +
' color: #fff9;\n' +
' font-size: .8rem;\n' +
'}\n'+
'\n' +
'.pswp__item img.pswp__img {\n' +
' display: block;\n' +
' max-width: 90vw;\n' +
' max-height: 78vh;\n' +
' object-fit: contain;\n' +
' border-radius: 4px; \n' +
' box-shadow: 0 20px 60px #00000080;\n' +
'}\n' +
'\n' +
'.pswp__item .pswp__img--placeholder {\n' +
' border-radius: 4px; \n' +
'}\n' +
'\n' +
'.pswp__button {\n' +
' border-radius: 50%;\n' +
' border: 1px solid rgba(255, 255, 255, .2);\n' +
' background: #1e1e2899;\n' +
' backdrop-filter: blur(8px);\n' +
' -webkit-backdrop-filter: blur(8px);\n' +
' color: #fff;\n' +
' transition: background .18s ease, transform .18s ease;\n' +
' outline: none;\n' +
'}\n' +
'\n' +
'.pswp__button:hover {\n' +
' background: #3c3c50d9;\n' +
' transform: scale(1.08);\n' +
' outline: none;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--close {\n' +
' width: 40px;\n' +
' height: 40px;\n' +
' margin-top: 16px;\n' +
' margin-right: 16px;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--close .pswp__icn {\n' +
' width: 24px;\n' +
' height: 24px;\n' +
' top: 7px;\n' +
' left: 7px;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--arrow {\n' +
' width: 48px;\n' +
' height: 48px;\n' +
' margin-top: -24px;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--arrow .pswp__icn {\n' +
' width: 32px;\n' +
' height: 32px;\n' +
' margin-top: 0;\n' +
' top: 7px;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--arrow.pswp__button--arrow--prev {\n' +
' left: 16px;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--arrow.pswp__button--arrow--prev .pswp__icn {\n' +
' left: 12px;\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--arrow.pswp__button--arrow--next {\n' +
' right: 16px\n' +
'}\n' +
'\n' +
'.pswp__button.pswp__button--arrow.pswp__button--arrow--next .pswp__icn { \n' +
' right: 12px;\n' +
'}';
@Directive({
selector: '[tbPhotoSwipeGallery]',
standalone: false
})
export class PhotoSwipeGalleryDirective implements OnInit, OnDestroy {
@Input() galleryChildrenSelector = '.tb-image';
@Input() imageCaptionSelector = '.tb-image-tooltip';
private lightbox: PhotoSwipeLightbox;
constructor(
private elementRef: ElementRef<HTMLElement>
) {}
ngOnInit(): void {
this.initPhotoSwipeGalleryStyle();
this.lightbox = new PhotoSwipeLightbox({
gallery: this.elementRef.nativeElement,
children: this.galleryChildrenSelector,
pswpModule: PhotoSwipe,
counter: false,
bgOpacity: 0,
mainClass: PHOTO_GALLERY_CLASS
});
this.lightbox.addFilter('domItemData', (itemData, element) => {
let image: HTMLImageElement;
if (element instanceof HTMLImageElement) {
image = element;
} else {
image = element.querySelector('img');
}
itemData.src = image.src;
itemData.width = image.naturalWidth;
itemData.height = image.naturalHeight;
itemData.thumbCropped = true;
return itemData;
});
this.lightbox.on('uiRegister', () => {
this.lightbox.pswp.ui.registerElement({
name: 'tb-photoswipe-caption',
order: 9,
isButton: false,
appendTo: 'root',
html: '<span class="tb-gallery-caption"></span><span class="tb-gallery-counter"></span>',
onInit: (el, pswp) => {
const caption = el.querySelector<HTMLElement>('.tb-gallery-caption');
const counter = el.querySelector<HTMLElement>('.tb-gallery-counter');
this.lightbox.pswp.on('change', () => {
counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems();
const currSlideElement = this.lightbox.pswp.currSlide.data.element;
let imageTooltip: Element;
if (currSlideElement) {
imageTooltip = currSlideElement.querySelector(this.imageCaptionSelector);
}
if (imageTooltip) {
caption.style.display = 'block';
caption.innerHTML = imageTooltip.innerHTML || '';
} else {
caption.style.display = 'none';
}
});
}
});
});
this.lightbox.init();
}
ngOnDestroy(): void {
if (this.lightbox) {
this.lightbox.destroy();
}
}
private initPhotoSwipeGalleryStyle(): void {
const existingElement = document.getElementById(PHOTO_GALLERY_STYLE_ID);
if (!existingElement) {
const cssParser = new cssjs();
cssParser.testMode = false;
cssParser.cssPreviewNamespace = PHOTO_GALLERY_CLASS;
cssParser.createStyleElement(PHOTO_GALLERY_STYLE_ID, PHOTO_GALLERY_STYLE);
}
}
}

48
ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts

@ -232,7 +232,7 @@ export enum InstallStepType {
RULE_CHAIN = 'RULE_CHAIN' RULE_CHAIN = 'RULE_CHAIN'
} }
export const ENTITY_STEP_TYPES: Set<string> = new Set([ export const ENTITY_STEP_TYPES = new Set<string>([
InstallStepType.DEVICE_PROFILE, InstallStepType.DEVICE_PROFILE,
InstallStepType.DEVICE, InstallStepType.DEVICE,
InstallStepType.GATEWAY, InstallStepType.GATEWAY,
@ -327,49 +327,3 @@ export interface EntityStepProgress {
conflictType?: ConflictType; conflictType?: ConflictType;
resolution?: string; resolution?: string;
} }
export interface DocLinks {
productURL?: string;
datasheetURL?: string;
}
export interface DocLinkLabels {
productPage: string;
datasheet: string;
}
export function resolveDocLinkPlaceholders(
markdown: string,
name: string,
links: DocLinks,
labels: DocLinkLabels
): string {
return markdown
.replace(/\$\{product\.button}/g, () =>
links.productURL ? buildDocLinkButton(links.productURL, `${name} ${labels.productPage}`, 'open_in_new') : '')
.replace(/\$\{datasheet\.button}/g, () =>
links.datasheetURL ? buildDocLinkButton(links.datasheetURL, `${name} ${labels.datasheet}`, 'description') : '');
}
function buildDocLinkButton(url: string, text: string, icon: string): string {
const safeUrl = escapeHtmlAttr(url);
const safeText = escapeHtml(text);
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" class="tb-doc-link-btn">` +
`<i class="material-icons">${icon}</i><span>${safeText}</span></a>`;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>]/g, ch => ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : '&gt;');
}
function escapeHtmlAttr(value: string): string {
return value.replace(/[&<>"']/g, ch => {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
default: return '&#39;';
}
});
}

3
ui-ngx/src/app/shared/shared.module.ts

@ -229,6 +229,7 @@ import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component';
import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle'; import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle';
import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -387,6 +388,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TruncateWithTooltipDirective, TruncateWithTooltipDirective,
ContextMenuDirective, ContextMenuDirective,
ChipOverflowDirective, ChipOverflowDirective,
PhotoSwipeGalleryDirective,
NospacePipe, NospacePipe,
MillisecondsToTimeStringPipe, MillisecondsToTimeStringPipe,
EnumToArrayPipe, EnumToArrayPipe,
@ -652,6 +654,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TruncateWithTooltipDirective, TruncateWithTooltipDirective,
ContextMenuDirective, ContextMenuDirective,
ChipOverflowDirective, ChipOverflowDirective,
PhotoSwipeGalleryDirective,
NospacePipe, NospacePipe,
MillisecondsToTimeStringPipe, MillisecondsToTimeStringPipe,
EnumToArrayPipe, EnumToArrayPipe,

Loading…
Cancel
Save