feat(plugins): add icon and terminal themes

This commit is contained in:
Neil
2026-07-13 03:33:43 -07:00
parent d8f27f9cd8
commit 3bc87fedcf
78 changed files with 2469 additions and 254 deletions
@@ -0,0 +1,4 @@
export type PluginChangeEvent = {
/** True only when declarative contribution catalogs were reconciled. */
contentPacksChanged: boolean
}
@@ -21,6 +21,9 @@ describe('content-pack manifest contributions', () => {
manifest({
themes: [{ id: 'nord', label: 'Nord', path: 'themes/nord.json' }],
iconThemes: [{ id: 'minimal', path: 'icons/minimal.json' }],
terminalThemes: [
{ id: 'nord-terminal', label: 'Nord Terminal', path: 'terminal/nord.json' }
],
languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }],
skills: [{ path: 'skills/', providers: ['codex', 'claude'] }],
commands: [
@@ -51,6 +54,7 @@ describe('content-pack manifest contributions', () => {
events: [],
themes: [],
iconThemes: [],
terminalThemes: [],
languagePacks: [],
skills: [],
keybindings: [],
@@ -8,6 +8,7 @@ import {
export const PLUGIN_THEME_LIMIT = 64
export const PLUGIN_ICON_THEME_LIMIT = 32
export const PLUGIN_TERMINAL_THEME_LIMIT = 64
export const PLUGIN_LANGUAGE_PACK_LIMIT = 16
export const PLUGIN_SKILL_LIMIT = 128
export const PLUGIN_KEYBINDING_LIMIT = 256
@@ -30,6 +31,14 @@ export const pluginIconThemeContributionSchema = z
})
.strict()
export const pluginTerminalThemeContributionSchema = z
.object({
id: pluginIdSchema,
label: z.string().min(1).max(128),
path: pluginRelativePathSchema
})
.strict()
// Why: locale ids become i18next bundle keys and filenames. This bounded BCP
// 47 subset covers current community packs without accepting path syntax.
export const pluginLocaleIdSchema = z
@@ -76,6 +85,7 @@ export const pluginAgentProfileContributionSchema = z
export type PluginThemeContribution = z.infer<typeof pluginThemeContributionSchema>
export type PluginIconThemeContribution = z.infer<typeof pluginIconThemeContributionSchema>
export type PluginTerminalThemeContribution = z.infer<typeof pluginTerminalThemeContributionSchema>
export type PluginLanguagePackContribution = z.infer<typeof pluginLanguagePackContributionSchema>
export type PluginSkillContribution = z.infer<typeof pluginSkillContributionSchema>
export type PluginKeybindingContribution = z.infer<typeof pluginKeybindingContributionSchema>
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { parsePluginIconThemeArtifact, sanitizePluginIconSvg } from './plugin-icon-theme-artifact'
describe('plugin icon theme artifacts', () => {
it('canonicalizes bounded host slots, file names, and extensions', () => {
expect(
parsePluginIconThemeArtifact(
JSON.stringify({
schemaVersion: 1,
icons: { file: 'icons/file.svg', 'sidebar.search': 'icons/search.svg' },
fileNames: { 'README.md': 'icons/readme.svg' },
fileExtensions: { TSX: 'icons/react.svg' }
})
)
).toEqual({
icons: { file: 'icons/file.svg', 'sidebar.search': 'icons/search.svg' },
fileNames: { 'readme.md': 'icons/readme.svg' },
fileExtensions: { tsx: 'icons/react.svg' }
})
})
it('rejects unknown slots, unsafe paths, and case-insensitive collisions', () => {
expect(() =>
parsePluginIconThemeArtifact(
JSON.stringify({ schemaVersion: 1, icons: { arbitrary: 'icon.svg' } })
)
).toThrow()
expect(() =>
parsePluginIconThemeArtifact(
JSON.stringify({ schemaVersion: 1, fileNames: { README: '../outside.svg' } })
)
).toThrow()
expect(() =>
parsePluginIconThemeArtifact(
JSON.stringify({
schemaVersion: 1,
fileExtensions: { TS: 'one.svg', ts: 'two.svg' }
})
)
).toThrow(/duplicate case-insensitive/)
})
})
describe('plugin SVG icon sanitizer', () => {
it('keeps a passive SVG subset and strips comments', () => {
expect(
sanitizePluginIconSvg(
'<!-- source --><svg viewBox="0 0 16 16"><path fill="currentColor" d="M0 0h16v16z"/></svg>'
)
).toBe('<svg viewBox="0 0 16 16"><path fill="currentColor" d="M0 0h16v16z"/></svg>')
})
it.each([
'<svg><script>alert(1)</script></svg>',
'<svg><foreignObject><iframe src="https://example.com"/></foreignObject></svg>',
'<svg onload="alert(1)"><path/></svg>',
'<svg><image href="https://example.com/pixel"/></svg>',
'<svg><path style="fill:url(https://example.com/x)"/></svg>',
'<svg><path style="fill:red"/></svg>',
'<svg><path fill="&#x75;rl(https://example.com/x)"/></svg>',
'<svg><path fill="u\\72l(https://example.com/x)"/></svg>',
'<svg/onload="alert(1)">',
'<svg><path/></svg><svg/>',
'<!DOCTYPE svg><svg><path/></svg>'
])('rejects active or externally-referencing SVG: %s', (svg) => {
expect(() => sanitizePluginIconSvg(svg)).toThrow()
})
})
@@ -0,0 +1,162 @@
import { z } from 'zod'
import { pluginRelativePathSchema } from './plugin-manifest-fields'
export const PLUGIN_ICON_THEME_SLOTS = [
'file',
'folder',
'folder-open',
'sidebar.workspaces',
'sidebar.source-control',
'sidebar.search',
'sidebar.plugins',
'agent.default',
'agent.codex',
'agent.claude'
] as const
const iconPathMapSchema = z.record(z.string(), pluginRelativePathSchema)
const pluginIconThemeArtifactSchema = z
.object({
schemaVersion: z.literal(1),
icons: z.partialRecord(z.enum(PLUGIN_ICON_THEME_SLOTS), pluginRelativePathSchema).default({}),
fileNames: iconPathMapSchema.default({}),
fileExtensions: iconPathMapSchema.default({})
})
.strict()
.superRefine((artifact, ctx) => {
if (
Object.keys(artifact.icons).length +
Object.keys(artifact.fileNames).length +
Object.keys(artifact.fileExtensions).length >
512
) {
ctx.addIssue({ code: 'custom', message: 'icon theme exceeds 512 mappings' })
}
for (const name of Object.keys(artifact.fileNames)) {
if (
name.length === 0 ||
name.length > 128 ||
/[\\/]/.test(name) ||
[...name].some((character) => character.charCodeAt(0) <= 31)
) {
ctx.addIssue({ code: 'custom', path: ['fileNames', name], message: 'unsafe file name' })
}
}
for (const extension of Object.keys(artifact.fileExtensions)) {
if (!/^[A-Za-z0-9][A-Za-z0-9+._-]{0,31}$/.test(extension)) {
ctx.addIssue({
code: 'custom',
path: ['fileExtensions', extension],
message: 'unsafe file extension'
})
}
}
})
export type PluginIconThemeArtifact = {
icons: Partial<Record<(typeof PLUGIN_ICON_THEME_SLOTS)[number], string>>
fileNames: Record<string, string>
fileExtensions: Record<string, string>
}
export type PluginIconThemeMetadata = {
id: `plugin:${string}`
pluginKey: string
label: string
}
export type PluginIconThemeImage = {
dataUrl: string
rendering: 'image' | 'mask'
}
export type PluginIconThemeRegistration = PluginIconThemeMetadata & {
icons: Partial<Record<(typeof PLUGIN_ICON_THEME_SLOTS)[number], PluginIconThemeImage>>
fileNames: Record<string, PluginIconThemeImage>
fileExtensions: Record<string, PluginIconThemeImage>
}
export function parsePluginIconThemeArtifact(raw: string): PluginIconThemeArtifact {
const parsed = pluginIconThemeArtifactSchema.parse(JSON.parse(raw))
const canonicalNames: Record<string, string> = {}
const canonicalExtensions: Record<string, string> = {}
for (const [name, path] of Object.entries(parsed.fileNames)) {
const canonical = name.toLowerCase()
if (canonicalNames[canonical]) {
throw new Error(`duplicate case-insensitive file name mapping: ${name}`)
}
canonicalNames[canonical] = path
}
for (const [extension, path] of Object.entries(parsed.fileExtensions)) {
const canonical = extension.toLowerCase()
if (canonicalExtensions[canonical]) {
throw new Error(`duplicate case-insensitive file extension mapping: ${extension}`)
}
canonicalExtensions[canonical] = path
}
return { icons: parsed.icons, fileNames: canonicalNames, fileExtensions: canonicalExtensions }
}
const ALLOWED_SVG_ELEMENTS = new Set([
'svg',
'g',
'path',
'circle',
'ellipse',
'rect',
'line',
'polyline',
'polygon',
'title',
'desc',
'defs',
'clippath',
'mask',
'lineargradient',
'radialgradient',
'stop'
])
export function sanitizePluginIconSvg(raw: string): string {
const withoutComments = raw.replace(/<!--[\s\S]*?-->/g, '')
if (withoutComments.length === 0 || withoutComments.length > 64 * 1024) {
throw new Error('SVG icon is empty or exceeds 65536 bytes')
}
if (
/<\?|<!/i.test(withoutComments) ||
/[\s/]on[a-z][a-z0-9:-]*\s*=/i.test(withoutComments) ||
/[\s/](?:href|xlink:href)\s*=/i.test(withoutComments) ||
/[\s/]style\s*=/i.test(withoutComments) ||
/url\s*\(/i.test(withoutComments) ||
/[&\\]/.test(withoutComments) ||
[...withoutComments].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 && code !== 9 && code !== 10 && code !== 13
})
) {
throw new Error('SVG icon contains active or external content')
}
const trimmed = withoutComments.trim()
if (
!/^<svg(?:\s[^<>]*)?\/>$/is.test(trimmed) &&
!/^<svg(?:\s[^<>]*)?>[\s\S]*<\/svg>$/i.test(trimmed)
) {
throw new Error('icon must contain exactly one SVG root')
}
const tags = [...trimmed.matchAll(/<\/?\s*([A-Za-z][A-Za-z0-9:-]*)\b/g)]
if (
tags.length === 0 ||
tags[0]?.[1]?.toLowerCase() !== 'svg' ||
[...trimmed.matchAll(/<\s*svg\b/gi)].length !== 1
) {
throw new Error('icon must have an SVG root element')
}
for (const tag of tags) {
const name = tag[1]!.toLowerCase()
if (!ALLOWED_SVG_ELEMENTS.has(name)) {
throw new Error(`SVG element is not allowed: ${name}`)
}
}
return trimmed
}
@@ -11,6 +11,7 @@ type ContributionValidationManifest = {
events: { on: string }[]
themes: IdentifiedContribution[]
iconThemes: IdentifiedContribution[]
terminalThemes: IdentifiedContribution[]
languagePacks: { locale: string }[]
skills: PathContribution[]
keybindings: { command: string; key: string }[]
@@ -45,7 +46,7 @@ export function validatePluginManifestContributions(
manifest: ContributionValidationManifest,
ctx: RefinementCtx
): void {
for (const path of ['panels', 'commands', 'themes', 'iconThemes'] as const) {
for (const path of ['panels', 'commands', 'themes', 'iconThemes', 'terminalThemes'] as const) {
rejectDuplicateValues(
manifest.contributes[path],
(entry) => (entry as IdentifiedContribution).id,
+7
View File
@@ -7,6 +7,7 @@ import {
PLUGIN_LANGUAGE_PACK_LIMIT,
PLUGIN_SKILL_LIMIT,
PLUGIN_THEME_LIMIT,
PLUGIN_TERMINAL_THEME_LIMIT,
PLUGIN_VM_RECIPE_LIMIT,
pluginAgentProfileContributionSchema,
pluginIconThemeContributionSchema,
@@ -14,6 +15,7 @@ import {
pluginLanguagePackContributionSchema,
pluginSkillContributionSchema,
pluginThemeContributionSchema,
pluginTerminalThemeContributionSchema,
pluginVmRecipeContributionSchema
} from './plugin-content-pack-contributions'
import {
@@ -112,6 +114,10 @@ export const pluginManifestSchema = z
.array(pluginIconThemeContributionSchema)
.max(PLUGIN_ICON_THEME_LIMIT)
.default([]),
terminalThemes: z
.array(pluginTerminalThemeContributionSchema)
.max(PLUGIN_TERMINAL_THEME_LIMIT)
.default([]),
languagePacks: z
.array(pluginLanguagePackContributionSchema)
.max(PLUGIN_LANGUAGE_PACK_LIMIT)
@@ -136,6 +142,7 @@ export const pluginManifestSchema = z
events: [],
themes: [],
iconThemes: [],
terminalThemes: [],
languagePacks: [],
skills: [],
keybindings: [],
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { parsePluginTerminalThemeArtifact } from './plugin-terminal-theme-artifact'
describe('plugin terminal theme artifacts', () => {
it('accepts a bounded terminal palette', () => {
expect(
parsePluginTerminalThemeArtifact(
JSON.stringify({
schemaVersion: 1,
mode: 'dark',
terminal: {
background: '#101010',
foreground: '#f0f0f0',
black: '#000000',
red: '#ff0000'
}
})
)
).toEqual({
mode: 'dark',
terminal: {
background: '#101010',
foreground: '#f0f0f0',
black: '#000000',
red: '#ff0000'
}
})
})
it('rejects invalid colors, unknown slots, and unusable palettes', () => {
expect(() =>
parsePluginTerminalThemeArtifact(
JSON.stringify({
schemaVersion: 1,
terminal: { background: '#000', foreground: '#fff', black: 'expression(alert(1))' }
})
)
).toThrow(/invalid color/)
expect(() =>
parsePluginTerminalThemeArtifact(
JSON.stringify({
schemaVersion: 1,
terminal: { background: '#000', foreground: '#fff', arbitrary: '#123456' }
})
)
).toThrow(/unknown terminal color slot/)
expect(() =>
parsePluginTerminalThemeArtifact(
JSON.stringify({ schemaVersion: 1, terminal: { background: '#000', foreground: '#fff' } })
)
).toThrow(/ANSI color/)
})
})
@@ -0,0 +1,44 @@
import { z } from 'zod'
import {
TERMINAL_COLOR_KEYS,
hasUsableTerminalThemeColors,
normalizeTerminalColorOverrides
} from '../terminal-custom-themes'
import type { TerminalColorOverrides } from '../types'
const pluginTerminalThemeArtifactSchema = z
.object({
schemaVersion: z.literal(1),
mode: z.enum(['dark', 'light', 'unknown']).default('unknown'),
terminal: z.record(z.string(), z.string()).superRefine((colors, ctx) => {
for (const key of Object.keys(colors)) {
if (!(TERMINAL_COLOR_KEYS as readonly string[]).includes(key)) {
ctx.addIssue({ code: 'custom', path: [key], message: 'unknown terminal color slot' })
}
}
})
})
.strict()
export type PluginTerminalThemeRegistration = {
id: `plugin:${string}`
pluginKey: string
label: string
mode: 'dark' | 'light' | 'unknown'
terminal: TerminalColorOverrides
}
export function parsePluginTerminalThemeArtifact(raw: string): {
mode: PluginTerminalThemeRegistration['mode']
terminal: TerminalColorOverrides
} {
const parsed = pluginTerminalThemeArtifactSchema.parse(JSON.parse(raw))
const terminal = normalizeTerminalColorOverrides(parsed.terminal)
if (Object.keys(terminal).length !== Object.keys(parsed.terminal).length) {
throw new Error('terminal theme contains an invalid color')
}
if (!hasUsableTerminalThemeColors(terminal)) {
throw new Error('terminal theme requires background, foreground, and at least one ANSI color')
}
return { mode: parsed.mode, terminal }
}