diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..2c7ed6f --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,22 @@ +name: Typecheck CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + typecheck: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + - run: npm install + - run: npm run check:types diff --git a/.gitignore b/.gitignore index e7dbc59..158d553 100755 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ /dist.zip /dist/ /build/ +/dist-types/ diff --git a/.npmignore b/.npmignore index 943f877..67992a8 100755 --- a/.npmignore +++ b/.npmignore @@ -3,3 +3,4 @@ !/dist.zip !/src/**/* !/index.js +!/index.d.ts diff --git a/gulpfile.js b/gulpfile.js index 3dbdb0e..7c72ab9 100755 --- a/gulpfile.js +++ b/gulpfile.js @@ -114,6 +114,17 @@ function taskClay() { gulp.task('clay', gulp.series('inlineHtml', taskClay)); +// Validates the curated index.d.ts against test/type-checks.ts. +// For per-file reference declarations in dist-types/, run: npm run build:types +gulp.task('types', function(done) { + var exec = require('child_process').exec; + exec('npx tsc -p tsconfig.typecheck.json', function(err, stdout, stderr) { + if (stdout) { console.log(stdout); } + if (stderr) { console.error(stderr); } + done(err); + }); +}); + /** * @returns {string} */ @@ -130,7 +141,7 @@ function taskDevJs() { gulp.task('dev-js', gulp.series('js', 'sass', taskDevJs)); -gulp.task('default', gulp.series('clay')); +gulp.task('default', gulp.series('clay', 'types')); /** * @returns {string} diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..9002916 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,312 @@ +/** + * TypeScript declarations for @rebble/clay + * + * A Pebble configuration framework that provides a simple way to generate a + * configuration page for Pebble watchapps and watchfaces. + */ + +/** Minimal DOM wrapper type from minified.js */ +interface M { + [index: number]: HTMLElement; + length: number; + add(child: M | string): M; + set(property: string, value?: unknown): M; + get(property: string): unknown; + select(selector: string): M; + on(events: string, handler: (...args: unknown[]) => void): M; + each(callback: (element: HTMLElement, index: number) => void): M; +} + +/** A Clay config item as provided in the config array */ +interface ClayConfigItem { + type: string; + defaultValue?: string | boolean | number; + messageKey?: string; + id?: string; + label?: string; + attributes?: Record; + options?: unknown[]; + items?: ClayConfigItem[]; + capabilities?: string[]; + group?: string; +} + +/** A Clay component definition */ +interface ClayComponent { + name: string; + template: string; + manipulator: string | ClayManipulator; + defaults?: Record; + style?: string; + initialize?: (this: ClayItem, minified: unknown, clay: ClayConfig) => void; +} + +/** A manipulator with get and set methods */ +interface ClayManipulator { + get: (this: ClayItem) => unknown; + set: (this: ClayItem, value: unknown) => ClayItem; + disable?: (this: ClayItem) => ClayItem; + enable?: (this: ClayItem) => ClayItem; + hide?: (this: ClayItem) => ClayItem; + show?: (this: ClayItem) => ClayItem; +} + +/** Clay meta information populated from the Pebble object */ +interface ClayMeta { + activeWatchInfo: PebbleActiveWatchInfo | null; + accountToken: string; + watchToken: string; + userData: Record; +} + +/** Watch info from the Pebble API */ +interface PebbleActiveWatchInfo { + platform: string; + model: string; + language: string; + firmware: { + major: number; + minor: number; + patch: number; + suffix: string; + }; +} + +/** Options for the Clay constructor */ +interface ClayOptions { + autoHandleEvents?: boolean; + userData?: Record; +} + +/** Event methods mixed in by ClayEvents */ +interface ClayEvents { + /** + * Attach an event listener. + * @param events - a space-separated list of events + * @param handler - the event handler + */ + on(events: string, handler: (...args: unknown[]) => void): this; + + /** + * Remove the given event handler from all registered events. + * @param handler - the handler to remove + */ + off(handler: (...args: unknown[]) => void): this; + + /** + * Trigger an event. + * @param name - a single event name to trigger + * @param eventObj - an object to pass to the event handler + */ + trigger(name: string, eventObj?: unknown): this; +} + +/** A ClayItem represents a single config component in the config page */ +interface ClayItem extends ClayEvents { + /** The item's id from the config, or null */ + id: string | null; + + /** The item's messageKey from the config, or null */ + messageKey: string | null; + + /** The raw config for this item */ + config: ClayConfigItem; + + /** The root DOM element wrapper */ + $element: M; + + /** The manipulator target DOM element wrapper */ + $manipulatorTarget: M; + + /** The precision for numeric values */ + precision?: number; + + /** + * Run the component initialiser. + * @param clay - the ClayConfig instance + */ + initialize(clay: ClayConfig): ClayItem; + + /** Get the current value of the item via its manipulator */ + get(): unknown; + + /** Set the value of the item via its manipulator */ + set(value: unknown): ClayItem; + + /** Disable the item */ + disable(): ClayItem; + + /** Enable the item */ + enable(): ClayItem; + + /** Hide the item */ + hide(): ClayItem; + + /** Show the item */ + show(): ClayItem; +} + +/** ClayConfig lifecycle event names */ +interface ClayConfigEvents { + readonly BEFORE_BUILD: 'BEFORE_BUILD'; + readonly AFTER_BUILD: 'AFTER_BUILD'; + readonly BEFORE_DESTROY: 'BEFORE_DESTROY'; + readonly AFTER_DESTROY: 'AFTER_DESTROY'; +} + +/** ClayConfig manages the config page and its items */ +interface ClayConfig extends ClayEvents { + /** Meta information from the Pebble object */ + meta: ClayMeta; + + /** The root container element */ + $rootContainer: M; + + /** Lifecycle event name constants */ + EVENTS: ClayConfigEvents; + + /** The config array (may be modified before build) */ + config: ClayConfigItem | ClayConfigItem[]; + + /** Get all items. Must call build() first. */ + getAllItems(): ClayItem[]; + + /** Get an item by its messageKey. Must call build() first. */ + getItemByMessageKey(messageKey: string): ClayItem; + + /** Get an item by its id. Must call build() first. */ + getItemById(id: string): ClayItem; + + /** Get all items of a given type. Must call build() first. */ + getItemsByType(type: string): ClayItem[]; + + /** Get all items belonging to a given group. Must call build() first. */ + getItemsByGroup(group: string): ClayItem[]; + + /** Serialise the current settings. Must call build() first. */ + serialize(): Record; + + /** Register a component. Alias for ClayConfig.registerComponent. */ + registerComponent(component: ClayComponent): boolean; + + /** Destroy the config page and reset items. */ + destroy(): ClayConfig; + + /** Build the config page. Must be called before get methods. */ + build(): ClayConfig; +} + +interface ClayConfigConstructor { + new ( + settings: Record, + config: ClayConfigItem | ClayConfigItem[], + $rootContainer: M, + meta: ClayMeta + ): ClayConfig; + + /** + * Register a component to Clay. Must be called before build(). + */ + registerComponent(component: ClayComponent): boolean; +} + +/** + * The main Clay constructor. + * + * @param config - the Clay config array + * @param customFn - custom code to run from the config page + * @param options - additional options + */ +declare class Clay { + constructor( + config: ClayConfigItem[], + customFn?: ((this: ClayConfig) => void) | null, + options?: ClayOptions + ); + + /** The Clay config array */ + config: ClayConfigItem[]; + + /** The custom function */ + customFn: (this: ClayConfig) => void; + + /** Registered components */ + components: Record; + + /** Meta information populated from the Pebble object */ + meta: ClayMeta; + + /** The Clay version string */ + version: string; + + /** + * Register a component to Clay. + * @param component - the clay component to register + */ + registerComponent(component: ClayComponent): void; + + /** + * Generate the Data URI used by the config page with settings injected. + */ + generateUrl(): string; + + /** + * Parse the response from the webviewclosed event data. + * @param response - the response string + * @param convert - if false, return raw settings without conversion + */ + getSettings(response: string, convert?: boolean): Record; + + /** + * Update settings with the given key/value pair. + * @param key - the setting key + * @param value - the setting value + */ + setSettings(key: string, value: unknown): void; + + /** + * Update settings with the given object. + * @param settings - an object of key/value pairs to set + */ + setSettings(settings: Record): void; + + /** + * Encode content as a data URI. + * @param input - the content to encode + * @param prefix - the URI prefix + */ + static encodeDataUri(input: string, prefix?: string): string; + + /** + * Convert a value to a type compatible with Pebble.sendAppMessage(). + */ + static prepareForAppMessage( + val: unknown + ): number | string | (number | string)[]; + + /** + * Convert Clay settings to a format compatible with Pebble.sendAppMessage(). + */ + static prepareSettingsForAppMessage( + settings: Record + ): Record; +} + +declare namespace Clay { + export { + ClayConfigItem, + ClayComponent, + ClayManipulator, + ClayMeta, + PebbleActiveWatchInfo, + ClayOptions, + ClayEvents, + ClayItem, + ClayConfig, + ClayConfigEvents, + ClayConfigConstructor, + M, + }; +} + +export = Clay; diff --git a/package-lock.json b/package-lock.json index e7e65f9..ef0a2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "stringify": "^3.2.0", "through": "^2.3.8", "tosource": "^1.0.0", + "typescript": "6.0.2", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^2.0.0", "watchify": "^3.11.1" @@ -12014,6 +12015,20 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ua-parser-js": { "version": "0.7.41", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", diff --git a/package.json b/package.json index b754431..0281974 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,15 @@ "name": "@rebble/clay", "version": "1.0.8", "description": "Pebble Config Framework", + "types": "index.d.ts", "scripts": { "test-travis": "./node_modules/.bin/gulp && ./node_modules/.bin/karma start ./test/karma.conf.js --single-run --browsers chromeTravisCI", "test-debug": "(export DEBUG=true && ./node_modules/.bin/gulp && ./node_modules/.bin/karma start ./test/karma.conf.js --no-single-run)", "test": "./node_modules/.bin/gulp && ./node_modules/.bin/karma start ./test/karma.conf.js --single-run", "lint": "./node_modules/.bin/eslint ./", "build": "gulp", + "build:types": "tsc -p tsconfig.declarations.json", + "check:types": "tsc -p tsconfig.typecheck.json", "dev": "gulp dev", "pebble-clean": "rm -rf tmp src/js/index.js && pebble clean", "pebble-publish": "npm run pebble-clean && npm run build && pebble build && pebble package publish && npm run pebble-clean", @@ -85,6 +88,7 @@ "stringify": "^3.2.0", "through": "^2.3.8", "tosource": "^1.0.0", + "typescript": "6.0.2", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^2.0.0", "watchify": "^3.11.1" diff --git a/test/type-checks.ts b/test/type-checks.ts new file mode 100644 index 0000000..c81efdb --- /dev/null +++ b/test/type-checks.ts @@ -0,0 +1,179 @@ +import Clay = require('../index'); + +// --- Exported types are accessible via the Clay namespace --- + +const configItem: Clay.ClayConfigItem = { + type: 'input', + messageKey: 'name', + defaultValue: '', + label: 'Name', + attributes: { placeholder: 'Enter name' }, + capabilities: ['COLOR'], + group: 'appearance', +}; + +const meta: Clay.ClayMeta = { + activeWatchInfo: { + platform: 'basalt', + model: 'qemu_platform_basalt', + language: 'en_US', + firmware: { major: 4, minor: 3, patch: 0, suffix: '' }, + }, + accountToken: 'abc', + watchToken: 'def', + userData: {}, +}; + +const options: Clay.ClayOptions = { + autoHandleEvents: true, + userData: { foo: 'bar' }, +}; + +const manipulator: Clay.ClayManipulator = { + get() { return this.$manipulatorTarget.get('value'); }, + set(value) { + this.$manipulatorTarget.set('value', value); + return this; + }, + disable() { return this; }, + enable() { return this; }, + hide() { return this; }, + show() { return this; }, +}; + +const component: Clay.ClayComponent = { + name: 'my-component', + template: '
', + manipulator, + defaults: { label: 'Default' }, +}; + +// --- Constructor --- + +const clay = new Clay( + [configItem, { type: 'heading', defaultValue: 'My App' }], + function() { + // `this` is ClayConfig — verify event methods + const self: Clay.ClayConfig = this; + + const handler = (..._args: unknown[]) => {}; + this.on('AFTER_BUILD', handler); + this.off(handler); + this.trigger('AFTER_BUILD'); + + // Verify chaining returns the same type + this.on('BEFORE_BUILD', handler).off(handler); + + // Verify EVENTS constants + const events: Clay.ClayConfigEvents = this.EVENTS; + const beforeBuild: 'BEFORE_BUILD' = events.BEFORE_BUILD; + const afterBuild: 'AFTER_BUILD' = events.AFTER_BUILD; + const beforeDestroy: 'BEFORE_DESTROY' = events.BEFORE_DESTROY; + const afterDestroy: 'AFTER_DESTROY' = events.AFTER_DESTROY; + + // Verify build/destroy return ClayConfig for chaining + this.build().destroy().build(); + + // Verify item getters + const allItems: Clay.ClayItem[] = this.getAllItems(); + const byKey: Clay.ClayItem = this.getItemByMessageKey('bg_color'); + const byId: Clay.ClayItem = this.getItemById('my-id'); + const byType: Clay.ClayItem[] = this.getItemsByType('color'); + const byGroup: Clay.ClayItem[] = this.getItemsByGroup('appearance'); + + // Verify ClayItem properties + const item = byKey; + const itemId: string | null = item.id; + const itemMsgKey: string | null = item.messageKey; + const itemConfig: Clay.ClayConfigItem = item.config; + const $el: Clay.M = item.$element; + const $target: Clay.M = item.$manipulatorTarget; + + // Verify ClayItem manipulator methods + const value: unknown = item.get(); + const afterSet: Clay.ClayItem = item.set('new value'); + const afterDisable: Clay.ClayItem = item.disable(); + const afterEnable: Clay.ClayItem = item.enable(); + const afterHide: Clay.ClayItem = item.hide(); + const afterShow: Clay.ClayItem = item.show(); + + // Verify ClayItem event methods + item.on('change', handler).off(handler); + item.trigger('change'); + + // Verify ClayItem initialize + const afterInit: Clay.ClayItem = item.initialize(this); + + // Verify serialize + const serialised: Record = + this.serialize(); + + // Verify meta + const configMeta: Clay.ClayMeta = this.meta; + + // Verify registerComponent on instance + const registered: boolean = this.registerComponent(component); + }, + options +); + +// Constructor with null customFn +const clay2 = new Clay([{ type: 'heading' }], null); + +// Constructor with no options +const clay3 = new Clay([{ type: 'heading' }]); + +// --- Instance properties --- + +const config: Clay.ClayConfigItem[] = clay.config; +const version: string = clay.version; +const components: Record = clay.components; +const clayMeta: Clay.ClayMeta = clay.meta; +const customFn: (this: Clay.ClayConfig) => void = clay.customFn; + +// --- Instance methods --- + +clay.registerComponent(component); + +// String manipulator name +clay.registerComponent({ + name: 'another', + template: '', + manipulator: 'val', +}); + +const url: string = clay.generateUrl(); +const settings: Record = clay.getSettings('{}'); +const rawSettings: Record = clay.getSettings('{}', false); +clay.setSettings('key', 'value'); +clay.setSettings({ key: 'value' }); + +// --- Static methods --- + +const uri: string = Clay.encodeDataUri(''); +const uriWithPrefix: string = Clay.encodeDataUri('', 'http://example.com/#'); +const prepared: number | string | (number | string)[] = Clay.prepareForAppMessage(42); +const appMsg: Record = + Clay.prepareSettingsForAppMessage({ bg_color: { value: 255 } }); + +// --- M interface --- + +function testM(el: Clay.M) { + const len: number = el.length; + const htmlEl: HTMLElement = el[0]; + const added: Clay.M = el.add(''); + const afterSet: Clay.M = el.set('className', 'active'); + const val: unknown = el.get('value'); + const selected: Clay.M = el.select('.child'); + el.on('click', () => {}); + el.each((element: HTMLElement, index: number) => {}); +} + +// --- PebbleActiveWatchInfo --- + +const watchInfo: Clay.PebbleActiveWatchInfo = { + platform: 'chalk', + model: 'qemu_platform_chalk', + language: 'en_US', + firmware: { major: 4, minor: 3, patch: 0, suffix: '' }, +}; diff --git a/tsconfig.declarations.json b/tsconfig.declarations.json new file mode 100644 index 0000000..e9773cc --- /dev/null +++ b/tsconfig.declarations.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "node16", + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "./dist-types", + "strict": false, + "skipLibCheck": true, + "moduleResolution": "node16", + "typeRoots": ["./types", "./node_modules/@types"] + }, + "include": [ + "src/scripts/lib/clay-config.js", + "src/scripts/lib/clay-events.js", + "src/scripts/lib/clay-item.js", + "src/scripts/lib/component-registry.js", + "src/scripts/lib/manipulators.js", + "src/scripts/lib/utils.js" + ], + "exclude": [ + "node_modules", + "test", + "dev", + "tmp", + "src/scripts/config-page.js", + "src/scripts/components", + "src/scripts/vendor", + "src/js" + ] +} diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..8be899a --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "commonjs", + "moduleResolution": "node10", + "esModuleInterop": true, + "strict": true, + "noEmit": true, + "ignoreDeprecations": "6.0" + }, + "include": [ + "test/type-checks.ts" + ] +} diff --git a/types/config-page-html.d.ts b/types/config-page-html.d.ts new file mode 100644 index 0000000..8158a5c --- /dev/null +++ b/types/config-page-html.d.ts @@ -0,0 +1,4 @@ +declare module './tmp/config-page.html' { + const html: string; + export = html; +} diff --git a/types/message_keys.d.ts b/types/message_keys.d.ts new file mode 100644 index 0000000..57614e3 --- /dev/null +++ b/types/message_keys.d.ts @@ -0,0 +1,4 @@ +declare module 'message_keys' { + const messageKeys: { [key: string]: number }; + export = messageKeys; +}