28 changes: 28 additions & 0 deletions packages/lib/services/style/cssToTheme.test.ts
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);
});

});
46 changes: 46 additions & 0 deletions packages/lib/services/style/cssToTheme.ts
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;
}
27 changes: 27 additions & 0 deletions packages/lib/services/style/loadCssToTheme.ts
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;
}
105 changes: 105 additions & 0 deletions packages/lib/services/style/themeToCss.test.ts
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());
});

});
24 changes: 24 additions & 0 deletions packages/lib/services/style/themeToCss.ts
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');
}
8 changes: 3 additions & 5 deletions packages/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const themes: any = {
[Setting.THEME_OLED_DARK]: theme_oledDark,
};

function themeById(themeId: string) {
export function themeById(themeId: string) {
if (!themes[themeId]) throw new Error(`Invalid theme ID: ${themeId}`);
const output = Object.assign({}, themes[themeId]);

Expand Down Expand Up @@ -365,7 +365,7 @@ function addExtraStyles(style: any) {

const themeCache_: any = {};

function themeStyle(themeId: number) {
export function themeStyle(themeId: number) {
if (!themeId) throw new Error('Theme must be specified');

const zoomRatio = 1;
Expand Down Expand Up @@ -405,7 +405,7 @@ const cachedStyles_: any = {
// cacheKey must be a globally unique key, and must change whenever
// the dependencies of the style change. If the style depends only
// on the theme, a static string can be provided as a cache key.
function buildStyle(cacheKey: any, themeId: number, callback: Function) {
export function buildStyle(cacheKey: any, themeId: number, callback: Function) {
cacheKey = Array.isArray(cacheKey) ? cacheKey.join('_') : cacheKey;

// We clear the cache whenever switching themes
Expand All @@ -425,5 +425,3 @@ function buildStyle(cacheKey: any, themeId: number, callback: Function) {

return cachedStyles_.styles[cacheKey].style;
}

export { themeStyle, buildStyle, themeById };
2 changes: 1 addition & 1 deletion packages/renderer/package-lock.json
48 changes: 48 additions & 0 deletions packages/tools/convertThemesToCss.ts
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);
});