| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import cssToTheme from './cssToTheme'; | ||
|
|
||
| describe('cssToTheme', function() { | ||
|
|
||
| it('should convert a CSS string to a theme', async () => { | ||
| const input = ` | ||
| :root { | ||
| --joplin-appearence: light; | ||
| --joplin-color: #333333; | ||
| --joplin-background-color: #778899; | ||
| /* Should skip this comment and empty lines */ | ||
| --joplin-background-color-transparent: rgba(255,255,255,0.9); | ||
| }`; | ||
|
|
||
| const expected = { | ||
| appearence: 'light', | ||
| color: '#333333', | ||
| backgroundColor: '#778899', | ||
| backgroundColorTransparent: 'rgba(255,255,255,0.9)', | ||
| }; | ||
|
|
||
| const actual = cssToTheme(input, 'test.css'); | ||
| expect(actual).toEqual(expected); | ||
| }); | ||
|
|
||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { Theme } from '../../themes/type'; | ||
|
|
||
| // Need to include it that way due to a bug in the lib: | ||
| // https://github.com/reworkcss/css/pull/146#issuecomment-740412799 | ||
| const cssParse = require('css/lib/parse'); | ||
|
|
||
| function formatCssToThemeVariable(cssVariable: string): string { | ||
| const elements = cssVariable.substr(2).split('-'); | ||
| if (elements[0] !== 'joplin') throw new Error(`CSS variable name must start with "--joplin": ${cssVariable}`); | ||
|
|
||
| elements.splice(0, 1); | ||
|
|
||
| return elements.map((e, i) => { | ||
| const c = i === 0 ? e[0] : e[0].toUpperCase(); | ||
| return c + e.substr(1); | ||
| }).join(''); | ||
| } | ||
|
|
||
| // function unquoteValue(v:string):string { | ||
| // if (v.startsWith("'") && v.endsWith("'") || v.startsWith('"') && v.endsWith('"')) return v.substr(1, v.length - 2); | ||
| // return v; | ||
| // } | ||
|
|
||
| export default function cssToTheme(css: string, sourceFilePath: string): Theme { | ||
| const o = cssParse(css, { | ||
| silent: false, | ||
| source: sourceFilePath, | ||
| }); | ||
|
|
||
| if (!o?.stylesheet?.rules?.length) throw new Error(`Invalid CSS color file: ${sourceFilePath}`); | ||
|
|
||
| // Need "as any" because outdated TS definition file | ||
|
|
||
| const rootRule = o.stylesheet.rules[0]; | ||
| if (!rootRule.selectors.includes(':root')) throw new Error('`:root` rule not found'); | ||
|
|
||
| const declarations: any[] = rootRule.declarations; | ||
|
|
||
| const output: any = {}; | ||
| for (const declaration of declarations) { | ||
| if (declaration.type !== 'declaration') continue; // Skip comment lines | ||
| output[formatCssToThemeVariable(declaration.property)] = declaration.value; | ||
| } | ||
|
|
||
| return output; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { Theme } from '../../themes/type'; | ||
| import { filename } from '../../path-utils'; | ||
| import shim from '../../shim'; | ||
| import cssToTheme from './cssToTheme'; | ||
|
|
||
| export default async function(cssBaseDir: string): Promise<Record<string, Theme>> { | ||
| const themeDirs = (await shim.fsDriver().readDirStats(cssBaseDir)).filter((f: any) => f.isDirectory()); | ||
|
|
||
| const output: Record<string, Theme> = {}; | ||
|
|
||
| for (const themeDir of themeDirs) { | ||
| const themeName = filename(themeDir.path); | ||
| const cssFile = `${cssBaseDir}/${themeDir.path}/colors.css`; | ||
| const cssContent = await shim.fsDriver().readFile(cssFile, 'utf8'); | ||
|
|
||
| let themeId = themeName; | ||
| const manifestFile = `${cssBaseDir}/${themeDir.path}/manifest.json`; | ||
| if (await shim.fsDriver().exists(manifestFile)) { | ||
| const manifest = JSON.parse(await shim.fsDriver().readFile(manifestFile, 'utf8')); | ||
| if (manifest.id) themeId = manifest.id; | ||
| } | ||
|
|
||
| output[themeId] = cssToTheme(cssContent, cssFile); | ||
| } | ||
|
|
||
| return output; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { Theme, ThemeAppearance } from '../../themes/type'; | ||
| import themeToCss from './themeToCss'; | ||
|
|
||
| const input: Theme = { | ||
| appearance: ThemeAppearance.Light, | ||
|
|
||
| // Color scheme "1" is the basic one, like used to display the note | ||
| // content. It's basically dark gray text on white background | ||
| backgroundColor: '#ffffff', | ||
| backgroundColorTransparent: 'rgba(255,255,255,0.9)', | ||
| oddBackgroundColor: '#eeeeee', | ||
| color: '#32373F', // For regular text | ||
| colorError: 'red', | ||
| colorWarn: 'rgb(228,86,0)', | ||
| colorWarnUrl: '#155BDA', | ||
| colorFaded: '#7C8B9E', // For less important text | ||
| colorBright: '#000000', // For important text | ||
| dividerColor: '#dddddd', | ||
| selectedColor: '#e5e5e5', | ||
| urlColor: '#155BDA', | ||
|
|
||
| // Color scheme "2" is used for the sidebar. It's white text over | ||
| // dark blue background. | ||
| backgroundColor2: '#313640', | ||
| color2: '#ffffff', | ||
| selectedColor2: '#131313', | ||
| colorError2: '#ff6c6c', | ||
| colorWarn2: '#ffcb81', | ||
|
|
||
| // Color scheme "3" is used for the config screens for example/ | ||
| // It's dark text over gray background. | ||
| backgroundColor3: '#F4F5F6', | ||
| backgroundColorHover3: '#CBDAF1', | ||
| color3: '#738598', | ||
|
|
||
| // Color scheme "4" is used for secondary-style buttons. It makes a white | ||
| // button with blue text. | ||
| backgroundColor4: '#ffffff', | ||
| color4: '#2D6BDC', | ||
|
|
||
| raisedBackgroundColor: '#e5e5e5', | ||
| raisedColor: '#222222', | ||
| searchMarkerBackgroundColor: '#F7D26E', | ||
| searchMarkerColor: 'black', | ||
|
|
||
| warningBackgroundColor: '#FFD08D', | ||
|
|
||
| tableBackgroundColor: 'rgb(247, 247, 247)', | ||
| codeBackgroundColor: 'rgb(243, 243, 243)', | ||
| codeBorderColor: 'rgb(220, 220, 220)', | ||
| codeColor: 'rgb(0,0,0)', | ||
|
|
||
| blockQuoteOpacity: 0.7, | ||
|
|
||
| codeMirrorTheme: 'default', | ||
| codeThemeCss: 'atom-one-light.css', | ||
| }; | ||
|
|
||
| const expected = ` | ||
| :root { | ||
| --joplin-appearance: light; | ||
| --joplin-background-color: #ffffff; | ||
| --joplin-background-color-transparent: rgba(255,255,255,0.9); | ||
| --joplin-odd-background-color: #eeeeee; | ||
| --joplin-color: #32373F; | ||
| --joplin-color-error: red; | ||
| --joplin-color-warn: rgb(228,86,0); | ||
| --joplin-color-warn-url: #155BDA; | ||
| --joplin-color-faded: #7C8B9E; | ||
| --joplin-color-bright: #000000; | ||
| --joplin-divider-color: #dddddd; | ||
| --joplin-selected-color: #e5e5e5; | ||
| --joplin-url-color: #155BDA; | ||
| --joplin-background-color2: #313640; | ||
| --joplin-color2: #ffffff; | ||
| --joplin-selected-color2: #131313; | ||
| --joplin-color-error2: #ff6c6c; | ||
| --joplin-color-warn2: #ffcb81; | ||
| --joplin-background-color3: #F4F5F6; | ||
| --joplin-background-color-hover3: #CBDAF1; | ||
| --joplin-color3: #738598; | ||
| --joplin-background-color4: #ffffff; | ||
| --joplin-color4: #2D6BDC; | ||
| --joplin-raised-background-color: #e5e5e5; | ||
| --joplin-raised-color: #222222; | ||
| --joplin-search-marker-background-color: #F7D26E; | ||
| --joplin-search-marker-color: black; | ||
| --joplin-warning-background-color: #FFD08D; | ||
| --joplin-table-background-color: rgb(247, 247, 247); | ||
| --joplin-code-background-color: rgb(243, 243, 243); | ||
| --joplin-code-border-color: rgb(220, 220, 220); | ||
| --joplin-code-color: rgb(0,0,0); | ||
| --joplin-block-quote-opacity: 0.7; | ||
| --joplin-code-mirror-theme: default; | ||
| --joplin-code-theme-css: atom-one-light.css; | ||
| }`; | ||
|
|
||
| describe('themeToCss', function() { | ||
|
|
||
| it('should a theme to a CSS string', async () => { | ||
| const actual = themeToCss(input); | ||
| expect(actual.trim()).toBe(expected.trim()); | ||
| }); | ||
|
|
||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { Theme } from '../../themes/type'; | ||
| const { camelCaseToDash, formatCssSize } = require('../../string-utils'); | ||
|
|
||
| // function quoteCssValue(name: string, value: string): string { | ||
| // const needsQuote = ['appearance', 'codeMirrorTheme', 'codeThemeCss'].includes(name); | ||
| // if (needsQuote) return `'${value}'`; | ||
| // return value; | ||
| // } | ||
|
|
||
| export default function(theme: Theme) { | ||
| const lines = []; | ||
| lines.push(':root {'); | ||
|
|
||
| for (const name in theme) { | ||
| const value = (theme as any)[name]; | ||
| const newName = `--joplin-${camelCaseToDash(name)}`; | ||
| const formattedValue = typeof value === 'number' && newName.indexOf('opacity') < 0 ? formatCssSize(value) : value; | ||
| lines.push(`\t${newName}: ${formattedValue};`); | ||
| } | ||
|
|
||
| lines.push('}'); | ||
|
|
||
| return lines.join('\n'); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import themeToCss from '@joplin/lib/services/style/themeToCss'; | ||
| import * as fs from 'fs-extra'; | ||
| import { rootDir } from './tool-utils'; | ||
| import { filename } from '@joplin/lib/path-utils'; | ||
|
|
||
| function themeIdFromName(name: string) { | ||
| const nameToId: Record<string, number> = { | ||
| light: 1, | ||
| dark: 2, | ||
| oledDark: 22, | ||
| solarizedLight: 3, | ||
| solarizedDark: 4, | ||
| dracula: 5, | ||
| nord: 6, | ||
| aritimDark: 7, | ||
| }; | ||
|
|
||
| if (!nameToId[name]) throw new Error(`Invalid name: ${name}`); | ||
|
|
||
| return nameToId[name]; | ||
| } | ||
|
|
||
| async function main() { | ||
| const baseThemeDir = `${rootDir}/packages/lib/themes`; | ||
| const themeFiles = (await fs.readdir(baseThemeDir)).filter(f => f.endsWith('.js') && f !== 'type.js'); | ||
|
|
||
| for (const themeFile of themeFiles) { | ||
| const themeName = filename(themeFile); | ||
| const themeDir = `${baseThemeDir}/${themeName}`; | ||
| await fs.mkdirp(themeDir); | ||
|
|
||
| const cssFile = `${themeDir}/colors.css`; | ||
| const content = require(`${baseThemeDir}/${themeFile}`).default; | ||
| const newContent = themeToCss(content); | ||
| await fs.writeFile(cssFile, newContent, 'utf8'); | ||
|
|
||
| const manifestFile = `${themeDir}/manifest.json`; | ||
| const manifestContent = { | ||
| id: themeIdFromName(themeName), | ||
| }; | ||
| await fs.writeFile(manifestFile, JSON.stringify(manifestContent, null, '\t'), 'utf8'); | ||
| } | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| console.error(error); | ||
| process.exit(1); | ||
| }); |