@vsceasy/cli 0.1.9 → 0.1.10

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.
@@ -1,6 +1,8 @@
1
1
  export interface VsceasyConfig {
2
2
  publisher?: string;
3
3
  commandPrefix?: string;
4
+ /** Extension shape. Omitted for legacy 'ui' projects. */
5
+ type?: 'ui' | 'language' | 'empty';
4
6
  ui?: 'react';
5
7
  defaultIcon?: string;
6
8
  defaultCategory?: string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Deep-merge helper for `contributes.extra.json`.
3
+ *
4
+ * This is the source of truth for the merge algorithm used by the scaffolded
5
+ * project's `scripts/gen.ts`. The template (`templates/react/scripts/gen.ts`)
6
+ * carries an inline copy because it must run standalone with no dependency on
7
+ * this package — keep the two in sync. This module exists so the algorithm is
8
+ * unit-testable in-process (no subprocess).
9
+ */
10
+ /** Keys gen.ts owns — never overridden by contributes.extra.json. */
11
+ export declare const GEN_OWNED_KEYS: Set<string>;
12
+ export declare function isPlainObject(v: unknown): v is Record<string, any>;
13
+ export declare function deepMerge(base: any, override: any): any;
14
+ /**
15
+ * Merge `extra` into `contributes` in place. gen-owned keys present in `extra`
16
+ * are ignored so gen.ts stays authoritative for them.
17
+ */
18
+ export declare function applyExtraContributes(contributes: Record<string, any>, extra: Record<string, any> | null | undefined): Record<string, any>;
@@ -1,4 +1,4 @@
1
- export type HelperKind = 'secrets' | 'config' | 'state' | 'notifications' | 'cache';
1
+ export type HelperKind = 'secrets' | 'config' | 'state' | 'notifications' | 'cache' | 'colorize';
2
2
  export declare const HELPER_KINDS: HelperKind[];
3
3
  export interface AddHelperOptions {
4
4
  kind: HelperKind;
@@ -1,4 +1,5 @@
1
1
  export type ScaffoldPreset = 'minimal' | 'full';
2
+ export type ScaffoldType = 'ui' | 'language' | 'empty';
2
3
  export interface ScaffoldOptions {
3
4
  name: string;
4
5
  displayName: string;
@@ -7,6 +8,9 @@ export interface ScaffoldOptions {
7
8
  ui: 'react';
8
9
  targetDir: string;
9
10
  templatesRoot: string;
11
+ /** Extension shape. Default: 'ui'. */
12
+ type?: ScaffoldType;
13
+ /** UI preset — only used when type === 'ui'. */
10
14
  preset?: ScaffoldPreset;
11
15
  }
12
16
  export declare function scaffold(opts: ScaffoldOptions): Promise<void>;
@@ -1,2 +1,2 @@
1
- export declare const TEMPLATES_VERSION = "0.1.9";
1
+ export declare const TEMPLATES_VERSION = "0.1.10";
2
2
  export declare const TEMPLATE_FILES: Record<string, string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsceasy/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Build VS Code extensions fast — React UI + typed RPC bridge between extension and webview + file-based routing for panels, commands, menus, tree views, and subpanels.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -0,0 +1,39 @@
1
+ ## {{displayName}} language support
2
+
3
+ This extension was scaffolded with `vsceasy create --type language`. It provides
4
+ editor support for `.{{langId}}` files:
5
+
6
+ - **Syntax highlighting** — TextMate grammar in `syntaxes/{{langId}}.tmLanguage.json`
7
+ - **Language configuration** — comments, brackets, auto-closing pairs, folding in
8
+ `language-configuration.json`
9
+ - **Snippets** — `snippets/{{langId}}.json`
10
+ - **File icon** (opt-in) — `fileicons/{{langId}}-icon-theme.json`
11
+
12
+ ### How contributions are wired
13
+
14
+ `vsceasy`'s `scripts/gen.ts` regenerates the generated parts of
15
+ `package.json#contributes` (commands, views…) on every build. Language
16
+ contributions are **not** generated — they live in **`contributes.extra.json`**
17
+ at the project root and are deep-merged into `package.json` by `gen.ts`. Edit
18
+ `contributes.extra.json` to change languages / grammars / snippets / iconThemes,
19
+ then run `bun run gen`.
20
+
21
+ ### File icon is opt-in
22
+
23
+ VS Code file icons are provided by an **icon theme**, which is global: activating
24
+ it replaces *all* file icons in the workbench, not just `.{{langId}}`. This
25
+ extension ships a `{{displayName}} Icons` theme but does **not** force it. To use
26
+ it: `Preferences: File Icon Theme` → pick `{{displayName}} Icons`. If you don't
27
+ want to override every icon, leave it unselected — highlighting, config and
28
+ snippets work regardless.
29
+
30
+ ### Develop
31
+
32
+ ```sh
33
+ bun install
34
+ bun run gen # sync package.json#contributes
35
+ bun run package # build a .vsix
36
+ ```
37
+
38
+ Press `F5` (or run `bun run launch`) to open an Extension Development Host and
39
+ open a `.{{langId}}` file to see highlighting.
@@ -0,0 +1,40 @@
1
+ {
2
+ "languages": [
3
+ {
4
+ "id": "{{langId}}",
5
+ "aliases": ["{{displayName}}", "{{langId}}"],
6
+ "extensions": [".{{langId}}"],
7
+ "configuration": "./language-configuration.json"
8
+ }
9
+ ],
10
+ "grammars": [
11
+ {
12
+ "language": "{{langId}}",
13
+ "scopeName": "{{scopeName}}",
14
+ "path": "./syntaxes/{{langId}}.tmLanguage.json"
15
+ }
16
+ ],
17
+ "snippets": [
18
+ {
19
+ "language": "{{langId}}",
20
+ "path": "./snippets/{{langId}}.json"
21
+ }
22
+ ],
23
+ "iconThemes": [
24
+ {
25
+ "id": "{{langId}}-icons",
26
+ "label": "{{displayName}} Icons",
27
+ "path": "./fileicons/{{langId}}-icon-theme.json"
28
+ }
29
+ ],
30
+ "configuration": {
31
+ "title": "{{displayName}}",
32
+ "properties": {
33
+ "{{commandPrefix}}.colorize": {
34
+ "type": "boolean",
35
+ "default": true,
36
+ "markdownDescription": "Automatically apply distinct section colors to `.{{langId}}` files (scoped to `{{scopeName}}`, so other languages are unaffected). Turn off to keep your theme's default colors."
37
+ }
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "iconDefinitions": {
3
+ "{{langId}}_file": {
4
+ "iconPath": "../icons/{{langId}}.svg"
5
+ }
6
+ },
7
+ "languageIds": {
8
+ "{{langId}}": "{{langId}}_file"
9
+ },
10
+ "fileExtensions": {
11
+ "{{langId}}": "{{langId}}_file"
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2
+ <path fill="#8a8a8a" d="M6 3h14l6 6v20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/>
3
+ <path fill="#5a5a5a" d="M20 3l6 6h-6z"/>
4
+ <text x="16" y="22" font-family="monospace" font-size="9" font-weight="bold" fill="#fff" text-anchor="middle">{{langExt}}</text>
5
+ </svg>
@@ -0,0 +1,30 @@
1
+ {
2
+ "comments": {
3
+ "lineComment": "#"
4
+ },
5
+ "brackets": [
6
+ ["{", "}"],
7
+ ["[", "]"],
8
+ ["(", ")"]
9
+ ],
10
+ "autoClosingPairs": [
11
+ { "open": "{", "close": "}" },
12
+ { "open": "[", "close": "]" },
13
+ { "open": "(", "close": ")" },
14
+ { "open": "\"", "close": "\"", "notIn": ["string"] },
15
+ { "open": "'", "close": "'", "notIn": ["string"] }
16
+ ],
17
+ "surroundingPairs": [
18
+ ["{", "}"],
19
+ ["[", "]"],
20
+ ["(", ")"],
21
+ ["\"", "\""],
22
+ ["'", "'"]
23
+ ],
24
+ "folding": {
25
+ "markers": {
26
+ "start": "^\\s*#\\s*region\\b",
27
+ "end": "^\\s*#\\s*endregion\\b"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "Section": {
3
+ "prefix": "section",
4
+ "body": ["[${1:name}]", "$0"],
5
+ "description": "A {{displayName}} section"
6
+ },
7
+ "Key/Value": {
8
+ "prefix": "kv",
9
+ "body": ["${1:key} = ${2:value}"],
10
+ "description": "A key/value pair"
11
+ }
12
+ }
@@ -0,0 +1,31 @@
1
+ import type * as vscode from 'vscode';
2
+ import { applyTokenColors, removeTokenColors, type TokenColorRule } from './helpers/colorize';
3
+
4
+ /** Root TextMate scope of this language — rules are applied only to these files. */
5
+ export const SCOPE = '{{scopeName}}';
6
+
7
+ /**
8
+ * Default token colors for {{displayName}}, emphasizing each construct. Edit
9
+ * freely — they are applied to `[{{scopeName}}]` only, so other languages keep
10
+ * the user's theme. Scope names must match your grammar
11
+ * (syntaxes/{{langId}}.tmLanguage.json).
12
+ */
13
+ export const RULES: TokenColorRule[] = [
14
+ { scope: 'comment.line.number-sign.{{langId}}', settings: { foreground: '#6b7a6e', fontStyle: 'italic' } },
15
+ { scope: 'string.quoted.double.basic.{{langId}}', settings: { foreground: '#98c379' } },
16
+ { scope: 'string.quoted.single.literal.{{langId}}', settings: { foreground: '#98c379' } },
17
+ { scope: 'constant.numeric.{{langId}}', settings: { foreground: '#d19a66' } },
18
+ ];
19
+
20
+ export async function applyColors(vscodeNs: typeof vscode): Promise<void> {
21
+ await applyTokenColors(SCOPE, RULES);
22
+ }
23
+
24
+ export async function removeColors(vscodeNs: typeof vscode): Promise<void> {
25
+ await removeTokenColors(SCOPE);
26
+ }
27
+
28
+ /** True when the user has opted in (default) to automatic coloring. */
29
+ export function colorizeEnabled(vscodeNs: typeof vscode): boolean {
30
+ return vscodeNs.workspace.getConfiguration('{{commandPrefix}}').get<boolean>('colorize', true);
31
+ }
@@ -0,0 +1,11 @@
1
+ import { defineCommand } from '../shared/vsceasy';
2
+ import { applyColors } from '../colorize';
3
+
4
+ export default defineCommand({
5
+ id: 'applyColors',
6
+ title: '{{displayName}}: Apply Colors',
7
+ run: async (vscode) => {
8
+ await applyColors(vscode);
9
+ vscode.window.showInformationMessage('{{displayName}} colors applied.');
10
+ },
11
+ });
@@ -0,0 +1,11 @@
1
+ import { defineCommand } from '../shared/vsceasy';
2
+ import { removeColors } from '../colorize';
3
+
4
+ export default defineCommand({
5
+ id: 'removeColors',
6
+ title: '{{displayName}}: Remove Colors',
7
+ run: async (vscode) => {
8
+ await removeColors(vscode);
9
+ vscode.window.showInformationMessage('{{displayName}} colors removed.');
10
+ },
11
+ });
@@ -0,0 +1,25 @@
1
+ import { bootstrap } from '../shared/vsceasy';
2
+ import { registry } from './_registry';
3
+ import { applyColors, removeColors, colorizeEnabled } from '../colorize';
4
+
5
+ export const activate = bootstrap(registry, {
6
+ onActivate: [
7
+ async (context, vscode) => {
8
+ // Auto-apply scoped token colors on activate when opted in (default).
9
+ // Scoped to {{scopeName}} only — other languages are untouched.
10
+ if (colorizeEnabled(vscode)) {
11
+ await applyColors(vscode);
12
+ }
13
+ // React to the user toggling `{{commandPrefix}}.colorize` at runtime.
14
+ context.subscriptions.push(
15
+ vscode.workspace.onDidChangeConfiguration(async (e) => {
16
+ if (!e.affectsConfiguration('{{commandPrefix}}.colorize')) return;
17
+ if (colorizeEnabled(vscode)) await applyColors(vscode);
18
+ else await removeColors(vscode);
19
+ }),
20
+ );
21
+ },
22
+ ],
23
+ });
24
+
25
+ export function deactivate() {}
@@ -0,0 +1,70 @@
1
+ import * as vscode from 'vscode';
2
+
3
+ /**
4
+ * Apply theme-independent token colors to a single TextMate scope (e.g. a
5
+ * language's root scope like `{{scopeName}}`), written to the user's
6
+ * `editor.tokenColorCustomizations`. Because the rules are keyed by
7
+ * `[<scope>]`, only files in that scope are recolored — every other language
8
+ * keeps the active theme's colors.
9
+ *
10
+ * Rules are tagged with a marker so {@link removeTokenColors} can strip exactly
11
+ * the ones this extension added, preserving any the user wrote by hand.
12
+ */
13
+
14
+ export interface TokenColorRule {
15
+ /** Comma-separated TextMate scopes, e.g. 'entity.name.section.foo, comment.line.foo'. */
16
+ scope: string;
17
+ settings: { foreground?: string; background?: string; fontStyle?: string };
18
+ }
19
+
20
+ type TaggedRule = TokenColorRule & { [MARK]?: true };
21
+
22
+ /** Marker key identifying rules this extension wrote (vs. the user's own). */
23
+ const MARK = '{{commandPrefix}}Colorize';
24
+ const SECTION = 'editor.tokenColorCustomizations';
25
+
26
+ const blockKey = (scope: string) => `[${scope}]`;
27
+
28
+ /**
29
+ * Merge `rules` into `editor.tokenColorCustomizations["[<scope>]"].textMateRules`,
30
+ * preserving the user's own rules and other scope keys. Idempotent — re-applying
31
+ * replaces only previously-applied rules from this extension.
32
+ */
33
+ export async function applyTokenColors(
34
+ scope: string,
35
+ rules: TokenColorRule[],
36
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
37
+ ): Promise<void> {
38
+ const cfg = vscode.workspace.getConfiguration();
39
+ const current = (cfg.get<Record<string, any>>(SECTION) ?? {}) as Record<string, any>;
40
+ const key = blockKey(scope);
41
+ const block = (current[key] ?? {}) as { textMateRules?: TaggedRule[] };
42
+ const existing = Array.isArray(block.textMateRules) ? block.textMateRules : [];
43
+ const userRules = existing.filter((r) => !r[MARK]);
44
+ const ours: TaggedRule[] = rules.map((r) => ({ ...r, [MARK]: true }));
45
+ const next = { ...current, [key]: { ...block, textMateRules: [...userRules, ...ours] } };
46
+ await cfg.update(SECTION, next, target);
47
+ }
48
+
49
+ /** Remove only the rules this extension added for `scope`; leave the rest intact. */
50
+ export async function removeTokenColors(
51
+ scope: string,
52
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
53
+ ): Promise<void> {
54
+ const cfg = vscode.workspace.getConfiguration();
55
+ const current = cfg.get<Record<string, any>>(SECTION);
56
+ const key = blockKey(scope);
57
+ if (!current || !current[key]) return;
58
+ const block = current[key] as { textMateRules?: TaggedRule[] };
59
+ const userRules = (block.textMateRules ?? []).filter((r) => !r[MARK]);
60
+
61
+ const nextBlock: Record<string, unknown> = { ...block };
62
+ if (userRules.length) nextBlock.textMateRules = userRules;
63
+ else delete nextBlock.textMateRules;
64
+
65
+ const next = { ...current };
66
+ if (Object.keys(nextBlock).length) next[key] = nextBlock;
67
+ else delete next[key];
68
+
69
+ await cfg.update(SECTION, Object.keys(next).length ? next : undefined, target);
70
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
3
+ "name": "{{displayName}}",
4
+ "scopeName": "{{scopeName}}",
5
+ "patterns": [
6
+ { "include": "#comments" },
7
+ { "include": "#strings" },
8
+ { "include": "#numbers" }
9
+ ],
10
+ "repository": {
11
+ "comments": {
12
+ "patterns": [
13
+ {
14
+ "name": "comment.line.number-sign.{{langId}}",
15
+ "match": "#.*$"
16
+ }
17
+ ]
18
+ },
19
+ "strings": {
20
+ "patterns": [
21
+ {
22
+ "name": "string.quoted.double.{{langId}}",
23
+ "begin": "\"",
24
+ "end": "\"",
25
+ "patterns": [
26
+ { "name": "constant.character.escape.{{langId}}", "match": "\\\\." }
27
+ ]
28
+ },
29
+ {
30
+ "name": "string.quoted.single.{{langId}}",
31
+ "begin": "'",
32
+ "end": "'"
33
+ }
34
+ ]
35
+ },
36
+ "numbers": {
37
+ "patterns": [
38
+ {
39
+ "name": "constant.numeric.{{langId}}",
40
+ "match": "\\b[0-9]+(\\.[0-9]+)?\\b"
41
+ }
42
+ ]
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,85 @@
1
+ import * as vscode from 'vscode';
2
+
3
+ /**
4
+ * Apply theme-independent token colors to a single TextMate scope (e.g. a
5
+ * language's root scope like `source.toml`), written to the user's
6
+ * `editor.tokenColorCustomizations`. Because the rules are keyed by
7
+ * `[<scope>]`, only files in that scope are recolored — every other language
8
+ * keeps the active theme's colors.
9
+ *
10
+ * Rules are tagged with a marker so {@link removeTokenColors} can strip exactly
11
+ * the ones this extension added, preserving any the user wrote by hand.
12
+ *
13
+ * Typical use — auto-apply on activate behind an opt-out setting:
14
+ *
15
+ * // extension.ts (onActivate hook)
16
+ * if (config.get<boolean>('colorize', true)) {
17
+ * await applyTokenColors('source.{{commandPrefix}}', MY_RULES);
18
+ * }
19
+ * vscode.workspace.onDidChangeConfiguration(async (e) => {
20
+ * if (!e.affectsConfiguration('{{commandPrefix}}.colorize')) return;
21
+ * if (config.get<boolean>('colorize', true)) await applyTokenColors('source.{{commandPrefix}}', MY_RULES);
22
+ * else await removeTokenColors('source.{{commandPrefix}}');
23
+ * });
24
+ *
25
+ * Declare the opt-out in package.json#contributes.configuration:
26
+ * "{{commandPrefix}}.colorize": { "type": "boolean", "default": true }
27
+ */
28
+
29
+ export interface TokenColorRule {
30
+ /** Comma-separated TextMate scopes, e.g. 'entity.name.section.foo, comment.line.foo'. */
31
+ scope: string;
32
+ settings: { foreground?: string; background?: string; fontStyle?: string };
33
+ }
34
+
35
+ type TaggedRule = TokenColorRule & { [MARK]?: true };
36
+
37
+ /** Marker key identifying rules this extension wrote (vs. the user's own). */
38
+ const MARK = '{{commandPrefix}}Colorize';
39
+ const SECTION = 'editor.tokenColorCustomizations';
40
+
41
+ const blockKey = (scope: string) => `[${scope}]`;
42
+
43
+ /**
44
+ * Merge `rules` into `editor.tokenColorCustomizations["[<scope>]"].textMateRules`,
45
+ * preserving the user's own rules and other scope keys. Idempotent — re-applying
46
+ * replaces only previously-applied rules from this extension.
47
+ */
48
+ export async function applyTokenColors(
49
+ scope: string,
50
+ rules: TokenColorRule[],
51
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
52
+ ): Promise<void> {
53
+ const cfg = vscode.workspace.getConfiguration();
54
+ const current = (cfg.get<Record<string, any>>(SECTION) ?? {}) as Record<string, any>;
55
+ const key = blockKey(scope);
56
+ const block = (current[key] ?? {}) as { textMateRules?: TaggedRule[] };
57
+ const existing = Array.isArray(block.textMateRules) ? block.textMateRules : [];
58
+ const userRules = existing.filter((r) => !r[MARK]);
59
+ const ours: TaggedRule[] = rules.map((r) => ({ ...r, [MARK]: true }));
60
+ const next = { ...current, [key]: { ...block, textMateRules: [...userRules, ...ours] } };
61
+ await cfg.update(SECTION, next, target);
62
+ }
63
+
64
+ /** Remove only the rules this extension added for `scope`; leave the rest intact. */
65
+ export async function removeTokenColors(
66
+ scope: string,
67
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
68
+ ): Promise<void> {
69
+ const cfg = vscode.workspace.getConfiguration();
70
+ const current = cfg.get<Record<string, any>>(SECTION);
71
+ const key = blockKey(scope);
72
+ if (!current || !current[key]) return;
73
+ const block = current[key] as { textMateRules?: TaggedRule[] };
74
+ const userRules = (block.textMateRules ?? []).filter((r) => !r[MARK]);
75
+
76
+ const nextBlock: Record<string, unknown> = { ...block };
77
+ if (userRules.length) nextBlock.textMateRules = userRules;
78
+ else delete nextBlock.textMateRules;
79
+
80
+ const next = { ...current };
81
+ if (Object.keys(nextBlock).length) next[key] = nextBlock;
82
+ else delete next[key];
83
+
84
+ await cfg.update(SECTION, Object.keys(next).length ? next : undefined, target);
85
+ }
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  // Scans src/panels, src/commands, and src/menus; writes src/extension/_registry.ts
3
3
  // and syncs package.json#contributes (commands, viewsContainers, views).
4
+ //
5
+ // Non-generated contributes (e.g. languages, grammars, snippets, themes,
6
+ // iconThemes, walkthroughs) go in an optional `contributes.extra.json` at the
7
+ // project root. It is deep-merged into package.json#contributes on every run —
8
+ // the keys gen.ts owns (commands, keybindings, menus.commandPalette,
9
+ // viewsContainers, views) always win, everything else from extra is preserved.
4
10
 
5
11
  import * as fs from 'fs';
6
12
  import * as path from 'path';
@@ -16,6 +22,10 @@ const TREE_VIEWS_DIR = path.join(SRC, 'treeViews');
16
22
  const JOBS_DIR = path.join(SRC, 'jobs');
17
23
  const OUT = path.join(SRC, 'extension', '_registry.ts');
18
24
  const PKG_PATH = path.join(ROOT, 'package.json');
25
+ const EXTRA_PATH = path.join(ROOT, 'contributes.extra.json');
26
+
27
+ /** Keys gen.ts owns — never overridden by contributes.extra.json. */
28
+ const GEN_OWNED_KEYS = new Set(['commands', 'keybindings', 'viewsContainers', 'views']);
19
29
 
20
30
  interface Discovered {
21
31
  id: string;
@@ -204,9 +214,54 @@ function syncPackageJson(
204
214
  delete contributes.views;
205
215
  }
206
216
 
217
+ mergeExtraContributes(contributes);
218
+
207
219
  fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n');
208
220
  }
209
221
 
222
+ /**
223
+ * Deep-merge the optional `contributes.extra.json` (project root) into the
224
+ * package's `contributes`. Use for any contribution point gen.ts doesn't
225
+ * generate — languages, grammars, snippets, themes, iconThemes, walkthroughs…
226
+ *
227
+ * Rules:
228
+ * - gen-owned keys (commands, keybindings, viewsContainers, views) are ignored
229
+ * if present in extra — gen.ts stays authoritative for those.
230
+ * - plain objects merge recursively; arrays and primitives from extra replace.
231
+ *
232
+ * NOTE: this is an inline copy of src/lib/contributesMerge.ts in the vsceasy
233
+ * source (the script must run standalone). Keep the two in sync.
234
+ */
235
+ function mergeExtraContributes(contributes: Record<string, any>) {
236
+ if (!fs.existsSync(EXTRA_PATH)) return;
237
+ let extra: Record<string, any>;
238
+ try {
239
+ extra = JSON.parse(fs.readFileSync(EXTRA_PATH, 'utf8'));
240
+ } catch (err) {
241
+ console.warn(`! Skipping contributes.extra.json — invalid JSON: ${(err as Error).message}`);
242
+ return;
243
+ }
244
+ if (!extra || typeof extra !== 'object') return;
245
+ for (const [key, value] of Object.entries(extra)) {
246
+ if (GEN_OWNED_KEYS.has(key)) continue;
247
+ contributes[key] = deepMerge(contributes[key], value);
248
+ }
249
+ }
250
+
251
+ function isPlainObject(v: unknown): v is Record<string, any> {
252
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
253
+ }
254
+
255
+ function deepMerge(base: any, override: any): any {
256
+ if (isPlainObject(base) && isPlainObject(override)) {
257
+ const out: Record<string, any> = { ...base };
258
+ for (const [k, v] of Object.entries(override)) out[k] = deepMerge(base[k], v);
259
+ return out;
260
+ }
261
+ // arrays and primitives: override wins
262
+ return override;
263
+ }
264
+
210
265
  function loadDef(file: string): {
211
266
  id?: string;
212
267
  title?: string;