@adea-ai/plugins 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +74 -0
- package/dist/agent-plugins-catalog.d.ts +26 -0
- package/dist/agent-plugins-catalog.js +699 -0
- package/dist/agent-plugins-catalog.js.map +1 -0
- package/dist/agent-plugins-harness.d.ts +54 -0
- package/dist/agent-plugins-harness.js +212 -0
- package/dist/agent-plugins-harness.js.map +1 -0
- package/dist/agent-plugins-schema.d.ts +402 -0
- package/dist/agent-plugins-schema.js +200 -0
- package/dist/agent-plugins-schema.js.map +1 -0
- package/dist/consumer-index.d.ts +217 -0
- package/dist/consumer-index.js +358 -0
- package/dist/consumer-index.js.map +1 -0
- package/dist/display-name.d.ts +8 -0
- package/dist/display-name.js +160 -0
- package/dist/display-name.js.map +1 -0
- package/dist/harness.d.ts +13 -0
- package/dist/harness.js +169 -0
- package/dist/harness.js.map +1 -0
- package/dist/icon-mirror.d.ts +69 -0
- package/dist/icon-mirror.js +147 -0
- package/dist/icon-mirror.js.map +1 -0
- package/dist/icons.d.ts +65 -0
- package/dist/icons.js +268 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.js +1877 -0
- package/dist/index.js.map +1 -0
- package/dist/portable-catalog.d.ts +9 -0
- package/dist/portable-catalog.js +175 -0
- package/dist/portable-catalog.js.map +1 -0
- package/dist/publication.d.ts +98 -0
- package/dist/publication.js +121 -0
- package/dist/publication.js.map +1 -0
- package/dist/schema.d.ts +705 -0
- package/dist/schema.js +267 -0
- package/dist/schema.js.map +1 -0
- package/dist/site-icons.d.ts +55 -0
- package/dist/site-icons.js +155 -0
- package/dist/site-icons.js.map +1 -0
- package/dist/sources.d.ts +54 -0
- package/dist/sources.js +306 -0
- package/dist/sources.js.map +1 -0
- package/package.json +102 -0
- package/schemas/agent-package.v1.json +387 -0
- package/schemas/catalog.v1.schema.json +407 -0
- package/schemas/compatibility.v1.schema.json +23 -0
- package/schemas/harness-profile.v1.json +75 -0
- package/schemas/installation-plan.v2.json +695 -0
- package/schemas/sources-lock.v1.schema.json +61 -0
- package/src/agent-plugins-catalog.test.ts +402 -0
- package/src/agent-plugins-catalog.ts +800 -0
- package/src/agent-plugins-harness.test.ts +272 -0
- package/src/agent-plugins-harness.ts +281 -0
- package/src/agent-plugins-schema.test.ts +27 -0
- package/src/agent-plugins-schema.ts +219 -0
- package/src/artifacts.test.ts +545 -0
- package/src/catalog-installation.test.ts +42 -0
- package/src/consumer-index.test.ts +465 -0
- package/src/consumer-index.ts +592 -0
- package/src/display-name.test.ts +56 -0
- package/src/display-name.ts +157 -0
- package/src/harness.ts +202 -0
- package/src/icon-mirror.ts +196 -0
- package/src/icons.test.ts +185 -0
- package/src/icons.ts +297 -0
- package/src/index.test.ts +888 -0
- package/src/index.ts +2538 -0
- package/src/portable-catalog.test.ts +186 -0
- package/src/portable-catalog.ts +203 -0
- package/src/publication.test.ts +117 -0
- package/src/publication.ts +171 -0
- package/src/schema.ts +309 -0
- package/src/site-icon-fallback.test.ts +267 -0
- package/src/site-icons.test.ts +171 -0
- package/src/site-icons.ts +161 -0
- package/src/sources.ts +360 -0
- package/src/upstream-headers.test.ts +148 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
iconAssetName,
|
|
4
|
+
iconOverrideFor,
|
|
5
|
+
inspectIconBytes,
|
|
6
|
+
MAX_ICON_BYTES,
|
|
7
|
+
parseProductIconOverrides,
|
|
8
|
+
selectIconPath,
|
|
9
|
+
sniffContentType,
|
|
10
|
+
} from './icons.js'
|
|
11
|
+
|
|
12
|
+
const PNG = new Uint8Array([
|
|
13
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
14
|
+
])
|
|
15
|
+
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46])
|
|
16
|
+
const GIF = new Uint8Array([...'GIF89a'].map((character) => character.charCodeAt(0)))
|
|
17
|
+
const WEBP = new Uint8Array([
|
|
18
|
+
...'RIFF'.split('').map((c) => c.charCodeAt(0)),
|
|
19
|
+
0,
|
|
20
|
+
0,
|
|
21
|
+
0,
|
|
22
|
+
0,
|
|
23
|
+
...'WEBP'.split('').map((c) => c.charCodeAt(0)),
|
|
24
|
+
])
|
|
25
|
+
const encode = (text: string): Uint8Array => new TextEncoder().encode(text)
|
|
26
|
+
const CLEAN_SVG =
|
|
27
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8"/></svg>'
|
|
28
|
+
|
|
29
|
+
describe('icon file selection', () => {
|
|
30
|
+
test('prefers the conventional mark in a conventional location', () => {
|
|
31
|
+
expect(selectIconPath(['assets/logo.png', 'assets/icon.svg'])).toBe('assets/icon.svg')
|
|
32
|
+
expect(selectIconPath(['assets/logo.png', 'docs/media/logo.png'])).toBe('assets/logo.png')
|
|
33
|
+
expect(selectIconPath(['images/logo.png', 'assets/logo.png'])).toBe('assets/logo.png')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('prefers a scalable variant and the larger size of the same stem', () => {
|
|
37
|
+
expect(selectIconPath(['assets/icon.png', 'assets/icon.svg'])).toBe('assets/icon.svg')
|
|
38
|
+
expect(selectIconPath(['assets/icon-small.png', 'assets/icon-large.png'])).toBe(
|
|
39
|
+
'assets/icon-large.png'
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('accepts a vendor-named mark inside an asset directory', () => {
|
|
44
|
+
expect(selectIconPath(['codex-plugin/assets/atlan-logo.png'])).toBe(
|
|
45
|
+
'codex-plugin/assets/atlan-logo.png'
|
|
46
|
+
)
|
|
47
|
+
expect(selectIconPath(['assets/databricks.svg'])).toBe('assets/databricks.svg')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('never mistakes documentation or repository infrastructure for a mark', () => {
|
|
51
|
+
expect(selectIconPath(['assets/screenshot-1.png', 'docs/preview.png'])).toBeNull()
|
|
52
|
+
expect(selectIconPath(['.github/img/cpu.svg'])).toBeNull()
|
|
53
|
+
expect(selectIconPath(['docs/architecture.svg'])).toBeNull()
|
|
54
|
+
expect(selectIconPath(['assets/banner.png'])).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('ignores unsupported files and escapes, and is order independent', () => {
|
|
58
|
+
expect(selectIconPath(['assets/logo.txt', 'assets/logo.svg'])).toBe('assets/logo.svg')
|
|
59
|
+
expect(selectIconPath(['../assets/logo.svg'])).toBeNull()
|
|
60
|
+
expect(selectIconPath([])).toBeNull()
|
|
61
|
+
// An unnamed image at the plugin root is as likely a screenshot as a mark.
|
|
62
|
+
expect(selectIconPath(['screenshot.png', 'readme.md'])).toBeNull()
|
|
63
|
+
expect(selectIconPath(['assets/logo.png', 'assets/logo.svg'])).toBe(
|
|
64
|
+
selectIconPath(['assets/logo.svg', 'assets/logo.png'])
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('keeps a bare mark at the plugin root when it is named like a mark', () => {
|
|
69
|
+
expect(selectIconPath(['logo.svg'])).toBe('logo.svg')
|
|
70
|
+
expect(selectIconPath(['icon.png', 'readme.png'])).toBe('icon.png')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('icon byte inspection', () => {
|
|
75
|
+
test('sniffs supported raster types from magic bytes', () => {
|
|
76
|
+
expect(sniffContentType(PNG)).toBe('image/png')
|
|
77
|
+
expect(sniffContentType(JPEG)).toBe('image/jpeg')
|
|
78
|
+
expect(sniffContentType(GIF)).toBe('image/gif')
|
|
79
|
+
expect(sniffContentType(WEBP)).toBe('image/webp')
|
|
80
|
+
expect(sniffContentType(encode(`<?xml version="1.0"?>${CLEAN_SVG}`))).toBe('image/svg+xml')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('accepts a clean mark and reports its content type', () => {
|
|
84
|
+
expect(inspectIconBytes(PNG)).toEqual({ contentType: 'image/png' })
|
|
85
|
+
expect(inspectIconBytes(encode(CLEAN_SVG))).toEqual({ contentType: 'image/svg+xml' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('accepts an exporter prologue and leading comments before the root element', () => {
|
|
89
|
+
const illustrator = `<?xml version="1.0" encoding="utf-8"?>
|
|
90
|
+
<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 -->
|
|
91
|
+
<!-- Second comment -->
|
|
92
|
+
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>`
|
|
93
|
+
expect(sniffContentType(encode(illustrator))).toBe('image/svg+xml')
|
|
94
|
+
expect(inspectIconBytes(encode(illustrator))).toEqual({ contentType: 'image/svg+xml' })
|
|
95
|
+
// A document whose root element is not `<svg>` is still not an icon.
|
|
96
|
+
const html = `<!DOCTYPE html><html><body><svg></svg></body></html>`
|
|
97
|
+
expect(sniffContentType(encode(html))).toBeNull()
|
|
98
|
+
const declared = `<svg-not xmlns="http://www.w3.org/2000/svg"></svg-not>`
|
|
99
|
+
expect(sniffContentType(encode(declared))).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('rejects empty, oversized and unsupported payloads', () => {
|
|
103
|
+
expect(inspectIconBytes(new Uint8Array())).toEqual({ rejected: 'EMPTY_FILE' })
|
|
104
|
+
expect(inspectIconBytes(new Uint8Array(MAX_ICON_BYTES + 1))).toEqual({
|
|
105
|
+
rejected: 'SIZE_LIMIT_EXCEEDED',
|
|
106
|
+
})
|
|
107
|
+
expect(inspectIconBytes(encode('#!/bin/sh\nrm -rf /'))).toEqual({
|
|
108
|
+
rejected: 'CONTENT_TYPE_UNSUPPORTED',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('rejects SVG that could execute or reach the network', () => {
|
|
113
|
+
const cases = [
|
|
114
|
+
`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`,
|
|
115
|
+
`<svg xmlns="http://www.w3.org/2000/svg"><foreignObject/></svg>`,
|
|
116
|
+
`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>`,
|
|
117
|
+
`<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/x.png"/></svg>`,
|
|
118
|
+
`<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="//evil.example">x</a></svg>`,
|
|
119
|
+
`<svg xmlns="http://www.w3.org/2000/svg"><use href="//evil.example#x"/></svg>`,
|
|
120
|
+
`<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><svg>&xxe;</svg>`,
|
|
121
|
+
]
|
|
122
|
+
for (const source of cases)
|
|
123
|
+
expect(inspectIconBytes(encode(source)), source.slice(0, 40)).toEqual({
|
|
124
|
+
rejected: expect.stringMatching(/^SVG_(ACTIVE_CONTENT|EXTERNAL_REFERENCE)$/) as never,
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('icon asset names', () => {
|
|
130
|
+
test('is content addressed and keeps a usable extension', () => {
|
|
131
|
+
const name = iconAssetName(`sha256:${'a'.repeat(64)}`, 'image/png')
|
|
132
|
+
expect(name).toBe(`icon-${'a'.repeat(32)}.png`)
|
|
133
|
+
expect(iconAssetName(`sha256:${'b'.repeat(64)}`, 'image/svg+xml')).toBe(
|
|
134
|
+
`icon-${'b'.repeat(32)}.svg`
|
|
135
|
+
)
|
|
136
|
+
// Identical bytes always resolve to the same asset, so caches stay warm.
|
|
137
|
+
expect(iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')).toBe(
|
|
138
|
+
iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('refuses a content type it cannot name', () => {
|
|
143
|
+
expect(() => iconAssetName(`sha256:${'d'.repeat(64)}`, 'text/html' as never)).toThrow(
|
|
144
|
+
'ICON_CONTENT_TYPE_UNSUPPORTED'
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('brand mark overrides', () => {
|
|
150
|
+
test('accepts a forced path, an explicit opt-out and a vendor site', () => {
|
|
151
|
+
const parsed = parseProductIconOverrides({
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
overrides: {
|
|
154
|
+
github: 'assets/logo.svg',
|
|
155
|
+
legacy: null,
|
|
156
|
+
servicenow: 'https://www.servicenow.com/',
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
expect(iconOverrideFor(parsed, 'github')).toEqual({ forced: 'assets/logo.svg' })
|
|
160
|
+
expect(iconOverrideFor(parsed, 'legacy')).toEqual({ forced: null })
|
|
161
|
+
expect(iconOverrideFor(parsed, 'servicenow')).toEqual({ site: 'https://www.servicenow.com/' })
|
|
162
|
+
expect(iconOverrideFor(parsed, 'unlisted')).toBeUndefined()
|
|
163
|
+
expect(iconOverrideFor(undefined, 'github')).toBeUndefined()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('rejects malformed configuration', () => {
|
|
167
|
+
expect(() => parseProductIconOverrides(null)).toThrow('PRODUCT_ICONS_INVALID')
|
|
168
|
+
expect(() => parseProductIconOverrides({ schemaVersion: 2, overrides: {} })).toThrow(
|
|
169
|
+
'PRODUCT_ICONS_INVALID'
|
|
170
|
+
)
|
|
171
|
+
expect(() => parseProductIconOverrides({ schemaVersion: 1 })).toThrow('PRODUCT_ICONS_INVALID')
|
|
172
|
+
expect(() =>
|
|
173
|
+
parseProductIconOverrides({ schemaVersion: 1, overrides: { Github: null } })
|
|
174
|
+
).toThrow('PRODUCT_ICONS_KEY_INVALID')
|
|
175
|
+
expect(() =>
|
|
176
|
+
parseProductIconOverrides({ schemaVersion: 1, overrides: { github: '/etc/passwd' } })
|
|
177
|
+
).toThrow('PRODUCT_ICONS_PATH_INVALID')
|
|
178
|
+
expect(() =>
|
|
179
|
+
parseProductIconOverrides({ schemaVersion: 1, overrides: { github: '../../secret.png' } })
|
|
180
|
+
).toThrow('PRODUCT_ICONS_PATH_INVALID')
|
|
181
|
+
expect(() =>
|
|
182
|
+
parseProductIconOverrides({ schemaVersion: 1, overrides: { github: 'https://localhost/' } })
|
|
183
|
+
).toThrow('PRODUCT_ICONS_SITE_UNSUPPORTED')
|
|
184
|
+
})
|
|
185
|
+
})
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time icon resolution.
|
|
3
|
+
*
|
|
4
|
+
* Marketplaces do not declare an icon field, so a plugin's mark is a file in
|
|
5
|
+
* its own content (`assets/logo.svg`, `assets/icon-large.png`, ...). Resolving
|
|
6
|
+
* one at compile time removes a runtime guess from consumers, and mirroring the
|
|
7
|
+
* bytes at publish time removes the upstream fetch entirely.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Why a candidate file was rejected as an icon. */
|
|
11
|
+
export type IconRejection =
|
|
12
|
+
| 'CONTENT_TYPE_UNSUPPORTED'
|
|
13
|
+
| 'EMPTY_FILE'
|
|
14
|
+
| 'SIZE_LIMIT_EXCEEDED'
|
|
15
|
+
| 'SVG_ACTIVE_CONTENT'
|
|
16
|
+
| 'SVG_EXTERNAL_REFERENCE'
|
|
17
|
+
|
|
18
|
+
/** Content types the catalog will publish as a brand mark. */
|
|
19
|
+
export type IconContentType =
|
|
20
|
+
| 'image/png'
|
|
21
|
+
| 'image/jpeg'
|
|
22
|
+
| 'image/gif'
|
|
23
|
+
| 'image/webp'
|
|
24
|
+
| 'image/svg+xml'
|
|
25
|
+
| 'image/x-icon'
|
|
26
|
+
|
|
27
|
+
/** Icon file size ceiling; a brand mark is never a large asset. */
|
|
28
|
+
export const MAX_ICON_BYTES = 1024 * 1024
|
|
29
|
+
|
|
30
|
+
const SUPPORTED_EXTENSIONS = ['svg', 'png', 'webp', 'jpg', 'jpeg', 'gif', 'ico'] as const
|
|
31
|
+
|
|
32
|
+
/** Ranked basenames; earlier entries win. Variants sort by the rank of the stem. */
|
|
33
|
+
const STEM_RANK: Readonly<Record<string, number>> = {
|
|
34
|
+
icon: 0,
|
|
35
|
+
appicon: 0,
|
|
36
|
+
'app-icon': 0,
|
|
37
|
+
avatar: 2,
|
|
38
|
+
logo: 3,
|
|
39
|
+
mark: 4,
|
|
40
|
+
brand: 4,
|
|
41
|
+
}
|
|
42
|
+
const EXTENSION_RANK: Readonly<Record<string, number>> = {
|
|
43
|
+
svg: 0,
|
|
44
|
+
png: 1,
|
|
45
|
+
webp: 2,
|
|
46
|
+
ico: 3,
|
|
47
|
+
jpg: 4,
|
|
48
|
+
jpeg: 5,
|
|
49
|
+
gif: 6,
|
|
50
|
+
}
|
|
51
|
+
/** Preferred containers, best first. Files outside these are considered last. */
|
|
52
|
+
const DIRECTORY_RANK: Readonly<Record<string, number>> = {
|
|
53
|
+
assets: 0,
|
|
54
|
+
'.assets': 1,
|
|
55
|
+
img: 2,
|
|
56
|
+
images: 3,
|
|
57
|
+
media: 4,
|
|
58
|
+
static: 5,
|
|
59
|
+
}
|
|
60
|
+
/** Files that are documentation, screenshots or brand collateral, not marks. */
|
|
61
|
+
const EXCLUDED =
|
|
62
|
+
/(screenshot|screen-shot|preview|example|sample|demo|mockup|diagram|architecture|flowchart|flow-|banner|hero|cover|thumbnail|chart|graph|illustration|photo)/
|
|
63
|
+
/** Repository infrastructure: CI images and templates, never a product mark. */
|
|
64
|
+
const EXCLUDED_DIRECTORIES = ['.github', '.gitlab']
|
|
65
|
+
|
|
66
|
+
const MARK_WORDS = new Set([
|
|
67
|
+
'icon',
|
|
68
|
+
'appicon',
|
|
69
|
+
'avatar',
|
|
70
|
+
'logo',
|
|
71
|
+
'mark',
|
|
72
|
+
'brand',
|
|
73
|
+
'logomark',
|
|
74
|
+
'wordmark',
|
|
75
|
+
])
|
|
76
|
+
const VARIANT_WORDS = new Set([
|
|
77
|
+
'large',
|
|
78
|
+
'small',
|
|
79
|
+
'light',
|
|
80
|
+
'dark',
|
|
81
|
+
'mono',
|
|
82
|
+
'white',
|
|
83
|
+
'black',
|
|
84
|
+
'positive',
|
|
85
|
+
'negative',
|
|
86
|
+
'color',
|
|
87
|
+
'colour',
|
|
88
|
+
'primary',
|
|
89
|
+
'secondary',
|
|
90
|
+
'horizontal',
|
|
91
|
+
'vertical',
|
|
92
|
+
'square',
|
|
93
|
+
'flat',
|
|
94
|
+
'glyph',
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Splits a file name into mark tokens. Separators are normalised because
|
|
99
|
+
* vendors write marks as `logo.svg`, `atlan-logo.png`, `Dynatrace_Logo_color_positive.png`
|
|
100
|
+
* or `cs logo big.png` alike.
|
|
101
|
+
*/
|
|
102
|
+
function splitName(basename: string): { stems: string[]; marks: boolean; size: number } {
|
|
103
|
+
const withoutExtension = basename.slice(0, basename.lastIndexOf('.'))
|
|
104
|
+
const words = withoutExtension
|
|
105
|
+
.split(/[\s._-]+/)
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((word) => word.toLowerCase())
|
|
108
|
+
const size = words.some((word) => word === 'small') ? 1 : 0
|
|
109
|
+
const marks = words.some((word) => MARK_WORDS.has(word))
|
|
110
|
+
const stems = words.filter((word) => !MARK_WORDS.has(word) && !VARIANT_WORDS.has(word))
|
|
111
|
+
return { stems, marks, size }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Chooses one icon file from a release's file index, deterministically.
|
|
116
|
+
*
|
|
117
|
+
* Preference: `assets/icon.svg` before `assets/icon.png` before `assets/logo.*`,
|
|
118
|
+
* an `icon`/`logo` stem before `avatar`/`mark`/`brand`, an `assets` directory
|
|
119
|
+
* before other asset directories, SVG before raster for the same stem, and a
|
|
120
|
+
* `-large` variant before `-small`. Anything that looks like documentation or a
|
|
121
|
+
* screenshot is never selected. Ties break on path so the result is stable.
|
|
122
|
+
*/
|
|
123
|
+
export function selectIconPath(fileIndex: readonly string[]): string | null {
|
|
124
|
+
let best: { path: string; score: readonly number[] } | null = null
|
|
125
|
+
for (const raw of fileIndex) {
|
|
126
|
+
const path = raw.replace(/^\.?\//, '')
|
|
127
|
+
if (path.includes('..')) continue
|
|
128
|
+
const segments = path.split('/')
|
|
129
|
+
const basename = segments.pop() ?? ''
|
|
130
|
+
const extension = basename.slice(basename.lastIndexOf('.') + 1).toLowerCase()
|
|
131
|
+
if (!(SUPPORTED_EXTENSIONS as readonly string[]).includes(extension)) continue
|
|
132
|
+
if (EXCLUDED.test(basename)) continue
|
|
133
|
+
const directories = segments.map((segment) => segment.toLowerCase())
|
|
134
|
+
if (directories.some((segment) => EXCLUDED.test(segment))) continue
|
|
135
|
+
if (directories.some((segment) => EXCLUDED_DIRECTORIES.includes(segment))) continue
|
|
136
|
+
const { stems, marks, size } = splitName(basename)
|
|
137
|
+
const single = stems.length === 1 ? stems[0]! : undefined
|
|
138
|
+
const stemRank = marks ? (single ? (STEM_RANK[single] ?? 0) : 0) : undefined
|
|
139
|
+
const nested = directories.filter((segment) => DIRECTORY_RANK[segment] !== undefined)
|
|
140
|
+
// A mark named as one — `logo.svg`, `atlan-logo.png`, `cs_logo_large.png` —
|
|
141
|
+
// is accepted anywhere, including the plugin root. An unnamed image must sit
|
|
142
|
+
// in a known asset directory, because a bare `banner.png` at the root is as
|
|
143
|
+
// likely to be documentation as a brand mark.
|
|
144
|
+
const directoryRank =
|
|
145
|
+
nested.length > 0
|
|
146
|
+
? (DIRECTORY_RANK[nested[nested.length - 1]!] ?? 0)
|
|
147
|
+
: stemRank === undefined
|
|
148
|
+
? Number.POSITIVE_INFINITY
|
|
149
|
+
: 0
|
|
150
|
+
if (!Number.isFinite(directoryRank)) continue
|
|
151
|
+
const score = [stemRank ?? 5, directoryRank, size, EXTENSION_RANK[extension] ?? 9, path.length]
|
|
152
|
+
if (!best || compareScore(score, best.score) < 0) best = { path, score }
|
|
153
|
+
}
|
|
154
|
+
return best ? best.path : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compareScore(left: readonly number[], right: readonly number[]): number {
|
|
158
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
159
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0)
|
|
160
|
+
if (difference !== 0) return difference
|
|
161
|
+
}
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const SVG_ACTIVE_CONTENT =
|
|
166
|
+
/<\s*script|<\s*foreignObject|<\s*iframe|<\s*use[^>]+href\s*=\s*["']?\s*(?:https?:)?\/\/|javascript:|<!ENTITY|<!DOCTYPE[^>]+\[|on(?:load|error|click|mouseover|focus|begin)\s*=/i
|
|
167
|
+
const SVG_EXTERNAL_REFERENCE = /(?:href|xlink:href|src|data|poster)\s*=\s*["']?\s*(?:https?:)?\/\//i
|
|
168
|
+
|
|
169
|
+
/** Sniffs a supported image type from magic bytes; SVG is detected as text. */
|
|
170
|
+
export function sniffContentType(bytes: Uint8Array): IconContentType | null {
|
|
171
|
+
const ascii = (offset: number, length: number): string =>
|
|
172
|
+
String.fromCharCode(...bytes.subarray(offset, offset + length))
|
|
173
|
+
if (bytes.length >= 8 && ascii(0, 8) === '\x89PNG\r\n\x1a\n') return 'image/png'
|
|
174
|
+
if (bytes.length >= 3 && ascii(0, 3) === '\xff\xd8\xff') return 'image/jpeg'
|
|
175
|
+
if (bytes.length >= 6 && ascii(0, 4) === 'GIF8') return 'image/gif'
|
|
176
|
+
if (bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return 'image/webp'
|
|
177
|
+
// Icon container: reserved(2), type 1 (icon) or 2 (cursor), count >= 1.
|
|
178
|
+
if (
|
|
179
|
+
bytes.length >= 6 &&
|
|
180
|
+
bytes[0] === 0 &&
|
|
181
|
+
bytes[1] === 0 &&
|
|
182
|
+
(bytes[2] === 1 || bytes[2] === 2) &&
|
|
183
|
+
bytes[3] === 0 &&
|
|
184
|
+
((bytes[4] ?? 0) | ((bytes[5] ?? 0) << 8)) > 0
|
|
185
|
+
)
|
|
186
|
+
return 'image/x-icon'
|
|
187
|
+
if (bytes.length > 0) {
|
|
188
|
+
// An XML prologue, a doctype and leading comments may all precede the root
|
|
189
|
+
// element: exporters such as Adobe Illustrator and Figma emit them. What
|
|
190
|
+
// matters is that the document's root element is `<svg>`, not `<html>`.
|
|
191
|
+
const head = new TextDecoder()
|
|
192
|
+
.decode(bytes.subarray(0, Math.min(bytes.length, 4096)))
|
|
193
|
+
.trimStart()
|
|
194
|
+
.replace(/^<\?xml[\s\S]*?\?>/i, '')
|
|
195
|
+
.replace(/^<!DOCTYPE[^>]*?(?:\[[\s\S]*?\])?>/i, '')
|
|
196
|
+
.replace(/^(?:\s*<!--[\s\S]*?-->)+/, '')
|
|
197
|
+
.trimStart()
|
|
198
|
+
if (/^<svg[\s>]/i.test(head)) return 'image/svg+xml'
|
|
199
|
+
}
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Verifies icon bytes before they are published.
|
|
205
|
+
*
|
|
206
|
+
* SVG is text that a browser can execute when it is inlined, so active content
|
|
207
|
+
* and external references are rejected outright. Consumers must still render a
|
|
208
|
+
* mirrored SVG through an `<img>` element and never inline it into the DOM.
|
|
209
|
+
*/
|
|
210
|
+
export function inspectIconBytes(
|
|
211
|
+
bytes: Uint8Array
|
|
212
|
+
): { contentType: IconContentType } | { rejected: IconRejection } {
|
|
213
|
+
if (bytes.length === 0) return { rejected: 'EMPTY_FILE' }
|
|
214
|
+
if (bytes.length > MAX_ICON_BYTES) return { rejected: 'SIZE_LIMIT_EXCEEDED' }
|
|
215
|
+
const contentType = sniffContentType(bytes)
|
|
216
|
+
if (!contentType) return { rejected: 'CONTENT_TYPE_UNSUPPORTED' }
|
|
217
|
+
if (contentType === 'image/svg+xml') {
|
|
218
|
+
const text = new TextDecoder().decode(bytes)
|
|
219
|
+
if (SVG_ACTIVE_CONTENT.test(text)) return { rejected: 'SVG_ACTIVE_CONTENT' }
|
|
220
|
+
if (SVG_EXTERNAL_REFERENCE.test(text)) return { rejected: 'SVG_EXTERNAL_REFERENCE' }
|
|
221
|
+
}
|
|
222
|
+
return { contentType }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<IconContentType, string>> = {
|
|
226
|
+
'image/png': 'png',
|
|
227
|
+
'image/jpeg': 'jpg',
|
|
228
|
+
'image/gif': 'gif',
|
|
229
|
+
'image/webp': 'webp',
|
|
230
|
+
'image/svg+xml': 'svg',
|
|
231
|
+
'image/x-icon': 'ico',
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Content-addressed asset name. Release assets cannot contain `/`, so the
|
|
236
|
+
* digest prefix and extension form one flat name that any release can host.
|
|
237
|
+
*/
|
|
238
|
+
export function iconAssetName(digest: string, contentType: IconContentType): string {
|
|
239
|
+
const extension = EXTENSION_BY_CONTENT_TYPE[contentType]
|
|
240
|
+
if (!extension) throw new Error(`ICON_CONTENT_TYPE_UNSUPPORTED: ${contentType}`)
|
|
241
|
+
const hex = digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest
|
|
242
|
+
return `icon-${hex.slice(0, 32)}.${extension}`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Curation override for a product's brand mark, keyed by product key.
|
|
247
|
+
*
|
|
248
|
+
* `null` publishes no mark; a path forces a file inside the plugin content; an
|
|
249
|
+
* `https://` value names the vendor site to take the mark from. The override
|
|
250
|
+
* exists because the selection rule is a heuristic over vendor filenames: many
|
|
251
|
+
* products ship no conventional mark and declare nothing but a repository, and
|
|
252
|
+
* a curator may know the real brand source.
|
|
253
|
+
*/
|
|
254
|
+
export interface ProductIconOverrides {
|
|
255
|
+
readonly schemaVersion: 1
|
|
256
|
+
readonly overrides: Readonly<Record<string, string | null>>
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function parseProductIconOverrides(input: unknown): ProductIconOverrides {
|
|
260
|
+
if (typeof input !== 'object' || input === null) throw new Error('PRODUCT_ICONS_INVALID')
|
|
261
|
+
const raw = input as Partial<ProductIconOverrides>
|
|
262
|
+
if (raw.schemaVersion !== 1) throw new Error('PRODUCT_ICONS_INVALID')
|
|
263
|
+
const overrides = raw.overrides
|
|
264
|
+
if (typeof overrides !== 'object' || overrides === null) throw new Error('PRODUCT_ICONS_INVALID')
|
|
265
|
+
for (const [productKey, value] of Object.entries(overrides)) {
|
|
266
|
+
if (!/^[a-z0-9][a-z0-9-]{1,127}$/.test(productKey))
|
|
267
|
+
throw new Error(`PRODUCT_ICONS_KEY_INVALID: ${productKey}`)
|
|
268
|
+
if (value === null) continue
|
|
269
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 1024)
|
|
270
|
+
throw new Error(`PRODUCT_ICONS_PATH_INVALID: ${productKey}`)
|
|
271
|
+
if (value.startsWith('https://')) {
|
|
272
|
+
// Only the shape is checked here; site-icons applies the full host policy
|
|
273
|
+
// when it resolves the mark, so a bad host simply yields no icon.
|
|
274
|
+
if (!/^https:\/\/[a-z0-9.-]+\.[a-z]{2,}/i.test(value))
|
|
275
|
+
throw new Error(`PRODUCT_ICONS_SITE_UNSUPPORTED: ${productKey}`)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (value.startsWith('/') || value.includes('..'))
|
|
279
|
+
throw new Error(`PRODUCT_ICONS_PATH_INVALID: ${productKey}`)
|
|
280
|
+
}
|
|
281
|
+
return { schemaVersion: 1, overrides: { ...overrides } }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Reads the override for a product: `forced` names a content path or `null` to
|
|
286
|
+
* publish no mark, and `site` names a vendor site to take the mark from.
|
|
287
|
+
*/
|
|
288
|
+
export function iconOverrideFor(
|
|
289
|
+
overrides: ProductIconOverrides | undefined,
|
|
290
|
+
productKey: string
|
|
291
|
+
): { readonly forced?: string | null; readonly site?: string } | undefined {
|
|
292
|
+
if (!overrides || !(productKey in overrides.overrides)) return undefined
|
|
293
|
+
const value = overrides.overrides[productKey]
|
|
294
|
+
if (value === null) return { forced: null }
|
|
295
|
+
if (typeof value !== 'string') return undefined
|
|
296
|
+
return value.startsWith('https://') ? { site: value } : { forced: value }
|
|
297
|
+
}
|