mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
feat(plugins): add icon and terminal themes
This commit is contained in:
@@ -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="url(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,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 }
|
||||
}
|
||||
Reference in New Issue
Block a user