@mattersimple/theme 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MatterSimple
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.
package/README.md CHANGED
@@ -1,3 +1,26 @@
1
- # Temporary Holding Version
1
+ # @mattersimple/theme
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Build, preview and publish your firm's website theme from your own code editor.
4
+
5
+ ```bash
6
+ npx @mattersimple/theme login --host https://<platform address> --token rtk_…
7
+ npx @mattersimple/theme init my-theme
8
+ cd my-theme
9
+ npm run dev
10
+ ```
11
+
12
+ Create the token in your firm's admin: **Themes → Developer tools → Developer access**. That dialog shows the exact `login` command.
13
+
14
+ | Command | |
15
+ | ---------------------------------------- | ----------------------------------------------------------------------- |
16
+ | `npx @mattersimple/theme init [folder]` | Start a theme from the starter kit |
17
+ | `npx @mattersimple/theme dev` | Live preview: sync every saved change to your private development theme |
18
+ | `npx @mattersimple/theme push [--theme <id>]` | Upload as a new theme, or update one |
19
+ | `npx @mattersimple/theme pull --theme <id>` | Download a theme's files |
20
+ | `npx @mattersimple/theme package` | Build a zip to upload by hand |
21
+ | `npx @mattersimple/theme check` | Validate without installing |
22
+ | `npx @mattersimple/theme list` / `open` / `publish` | Manage the firm's themes |
23
+
24
+ Run `npx @mattersimple/theme --help` for every option. The full theme developer guide is in the starter kit's `docs/` folder.
25
+
26
+ Requires Node.js 20.10 or newer. No dependencies.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
- "name": "@mattersimple/theme",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@mattersimple/theme",
3
+ "version": "0.1.1",
4
+ "description": "Build, preview and publish firm website themes from your own editor.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "mattersimple-theme": "bin/mattersimple-theme.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "LICENSE",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20.10"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test"
21
+ }
22
+ }
package/src/api.js ADDED
@@ -0,0 +1,160 @@
1
+ // The platform's theme API (/api/theme/v1), authenticated with a theme
2
+ // access token.
3
+ import { loadCredentials } from './config.js';
4
+
5
+ export class ApiError extends Error {
6
+ constructor(message, status, problems = []) {
7
+ super(message);
8
+ this.status = status;
9
+ this.problems = problems;
10
+ }
11
+ }
12
+
13
+ export class ThemeApi {
14
+ constructor({ host, token }) {
15
+ this.base = host.replace(/\/+$/, '') + '/api/theme/v1/';
16
+ this.token = token;
17
+ }
18
+
19
+ static fromSavedLogin() {
20
+ const credentials = loadCredentials();
21
+
22
+ if (!credentials) {
23
+ throw new ApiError(
24
+ 'You are not signed in. Run `mattersimple-theme login --host <url> --token <token>` first (create a token in Admin → Themes → Developer tools).',
25
+ 401,
26
+ );
27
+ }
28
+
29
+ return new ThemeApi(credentials);
30
+ }
31
+
32
+ async request(method, path, { json, form, raw } = {}) {
33
+ let response;
34
+
35
+ try {
36
+ response = await fetch(this.base + path, {
37
+ method,
38
+ headers: {
39
+ Authorization: `Bearer ${this.token}`,
40
+ Accept: 'application/json',
41
+ ...(json !== undefined
42
+ ? { 'Content-Type': 'application/json' }
43
+ : {}),
44
+ },
45
+ body: json !== undefined ? JSON.stringify(json) : form,
46
+ });
47
+ } catch (error) {
48
+ throw new ApiError(
49
+ `Couldn't reach ${this.base} (${error.cause?.code ?? error.message}).`,
50
+ 0,
51
+ );
52
+ }
53
+
54
+ if (!response.ok) {
55
+ let body = {};
56
+
57
+ try {
58
+ body = await response.json();
59
+ } catch {
60
+ // Not JSON: fall back to the status text.
61
+ }
62
+
63
+ const validation = body.errors
64
+ ? Object.values(body.errors).flat()
65
+ : [];
66
+
67
+ throw new ApiError(
68
+ body.message || `${response.status} ${response.statusText}`,
69
+ response.status,
70
+ body.problems ?? validation,
71
+ );
72
+ }
73
+
74
+ if (raw) {
75
+ return Buffer.from(await response.arrayBuffer());
76
+ }
77
+
78
+ return response.status === 204 ? null : response.json();
79
+ }
80
+
81
+ me() {
82
+ return this.request('GET', 'me');
83
+ }
84
+
85
+ starterKit(name) {
86
+ return this.request(
87
+ 'GET',
88
+ `starter-kit?name=${encodeURIComponent(name)}`,
89
+ { raw: true },
90
+ );
91
+ }
92
+
93
+ themes() {
94
+ return this.request('GET', 'themes');
95
+ }
96
+
97
+ theme(id) {
98
+ return this.request('GET', `themes/${id}`);
99
+ }
100
+
101
+ createTheme(zip, { role = 'unpublished', name } = {}) {
102
+ const form = new FormData();
103
+ form.append(
104
+ 'theme',
105
+ new Blob([zip], { type: 'application/zip' }),
106
+ 'theme.zip',
107
+ );
108
+ form.append('role', role);
109
+
110
+ if (name) {
111
+ form.append('name', name);
112
+ }
113
+
114
+ return this.request('POST', 'themes', { form });
115
+ }
116
+
117
+ check(zip) {
118
+ const form = new FormData();
119
+ form.append(
120
+ 'theme',
121
+ new Blob([zip], { type: 'application/zip' }),
122
+ 'theme.zip',
123
+ );
124
+
125
+ return this.request('POST', 'themes/check', { form });
126
+ }
127
+
128
+ files(id) {
129
+ return this.request('GET', `themes/${id}/files`);
130
+ }
131
+
132
+ file(id, path) {
133
+ return this.request(
134
+ 'GET',
135
+ `themes/${id}/files/${path.split('/').map(encodeURIComponent).join('/')}`,
136
+ { raw: true },
137
+ );
138
+ }
139
+
140
+ pushFiles(id, upsert, deletes, { allowLive = false } = {}) {
141
+ return this.request('PUT', `themes/${id}/files`, {
142
+ json: {
143
+ upsert: upsert.map(([path, content]) => ({
144
+ path,
145
+ content: content.toString('base64'),
146
+ })),
147
+ delete: deletes,
148
+ allow_live: allowLive,
149
+ },
150
+ });
151
+ }
152
+
153
+ preview(id) {
154
+ return this.request('GET', `themes/${id}/preview`);
155
+ }
156
+
157
+ publish(id) {
158
+ return this.request('POST', `themes/${id}/publish`);
159
+ }
160
+ }
package/src/cli.js ADDED
@@ -0,0 +1,126 @@
1
+ import { parseArgs } from 'node:util';
2
+
3
+ import { ApiError } from './api.js';
4
+ import { check } from './commands/check.js';
5
+ import { dev } from './commands/dev.js';
6
+ import { init } from './commands/init.js';
7
+ import { list } from './commands/list.js';
8
+ import { login, logout } from './commands/login.js';
9
+ import { open } from './commands/open.js';
10
+ import { packageTheme } from './commands/package.js';
11
+ import { publish } from './commands/publish.js';
12
+ import { pull } from './commands/pull.js';
13
+ import { push } from './commands/push.js';
14
+ import { problem } from './output.js';
15
+
16
+ const HELP = `mattersimple-theme — build, preview and publish your firm's website theme
17
+
18
+ Usage: mattersimple-theme <command> [options]
19
+
20
+ Commands:
21
+ login --host <url> --token <token> Sign in with a token from Admin → Themes → Developer tools
22
+ logout Forget the saved token
23
+ init [folder] [--name <name>] Start a theme from the starter kit (--no-install to skip npm install)
24
+ dev Live preview: sync every saved change to your development theme
25
+ push [--theme <id>] Upload as a new unpublished theme, or update theme <id>
26
+ [--skip-json] [--allow-live] --skip-json keeps the pages and settings edited in the editor
27
+ pull --theme <id> [--only-json] Download a theme's files (e.g. pages and settings edited in the editor)
28
+ package Build dist/<name>-<version>.zip to upload by hand
29
+ check Validate the theme on the platform without installing it
30
+ list List the firm's themes
31
+ open --theme <id> [--editor] Open a theme's preview (or the theme editor)
32
+ publish --theme <id> [--yes] Make a theme the live theme
33
+
34
+ Options:
35
+ --path <folder> The theme folder (default: the current folder)
36
+ --no-build Don't run the project's CSS build first
37
+ --no-open dev: don't open the preview in the browser
38
+ `;
39
+
40
+ const options = {
41
+ host: { type: 'string' },
42
+ token: { type: 'string' },
43
+ name: { type: 'string' },
44
+ theme: { type: 'string' },
45
+ path: { type: 'string' },
46
+ unpublished: { type: 'boolean' },
47
+ 'skip-json': { type: 'boolean' },
48
+ 'only-json': { type: 'boolean' },
49
+ 'allow-live': { type: 'boolean' },
50
+ 'no-build': { type: 'boolean' },
51
+ 'no-install': { type: 'boolean' },
52
+ 'no-open': { type: 'boolean' },
53
+ editor: { type: 'boolean' },
54
+ yes: { type: 'boolean', short: 'y' },
55
+ help: { type: 'boolean', short: 'h' },
56
+ version: { type: 'boolean', short: 'v' },
57
+ };
58
+
59
+ export async function main(argv) {
60
+ const { values, positionals } = parseArgs({
61
+ args: argv,
62
+ options,
63
+ allowPositionals: true,
64
+ });
65
+ const [command, ...rest] = positionals;
66
+ const flags = {
67
+ host: values.host,
68
+ token: values.token,
69
+ name: values.name,
70
+ theme: values.theme,
71
+ path: values.path,
72
+ unpublished: values.unpublished,
73
+ skipJson: values['skip-json'],
74
+ onlyJson: values['only-json'],
75
+ allowLive: values['allow-live'],
76
+ build: !values['no-build'],
77
+ install: !values['no-install'],
78
+ open: !values['no-open'],
79
+ editor: values.editor,
80
+ yes: values.yes,
81
+ };
82
+
83
+ if (values.version) {
84
+ const { default: pkg } = await import('../package.json', {
85
+ with: { type: 'json' },
86
+ });
87
+ console.log(pkg.version);
88
+
89
+ return;
90
+ }
91
+
92
+ const commands = {
93
+ login: () => login(flags),
94
+ logout: () => logout(),
95
+ init: () => init({ ...flags, dir: rest[0] }),
96
+ dev: () => dev(flags),
97
+ push: () => push(flags),
98
+ pull: () => pull(flags),
99
+ package: () => packageTheme(flags),
100
+ check: () => check(flags),
101
+ list: () => list(),
102
+ open: () => open(flags),
103
+ publish: () => publish(flags),
104
+ };
105
+
106
+ if (!command || values.help || !commands[command]) {
107
+ console.log(HELP);
108
+ process.exitCode =
109
+ command && !values.help && !commands[command] ? 1 : 0;
110
+
111
+ return;
112
+ }
113
+
114
+ try {
115
+ await commands[command]();
116
+ } catch (error) {
117
+ if (error instanceof ApiError || error instanceof Error) {
118
+ problem(error);
119
+ process.exitCode = 1;
120
+
121
+ return;
122
+ }
123
+
124
+ throw error;
125
+ }
126
+ }
@@ -0,0 +1,16 @@
1
+ import { ThemeApi } from '../api.js';
2
+ import { success, warn } from '../output.js';
3
+ import { Project } from '../project.js';
4
+
5
+ /** Have the platform validate the theme, without installing it. */
6
+ export async function check({ path }) {
7
+ const project = new Project(path).assertTheme();
8
+ const { unknown } = project.files();
9
+
10
+ for (const file of unknown) {
11
+ warn(`${file}: themes can't contain this file; it's left out.`);
12
+ }
13
+
14
+ await ThemeApi.fromSavedLogin().check(project.zip());
15
+ success('No problems found.');
16
+ }
@@ -0,0 +1,106 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ import { ApiError, ThemeApi } from '../api.js';
4
+ import { isThemeFile, toPosix } from '../files.js';
5
+ import { bold, dim, info, problem, success } from '../output.js';
6
+ import { Project } from '../project.js';
7
+ import { openInBrowser, runScript } from '../run.js';
8
+ import { printLinks, pushChanges } from './push.js';
9
+
10
+ /**
11
+ * Live development: keeps the CSS build watching, uploads the project to
12
+ * your private development theme, then pushes every saved change. The
13
+ * preview reloads itself after each push.
14
+ */
15
+ export async function dev({ build = true, open: openPreview = true, path }) {
16
+ const project = new Project(path).assertTheme();
17
+ const api = ThemeApi.fromSavedLogin();
18
+ const children = [];
19
+
20
+ if (build && project.hasScript('build:css')) {
21
+ await runScript(project.root, 'build:css');
22
+ }
23
+
24
+ if (build && project.hasScript('watch:css')) {
25
+ children.push(runScript(project.root, 'watch:css', { wait: false }));
26
+ }
27
+
28
+ const stop = () => {
29
+ children.forEach((child) => child.kill());
30
+ process.exit(0);
31
+ };
32
+ process.on('SIGINT', stop);
33
+ process.on('SIGTERM', stop);
34
+
35
+ info('Uploading your theme to its development theme…');
36
+ let theme;
37
+
38
+ try {
39
+ theme = await api.createTheme(project.zip(), { role: 'development' });
40
+ } catch (error) {
41
+ if (error instanceof ApiError) {
42
+ problem(error);
43
+ info(dim('Fix the problems above, then run `npm run dev` again.'));
44
+ children.forEach((child) => child.kill());
45
+ process.exitCode = 1;
46
+
47
+ return;
48
+ }
49
+
50
+ throw error;
51
+ }
52
+
53
+ success(`Synced to ${bold(theme.name)}.`);
54
+ const links = await printLinks(api, theme.id);
55
+
56
+ if (openPreview) {
57
+ openInBrowser(links.preview_url);
58
+ }
59
+
60
+ info(dim('\nWatching for changes. Press Ctrl+C to stop.\n'));
61
+
62
+ const pending = new Set();
63
+ let timer = null;
64
+ let pushing = Promise.resolve();
65
+
66
+ const flush = () => {
67
+ const only = new Set(pending);
68
+ pending.clear();
69
+
70
+ pushing = pushing.then(async () => {
71
+ try {
72
+ const changed = await pushChanges(api, project, theme.id, {
73
+ only,
74
+ });
75
+
76
+ if (changed > 0) {
77
+ success(
78
+ `${[...only].join(', ')} ${dim(new Date().toLocaleTimeString())}`,
79
+ );
80
+ }
81
+ } catch (error) {
82
+ if (!(error instanceof ApiError)) {
83
+ throw error;
84
+ }
85
+
86
+ problem(error);
87
+ }
88
+ });
89
+ };
90
+
91
+ watch(project.root, { recursive: true }, (_event, filename) => {
92
+ if (!filename) {
93
+ return;
94
+ }
95
+
96
+ const path = toPosix(filename.toString());
97
+
98
+ if (!isThemeFile(path)) {
99
+ return;
100
+ }
101
+
102
+ pending.add(path);
103
+ clearTimeout(timer);
104
+ timer = setTimeout(flush, 150);
105
+ });
106
+ }
@@ -0,0 +1,50 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+
5
+ import { ThemeApi } from '../api.js';
6
+ import { bold, info, success } from '../output.js';
7
+ import { readZip } from '../zip.js';
8
+
9
+ export async function init({ dir = 'my-theme', name, install = true }) {
10
+ const root = resolve(dir);
11
+
12
+ if (existsSync(root) && readdirSync(root).length > 0) {
13
+ throw new Error(`${root} already exists and isn't empty.`);
14
+ }
15
+
16
+ const api = ThemeApi.fromSavedLogin();
17
+ const files = readZip(await api.starterKit(name || 'My theme'));
18
+
19
+ for (const [path, content] of files) {
20
+ const relative = path.replace(/^[^/]+\//, '');
21
+ const target = join(root, relative);
22
+
23
+ if (!target.startsWith(root + sep)) {
24
+ continue;
25
+ }
26
+
27
+ mkdirSync(dirname(target), { recursive: true });
28
+ writeFileSync(target, content);
29
+ }
30
+
31
+ success(`Created ${bold(root)} from the starter kit.`);
32
+
33
+ if (install) {
34
+ info('Installing the build tools (npm install)…');
35
+ await new Promise((done, fail) => {
36
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
37
+ spawn(npm, ['install'], { cwd: root, stdio: 'inherit' })
38
+ .on('exit', (code) =>
39
+ code === 0
40
+ ? done()
41
+ : fail(new Error('npm install failed.')),
42
+ )
43
+ .on('error', fail);
44
+ });
45
+ }
46
+
47
+ info(
48
+ `\nNext:\n cd ${dir}\n npm run dev # live preview while you edit\n`,
49
+ );
50
+ }
@@ -0,0 +1,20 @@
1
+ import { ThemeApi } from '../api.js';
2
+ import { bold, dim, info } from '../output.js';
3
+
4
+ export async function list() {
5
+ const themes = await ThemeApi.fromSavedLogin().themes();
6
+
7
+ if (themes.length === 0) {
8
+ info('No themes yet.');
9
+
10
+ return;
11
+ }
12
+
13
+ for (const theme of themes) {
14
+ const role =
15
+ theme.role === 'main' ? bold('[live]') : dim(`[${theme.role}]`);
16
+ info(
17
+ `${theme.id} ${role} ${theme.name}${theme.version ? dim(' v' + theme.version) : ''}`,
18
+ );
19
+ }
20
+ }
@@ -0,0 +1,24 @@
1
+ import { ThemeApi } from '../api.js';
2
+ import { forgetCredentials, saveCredentials } from '../config.js';
3
+ import { bold, success } from '../output.js';
4
+
5
+ export async function login({ host, token }) {
6
+ if (!host || !token) {
7
+ throw new Error(
8
+ 'Usage: mattersimple-theme login --host <platform url> --token <token>. Admin → Themes → Developer tools → Developer access shows the full command.',
9
+ );
10
+ }
11
+
12
+ const credentials = { host: host.replace(/\/+$/, ''), token };
13
+ const me = await new ThemeApi(credentials).me();
14
+ const path = saveCredentials({ ...credentials, firm: me.firm.name });
15
+
16
+ success(
17
+ `Signed in to ${bold(me.firm.name)} as ${me.user.name} (token “${me.token.name}”). Saved to ${path}.`,
18
+ );
19
+ }
20
+
21
+ export function logout() {
22
+ forgetCredentials();
23
+ success('Signed out.');
24
+ }
@@ -0,0 +1,14 @@
1
+ import { ThemeApi } from '../api.js';
2
+ import { openInBrowser } from '../run.js';
3
+ import { printLinks } from './push.js';
4
+
5
+ export async function open({ theme: id, editor }) {
6
+ if (!id) {
7
+ throw new Error(
8
+ 'Which theme? Pass --theme <id> (see `mattersimple-theme list`).',
9
+ );
10
+ }
11
+
12
+ const links = await printLinks(ThemeApi.fromSavedLogin(), id);
13
+ openInBrowser(editor ? links.editor_url : links.preview_url);
14
+ }
@@ -0,0 +1,41 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { bold, success, warn } from '../output.js';
5
+ import { Project } from '../project.js';
6
+ import { runScript } from '../run.js';
7
+ import { createZip } from '../zip.js';
8
+
9
+ /** Build a zip to upload by hand in Admin → Themes. */
10
+ export async function packageTheme({ build = true, path }) {
11
+ const project = new Project(path).assertTheme();
12
+
13
+ if (build && project.hasScript('build:css')) {
14
+ await runScript(project.root, 'build:css');
15
+ }
16
+
17
+ const { files, unknown } = project.files();
18
+
19
+ for (const file of unknown) {
20
+ warn(`Left out ${file}: themes can't contain it.`);
21
+ }
22
+
23
+ const theme = project.themeJson();
24
+ const slug =
25
+ String(theme.name || 'theme')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-|-$/g, '') || 'theme';
29
+ const target = join(
30
+ project.root,
31
+ 'dist',
32
+ `${slug}-${theme.version || '0.0.0'}.zip`,
33
+ );
34
+
35
+ mkdirSync(join(project.root, 'dist'), { recursive: true });
36
+ writeFileSync(target, createZip(files));
37
+
38
+ success(`Packaged ${files.size} files into ${bold(target)}.`);
39
+
40
+ return target;
41
+ }
@@ -0,0 +1,34 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+
3
+ import { ThemeApi } from '../api.js';
4
+ import { bold, success } from '../output.js';
5
+
6
+ /** Make a theme the firm's live theme. */
7
+ export async function publish({ theme: id, yes }) {
8
+ if (!id) {
9
+ throw new Error(
10
+ 'Which theme? Pass --theme <id> (see `mattersimple-theme list`).',
11
+ );
12
+ }
13
+
14
+ const api = ThemeApi.fromSavedLogin();
15
+ const theme = await api.theme(id);
16
+
17
+ if (!yes) {
18
+ const prompt = createInterface({
19
+ input: process.stdin,
20
+ output: process.stdout,
21
+ });
22
+ const answer = await prompt.question(
23
+ `Publish ${bold(theme.name)}? It replaces the live theme for every visitor. [y/N] `,
24
+ );
25
+ prompt.close();
26
+
27
+ if (!/^y(es)?$/i.test(answer.trim())) {
28
+ return;
29
+ }
30
+ }
31
+
32
+ await api.publish(id);
33
+ success(`${bold(theme.name)} is now live.`);
34
+ }