From b7019d963bf9c6563cb4ee6fc7475d7afe22c21e Mon Sep 17 00:00:00 2001
From: Michael Kreil <github@michael-kreil.de>
Date: Tue, 28 Nov 2023 14:03:56 +0100
Subject: [PATCH] feat: simplify API

---
 browser_test/browser.test.ts  | 46 +++++++-----------
 scripts/build-styles.ts       | 31 ++++--------
 src/index.test.ts             | 40 ++++++++-------
 src/index.ts                  | 23 +++++++--
 src/lib/recolor.test.ts       |  2 +-
 src/lib/style_builder.test.ts |  3 +-
 src/lib/style_builder.ts      | 91 +++++++++++++++++------------------
 src/lib/style_guesser.ts      | 10 ++--
 src/lib/types.ts              | 12 +++++
 9 files changed, 132 insertions(+), 126 deletions(-)

diff --git a/browser_test/browser.test.ts b/browser_test/browser.test.ts
index 0674ef6..4c1a473 100644
--- a/browser_test/browser.test.ts
+++ b/browser_test/browser.test.ts
@@ -1,45 +1,35 @@
 
 import type { SymbolLayerSpecification } from '@maplibre/maplibre-gl-style-spec';
 import * as V from '../release/versatiles-style.js';
-import type { LanguageSuffix } from '../src/lib/types.js';
+import type { LanguageSuffix, MaplibreStyle, StylemakerOptions } from '../src/lib/types.js';
+import type Colorful from '../src/style/colorful.js';
+import type StyleBuilder from '../src/lib/style_builder.js';
+import type Graybeard from '../src/style/graybeard.ts';
+import type Neutrino from '../src/style/neutrino.ts';
 
-type Builder = typeof V.Colorful | typeof V.Neutrino;
-type Style = V.Colorful | V.Neutrino;
+type Builder<T extends StyleBuilder<T>> = (options?: StylemakerOptions<T>) => MaplibreStyle;
+type GenericBuilder = Builder<Colorful> | Builder<Graybeard> | Builder<Neutrino>;
 
