diff --git a/docs/elements/components/pos-html-tool/readme.md b/docs/elements/components/pos-html-tool/readme.md new file mode 100644 index 00000000..c8634bd7 --- /dev/null +++ b/docs/elements/components/pos-html-tool/readme.md @@ -0,0 +1,14 @@ + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ---------- | ---------- | ------------------------------------ | -------- | ----------- | +| `fragment` | `fragment` | HTML fragment to sanitize and render | `string` | `undefined` | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/elements/package.json b/elements/package.json index 1577ad64..95a015c7 100644 --- a/elements/package.json +++ b/elements/package.json @@ -33,6 +33,7 @@ "@tiptap/pm": "^3.10.1", "@tiptap/starter-kit": "^3.10.1", "@uvdsl/solid-oidc-client-browser": "^0.1.3", + "dompurify": "^3.3.0", "idb": "^8.0.3", "neverthrow": "^8.2.0", "pollen-css": "^5.0.2", diff --git a/elements/src/components.d.ts b/elements/src/components.d.ts index 3b3cfc51..82e33369 100644 --- a/elements/src/components.d.ts +++ b/elements/src/components.d.ts @@ -66,6 +66,12 @@ export namespace Components { } interface PosGettingStarted { } + interface PosHtmlTool { + /** + * HTML fragment to sanitize and render + */ + "fragment": string; + } /** * Tries fetch an image with the solid authentication, and can visualize http errors like 403 or 404 if this fails. * Falls back to classic `` on network errors like CORS. @@ -625,6 +631,12 @@ declare global { prototype: HTMLPosGettingStartedElement; new (): HTMLPosGettingStartedElement; }; + interface HTMLPosHtmlToolElement extends Components.PosHtmlTool, HTMLStencilElement { + } + var HTMLPosHtmlToolElement: { + prototype: HTMLPosHtmlToolElement; + new (): HTMLPosHtmlToolElement; + }; interface HTMLPosImageElementEventMap { "pod-os:init": any; "pod-os:resource-loaded": string; @@ -1113,6 +1125,7 @@ declare global { "pos-error-toast": HTMLPosErrorToastElement; "pos-example-resources": HTMLPosExampleResourcesElement; "pos-getting-started": HTMLPosGettingStartedElement; + "pos-html-tool": HTMLPosHtmlToolElement; "pos-image": HTMLPosImageElement; "pos-internal-router": HTMLPosInternalRouterElement; "pos-label": HTMLPosLabelElement; @@ -1238,6 +1251,12 @@ declare namespace LocalJSX { interface PosGettingStarted { "onPod-os:login"?: (event: PosGettingStartedCustomEvent) => void; } + interface PosHtmlTool { + /** + * HTML fragment to sanitize and render + */ + "fragment"?: string; + } /** * Tries fetch an image with the solid authentication, and can visualize http errors like 403 or 404 if this fails. * Falls back to classic `` on network errors like CORS. @@ -1476,6 +1495,7 @@ declare namespace LocalJSX { "pos-error-toast": PosErrorToast; "pos-example-resources": PosExampleResources; "pos-getting-started": PosGettingStarted; + "pos-html-tool": PosHtmlTool; "pos-image": PosImage; "pos-internal-router": PosInternalRouter; "pos-label": PosLabel; @@ -1533,6 +1553,7 @@ declare module "@stencil/core" { "pos-error-toast": LocalJSX.PosErrorToast & JSXBase.HTMLAttributes; "pos-example-resources": LocalJSX.PosExampleResources & JSXBase.HTMLAttributes; "pos-getting-started": LocalJSX.PosGettingStarted & JSXBase.HTMLAttributes; + "pos-html-tool": LocalJSX.PosHtmlTool & JSXBase.HTMLAttributes; /** * Tries fetch an image with the solid authentication, and can visualize http errors like 403 or 404 if this fails. * Falls back to classic `` on network errors like CORS. diff --git a/elements/src/components/pos-html-tool/pos-html-tool.integration.spec.tsx b/elements/src/components/pos-html-tool/pos-html-tool.integration.spec.tsx new file mode 100644 index 00000000..2b9bcf6a --- /dev/null +++ b/elements/src/components/pos-html-tool/pos-html-tool.integration.spec.tsx @@ -0,0 +1,38 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { mockPodOS } from '../../test/mockPodOS'; +import { PosHtmlTool } from './pos-html-tool'; +import { PosApp } from '../pos-app/pos-app'; +import { PosLabel } from '../pos-label/pos-label'; +import { PosResource } from '../pos-resource/pos-resource'; +import { Thing } from '@pod-os/core'; +import { when } from 'jest-when'; + +describe('pos-html-tool', () => { + it('respects the resource event and renders inserted pos-label', async () => { + const os = mockPodOS(); + when(os.store.get) + .calledWith('https://resource.test') + .mockReturnValue({ + label: () => 'Test Resource', + } as unknown as Thing); + const page = await newSpecPage({ + components: [PosApp, PosResource, PosLabel, PosHtmlTool], + html: ` + + + + `, + }); + const el = page.root?.querySelector('pos-html-tool') as unknown as PosHtmlTool; + el.fragment = ''; + await page.waitForChanges(); + const label = page.root?.querySelector('pos-label'); + expect(label).toEqualHtml(` + + + Test Resource + + + `); + }); +}); diff --git a/elements/src/components/pos-html-tool/pos-html-tool.spec.tsx b/elements/src/components/pos-html-tool/pos-html-tool.spec.tsx new file mode 100644 index 00000000..a023a9bc --- /dev/null +++ b/elements/src/components/pos-html-tool/pos-html-tool.spec.tsx @@ -0,0 +1,20 @@ +/** + * @jest-environment @happy-dom/jest-environment + * + * => dompurify needs a real DOM to work + */ + +import { newSpecPage } from '@stencil/core/testing'; +import { PosHtmlTool } from './pos-html-tool'; + +describe('pos-html-tool', () => { + it('inserts sanitized HTML into the page', async () => { + const page = await newSpecPage({ + components: [PosHtmlTool], + html: ``, + }); + page.rootInstance.fragment = ''; + await page.waitForChanges(); + expect(page.root?.innerHTML).toEqualHtml(''); + }); +}); diff --git a/elements/src/components/pos-html-tool/pos-html-tool.tsx b/elements/src/components/pos-html-tool/pos-html-tool.tsx new file mode 100644 index 00000000..63324981 --- /dev/null +++ b/elements/src/components/pos-html-tool/pos-html-tool.tsx @@ -0,0 +1,17 @@ +import { Component, h, Host, Prop } from '@stencil/core'; +import { sanitizeHtmlTool } from './sanitizeHtmlTool'; + +@Component({ + tag: 'pos-html-tool', + shadow: false, +}) +export class PosHtmlTool { + /** + * HTML fragment to sanitize and render + */ + @Prop() fragment: string; + + render() { + return ; + } +} diff --git a/elements/src/components/pos-html-tool/sanitizeHtmlTool.spec.tsx b/elements/src/components/pos-html-tool/sanitizeHtmlTool.spec.tsx new file mode 100644 index 00000000..748359bd --- /dev/null +++ b/elements/src/components/pos-html-tool/sanitizeHtmlTool.spec.tsx @@ -0,0 +1,19 @@ +/** + * @jest-environment @happy-dom/jest-environment + * + * => dompurify needs a real DOM to work + */ + +import { sanitizeHtmlTool } from './sanitizeHtmlTool'; + +describe('sanitizeHtmlTool', () => { + it('keeps whitelisted elements', () => { + const sanitized = sanitizeHtmlTool(''); + expect(sanitized).toEqual(''); + }); + + it('removes unknown HTML elements from fragment', () => { + const sanitized = sanitizeHtmlTool(''); + expect(sanitized).toEqual(''); + }); +}); diff --git a/elements/src/components/pos-html-tool/sanitizeHtmlTool.tsx b/elements/src/components/pos-html-tool/sanitizeHtmlTool.tsx new file mode 100644 index 00000000..8944088e --- /dev/null +++ b/elements/src/components/pos-html-tool/sanitizeHtmlTool.tsx @@ -0,0 +1,5 @@ +import DOMPurify from 'dompurify'; + +export function sanitizeHtmlTool(htmlToolFragment: string) { + return DOMPurify.sanitize(htmlToolFragment, { ADD_TAGS: ['pos-label'] }); +} diff --git a/elements/src/components/pos-label/pos-label.sanitizeHtmlTool.spec.tsx b/elements/src/components/pos-label/pos-label.sanitizeHtmlTool.spec.tsx new file mode 100644 index 00000000..08606db7 --- /dev/null +++ b/elements/src/components/pos-label/pos-label.sanitizeHtmlTool.spec.tsx @@ -0,0 +1,13 @@ +/** + * @jest-environment @happy-dom/jest-environment + * + * => dompurify needs a real DOM to work + */ +import { sanitizeHtmlTool } from '../pos-html-tool/sanitizeHtmlTool'; + +describe('pos-label', () => { + it('is whitelisted by sanitizeHtmlTool', () => { + const sanitized = sanitizeHtmlTool(''); + expect(sanitized).toEqual(''); + }); +}); diff --git a/elements/src/components/pos-type-router/pos-type-router.spec.tsx b/elements/src/components/pos-type-router/pos-type-router.spec.tsx index d2db70a2..46bc6677 100644 --- a/elements/src/components/pos-type-router/pos-type-router.spec.tsx +++ b/elements/src/components/pos-type-router/pos-type-router.spec.tsx @@ -136,6 +136,29 @@ describe('pos-type-router', () => { `); }); + it('renders HTML tool if available', async () => { + const page = await newSpecPage({ + components: [PosTypeRouter], + html: ``, + supportsShadowDom: false, + }); + await page.rootInstance.receiveResource({ + types: () => [{ uri: 'https://schema.org/Recipe', label: 'Recipe' }], + }); + await page.waitForChanges(); + + expect(page.root).toEqualHtml(` + +
+ +
+ +
+
+
+`); + }); + it('renders the selected tool and updates query param', async () => { const page = await newSpecPage({ components: [PosTypeRouter], diff --git a/elements/src/components/pos-type-router/pos-type-router.tsx b/elements/src/components/pos-type-router/pos-type-router.tsx index 66ed2032..cb48f3c5 100644 --- a/elements/src/components/pos-type-router/pos-type-router.tsx +++ b/elements/src/components/pos-type-router/pos-type-router.tsx @@ -1,7 +1,7 @@ import { Thing } from '@pod-os/core'; import { Component, Event, EventEmitter, h, Listen, State } from '@stencil/core'; import { ResourceAware, subscribeResource } from '../events/ResourceAware'; -import { selectToolsForTypes, ToolConfig } from './selectToolsForTypes'; +import { HTMLToolConfig, selectToolsForTypes, ToolConfig } from './selectToolsForTypes'; /** * This component is responsible for rendering tools that are useful to interact with the current resource. @@ -43,7 +43,21 @@ export class PosTypeRouter implements ResourceAware { receiveResource = (resource: Thing) => { const types = resource.types(); - this.availableTools = selectToolsForTypes(types); + const registeredTools = [ + { + element: 'pos-html-tool', + label: 'Example tool', + icon: 'list-ul', + types: [ + { + uri: 'https://schema.org/Recipe', + priority: 20, + }, + ], + fragment: '', + }, + ]; + this.availableTools = selectToolsForTypes(types, registeredTools); this.currentTool = this.availableTools.find(it => it.element === this.initialTool) ?? this.availableTools[0]; }; @@ -59,7 +73,14 @@ export class PosTypeRouter implements ResourceAware {
this.removeOldTool()}> {OldTool && } - + {SelectedTool == 'pos-html-tool' ? ( + + ) : ( + + )}
); diff --git a/elements/src/components/pos-type-router/selectToolsForTypes.spec.ts b/elements/src/components/pos-type-router/selectToolsForTypes.spec.ts index ed709f58..9b92f949 100644 --- a/elements/src/components/pos-type-router/selectToolsForTypes.spec.ts +++ b/elements/src/components/pos-type-router/selectToolsForTypes.spec.ts @@ -97,4 +97,23 @@ describe('select tools for types', () => { AvailableTools.Generic, ]); }); + + it('favours HTML tool over generic if one is available', () => { + const registeredTools = [ + { + element: 'pos-html-tool', + label: 'Example tool', + icon: 'list-ul', + types: [ + { + uri: 'https://schema.org/Recipe', + priority: 20, + }, + ], + }, + ]; + const types = [{ uri: 'https://schema.org/Recipe', label: 'Recipe' }]; + const tools = selectToolsForTypes(types, registeredTools); + expect(tools).toEqual([registeredTools[0], AvailableTools.Generic]); + }); }); diff --git a/elements/src/components/pos-type-router/selectToolsForTypes.ts b/elements/src/components/pos-type-router/selectToolsForTypes.ts index 261e2968..ea5b8618 100644 --- a/elements/src/components/pos-type-router/selectToolsForTypes.ts +++ b/elements/src/components/pos-type-router/selectToolsForTypes.ts @@ -24,6 +24,13 @@ export interface ToolConfig { types: TypePriority[]; } +/** + * Describes a tool that can be used implemented as a HTML fragment + */ +export interface HTMLToolConfig extends ToolConfig { + fragment: string; +} + /** * Describes how well a given RDF type can be handled */ @@ -52,10 +59,10 @@ interface ToolPriority { priority: number; } -export function selectToolsForTypes(types: RdfType[]) { +export function selectToolsForTypes(types: RdfType[], registeredTools: ToolConfig[] = []) { const typeUris = new Set(types.map(type => type.uri)); - return Object.values(AvailableTools) + return [...Object.values(AvailableTools), ...registeredTools] .map(maxPriorityFor(typeUris)) .filter(onlyRelevant) .toSorted(byPriority) diff --git a/package-lock.json b/package-lock.json index 529a135a..015e1f38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,6 +113,9 @@ "typedoc-plugin-markdown": "^4.9.0", "typescript": "5.9.3", "typescript-eslint": "^8.46.3" + }, + "engines": { + "node": ">=18.0.0" } }, "core/node_modules/@jest/console": { @@ -1303,6 +1306,7 @@ "@tiptap/pm": "^3.10.1", "@tiptap/starter-kit": "^3.10.1", "@uvdsl/solid-oidc-client-browser": "^0.1.3", + "dompurify": "^3.3.0", "idb": "^8.0.3", "neverthrow": "^8.2.0", "pollen-css": "^5.0.2", @@ -10984,6 +10988,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",