├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── license ├── package.json ├── readme.md ├── source ├── cli.ts ├── commands │ ├── build.ts │ ├── index.ts │ ├── init.ts │ ├── lint.ts │ ├── tidy.ts │ └── watch.ts ├── config.ts ├── index.ts └── utilities.ts ├── test └── main.ts └── tsconfig.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: macos-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 18 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm install 20 | - run: npm run build 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) mvllow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pinecone-cli", 3 | "version": "4.0.1", 4 | "description": "Lovely VSCode theme builder", 5 | "license": "MIT", 6 | "repository": "mvllow/pinecone", 7 | "funding": "https://github.com/mvllow/sponsors/mvllow", 8 | "author": "mvllow", 9 | "type": "module", 10 | "exports": "./dist/index.js", 11 | "bin": { 12 | "pinecone": "dist/cli.js", 13 | "pinecone-cli": "dist/cli.js" 14 | }, 15 | "engines": { 16 | "node": ">=14" 17 | }, 18 | "scripts": { 19 | "build": "del-cli dist && tsc && npm test", 20 | "test": "xo && ava", 21 | "release": "npx np@latest", 22 | "version": "npm run build" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "types": "dist", 28 | "keywords": [ 29 | "vscode", 30 | "vscode-themes", 31 | "theme", 32 | "variants" 33 | ], 34 | "dependencies": { 35 | "@sindresorhus/slugify": "^2.1.1", 36 | "chalk": "^5.2.0", 37 | "chokidar": "^3.5.3", 38 | "colorish": "^0.2.1", 39 | "escape-string-regexp": "^5.0.0", 40 | "meow": "^11.0.0", 41 | "read-pkg": "^7.1.0", 42 | "ts-dedent": "^2.2.0", 43 | "write-pkg": "^5.1.0" 44 | }, 45 | "devDependencies": { 46 | "@esbuild-kit/esm-loader": "^2.5.4", 47 | "@mvllow/tsconfig": "^0.2.2", 48 | "@types/node": "^18.11.18", 49 | "@types/sinon": "^10.0.13", 50 | "ava": "^5.1.1", 51 | "del-cli": "^5.0.0", 52 | "np": "^7.6.3", 53 | "prettier": "^2.8.3", 54 | "sinon": "^15.0.1", 55 | "tempy": "^3.0.0", 56 | "typescript": "^4.9.4", 57 | "xo": "^0.53.1" 58 | }, 59 | "prettier": { 60 | "bracketSpacing": false, 61 | "semi": true, 62 | "singleQuote": true, 63 | "trailingComma": "all", 64 | "useTabs": true 65 | }, 66 | "xo": { 67 | "prettier": "true", 68 | "ignores": [ 69 | "./themes", 70 | "./pinecone.config.js" 71 | ], 72 | "rules": { 73 | "unicorn/no-array-reduce": "off" 74 | } 75 | }, 76 | "ava": { 77 | "extensions": { 78 | "ts": "module" 79 | }, 80 | "nodeArguments": [ 81 | "--loader=@esbuild-kit/esm-loader" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # pinecone 2 | 3 | > Lovely VSCode theme builder 4 | 5 | Create multiple theme variants from a single source _with variables_. 6 | 7 | ## Install 8 | 9 | ```sh 10 | npm install --global pinecone-cli 11 | ``` 12 | 13 | ## Usage 14 | 15 | > pinecone requires `"type": "module"` to be set in your package.json 16 | 17 | ``` 18 | $ pinecone --help 19 | 20 | Usage 21 | $ pinecone [options] 22 | 23 | Commands 24 | init Create new theme 25 | 26 | Options 27 | -s, --source Path to pinecone theme file 28 | -o, --output Directory for generated themes 29 | -p, --prefix Variable prefix 30 | -w, --watch Rebuild themes on change 31 | -t, --tidy Remove non-pinecone themes from output and package.json 32 | 33 | --include-non-italic-variants Generate additional non-italic variants 34 | 35 | Examples 36 | $ pinecone 37 | $ pinecone init 38 | $ pinecone --watch --tidy --include-non-italic-variants 39 | ``` 40 | 41 | ## Theme 42 | 43 | Pinecone themes look similar to any other theme with the addition of variables and difference in how empty values are handled. VSCode treats empty values as `#ff0000` whereas pinecone removes empty values for cleaner intellisense and organisation. 44 | 45 | **Example `./themes/_pinecone-color-theme.json`** 46 | 47 | ```json 48 | { 49 | "colors": { 50 | "editor.background": "$background", 51 | "editor.foreground": "$foreground", 52 | "editor.hoverHighlightBackground": "$transparent", 53 | "widget.shadow": "$shadow" 54 | }, 55 | "tokenColors": [ 56 | { 57 | "scope": ["comment"], 58 | "settings": { 59 | "foreground": "$foreground", 60 | "fontStyle": "italic" 61 | } 62 | } 63 | ] 64 | } 65 | ``` 66 | 67 | ## Config 68 | 69 | **Example `./pinecone.config.js`** 70 | 71 | ```js 72 | import {colorish, defineConfig} from 'pinecone-cli'; 73 | 74 | export default defineConfig({ 75 | options: { 76 | source: './themes/_pinecone-color-theme.json', 77 | output: './themes', 78 | prefix: '$', 79 | includeNonItalicVariants: false, 80 | }, 81 | variants: { 82 | latte: { 83 | name: 'Latte', 84 | type: 'light', 85 | }, 86 | cappuccino: { 87 | name: 'Cappuccino', 88 | type: 'light', 89 | }, 90 | espresso: { 91 | name: 'Espresso', 92 | type: 'dark', 93 | }, 94 | }, 95 | colors: { 96 | transparent: '#0000', // Shorthand to set all variants 97 | background: { 98 | latte: '#faf8f6', 99 | cappuccino: '#c29d84', 100 | espresso: '#36261b', 101 | }, 102 | foreground: { 103 | latte: '#c29d84', 104 | cappuccino: '#573d2b', 105 | espresso: '#d5bbaa', 106 | }, 107 | shadow: { 108 | latte: colorish('#c29d84', 0.1), 109 | cappuccino: colorish('#573d2b', 0.1), 110 | espresso: colorish('#d5bbaa', 0.1), 111 | }, 112 | }, 113 | }); 114 | ``` 115 | 116 | ## Made with pinecone 117 | 118 | - [Rosé Pine](https://github.com/rose-pine/vscode) 119 | -------------------------------------------------------------------------------- /source/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import meow from 'meow'; 3 | import pinecone from './index.js'; 4 | 5 | const cli = meow( 6 | ` 7 | Usage 8 | $ pinecone [options] 9 | 10 | Commands 11 | init Create new theme 12 | 13 | Options 14 | -s, --source Path to pinecone theme file 15 | -o, --output Directory for generated themes 16 | -p, --prefix Variable prefix 17 | -w, --watch Rebuild themes on change 18 | -t, --tidy Remove non-pinecone themes from output and package.json 19 | 20 | --include-non-italic-variants Generate additional non-italic variants 21 | 22 | Examples 23 | $ pinecone 24 | $ pinecone init 25 | $ pinecone --watch --tidy --include-non-italic-variants 26 | `, 27 | { 28 | booleanDefault: undefined, 29 | importMeta: import.meta, 30 | flags: { 31 | source: { 32 | alias: 's', 33 | type: 'string', 34 | }, 35 | output: { 36 | alias: 'o', 37 | type: 'string', 38 | }, 39 | prefix: { 40 | alias: 'p', 41 | type: 'string', 42 | }, 43 | watch: { 44 | alias: 'w', 45 | type: 'boolean', 46 | }, 47 | tidy: { 48 | alias: 't', 49 | type: 'boolean', 50 | }, 51 | includeNonItalicVariants: { 52 | type: 'boolean', 53 | }, 54 | }, 55 | }, 56 | ); 57 | 58 | await pinecone(cli.input[0], cli.flags); 59 | -------------------------------------------------------------------------------- /source/commands/build.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import escapeStringRegexp from 'escape-string-regexp'; 3 | import slugify from '@sindresorhus/slugify'; 4 | import { 5 | log, 6 | readToJson, 7 | styles, 8 | toRelativePath, 9 | writeToFile, 10 | type Theme, 11 | } from '../utilities.js'; 12 | import type {Config} from '../config.js'; 13 | 14 | const parseVariant = ( 15 | currentVariant: string, 16 | {name, type, ...baseTheme}: Theme, 17 | {options, variants, colors}: Config, 18 | ) => { 19 | const variant = variants[currentVariant]; 20 | 21 | if ( 22 | typeof variant === 'undefined' || 23 | typeof variant.name === 'undefined' || 24 | typeof variant.type === 'undefined' 25 | ) { 26 | log.error(` 27 | Check that variants are set properly, e.g. 28 | 29 | variants: { 30 | caffe: { 31 | name: "Caffè", 32 | type: "dark" 33 | }, 34 | ... 35 | } 36 | 37 | Documentation: 38 | ${styles.url('https://github.com/mvllow/pinecone#config')} 39 | `); 40 | 41 | throw new TypeError(`Unable to read variant.`); 42 | } 43 | 44 | const theme = Object.keys(colors).reduce((result, currentColor) => { 45 | const color = colors[currentColor]; 46 | 47 | const undefinedColor = () => { 48 | log.warn(` 49 | Unable to find ${styles.string( 50 | currentColor, 51 | )} in colors. Check that color exists, e.g. 52 | 53 | colors: { 54 | "background": { 55 | "caffe": "#36261b", 56 | "latte": "#faf8f6" 57 | }, 58 | ... 59 | } 60 | 61 | Documentation: 62 | ${styles.url('https://github.com/mvllow/pinecone#config')} 63 | `); 64 | return ''; 65 | }; 66 | 67 | if (typeof color === 'undefined') { 68 | return undefinedColor(); 69 | } 70 | 71 | const colorValue = 72 | typeof color === 'string' ? color : color[currentVariant]; 73 | 74 | if (typeof colorValue === 'undefined') { 75 | return undefinedColor(); 76 | } 77 | 78 | return result.replace( 79 | new RegExp(escapeStringRegexp(`"${options.prefix}${currentColor}"`), 'g'), 80 | `"${colorValue}"`, 81 | ); 82 | }, JSON.stringify(baseTheme)); 83 | 84 | const parsedTheme = { 85 | name: variant.name, 86 | type: variant.type, 87 | ...(JSON.parse(theme) as Theme), 88 | }; 89 | 90 | // TODO: Refactor this to be more predictable/less destructive. 91 | const workingColors = Object.keys(parsedTheme.colors ?? {}); 92 | if (typeof workingColors !== 'undefined') { 93 | for (const key of workingColors) { 94 | if ( 95 | typeof parsedTheme.colors !== 'undefined' && 96 | parsedTheme.colors[key] === '' 97 | ) { 98 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 99 | delete parsedTheme.colors[key]; 100 | } 101 | } 102 | } 103 | 104 | const slug = slugify(parsedTheme.name, {lowercase: true}); 105 | 106 | writeToFile( 107 | path.join(toRelativePath(options.output), `${slug}-color-theme.json`), 108 | JSON.stringify(parsedTheme, null, '\t'), 109 | ); 110 | 111 | if (options.includeNonItalicVariants) { 112 | const removeItalics = JSON.stringify(parsedTheme).replace( 113 | /(fontStyle":\s*?"[^.]*?)(italic)([^.]*?")/g, 114 | '$1$3', 115 | ); 116 | const parsedNonItalicTheme = { 117 | ...(JSON.parse(removeItalics) as Theme), 118 | name: `${variant.name} (no italics)`, 119 | }; 120 | 121 | writeToFile( 122 | path.join( 123 | toRelativePath(options.output), 124 | `${slug}-no-italics-color-theme.json`, 125 | ), 126 | JSON.stringify(parsedNonItalicTheme, null, '\t'), 127 | ); 128 | } 129 | 130 | return variant.name; 131 | }; 132 | 133 | export const build = (config: Config) => { 134 | const {source} = config.options; 135 | 136 | if (!source.includes('-color-theme')) { 137 | console.log( 138 | `🌸 Maybe rename ${styles.string(source)} to ${styles.string( 139 | source.replace('.json', '-color-theme.json'), 140 | )}\n for improved code completions, color decorators, and color pickers when editing.\n`, 141 | ); 142 | } 143 | 144 | const template = readToJson(source); 145 | const themeList = new Set(); 146 | 147 | for (const variant in config.variants) { 148 | if (Object.prototype.hasOwnProperty.call(config.variants, variant)) { 149 | const themeName = parseVariant(variant, template, config); 150 | themeList.add(themeName); 151 | } 152 | } 153 | 154 | const variantNames: string[] = []; 155 | for (const theme of [...themeList].sort()) { 156 | variantNames.push(theme); 157 | if (config.options.includeNonItalicVariants) { 158 | variantNames.push(`${theme} (no italics)`); 159 | } 160 | } 161 | 162 | log.list('🌿 Generated variants:', variantNames); 163 | }; 164 | -------------------------------------------------------------------------------- /source/commands/index.ts: -------------------------------------------------------------------------------- 1 | export {build} from './build.js'; 2 | export {init} from './init.js'; 3 | export {lint} from './lint.js'; 4 | export {tidy} from './tidy.js'; 5 | export {watch} from './watch.js'; 6 | -------------------------------------------------------------------------------- /source/commands/init.ts: -------------------------------------------------------------------------------- 1 | import {log, writeToFile, toRelativePath, makeDirectory} from '../utilities.js'; 2 | import {defaultConfig} from '../config.js'; 3 | 4 | export const init = async (source: string) => { 5 | const themePath = toRelativePath(source); 6 | const configPath = toRelativePath('./pinecone.config.js'); 7 | 8 | log.list('🌱 Created theme files:', [themePath, configPath]); 9 | 10 | makeDirectory(themePath); 11 | 12 | writeToFile( 13 | themePath, 14 | `{ 15 | \t"colors": { 16 | \t\t"badge.background": "", 17 | \t\t"editor.background": "$background", 18 | \t\t"editor.foreground": "$foreground", 19 | \t\t"widget.shadow": "$transparent" 20 | \t}, 21 | \t"tokenColors": [ 22 | \t\t{ 23 | \t\t\t"scope": ["comment"], 24 | \t\t\t"settings": { 25 | \t\t\t\t"foreground": "$foreground", 26 | \t\t\t\t"fontStyle": "italic" 27 | \t\t\t} 28 | \t\t}, 29 | \t\t{ 30 | \t\t\t"scope": "markup.italic.markdown", 31 | \t\t\t"settings": { 32 | \t\t\t\t"fontStyle": "italic" 33 | \t\t\t} 34 | \t\t} 35 | \t] 36 | }`, 37 | ); 38 | 39 | writeToFile( 40 | configPath, 41 | `/** @type require('pinecone-cli').Config */\nexport default ${JSON.stringify( 42 | defaultConfig, 43 | null, 44 | '\t', 45 | )}`, 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /source/commands/lint.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import slugify from '@sindresorhus/slugify'; 3 | import type {Config} from '../config.js'; 4 | import {log, readToJson, type Theme} from '../utilities.js'; 5 | 6 | export const lint = ({options, variants}: Config) => { 7 | const firstVariant = Object.keys(variants)[0]; 8 | 9 | if (typeof firstVariant === 'undefined') { 10 | throw new TypeError('No themes found'); 11 | } 12 | 13 | const selectedVariant = variants[firstVariant]; 14 | 15 | const slug = slugify(selectedVariant?.name ?? '', {lowercase: true}); 16 | const theme = readToJson( 17 | path.join(options.output, `${slug}-color-theme.json`), 18 | ); 19 | 20 | const checkThemeValues = (source: Theme) => { 21 | for (const key in source) { 22 | if (key) { 23 | const currentValue = source[key]; 24 | 25 | if (typeof currentValue === 'object') { 26 | checkThemeValues(currentValue as Record); 27 | return; 28 | } 29 | 30 | if (typeof currentValue === 'undefined') { 31 | log.warn(` 32 | Color is undefined. 33 | 34 | { 35 | "${key}": "undefined" 36 | } 37 | `); 38 | return; 39 | } 40 | 41 | if (typeof currentValue === 'string') { 42 | if (currentValue.includes('[object Object]')) { 43 | log.warn(` 44 | Color has invalid value: 45 | 46 | { 47 | "${key}": "${currentValue}" 48 | } 49 | `); 50 | return; 51 | } 52 | 53 | if (currentValue.includes(options.prefix)) { 54 | log.warn(` 55 | Color was not formatted: 56 | 57 | { 58 | "${key}": "${currentValue}" 59 | } 60 | `); 61 | return; 62 | } 63 | 64 | if (currentValue.includes('#ff0000')) { 65 | log.warn(` 66 | Color has default value: 67 | 68 | { 69 | "${key}": "${currentValue}" 70 | } 71 | 72 | This usually occurs when a color is not formatted. 73 | `); 74 | return; 75 | } 76 | } 77 | } 78 | } 79 | }; 80 | 81 | checkThemeValues(theme); 82 | }; 83 | -------------------------------------------------------------------------------- /source/commands/tidy.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import slugify from '@sindresorhus/slugify'; 4 | import {readPackage} from 'read-pkg'; 5 | import {writePackage} from 'write-pkg'; 6 | import {log, toRelativePath, type PackageTheme} from '../utilities.js'; 7 | import type {Config} from '../config.js'; 8 | 9 | export const tidy = async ({options, variants}: Config) => { 10 | const safeFiles: string[] = [path.basename(options.source)]; 11 | const themes: PackageTheme[] = []; 12 | const themesDir = './' + path.normalize(options.output); 13 | 14 | for (const variant of Object.keys(variants)) { 15 | const currentVariant = variants[variant]; 16 | 17 | if (typeof currentVariant !== 'undefined') { 18 | const {name, type} = currentVariant; 19 | const slug = slugify(name, {lowercase: true}); 20 | 21 | safeFiles.push(`${slug}-color-theme.json`); 22 | themes.push({ 23 | label: name, 24 | uiTheme: type === 'light' ? 'vs' : 'vs-dark', 25 | path: `${themesDir}/${slug}-color-theme.json`, 26 | }); 27 | 28 | if (options.includeNonItalicVariants) { 29 | safeFiles.push(`${slug}-no-italics-color-theme.json`); 30 | themes.push({ 31 | label: `${name} (no italics)`, 32 | uiTheme: type === 'light' ? 'vs' : 'vs-dark', 33 | path: `${themesDir}/${slug}-color-theme.json`, 34 | }); 35 | } 36 | } 37 | } 38 | 39 | // Remove non-pinecone themes from output directory 40 | const outputPath = toRelativePath(options.output); 41 | fs.readdir(outputPath, (error, files) => { 42 | if (error) { 43 | log.error(error.message); 44 | } 45 | 46 | for (const file of files) { 47 | const filePath = path.join(outputPath, file); 48 | 49 | if (!safeFiles.includes(file) && file.includes('-color-theme.json')) { 50 | try { 51 | fs.unlinkSync(filePath); 52 | } catch {} 53 | } 54 | } 55 | }); 56 | 57 | const pkg = await readPackage({normalize: false}); 58 | await writePackage({...pkg, contributes: {themes: themes ?? []}} as any); 59 | }; 60 | -------------------------------------------------------------------------------- /source/commands/watch.ts: -------------------------------------------------------------------------------- 1 | import chokidar from 'chokidar'; 2 | import pinecone from '../index.js'; 3 | import {log, toRelativePath} from '../utilities.js'; 4 | import type {Config} from '../config.js'; 5 | 6 | export const watch = async (config: Config) => { 7 | const themePath = toRelativePath(config.options.source); 8 | const configPath = toRelativePath('pinecone.config.js'); 9 | 10 | const watcher = chokidar.watch([themePath, configPath]); 11 | 12 | watcher.on('change', async () => { 13 | await pinecone() 14 | .then(() => { 15 | log.list('👀 Watching for changes...', [ 16 | config.options.source, 17 | './pinecone.config.js', 18 | ]); 19 | }) 20 | .catch((error: unknown) => { 21 | log.error('Unable to watch for changes.'); 22 | throw new Error(error as string); 23 | }); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /source/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import {init} from './commands/index.js'; 3 | import {importFresh, log, styles, toRelativePath} from './utilities.js'; 4 | 5 | export type Options = { 6 | /** 7 | * Path to pinecone theme file. 8 | * Append "-color-theme" to your source file for intellisense. 9 | * @default './themes/_pinecone-color-theme.json' 10 | */ 11 | source: string; 12 | 13 | /** 14 | * Path to pinecone theme file. 15 | * Append "-color-theme" to your source file for intellisense. 16 | * @default './themes/_pinecone-color-theme.json' 17 | */ 18 | output: string; 19 | 20 | /** 21 | * Variable prefix. 22 | * @default '$' 23 | */ 24 | prefix: string; 25 | 26 | /** 27 | * Rebuild themes on change. 28 | * @default false 29 | */ 30 | watch: boolean; 31 | 32 | /** 33 | * Purge non-pinecone themes and sync package.json contributes 34 | * section. 35 | * @default false 36 | */ 37 | tidy?: boolean; 38 | 39 | /** 40 | * Generate additional variants with no italics. 41 | * @default false 42 | */ 43 | includeNonItalicVariants: boolean; 44 | }; 45 | 46 | export type Variant = { 47 | name: string; 48 | type: 'dark' | 'light'; 49 | }; 50 | 51 | export type Config = { 52 | options: Options; 53 | variants: Record; 54 | colors: Record>; 55 | }; 56 | 57 | export type UserOptions = Partial; 58 | export type UserConfig = Partial; 59 | 60 | export const defaultConfig: Config = { 61 | options: { 62 | source: './themes/_pinecone-color-theme.json', 63 | output: './themes', 64 | prefix: '$', 65 | watch: false, 66 | tidy: false, 67 | includeNonItalicVariants: false, 68 | }, 69 | variants: { 70 | caffe: { 71 | name: 'Caffè', 72 | type: 'dark', 73 | }, 74 | latte: { 75 | name: 'Caffè Latte', 76 | type: 'light', 77 | }, 78 | }, 79 | colors: { 80 | transparent: '#0000', 81 | background: { 82 | caffe: '#36261b', 83 | latte: '#faf8f6', 84 | }, 85 | foreground: { 86 | caffe: '#d5bbaa', 87 | latte: '#c29d84', 88 | }, 89 | }, 90 | }; 91 | 92 | export const resolveConfig = async (flags?: UserOptions) => { 93 | const configPath = toRelativePath('pinecone.config.js'); 94 | const userConfig = await importFresh(configPath); 95 | const options: Options = Object.assign( 96 | defaultConfig.options, 97 | userConfig?.options, 98 | flags, 99 | ); 100 | 101 | if (typeof userConfig === 'undefined') { 102 | if (fs.existsSync(configPath)) { 103 | log.error(` 104 | Unable to read ${styles.string( 105 | 'pinecone.config.js', 106 | )}. This is likely due to invalid syntax. 107 | `); 108 | 109 | throw new TypeError('Unable to read user config.'); 110 | } 111 | 112 | await init(flags?.source ?? defaultConfig.options.source); 113 | } 114 | 115 | return {...defaultConfig, ...userConfig, options: {...options}}; 116 | }; 117 | 118 | export const defineConfig = (config: UserConfig): UserConfig => config; 119 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | import {build, init, lint, tidy, watch} from './commands/index.js'; 2 | import {resolveConfig, type UserOptions} from './config.js'; 3 | import {log} from './utilities.js'; 4 | 5 | export const pinecone = async (command?: string, flags?: UserOptions) => { 6 | console.clear(); 7 | console.log('🌲 Pinecone\n'); 8 | 9 | const config = await resolveConfig(flags); 10 | 11 | if (!config || command === 'init') { 12 | await init(config.options.source); 13 | return; 14 | } 15 | 16 | build(config); 17 | 18 | if (config.options.tidy) await tidy(config); 19 | 20 | lint(config); 21 | 22 | if (config.options.watch) { 23 | log.list('👀 Watching for changes...', [ 24 | config.options.source, 25 | './pinecone.config.js', 26 | ]); 27 | 28 | await watch(config); 29 | } 30 | }; 31 | 32 | export {colorish} from 'colorish'; 33 | export {defineConfig, type UserConfig as Config} from './config.js'; 34 | export default pinecone; 35 | -------------------------------------------------------------------------------- /source/utilities.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import process from 'node:process'; 4 | import chalk from 'chalk'; 5 | import {dedent} from 'ts-dedent'; 6 | 7 | /** 8 | * VSCode package.json section. 9 | * @example { "contributes": { "themes": [...] } } 10 | */ 11 | export type PackageTheme = { 12 | label: string; 13 | uiTheme: 'vs' | 'vs-dark'; 14 | path: string; 15 | }; 16 | 17 | /** 18 | * VSCode theme file. 19 | */ 20 | export type Theme = { 21 | [key: string]: unknown; 22 | name?: string; 23 | type?: 'light' | 'dark'; 24 | colors?: Record; 25 | tokenColors?: unknown[]; 26 | semanticHighlighting?: boolean; 27 | semanticTokenColors?: Record; 28 | }; 29 | 30 | const formatMessage = (message: string) => 31 | dedent(message.replace(/\t/g, ' ')); 32 | 33 | export const styles = { 34 | item: (message: string) => ` ${chalk.dim('-')} ${chalk.magenta(message)}`, 35 | string: (message: string) => chalk.yellow(`"${message}"`), 36 | url: (message: string) => chalk.magenta(message), 37 | }; 38 | 39 | export const log = { 40 | list(title: string, items: string[]) { 41 | console.log(title); 42 | for (const item of items) { 43 | console.log(styles.item(item)); 44 | } 45 | 46 | console.log(); 47 | }, 48 | warn(message: string) { 49 | console.log( 50 | chalk(chalk.yellow.inverse(' Warn '), '%s\n'), 51 | formatMessage(message), 52 | ); 53 | }, 54 | error(message: string) { 55 | console.log( 56 | chalk(chalk.red.inverse(' Error '), '%s\n'), 57 | formatMessage(message), 58 | ); 59 | }, 60 | }; 61 | 62 | export const toRelativePath = (to: string) => path.join(process.cwd(), to); 63 | 64 | export const readToString = (file: string) => { 65 | try { 66 | return fs.readFileSync(toRelativePath(file), 'utf8'); 67 | } catch (error: unknown) { 68 | log.error(`Unable to read file, ${toRelativePath(file)}`); 69 | throw new Error(error as string); 70 | } 71 | }; 72 | 73 | export const readToJson = (file: string): T => { 74 | try { 75 | const contents = fs 76 | .readFileSync(toRelativePath(file), 'utf8') 77 | .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => 78 | g ? '' : m, 79 | ); 80 | 81 | return JSON.parse(contents) as T; 82 | } catch (error: unknown) { 83 | log.error(`Unable to read file, ${toRelativePath(file)}`); 84 | throw new Error(error as string); 85 | } 86 | }; 87 | 88 | export const writeToFile = (where: string, what: string) => { 89 | try { 90 | fs.writeFileSync(where, what, 'utf8'); 91 | } catch (error: unknown) { 92 | log.error(`Unable to write file, ${where}`); 93 | throw new Error(error as string); 94 | } 95 | }; 96 | 97 | export const makeDirectory = (where: string) => { 98 | try { 99 | fs.mkdirSync(path.dirname(where), {recursive: true}); 100 | } catch (error: unknown) { 101 | log.error(`Unable to make directory, ${path.dirname(where)}`); 102 | throw new Error(error as string); 103 | } 104 | }; 105 | 106 | export const importFresh = async ( 107 | modulePath: string, 108 | ): Promise | undefined> => { 109 | const freshModulePath = `${modulePath}?update=${Date.now()}`; 110 | 111 | try { 112 | const freshModule = (await import(freshModulePath)) as {default: T}; 113 | return freshModule.default; 114 | } catch { 115 | return undefined; 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /test/main.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import process from 'node:process'; 3 | import test from 'ava'; 4 | import {temporaryDirectory} from 'tempy'; 5 | import {readPackage} from 'read-pkg'; 6 | import {writePackage} from 'write-pkg'; 7 | import {restore, stub} from 'sinon'; 8 | import pinecone from '../source/index.js'; 9 | import { 10 | readToJson, 11 | readToString, 12 | toRelativePath, 13 | writeToFile, 14 | type Theme, 15 | } from '../source/utilities.js'; 16 | 17 | test.before(async () => { 18 | const temporary = temporaryDirectory(); 19 | const pkg = await readPackage({normalize: false}); 20 | 21 | await writePackage(temporary, {...pkg} as any); 22 | 23 | stub(console, 'log'); 24 | stub(process, 'cwd').callsFake(() => temporary); 25 | 26 | await pinecone('init'); 27 | }); 28 | 29 | test.after(() => { 30 | restore(); 31 | }); 32 | 33 | test('creates default config', async (t) => { 34 | await pinecone(); 35 | 36 | const theme1 = readToJson>( 37 | './themes/caffe-color-theme.json', 38 | ); 39 | const theme2 = readToJson>( 40 | './themes/caffe-latte-color-theme.json', 41 | ); 42 | 43 | t.is(theme1['colors']?.['editor.background'], '#36261b'); 44 | t.is(theme2['colors']?.['editor.background'], '#faf8f6'); 45 | }); 46 | 47 | test('replaces prefixed values', async (t) => { 48 | await pinecone(); 49 | 50 | const theme1 = readToString(`/themes/caffe-color-theme.json`); 51 | const theme2 = readToString(`/themes/caffe-latte-color-theme.json`); 52 | 53 | t.notRegex(theme1, /\$\w/g); 54 | t.notRegex(theme2, /\$\w/g); 55 | }); 56 | 57 | test('includes non-italic variants', async (t) => { 58 | await pinecone('', {includeNonItalicVariants: true}); 59 | 60 | const theme1 = readToString(`/themes/caffe-no-italics-color-theme.json`); 61 | const theme2 = readToString( 62 | `/themes/caffe-latte-no-italics-color-theme.json`, 63 | ); 64 | 65 | t.notRegex(theme1, /fontStyle.*?italic/g); 66 | t.notRegex(theme2, /fontStyle.*?italic/g); 67 | // Ensure scope names are left unmodified 68 | t.regex(theme2, /markup\.italic\.markdown/g); 69 | }); 70 | 71 | test('removes empty values', async (t) => { 72 | await pinecone(); 73 | 74 | const theme = readToJson(`./themes/caffe-latte-color-theme.json`); 75 | t.is(theme.colors?.['badge.background'], undefined); 76 | }); 77 | 78 | test('removes unused themes and syncs package.json contributes', async (t) => { 79 | const extraThemePath = toRelativePath('./themes/extra-color-theme.json'); 80 | writeToFile(extraThemePath, '{}'); 81 | 82 | await pinecone('', {tidy: true}); 83 | 84 | const pkg = await readPackage(); 85 | 86 | t.is(pkg.contributes.themes[0].label, 'Caffè'); 87 | t.false(fs.existsSync(extraThemePath)); 88 | }); 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@mvllow/tsconfig", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "dist" 6 | }, 7 | "include": ["source"] 8 | } 9 | --------------------------------------------------------------------------------