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..2d2293e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "gitlab-component-helper",
- "version": "0.18.1",
+ "version": "0.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitlab-component-helper",
- "version": "0.18.1",
+ "version": "0.19.0",
"license": "MIT",
"devDependencies": {
"@commitlint/cli": "^21.2.3",
@@ -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..3964a66 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "gitlab-component-helper",
"displayName": "GitLab Component Helper",
"description": "Provides intellisense for GitLab CI components",
- "version": "0.18.1",
+ "version": "0.19.0",
"icon": "images/icon.png",
"engines": {
"node": ">=22.0.0",
@@ -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 `