From 79f0cf82183aedce424d8f7648e43e068fcf5dde Mon Sep 17 00:00:00 2001
From: Simon Heather <32168619+X-Guardian@users.noreply.github.com>
Date: Tue, 6 Oct 2026 15:53:03 +0100
Subject: [PATCH 1/2] refactor(webview): render webview documents from TSX
views (#325)
Move every webview document from template literals into TSX views rendered with preact-render-to-string. Escaping is now on by default, markup errors fail tsc, and lint bans inline styles, handlers and raw HTML outside Page.tsx.
---
eslint.config.js | 47 +-
package-lock.json | 31 ++
package.json | 2 +
src/providers/componentBrowserProvider.ts | 519 +++------------------
src/webview/csp.ts | 10 +-
src/webview/render.ts | 23 +
src/webview/views/ComponentBrowserView.tsx | 224 +++++++++
src/webview/views/ComponentDetailsView.tsx | 200 ++++++++
src/webview/views/ErrorView.tsx | 43 ++
src/webview/views/ErrorsView.tsx | 51 ++
src/webview/views/LoadingView.tsx | 18 +
src/webview/views/NoSourcesView.tsx | 48 ++
src/webview/views/Page.tsx | 78 ++++
src/webview/webviewHtml.ts | 6 +-
tests/unit/csp.test.ts | 22 +-
tests/unit/loadingView.test.ts | 33 ++
tests/unit/webviewBuilderMarkup.test.ts | 290 +++++++-----
tsconfig.json | 2 +
18 files changed, 1061 insertions(+), 586 deletions(-)
create mode 100644 src/webview/render.ts
create mode 100644 src/webview/views/ComponentBrowserView.tsx
create mode 100644 src/webview/views/ComponentDetailsView.tsx
create mode 100644 src/webview/views/ErrorView.tsx
create mode 100644 src/webview/views/ErrorsView.tsx
create mode 100644 src/webview/views/LoadingView.tsx
create mode 100644 src/webview/views/NoSourcesView.tsx
create mode 100644 src/webview/views/Page.tsx
create mode 100644 tests/unit/loadingView.test.ts
diff --git a/eslint.config.js b/eslint.config.js
index f5cf907..601370b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -2,6 +2,37 @@ const js = require('@eslint/js');
const tseslint = require('typescript-eslint');
const globals = require('globals');
+/**
+ * JSX the webview views must not contain.
+ *
+ * @param {{ allowRawHtml?: boolean }} [options] `allowRawHtml` admits `dangerouslySetInnerHTML`, for `Page.tsx` only.
+ * @returns {{ selector: string, message: string }[]} `no-restricted-syntax` entries.
+ */
+function viewRestrictions({ allowRawHtml = false } = {}) {
+ return [
+ {
+ selector: "JSXAttribute[name.name='style']",
+ message: 'Inline styles are blocked by the webview CSP; add a class in src/webview/styles.',
+ },
+ {
+ selector: 'JSXAttribute[name.name=/^on/]',
+ message: 'Inline handlers are blocked by the webview CSP; use a data-action attribute.',
+ },
+ {
+ selector: "JSXOpeningElement[name.name='script']:not(:has(JSXAttribute[name.name=/^(src|type)$/]))",
+ message: 'Inline scripts are blocked by the webview CSP; put code in src/webview/client.',
+ },
+ {
+ selector: "JSXOpeningElement[name.name='style']",
+ message: 'Inline styles are blocked by the webview CSP; add a stylesheet in src/webview/styles.',
+ },
+ ...(allowRawHtml ? [] : [{
+ selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
+ message: 'Raw HTML bypasses escaping; use JsonScript or InlineMarkdown from Page.tsx.',
+ }]),
+ ];
+}
+
/** @type {import('eslint').Linter.Config[]} */
module.exports = [
{
@@ -20,7 +51,7 @@ module.exports = [
},
},
...tseslint.config({
- files: ['**/*.ts'],
+ files: ['**/*.ts', '**/*.tsx'],
extends: [...tseslint.configs.recommended],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
@@ -51,6 +82,20 @@ module.exports = [
...js.configs.recommended.rules,
},
},
+ {
+ // Webview views render under a nonce CSP: inline styles and handlers are blocked, and raw HTML bypasses escaping.
+ files: ['src/webview/views/**/*.tsx'],
+ rules: {
+ 'no-restricted-syntax': ['error', ...viewRestrictions()],
+ },
+ },
+ {
+ // The two helpers that emit pre-sanitised content are the only place raw HTML is allowed.
+ files: ['src/webview/views/Page.tsx'],
+ rules: {
+ 'no-restricted-syntax': ['error', ...viewRestrictions({ allowRawHtml: true })],
+ },
+ },
{
// Webview client scripts run in the Electron renderer, not the extension host
files: ['src/webview/client/**/*.ts'],
diff --git a/package-lock.json b/package-lock.json
index 2bfee12..41da2ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,8 @@
"minimatch": "^10.2.6",
"mocha": "^12.0.2",
"npm-run-all": "^4.1.5",
+ "preact": "^10.29.8",
+ "preact-render-to-string": "^6.7.0",
"release-it": "^21.1.0",
"semver": "^7.8.5",
"stylelint": "^17.15.0",
@@ -8175,6 +8177,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/preact": {
+ "version": "10.29.8",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
+ "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ },
+ "peerDependencies": {
+ "preact-render-to-string": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "preact-render-to-string": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.7.0",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.7.0.tgz",
+ "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10 || >= 11.0.0-0"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
diff --git a/package.json b/package.json
index faf0e9b..39137cc 100644
--- a/package.json
+++ b/package.json
@@ -349,6 +349,8 @@
"minimatch": "^10.2.6",
"mocha": "^12.0.2",
"npm-run-all": "^4.1.5",
+ "preact": "^10.29.8",
+ "preact-render-to-string": "^6.7.0",
"release-it": "^21.1.0",
"semver": "^7.8.5",
"stylelint": "^17.15.0",
diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts
index 6a63baa..f1bc29d 100644
--- a/src/providers/componentBrowserProvider.ts
+++ b/src/providers/componentBrowserProvider.ts
@@ -2,22 +2,27 @@ import * as vscode from 'vscode';
import { getComponentService } from '../services/component';
import { ComponentCacheManager } from '../services/cache/componentCacheManager';
import { GitLabCatalogComponent, GitLabCatalogVariable } from '../types/gitlab-catalog';
-import type { ComponentParameter, Component } from './componentDetector';
+import type { Component } from './componentDetector';
import { resolveServerFqdn } from './componentDetector';
import { isVersionLookupShape } from '../services/component/versionLookupShape';
import type { SourceGroup, ComponentGroup, ComponentVersion } from './componentBrowserTypes';
import type { HoverContext } from './hoverContentBuilder';
import { Logger } from '../utils/logger';
import { templateFileUrlForResolved } from '../utils/templateFileUrl';
-import { escapeHtml, renderInlineMarkdown } from '../webview/inlineMarkdown';
-import { serializeForScript } from '../webview/scriptData';
import { generateComponentText } from './componentBrowserGenerate';
import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit';
import { chooseComponentVersion, transformCachedComponentsToGroups } from './componentBrowserTransform';
import { LATEST_VERSION_PREFERENCE, versionPreferenceKey } from './versionPreferences';
import { readVersionPreferences, saveVersionPreference } from './versionPreferenceSettings';
-import { buildVersionLabels, compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping';
-import { assetRoots, assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml';
+import { buildVersionLabels } from '../services/component/tagScoping';
+import { assetRoots, assetUri, createNonce } from '../webview/webviewHtml';
+import { renderDocument } from '../webview/render';
+import { ComponentBrowserView } from '../webview/views/ComponentBrowserView';
+import { ComponentDetailsView } from '../webview/views/ComponentDetailsView';
+import { ErrorsView } from '../webview/views/ErrorsView';
+import { ErrorView } from '../webview/views/ErrorView';
+import { LoadingView } from '../webview/views/LoadingView';
+import { NoSourcesView } from '../webview/views/NoSourcesView';
import { safeHttpUrl } from '../webview/safeUrl';
/**
@@ -611,50 +616,7 @@ export class ComponentBrowserProvider {
private getLoadingHtml(webview: vscode.Webview): string {
const nonce = createNonce();
const styleUri = assetUri(webview, this.context.extensionUri, 'styles/loading.css');
- return `
-
-
-
-
-
- ${cspMetaTag(webview.cspSource, nonce)}
-
- GitLab CI/CD Components
-
-
-
-
-
Loading GitLab CI/CD components...
-
-
-
- `;
- }
-
- /**
- * Render the `