semicons 0.0.1

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.
@@ -0,0 +1,90 @@
1
+ import {
2
+ languages,
3
+ MarkdownString,
4
+ window,
5
+ Hover,
6
+ Disposable,
7
+ type TextDocument,
8
+ type Position,
9
+ type CancellationToken,
10
+ type ProviderResult,
11
+ } from 'vscode';
12
+ import { getRegistryForFile } from '../registry/cache';
13
+ import { getSemiconsConfig } from '../utils/config';
14
+ import { extractTokenNameAtPosition } from '../utils/tokenParse';
15
+
16
+ export function registerHoverProvider(): Disposable {
17
+ const provider = languages.registerHoverProvider(
18
+ [
19
+ { language: 'typescriptreact' },
20
+ { language: 'javascriptreact' },
21
+ { language: 'vue' },
22
+ { language: 'astro' },
23
+ ],
24
+ {
25
+ async provideHover(
26
+ document: TextDocument,
27
+ position: Position,
28
+ cancellationToken: CancellationToken
29
+ ): Promise<Hover | null | undefined> {
30
+ const registry = await getRegistryForFile(document.uri);
31
+
32
+ if (!registry || !registry.tokens) {
33
+ return null;
34
+ }
35
+
36
+ const tokenName = extractTokenNameAtPosition(document, position);
37
+
38
+ if (!tokenName) {
39
+ return null;
40
+ }
41
+
42
+ const foundToken = registry.tokens.find((t: { name: string }) => t.name === tokenName);
43
+
44
+ if (!foundToken) {
45
+ return null;
46
+ }
47
+
48
+ const markdown = new MarkdownString();
49
+ markdown.isTrusted = true;
50
+
51
+ const deprecated = foundToken.meta.deprecated;
52
+ const theme = registry.defaultTheme;
53
+ const assetRef = foundToken.themes[theme] || Object.values(foundToken.themes)[0];
54
+
55
+ // Build hover content
56
+ const lines: string[] = [];
57
+
58
+ // Token name with deprecated marker
59
+ if (deprecated) {
60
+ lines.push(`~~${foundToken.name}~~ ⚠️`);
61
+ } else {
62
+ lines.push(`**${foundToken.name}**`);
63
+ }
64
+
65
+ // Asset ref
66
+ lines.push(`\`${assetRef}\``);
67
+
68
+ // A11y label
69
+ if (foundToken.a11y?.label) {
70
+ lines.push(`a11y: "${foundToken.a11y.label}"`);
71
+ }
72
+
73
+ // Tags
74
+ if (foundToken.meta.tags && foundToken.meta.tags.length > 0) {
75
+ lines.push(`tags: ${foundToken.meta.tags.join(', ')}`);
76
+ }
77
+
78
+ // Preview command
79
+ const previewCommand = `[Preview icon](command:semicons.previewIcon?${encodeURIComponent(JSON.stringify([foundToken.name]))})`;
80
+ lines.push('', previewCommand);
81
+
82
+ markdown.value = lines.join('\n\n');
83
+
84
+ return new Hover(markdown);
85
+ },
86
+ }
87
+ );
88
+
89
+ return provider;
90
+ }
@@ -0,0 +1,71 @@
1
+ import { Uri, workspace, type Uri as VSCodeUri } from 'vscode';
2
+ import type { Registry } from './types';
3
+ import { loadRegistry } from './loader';
4
+
5
+ interface RegistryCache {
6
+ [folderUri: string]: Registry | null | Promise<Registry | null>;
7
+ }
8
+
9
+ const cache: RegistryCache = {};
10
+
11
+ export async function getRegistryForFile(fileUri: VSCodeUri): Promise<Registry | null> {
12
+ const folder = workspace.getWorkspaceFolder(fileUri);
13
+
14
+ if (folder) {
15
+ const folderKey = folder.uri.toString();
16
+
17
+ if (!cache[folderKey]) {
18
+ cache[folderKey] = await loadRegistry(folder.uri);
19
+ }
20
+
21
+ return cache[folderKey];
22
+ }
23
+
24
+ // Fallback: use first available registry
25
+ const folders = workspace.workspaceFolders;
26
+ if (folders && folders.length > 0) {
27
+ const firstFolder = folders[0];
28
+ const firstKey = firstFolder.uri.toString();
29
+
30
+ if (!cache[firstKey]) {
31
+ cache[firstKey] = await loadRegistry(firstFolder.uri);
32
+ }
33
+
34
+ return cache[firstKey];
35
+ }
36
+
37
+ return null;
38
+ }
39
+
40
+ export async function getRegistryForFolder(folderUri: VSCodeUri): Promise<Registry | null> {
41
+ const folderKey = folderUri.toString();
42
+
43
+ if (!cache[folderKey]) {
44
+ cache[folderKey] = await loadRegistry(folderUri);
45
+ }
46
+
47
+ return cache[folderKey] as Promise<Registry | null> | Registry | null;
48
+ }
49
+
50
+ export function setRegistry(folderUri: VSCodeUri, registry: Registry | null): void {
51
+ const folderKey = folderUri.toString();
52
+ cache[folderKey] = registry;
53
+ }
54
+
55
+ export function clearCache(): void {
56
+ for (const key of Object.keys(cache)) {
57
+ delete cache[key];
58
+ }
59
+ }
60
+
61
+ export async function getAllRegistries(): Promise<Registry[]> {
62
+ const registries: Registry[] = [];
63
+
64
+ for (const value of Object.values(cache)) {
65
+ if (value && !(value instanceof Promise)) {
66
+ registries.push(value);
67
+ }
68
+ }
69
+
70
+ return registries;
71
+ }
@@ -0,0 +1,26 @@
1
+ import { Uri, workspace } from 'vscode';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import type { Registry } from './types';
5
+
6
+ export async function loadRegistry(folderUri: Uri): Promise<Registry | null> {
7
+ const config = workspace.getConfiguration('semicons', folderUri);
8
+ const registryPath = config.get<string>('registryPath', 'src/icons.generated/registry.json');
9
+
10
+ const registryUri = Uri.joinPath(folderUri, registryPath);
11
+
12
+ try {
13
+ const document = await workspace.openTextDocument(registryUri);
14
+ const content = document.getText();
15
+ const registry = JSON.parse(content) as Registry;
16
+ return registry;
17
+ } catch (error) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function resolveRegistryPath(folderUri: Uri): Uri {
23
+ const config = workspace.getConfiguration('semicons', folderUri);
24
+ const registryPath = config.get<string>('registryPath', 'src/icons.generated/registry.json');
25
+ return Uri.joinPath(folderUri, registryPath);
26
+ }
@@ -0,0 +1,38 @@
1
+ import { Uri } from 'vscode';
2
+
3
+ export interface Registry {
4
+ version: string;
5
+ defaultTheme: string;
6
+ themes: string[];
7
+ tokens: Token[];
8
+ }
9
+
10
+ export interface Token {
11
+ name: string;
12
+ themes: Record<string, AssetRef>;
13
+ meta: {
14
+ deprecated?: boolean | string;
15
+ description?: string;
16
+ tags?: string[];
17
+ };
18
+ a11y?: {
19
+ label?: string;
20
+ description?: string;
21
+ };
22
+ }
23
+
24
+ export type AssetRef = string; // e.g., "local:menu", "lucide:github"
25
+
26
+ export interface TokenCompletionItem {
27
+ label: string;
28
+ detail: string;
29
+ documentation: string;
30
+ deprecated: boolean | string;
31
+ category?: string;
32
+ }
33
+
34
+ export interface IconComponentInfo {
35
+ name: string;
36
+ nameProp: string;
37
+ uri: Uri;
38
+ }
@@ -0,0 +1,67 @@
1
+ import { workspace, Uri, Disposable } from 'vscode';
2
+ import { clearCache, setRegistry } from './cache';
3
+ import { loadRegistry, resolveRegistryPath } from './loader';
4
+
5
+ const watchers: Map<string, Disposable> = new Map();
6
+
7
+ export function setupRegistryWatcher(): void {
8
+ const folders = workspace.workspaceFolders;
9
+ if (!folders) {
10
+ return;
11
+ }
12
+
13
+ for (const folder of folders) {
14
+ const registryUri = resolveRegistryPath(folder.uri);
15
+ const watcherKey = folder.uri.toString();
16
+
17
+ if (watchers.has(watcherKey)) {
18
+ continue;
19
+ }
20
+
21
+ const watcher = workspace.createFileSystemWatcher(
22
+ registryUri.fsPath,
23
+ false,
24
+ false,
25
+ false
26
+ );
27
+
28
+ watcher.onDidChange(async () => {
29
+ const registry = await loadRegistry(folder.uri);
30
+ setRegistry(folder.uri, registry);
31
+ });
32
+
33
+ watcher.onDidCreate(async () => {
34
+ const registry = await loadRegistry(folder.uri);
35
+ setRegistry(folder.uri, registry);
36
+ });
37
+
38
+ watcher.onDidDelete(() => {
39
+ setRegistry(folder.uri, null);
40
+ });
41
+
42
+ watchers.set(watcherKey, watcher);
43
+ }
44
+ }
45
+
46
+ export function disposeWatchers(): void {
47
+ for (const disposable of watchers.values()) {
48
+ disposable.dispose();
49
+ }
50
+ watchers.clear();
51
+ }
52
+
53
+ export function refreshAllRegistries(): Promise<void> {
54
+ clearCache();
55
+
56
+ const folders = workspace.workspaceFolders;
57
+ if (!folders) {
58
+ return Promise.resolve();
59
+ }
60
+
61
+ const promises = folders.map(async (folder) => {
62
+ const registry = await loadRegistry(folder.uri);
63
+ setRegistry(folder.uri, registry);
64
+ });
65
+
66
+ return Promise.all(promises).then(() => undefined);
67
+ }
@@ -0,0 +1,17 @@
1
+ import { Uri, workspace } from 'vscode';
2
+
3
+ export interface SemiconsConfig {
4
+ registryPath: string;
5
+ localIconDir: string;
6
+ iconComponentName: string;
7
+ }
8
+
9
+ export function getSemiconsConfig(folder?: Uri): SemiconsConfig {
10
+ const config = workspace.getConfiguration('semicons', folder);
11
+
12
+ return {
13
+ registryPath: config.get<string>('registryPath', 'src/icons.generated/registry.json'),
14
+ localIconDir: config.get<string>('localIconDir', 'icons/local'),
15
+ iconComponentName: config.get<string>('iconComponentName', 'Icon'),
16
+ };
17
+ }
@@ -0,0 +1,49 @@
1
+ import { Uri, workspace } from 'vscode';
2
+ import type { AssetRef } from '../registry/types';
3
+ import { getSemiconsConfig } from './config';
4
+
5
+ export function parseAssetRef(assetRef: AssetRef): { namespace: string; id: string } | null {
6
+ const match = assetRef.match(/^([^:]+):(.+)$/);
7
+ if (!match) {
8
+ return null;
9
+ }
10
+ return { namespace: match[1], id: match[2] };
11
+ }
12
+
13
+ export async function resolveAssetToFileUri(
14
+ assetRef: AssetRef,
15
+ folder: Uri
16
+ ): Promise<Uri | null> {
17
+ const parsed = parseAssetRef(assetRef);
18
+ if (!parsed) {
19
+ return null;
20
+ }
21
+
22
+ const { namespace, id } = parsed;
23
+
24
+ if (namespace === 'local') {
25
+ const config = getSemiconsConfig(folder);
26
+ const filePath = `${config.localIconDir}/${id}.svg`;
27
+ const fileUri = Uri.joinPath(folder, filePath);
28
+
29
+ try {
30
+ // Check if file exists
31
+ await workspace.fs.stat(fileUri);
32
+ return fileUri;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // For other namespaces (lucide, etc.), we can't resolve to local file
39
+ return null;
40
+ }
41
+
42
+ export async function readSvgContent(fileUri: Uri): Promise<string | null> {
43
+ try {
44
+ const bytes = await workspace.fs.readFile(fileUri);
45
+ return new TextDecoder().decode(bytes);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
@@ -0,0 +1,79 @@
1
+ import { Position, Range, type TextDocument } from 'vscode';
2
+
3
+ export interface TokenMatch {
4
+ name: string;
5
+ range: Range;
6
+ isAttributeValue: boolean;
7
+ }
8
+
9
+ const ICON_TAG_REGEX = /<Icon\b/gi;
10
+ const NAME_ATTR_REGEX = /\s+name\s*=\s*["']([^"']+)["']/g;
11
+ const VUE_NAME_ATTR_REGEX = /\s+:name\s*=\s*["']([^"']+)["']/g;
12
+
13
+ export function findIconTokens(document: TextDocument): TokenMatch[] {
14
+ const text = document.getText();
15
+ const matches: TokenMatch[] = [];
16
+
17
+ let match;
18
+
19
+ // Match <Icon name="..."> patterns
20
+ while ((match = ICON_TAG_REGEX.exec(text)) !== null) {
21
+ const tagStart = match.index;
22
+ const tagEnd = tagStart + match[0].length;
23
+
24
+ // Look for name attribute after the tag
25
+ const remainingText = text.substring(tagEnd);
26
+
27
+ // Check for name="value"
28
+ let nameMatch;
29
+ const nameRegex = new RegExp(NAME_ATTR_REGEX);
30
+ while ((nameMatch = nameRegex.exec(remainingText)) !== null) {
31
+ const nameStart = tagEnd + nameMatch.index;
32
+ const nameEnd = nameStart + nameMatch[0].length;
33
+ const nameValue = nameMatch[1];
34
+
35
+ const startPos = document.positionAt(nameStart);
36
+ const endPos = document.positionAt(nameEnd);
37
+
38
+ matches.push({
39
+ name: nameValue,
40
+ range: new Range(startPos, endPos),
41
+ isAttributeValue: true,
42
+ });
43
+ }
44
+
45
+ // Check for Vue :name="'value'"
46
+ const vueNameRegex = new RegExp(VUE_NAME_ATTR_REGEX);
47
+ while ((nameMatch = vueNameRegex.exec(remainingText)) !== null) {
48
+ const nameStart = tagEnd + nameMatch.index;
49
+ const nameEnd = nameStart + nameMatch[0].length;
50
+ const nameValue = nameMatch[1];
51
+
52
+ const startPos = document.positionAt(nameStart);
53
+ const endPos = document.positionAt(nameEnd);
54
+
55
+ matches.push({
56
+ name: nameValue,
57
+ range: new Range(startPos, endPos),
58
+ isAttributeValue: true,
59
+ });
60
+ }
61
+ }
62
+
63
+ return matches;
64
+ }
65
+
66
+ export function extractTokenNameAtPosition(
67
+ document: TextDocument,
68
+ position: Position
69
+ ): string | null {
70
+ const matches = findIconTokens(document);
71
+
72
+ for (const match of matches) {
73
+ if (match.range.contains(position)) {
74
+ return match.name;
75
+ }
76
+ }
77
+
78
+ return null;
79
+ }
@@ -0,0 +1,253 @@
1
+ import { window, ViewColumn, Uri, type WebviewPanel } from 'vscode';
2
+ import type { Token, Registry } from '../registry/types';
3
+ import { resolveAssetToFileUri, readSvgContent } from '../utils/path';
4
+
5
+ interface PreviewState {
6
+ token: Token;
7
+ registry: Registry;
8
+ folderUri: Uri;
9
+ }
10
+
11
+ export class PreviewWebview {
12
+ private panel: WebviewPanel | null = null;
13
+ private state: PreviewState | null = null;
14
+
15
+ async show(token: Token, registry: Registry, folderUri: Uri): Promise<void> {
16
+ this.state = { token, registry, folderUri };
17
+
18
+ if (this.panel) {
19
+ this.panel.reveal(ViewColumn.Beside);
20
+ this.updatePanel();
21
+ } else {
22
+ this.panel = window.createWebviewPanel(
23
+ 'semiconsPreview',
24
+ `Preview: ${token.name}`,
25
+ ViewColumn.Beside,
26
+ {
27
+ enableScripts: true,
28
+ localResourceRoots: [folderUri],
29
+ }
30
+ );
31
+
32
+ this.panel.onDidDispose(() => {
33
+ this.panel = null;
34
+ this.state = null;
35
+ });
36
+
37
+ this.updatePanel();
38
+ }
39
+ }
40
+
41
+ private async updatePanel(): Promise<void> {
42
+ if (!this.panel || !this.state) {
43
+ return;
44
+ }
45
+
46
+ const { token, registry, folderUri } = this.state;
47
+ const theme = registry.defaultTheme;
48
+ const assetRef = token.themes[theme] || Object.values(token.themes)[0];
49
+
50
+ // Try to get real SVG content
51
+ const fileUri = await resolveAssetToFileUri(assetRef, folderUri);
52
+ let svgContent: string | null = null;
53
+ let svgExists = false;
54
+
55
+ if (fileUri) {
56
+ svgContent = await readSvgContent(fileUri);
57
+ svgExists = svgContent !== null;
58
+ }
59
+
60
+ // Build preview HTML
61
+ const previewHtml = svgExists && svgContent
62
+ ? this.wrapSvg(svgContent)
63
+ : this.createPlaceholder(token.name, assetRef, svgExists);
64
+
65
+ const html = this.buildHtml(token, registry, assetRef, previewHtml);
66
+
67
+ this.panel.webview.html = html;
68
+ }
69
+
70
+ private wrapSvg(svg: string): string {
71
+ // Remove existing width/height/viewBox for consistent sizing
72
+ return svg
73
+ .replace(/\s+width="[^"]*"/g, '')
74
+ .replace(/\s+height="[^"]*"/g, '')
75
+ .replace(/\s+viewBox="[^"]*"/g, '')
76
+ .replace(/^<svg/, '<svg width="100%" height="100%" viewBox="0 0 24 24"');
77
+ }
78
+
79
+ private createPlaceholder(name: string, assetRef: string, fileExists: boolean): string {
80
+ return `
81
+ <div class="placeholder">
82
+ <div class="placeholder-icon">
83
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
84
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
85
+ <line x1="9" y1="9" x2="15" y2="15"/>
86
+ <line x1="15" y1="9" x2="9" y2="15"/>
87
+ </svg>
88
+ </div>
89
+ <p class="placeholder-text">${fileExists ? 'Preview unavailable' : 'File not found'}</p>
90
+ <p class="placeholder-ref">${assetRef}</p>
91
+ ${!fileExists ? `<p class="placeholder-hint">Expected: ${name.replace('local:', 'icons/local/') + '.svg'}</p>` : ''}
92
+ </div>
93
+ `;
94
+ }
95
+
96
+ private buildHtml(token: Token, registry: Registry, assetRef: string, previewHtml: string): string {
97
+ const deprecated = token.meta.deprecated;
98
+
99
+ return `<!DOCTYPE html>
100
+ <html>
101
+ <head>
102
+ <meta charset="UTF-8">
103
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
104
+ <title>${token.name}</title>
105
+ <style>
106
+ body {
107
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
108
+ margin: 0;
109
+ padding: 16px;
110
+ background: #1e1e2e;
111
+ color: #cdd6f4;
112
+ }
113
+ .header {
114
+ margin-bottom: 16px;
115
+ }
116
+ .token-name {
117
+ font-size: 18px;
118
+ font-weight: 600;
119
+ margin: 0;
120
+ }
121
+ .token-name.deprecated {
122
+ text-decoration: line-through;
123
+ opacity: 0.7;
124
+ }
125
+ .token-ref {
126
+ font-family: 'Fira Code', monospace;
127
+ font-size: 12px;
128
+ color: #a6adc8;
129
+ background: #313244;
130
+ padding: 2px 8px;
131
+ border-radius: 4px;
132
+ }
133
+ .section {
134
+ margin-bottom: 16px;
135
+ }
136
+ .section-title {
137
+ font-size: 12px;
138
+ text-transform: uppercase;
139
+ color: #6c7086;
140
+ margin-bottom: 8px;
141
+ }
142
+ .preview-container {
143
+ background: #181825;
144
+ border-radius: 8px;
145
+ padding: 24px;
146
+ display: flex;
147
+ justify-content: center;
148
+ align-items: center;
149
+ min-height: 120px;
150
+ }
151
+ .preview-container svg {
152
+ max-width: 100%;
153
+ max-height: 100px;
154
+ }
155
+ .placeholder {
156
+ text-align: center;
157
+ padding: 24px;
158
+ }
159
+ .placeholder-icon {
160
+ opacity: 0.3;
161
+ margin-bottom: 8px;
162
+ }
163
+ .placeholder-text {
164
+ font-size: 14px;
165
+ color: #a6adc8;
166
+ margin: 0 0 4px 0;
167
+ }
168
+ .placeholder-ref {
169
+ font-family: 'Fira Code', monospace;
170
+ font-size: 11px;
171
+ color: #6c7086;
172
+ margin: 0 0 8px 0;
173
+ }
174
+ .placeholder-hint {
175
+ font-size: 11px;
176
+ color: #f38ba8;
177
+ margin: 0;
178
+ }
179
+ .meta-row {
180
+ display: flex;
181
+ gap: 8px;
182
+ flex-wrap: wrap;
183
+ }
184
+ .tag {
185
+ font-size: 11px;
186
+ background: #313244;
187
+ padding: 2px 8px;
188
+ border-radius: 4px;
189
+ }
190
+ .warning {
191
+ background: #f9e2af;
192
+ color: #1e1e2e;
193
+ padding: 8px 12px;
194
+ border-radius: 4px;
195
+ font-size: 12px;
196
+ margin-top: 8px;
197
+ }
198
+ </style>
199
+ </head>
200
+ <body>
201
+ <div class="header">
202
+ <h1 class="token-name ${deprecated ? 'deprecated' : ''}">${token.name}</h1>
203
+ <span class="token-ref">${assetRef}</span>
204
+ </div>
205
+
206
+ ${deprecated ? `<div class="warning">⚠️ Deprecated: ${typeof deprecated === 'string' ? deprecated : 'This icon is deprecated'}</div>` : ''}
207
+
208
+ <div class="section">
209
+ <div class="section-title">Preview</div>
210
+ <div class="preview-container">
211
+ ${previewHtml}
212
+ </div>
213
+ </div>
214
+
215
+ ${token.a11y?.label ? `
216
+ <div class="section">
217
+ <div class="section-title">Accessibility</div>
218
+ <div>aria-label: "${token.a11y.label}"</div>
219
+ </div>
220
+ ` : ''}
221
+
222
+ ${token.meta.description ? `
223
+ <div class="section">
224
+ <div class="section-title">Description</div>
225
+ <div>${token.meta.description}</div>
226
+ </div>
227
+ ` : ''}
228
+
229
+ ${token.meta.tags && token.meta.tags.length > 0 ? `
230
+ <div class="section">
231
+ <div class="section-title">Tags</div>
232
+ <div class="meta-row">
233
+ ${token.meta.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
234
+ </div>
235
+ </div>
236
+ ` : ''}
237
+
238
+ <div class="section">
239
+ <div class="section-title">Usage</div>
240
+ <pre style="background: #313244; padding: 12px; border-radius: 6px; overflow-x: auto;"><code>&lt;Icon name="${token.name}" /&gt;</code></pre>
241
+ </div>
242
+ </body>
243
+ </html>`;
244
+ }
245
+
246
+ dispose(): void {
247
+ if (this.panel) {
248
+ this.panel.dispose();
249
+ this.panel = null;
250
+ }
251
+ this.state = null;
252
+ }
253
+ }