-interface StyleTestConfig {
-	name: string;
-	builder: Builder;
-	labelLayers?: RegExp;
-}
-
-[
-	{ name: 'Colorful', builder: V.Colorful, labelLayers: /^label-(street|place|boundary|transit)-/ },
-	{ name: 'Graybeard', builder: V.Graybeard, labelLayers: /^label-(street|place|boundary|transit)-/ },
-	{ name: 'Neutrino', builder: V.Neutrino },
-].forEach(config => {
-	test(config);
-});
-
-function test(config: StyleTestConfig): void {
+test('Colorful', V.colorful, /^label-(street|place|boundary|transit)-/);
+test('Graybeard', V.graybeard, /^label-(street|place|boundary|transit)-/);
+test('Neutrino', V.neutrino);
 
-	describe('Style: ' + config.name, () => {
-		let style: Style;
-		beforeEach(() => style = new config.builder());
+function test(name: string, build: GenericBuilder, labelLayers?: RegExp): void {
 
+	describe('Style: ' + name, () => {
 		it('should be buildable', () => {
-			expect(style.build()).toBeTruthy();
+			expect(build()).toBeTruthy();
 		});
 
-		if (config.labelLayers) {
+		if (labelLayers) {
 			it('should use correct language suffix', () => {
-				(['', '_de', '_en'] as LanguageSuffix[]).forEach(langSuffix => {
-					style.languageSuffix = langSuffix;
-					const textLayers = style.build().layers
-						.filter(l => config.labelLayers?.test(l.id))
+				(['', '_de', '_en'] as LanguageSuffix[]).forEach(languageSuffix => {
+					const textLayers = build({ languageSuffix }).layers
+						.filter(l => labelLayers.test(l.id))
 						.filter(l => l.type === 'symbol') as SymbolLayerSpecification[];
 					const layersTextFields = textLayers.map(l => l.layout?.['text-field'] as unknown);
 					expect(layersTextFields)
-						.toMatchObject(layersTextFields.slice().fill(`{name${langSuffix}}`));
+						.toMatchObject(layersTextFields.slice().fill(`{name${languageSuffix}}`));
 				});
 			});
 		}
diff --git a/scripts/build-styles.ts b/scripts/build-styles.ts
index e2b89ca..01838de 100755
--- a/scripts/build-styles.ts
+++ b/scripts/build-styles.ts
@@ -2,11 +2,10 @@
 
 import { mkdirSync, writeFileSync } from 'node:fs';
 import { resolve } from 'node:path';
-import { Colorful, Graybeard, Neutrino } from '../src/index.js';
+import { colorful, graybeard, neutrino } from '../src/index.js';
 import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec';
 import { prettyStyleJSON } from './lib/utils.js';
-import type { MaplibreStyle } from '../src/index.js';
-import StyleBuilder from '../src/lib/style_builder.ts';
+import type { MaplibreStyle } from '../src/lib/types.js';
 
 
 
@@ -17,24 +16,14 @@ mkdirSync(dirDst, { recursive: true });
 
 // load styles
 [
-	Colorful,
-	Graybeard,
-	Neutrino,
-].forEach(styleBuilderClass => {
-	const styleBuilder = new styleBuilderClass();
-	const { name } = styleBuilder;
-
-	styleBuilder.languageSuffix = '';
-	produce(name, styleBuilder.build());
-
-	styleBuilder.languageSuffix = '_en';
-	produce(name + '.en', styleBuilder.build());
-
-	styleBuilder.languageSuffix = '_de';
-	produce(name + '.de', styleBuilder.build());
-
-	styleBuilder.hideLabels = true;
-	produce(name + '.nolabel', styleBuilder.build());
+	{ name: 'colorful', build: colorful },
+	{ name: 'graybeard', build: graybeard },
+	{ name: 'neutrino', build: neutrino },
+].forEach(({ name, build }) => {
+	produce(name, build({ languageSuffix: '' }));
+	produce(name + '.en', build({ languageSuffix: '_en' }));
+	produce(name + '.de', build({ languageSuffix: '_de' }));
+	produce(name + '.nolabel', build({ hideLabels: true }));
 })
 
 function produce(name: string, style: MaplibreStyle): void {
diff --git a/src/index.test.ts b/src/index.test.ts
index 8a74f6c..758784a 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -1,23 +1,18 @@
 /* eslint-disable @typescript-eslint/naming-convention */
-import * as builderClasses from './index.js';
-import StyleBuilder from './lib/style_builder.js';
+import * as builders from './index.js';
 
 describe('Style Builders', () => {
 	const styles = [
-		{ name: 'Colorful', builderClass: builderClasses.Colorful },
-		{ name: 'Graybeard', builderClass: builderClasses.Graybeard },
-		{ name: 'Neutrino', builderClass: builderClasses.Neutrino },
+		{ name: 'Colorful', builder: builders.colorful },
+		{ name: 'Graybeard', builder: builders.graybeard },
+		{ name: 'Neutrino', builder: builders.neutrino },
 	];
 
-	styles.forEach(({ name, builderClass }) => {
+	styles.forEach(({ name, builder }) => {
 		it(`should create and test an instance of ${name}`, () => {
-			const builder = new builderClass();
-			expect(builder).toBeInstanceOf(StyleBuilder);
-			expect(typeof builder.name).toBe('string');
-			expect(builder.name).toBe(name);
+			expect(typeof builder).toBe('function');
 
-			builder.baseUrl = 'https://example.org';
-			const style = builder.build();
+			const style = builder({ baseUrl: 'https://example.org' });
 			expect(JSON.stringify(style).length).toBeGreaterThan(50000);
 
 			expect(style.name).toBe('versatiles-' + name.toLowerCase());
@@ -31,16 +26,25 @@ describe('Style Builders', () => {
 });
 
 describe('Colorful', () => {
-	const colorful = new builderClasses.Colorful();
-	colorful.baseUrl = 'https://dev.null';
-	colorful.defaultColors.commercial = '#f00';
-	const style = colorful.build();
+	const style = builders.colorful({
+		baseUrl: 'https://dev.null',
+		colors: { commercial: '#f00' },
+	});
 	expect(style.glyphs).toBe('https://dev.null/assets/fonts/{fontstack}/{range}.pbf');
+	const paint = style.layers.find(l => l.id === 'land-commercial')?.paint;
+
+	expect(paint).toBeDefined();
+	if (paint == null) throw Error();
+
+	expect(paint).toHaveProperty('fill-color');
+	if (!('fill-color' in paint)) throw Error();
+
+	expect(paint['fill-color']).toBe('#ff0000');
 });
 
 describe('guessStyle', () => {
 	it('should build raster styles', () => {
-		const style = builderClasses.guessStyle({
+		const style = builders.guessStyle({
 			type: 'raster',
 			tiles: [],
 			format: 'avif',
@@ -54,7 +58,7 @@ describe('guessStyle', () => {
 
 
 	it('should build vector styles', () => {
-		const style = builderClasses.guessStyle({
+		const style = builders.guessStyle({
 			type: 'vector',
 			tiles: [],
 			format: 'pbf',
diff --git a/src/index.ts b/src/index.ts
index b3b5bfc..009a618 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,21 @@
+import type { MaplibreStyle, StylemakerOptions } from './lib/types.js';
 
-export type { MaplibreStyle, TileJSONSpecification, TileJSONSpecificationRaster, TileJSONSpecificationVector } from './lib/types.js';
+import Colorful from './style/colorful.js';
+export function colorful(options?: StylemakerOptions<Colorful>): MaplibreStyle {
+	return new Colorful().build(options);
+}
+
+import Graybeard from './style/graybeard.js';
+export function graybeard(options?: StylemakerOptions<Graybeard>): MaplibreStyle {
+	return new Graybeard().build(options);
+}
+
+import Neutrino from './style/neutrino.js';
+export function neutrino(options?: StylemakerOptions<Neutrino>): MaplibreStyle {
+	return new Neutrino().build(options);
+}
 
-export { default as guessStyle } from './lib/style_guesser.js';
 
-export { default as Colorful } from './style/colorful.js';
-export { default as Graybeard } from './style/graybeard.js';
-export { default as Neutrino } from './style/neutrino.js';
+export type { TileJSONSpecification, TileJSONSpecificationRaster, TileJSONSpecificationVector } from './lib/types.js';
+
+export { default as guessStyle } from './lib/style_guesser.js';
diff --git a/src/lib/recolor.test.ts b/src/lib/recolor.test.ts
index 8b37bc5..ba4442c 100644
--- a/src/lib/recolor.test.ts
+++ b/src/lib/recolor.test.ts
@@ -225,5 +225,5 @@ export default class TestStyle extends StyleBuilder<TestStyle> {
 
 function getDefaultColors(): StylemakerColors<TestStyle> {
 	const style = new TestStyle();
-	return style.getColors();
+	return style.getColors(style.defaultColors);
 }
diff --git a/src/lib/style_builder.test.ts b/src/lib/style_builder.test.ts
index 89ccb85..8964e5d 100644
--- a/src/lib/style_builder.test.ts
+++ b/src/lib/style_builder.test.ts
@@ -67,8 +67,7 @@ describe('StyleBuilder', () => {
 		});
 
 		it('should resolve urls correctly', () => {
-			builder.baseUrl = 'https://my.base.url/';
-			const style: MaplibreStyle = builder.build();
+			const style: MaplibreStyle = builder.build({ baseUrl: 'https://my.base.url/' });
 			expect(style.glyphs).toBe('https://my.base.url/assets/fonts/{fontstack}/{range}.pbf');
 			expect(style.sprite).toBe('https://my.base.url/assets/sprites/sprites');
 
diff --git a/src/lib/style_builder.ts b/src/lib/style_builder.ts
index 9f0861e..e79dccc 100644
--- a/src/lib/style_builder.ts
+++ b/src/lib/style_builder.ts
@@ -5,35 +5,20 @@ import { decorate } from './decorator.js';
 import { getDefaultRecolorFlags, recolor } from './recolor.js';
 import { deepClone } from './utils.js';
 import type {
-	LanguageSuffix,
 	MaplibreLayer,
 	MaplibreLayerDefinition,
 	MaplibreStyle,
-	RecolorOptions,
 	StyleRules,
 	StyleRulesOptions,
 	StylemakerColorKeys,
 	StylemakerColorStrings,
 	StylemakerColors,
 	StylemakerFontStrings,
+	StylemakerOptions,
 } from './types.js';
 
 // Stylemaker class definition
 export default abstract class StyleBuilder<Subclass extends StyleBuilder<Subclass>> {
-	public baseUrl: string;
-
-	public glyphsUrl = '/assets/fonts/{fontstack}/{range}.pbf';
-
-	public spriteUrl = '/assets/sprites/sprites';
-
-	public tilesUrls: string[] = ['/tiles/osm/{z}/{x}/{y}'];
-
-	public hideLabels = false;
-
-	public languageSuffix: LanguageSuffix = '';
-
-	public recolor: RecolorOptions = getDefaultRecolorFlags();
-
 	readonly #sourceName = 'versatiles-shortbread';
 
 	public abstract readonly name: string;
@@ -42,44 +27,51 @@ export default abstract class StyleBuilder<Subclass extends StyleBuilder<Subclas
 
 	public abstract readonly defaultFonts: StylemakerFontStrings<Subclass>;
 
-	// Constructor
-	public constructor() {
-		try {
-			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-			this.baseUrl = document?.location?.href;
-		} catch (e) { }
+	public build(options?: StylemakerOptions<Subclass>): MaplibreStyle {
+
+		options ??= {};
+
 		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-		this.baseUrl ??= 'https://tiles.versatiles.org';
+		const baseUrl = options.baseUrl ?? globalThis?.document?.location?.href ?? 'https://tiles.versatiles.org';
+		const glyphsUrl = options.glyphsUrl ?? '/assets/fonts/{fontstack}/{range}.pbf';
+
+		const spriteUrl = options.spriteUrl ?? '/assets/sprites/sprites';
+		const tilesUrls = options.tilesUrls ?? ['/tiles/osm/{z}/{x}/{y}'];
+		const hideLabels = options.hideLabels ?? false;
+		const languageSuffix = options.languageSuffix ?? '';
+		const recolorOptions = options.recolor ?? getDefaultRecolorFlags();
+
+		const colors = this.getColors(this.defaultColors);
+		if (options.colors) {
+			for (const key in options.colors) colors[key] = Color(options.colors[key]);
+		}
 
-	}
+		// transform colors
+		recolor(colors, recolorOptions);
+
+		const fonts = deepClone(this.defaultColors);
+		if (options.fonts) {
+			for (const key in options.fonts) {
+				const fontName = options.fonts[key];
+				if (fontName != null) fonts[key] = fontName;
+			}
+		}
 
-	public getColors(): StylemakerColors<Subclass> {
-		const entriesString = Object.entries(this.defaultColors) as [StylemakerColorKeys<Subclass>, StylemakerColorStrings<Subclass>][];
-		const entriesColor = entriesString.map(([key, value]) => [key, Color(value)]) as [StylemakerColorKeys<Subclass>, StylemakerColors<Subclass>][];
-		const result = Object.fromEntries(entriesColor) as StylemakerColors<Subclass>;
-		return result;
-	}
 
-	public build(): MaplibreStyle {
 		// get empty shortbread style
 		const style: MaplibreStyle = getShortbreadTemplate();
 
-		const colors = this.getColors();
-
-		// transform colors
-		recolor(colors, this.recolor);
-
 		const styleRuleOptions: StyleRulesOptions<typeof this> = {
 			colors,
 			fonts: deepClone(this.defaultFonts),
-			languageSuffix: this.languageSuffix,
+			languageSuffix,
 		};
 
 		// get layer style rules from child class
 		const layerStyleRules = this.getStyleRules(styleRuleOptions);
 
 		// get shortbread layers
-		const layerDefinitions: MaplibreLayerDefinition[] = getShortbreadLayers({ languageSuffix: this.languageSuffix });
+		const layerDefinitions: MaplibreLayerDefinition[] = getShortbreadLayers({ languageSuffix });
 		let layers: MaplibreLayer[] = layerDefinitions.map(layer => {
 			switch (layer.type) {
 				case 'background':
@@ -98,29 +90,36 @@ export default abstract class StyleBuilder<Subclass extends StyleBuilder<Subclas
 		layers = decorate(layers, layerStyleRules);
 
 		// hide labels, if wanted
-		if (this.hideLabels) layers = layers.filter(l => l.type !== 'symbol');
+		if (hideLabels) layers = layers.filter(l => l.type !== 'symbol');
 
 		style.layers = layers;
 		style.name = 'versatiles-' + this.name.toLowerCase();
-		style.glyphs = resolveUrl(this.baseUrl, this.glyphsUrl);
-		style.sprite = resolveUrl(this.baseUrl, this.spriteUrl);
+		style.glyphs = resolveUrl(baseUrl, glyphsUrl);
+		style.sprite = resolveUrl(baseUrl, spriteUrl);
 
 		const source = style.sources[this.#sourceName];
-		if ('tiles' in source) source.tiles = this.tilesUrls.map(url => resolveUrl(this.baseUrl, url));
+		if ('tiles' in source) source.tiles = tilesUrls.map(url => resolveUrl(baseUrl, url));
 
 		return style;
 
-		function resolveUrl(baseUrl: string, url: string): string {
-			if (!Boolean(baseUrl)) return url;
-			url = new URL(url, baseUrl).href;
+		function resolveUrl(base: string, url: string): string {
+			if (!Boolean(base)) return url;
+			url = new URL(url, base).href;
 			url = url.replace(/%7B/gi, '{');
 			url = url.replace(/%7D/gi, '}');
 			return url;
 		}
 	}
 
+	public getColors(colors: StylemakerColorStrings<Subclass>): StylemakerColors<Subclass> {
+		const entriesString = Object.entries(colors) as [StylemakerColorKeys<Subclass>, StylemakerColorStrings<Subclass>][];
+		const entriesColor = entriesString.map(([key, value]) => [key, Color(value)]) as [StylemakerColorKeys<Subclass>, StylemakerColors<Subclass>][];
+		const result = Object.fromEntries(entriesColor) as StylemakerColors<Subclass>;
+		return result;
+	}
+
 	protected transformDefaultColors(callback: (color: Color) => Color): void {
-		const colors = this.getColors();
+		const colors = this.getColors(this.defaultColors);
 		for (const key in colors) {
 			this.defaultColors[key] = callback(colors[key]).hexa();
 		}
diff --git a/src/lib/style_guesser.ts b/src/lib/style_guesser.ts
index 073ea6b..d8382a1 100644
--- a/src/lib/style_guesser.ts
+++ b/src/lib/style_guesser.ts
@@ -2,10 +2,10 @@
 /* eslint-disable @typescript-eslint/naming-convention */
 
 import type { BackgroundLayerSpecification, CircleLayerSpecification, FillLayerSpecification, LineLayerSpecification } from '@maplibre/maplibre-gl-style-spec';
-import { Colorful } from '../index.js';
 import type { MaplibreStyle, TileJSONSpecification, TileJSONSpecificationRaster, TileJSONSpecificationVector, VectorLayer } from './types.js';
 import { isTileJSONSpecification } from './types.js';
 import randomColorGenerator from './random_color.js';
+import Colorful from '../style/colorful.js';
 
 
 
@@ -39,10 +39,10 @@ function isShortbread(spec: TileJSONSpecificationVector): boolean {
 }
 
 function getShortbreadStyle(spec: TileJSONSpecificationVector): MaplibreStyle {
-	const builder = new Colorful();
-	builder.hideLabels = true;
-	builder.tilesUrls = spec.tiles;
-	return builder.build();
+	return new Colorful().build({
+		hideLabels: true,
+		tilesUrls: spec.tiles,
+	});
 }
 
 function getInspectorStyle(spec: TileJSONSpecificationVector): MaplibreStyle {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 491e751..f483e36 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -97,6 +97,18 @@ export interface RecolorOptions {
 	tintColor?: string;
 }
 
+export interface StylemakerOptions<T extends StyleBuilder<T>> {
+	baseUrl?: string;
+	glyphsUrl?: string;
+	spriteUrl?: string;
+	tilesUrls?: string[];
+	hideLabels?: boolean;
+	languageSuffix?: LanguageSuffix;
+	colors?: Partial<StylemakerColorStrings<T>>;
+	fonts?: Partial<StylemakerFontStrings<T>>;
+	recolor?: RecolorOptions;
+}
+
 
 
 export function isTileJSONSpecification(obj: unknown): obj is TileJSONSpecification {