@mattersimple/theme 0.0.0-stage → 0.1.0
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/README.md +25 -2
- package/bin/mattersimple-theme.js +4 -0
- package/package.json +20 -5
- package/src/api.js +160 -0
- package/src/cli.js +126 -0
- package/src/commands/check.js +16 -0
- package/src/commands/dev.js +106 -0
- package/src/commands/init.js +50 -0
- package/src/commands/list.js +20 -0
- package/src/commands/login.js +24 -0
- package/src/commands/open.js +14 -0
- package/src/commands/package.js +41 -0
- package/src/commands/publish.js +34 -0
- package/src/commands/pull.js +51 -0
- package/src/commands/push.js +122 -0
- package/src/config.js +54 -0
- package/src/files.js +138 -0
- package/src/output.js +31 -0
- package/src/project.js +54 -0
- package/src/rules.json +28 -0
- package/src/run.js +37 -0
- package/src/zip.js +156 -0
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mattersimple/theme
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@mattersimple/theme",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.10"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
}
|
|
21
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ThemeApi } from '../api.js';
|
|
5
|
+
import { checksum, isDocument } from '../files.js';
|
|
6
|
+
import { info, success } from '../output.js';
|
|
7
|
+
import { Project } from '../project.js';
|
|
8
|
+
|
|
9
|
+
/** Download a theme's files — e.g. the pages and settings changed in the editor. */
|
|
10
|
+
export async function pull({ theme: id, onlyJson, path }) {
|
|
11
|
+
if (!id) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
'Which theme? Pass --theme <id> (see `mattersimple-theme list`).',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const root = resolve(path ?? process.cwd());
|
|
18
|
+
const api = ThemeApi.fromSavedLogin();
|
|
19
|
+
mkdirSync(root, { recursive: true });
|
|
20
|
+
const local = new Project(root).files().files;
|
|
21
|
+
let written = 0;
|
|
22
|
+
|
|
23
|
+
for (const file of (await api.files(id)).files) {
|
|
24
|
+
if (onlyJson && !isDocument(file.path)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const current = local.get(file.path);
|
|
29
|
+
|
|
30
|
+
if (current && checksum(current, file.path) === file.checksum) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const target = join(root, file.path);
|
|
35
|
+
|
|
36
|
+
if (!target.startsWith(root + sep)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
41
|
+
writeFileSync(target, await api.file(id, file.path));
|
|
42
|
+
info(` ${file.path}`);
|
|
43
|
+
written++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
success(
|
|
47
|
+
written === 0
|
|
48
|
+
? 'Already up to date.'
|
|
49
|
+
: `Pulled ${written} file${written === 1 ? '' : 's'}.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ThemeApi } from '../api.js';
|
|
2
|
+
import { batchChanges, checksum, isDocument } from '../files.js';
|
|
3
|
+
import { bold, dim, info, success } from '../output.js';
|
|
4
|
+
import { Project } from '../project.js';
|
|
5
|
+
import { runScript } from '../run.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Upload the project: as a new unpublished theme, or onto an existing theme
|
|
9
|
+
* (only what changed is sent).
|
|
10
|
+
*/
|
|
11
|
+
export async function push({
|
|
12
|
+
theme: id,
|
|
13
|
+
unpublished,
|
|
14
|
+
skipJson,
|
|
15
|
+
allowLive,
|
|
16
|
+
name,
|
|
17
|
+
build = true,
|
|
18
|
+
path,
|
|
19
|
+
}) {
|
|
20
|
+
const project = new Project(path).assertTheme();
|
|
21
|
+
const api = ThemeApi.fromSavedLogin();
|
|
22
|
+
|
|
23
|
+
if (build && project.hasScript('build:css')) {
|
|
24
|
+
await runScript(project.root, 'build:css');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!id || unpublished) {
|
|
28
|
+
const created = await api.createTheme(project.zip(), {
|
|
29
|
+
name: name || project.themeJson().name,
|
|
30
|
+
});
|
|
31
|
+
success(
|
|
32
|
+
`Uploaded as ${bold(created.name)} (${created.id}), unpublished.`,
|
|
33
|
+
);
|
|
34
|
+
await printLinks(api, created.id);
|
|
35
|
+
|
|
36
|
+
return created;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const changed = await pushChanges(api, project, id, {
|
|
40
|
+
skipJson,
|
|
41
|
+
allowLive,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (changed === 0) {
|
|
45
|
+
info('Nothing to push: the theme is up to date.');
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
success(`Pushed ${changed} change${changed === 1 ? '' : 's'}.`);
|
|
51
|
+
await printLinks(api, id);
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @returns {Promise<number>} the number of files sent or deleted */
|
|
57
|
+
export async function pushChanges(
|
|
58
|
+
api,
|
|
59
|
+
project,
|
|
60
|
+
id,
|
|
61
|
+
{ skipJson = false, allowLive = false, only } = {},
|
|
62
|
+
) {
|
|
63
|
+
const { files } = project.files();
|
|
64
|
+
// Documents are compared with the version last pushed, so a push only
|
|
65
|
+
// sends the ones changed here and keeps what was changed in the editor.
|
|
66
|
+
const remote = new Map(
|
|
67
|
+
(await api.files(id)).files.map((file) => [
|
|
68
|
+
file.path,
|
|
69
|
+
file.pushed_checksum ?? file.checksum,
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
const upserts = new Map();
|
|
73
|
+
const deletes = [];
|
|
74
|
+
|
|
75
|
+
for (const [path, content] of files) {
|
|
76
|
+
if ((only && !only.has(path)) || (skipJson && isDocument(path))) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (remote.get(path) !== checksum(content, path)) {
|
|
81
|
+
upserts.set(path, content);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const path of remote.keys()) {
|
|
86
|
+
if (
|
|
87
|
+
(only && !only.has(path)) ||
|
|
88
|
+
(skipJson && isDocument(path)) ||
|
|
89
|
+
files.has(path)
|
|
90
|
+
) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The editor's documents stay unless this project ships its own.
|
|
95
|
+
if (isDocument(path) && !only) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
deletes.push(path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (upserts.size === 0 && deletes.length === 0) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const batch of batchChanges(upserts, deletes)) {
|
|
107
|
+
if (batch.upsert.length > 0 || batch.delete.length > 0) {
|
|
108
|
+
await api.pushFiles(id, batch.upsert, batch.delete, { allowLive });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return upserts.size + deletes.length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function printLinks(api, id) {
|
|
116
|
+
const links = await api.preview(id);
|
|
117
|
+
info(` Preview: ${links.preview_url}`);
|
|
118
|
+
info(` Editor: ${links.editor_url}`);
|
|
119
|
+
info(dim(' The preview link is valid for 12 hours.'));
|
|
120
|
+
|
|
121
|
+
return links;
|
|
122
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Saved sign-in: ~/.config/mattersimple/credentials.json (readable only by you),
|
|
2
|
+
// or MATTERSIMPLE_THEME_HOST + MATTERSIMPLE_THEME_TOKEN in the environment (CI).
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export function credentialsPath() {
|
|
15
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
16
|
+
|
|
17
|
+
return join(base, 'mattersimple', 'credentials.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadCredentials() {
|
|
21
|
+
if (process.env.MATTERSIMPLE_THEME_TOKEN && process.env.MATTERSIMPLE_THEME_HOST) {
|
|
22
|
+
return {
|
|
23
|
+
host: process.env.MATTERSIMPLE_THEME_HOST,
|
|
24
|
+
token: process.env.MATTERSIMPLE_THEME_TOKEN,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const path = credentialsPath();
|
|
29
|
+
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveCredentials(credentials) {
|
|
42
|
+
const path = credentialsPath();
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
44
|
+
writeFileSync(path, JSON.stringify(credentials, null, 4) + '\n', {
|
|
45
|
+
mode: 0o600,
|
|
46
|
+
});
|
|
47
|
+
chmodSync(path, 0o600);
|
|
48
|
+
|
|
49
|
+
return path;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function forgetCredentials() {
|
|
53
|
+
rmSync(credentialsPath(), { force: true });
|
|
54
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Which files of a theme project belong in the theme — the same rules the
|
|
2
|
+
// platform applies to uploads (src/rules.json, exported by the platform's
|
|
3
|
+
// `php artisan themes:export-rules`).
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import rules from './rules.json' with { type: 'json' };
|
|
9
|
+
|
|
10
|
+
const allowed = rules.allowed.map((source) => new RegExp(source));
|
|
11
|
+
const ignored = rules.ignored.map((source) => new RegExp(source));
|
|
12
|
+
|
|
13
|
+
export const limits = rules.limits;
|
|
14
|
+
|
|
15
|
+
export function isIgnored(path) {
|
|
16
|
+
return ignored.some((pattern) => pattern.test(path));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isThemeFile(path) {
|
|
20
|
+
return !isIgnored(path) && allowed.some((pattern) => pattern.test(path));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Templates, section groups and theme settings — what the theme editor changes. */
|
|
24
|
+
export function isDocument(path) {
|
|
25
|
+
return (
|
|
26
|
+
/^templates\/[a-z0-9_./-]+\.json$/.test(path) ||
|
|
27
|
+
/^sections\/[a-z0-9_-]+\.json$/.test(path) ||
|
|
28
|
+
path === 'config/settings_data.json'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function toPosix(path) {
|
|
33
|
+
return path.split(sep).join('/');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The theme's files in a project folder, skipping development files.
|
|
38
|
+
*
|
|
39
|
+
* @returns {{ files: Map<string, Buffer>, unknown: string[] }} unknown: files the platform wouldn't accept
|
|
40
|
+
*/
|
|
41
|
+
export function readThemeFiles(root) {
|
|
42
|
+
const files = new Map();
|
|
43
|
+
const unknown = [];
|
|
44
|
+
|
|
45
|
+
function walk(dir) {
|
|
46
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
47
|
+
const absolute = join(dir, entry.name);
|
|
48
|
+
const path = toPosix(relative(root, absolute));
|
|
49
|
+
|
|
50
|
+
if (isIgnored(path) || isIgnored(path + '/')) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
walk(absolute);
|
|
56
|
+
} else if (entry.isFile()) {
|
|
57
|
+
if (isThemeFile(path)) {
|
|
58
|
+
files.set(path, readFileSync(absolute));
|
|
59
|
+
} else {
|
|
60
|
+
unknown.push(path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
walk(root);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
files: new Map([...files].sort(([a], [b]) => a.localeCompare(b))),
|
|
70
|
+
unknown,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The checksum the platform lists for a file. Documents are compared as
|
|
76
|
+
* compact JSON, so reformatting one isn't a change.
|
|
77
|
+
*/
|
|
78
|
+
export function checksum(buffer, path = '') {
|
|
79
|
+
let content = buffer;
|
|
80
|
+
|
|
81
|
+
if (isDocument(path)) {
|
|
82
|
+
try {
|
|
83
|
+
content = Buffer.from(
|
|
84
|
+
JSON.stringify(JSON.parse(buffer.toString('utf8'))),
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
// Invalid JSON: compare as is; the platform reports the problem.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return createHash('sha256').update(content).digest('hex');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Splits pushes so no request body grows past `maxBytes` of file content.
|
|
96
|
+
* Assets and screenshots go first, in batches of their own: they can't make
|
|
97
|
+
* the theme invalid on their own. Everything Liquid and JSON goes in the last
|
|
98
|
+
* batch, with the deletions, so the theme is validated as a whole once.
|
|
99
|
+
*
|
|
100
|
+
* @param {Map<string, Buffer>} upserts
|
|
101
|
+
* @param {string[]} deletes
|
|
102
|
+
*/
|
|
103
|
+
export function batchChanges(upserts, deletes, maxBytes = 8 * 1024 * 1024) {
|
|
104
|
+
const binaries = [];
|
|
105
|
+
const rest = [];
|
|
106
|
+
|
|
107
|
+
for (const [path, content] of upserts) {
|
|
108
|
+
(/^(assets|screenshots)\//.test(path) ? binaries : rest).push([
|
|
109
|
+
path,
|
|
110
|
+
content,
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const batches = [];
|
|
115
|
+
let current = [];
|
|
116
|
+
let size = 0;
|
|
117
|
+
|
|
118
|
+
for (const entry of binaries) {
|
|
119
|
+
const bytes = Math.ceil(entry[1].length / 3) * 4;
|
|
120
|
+
|
|
121
|
+
if (current.length > 0 && size + bytes > maxBytes) {
|
|
122
|
+
batches.push({ upsert: current, delete: [] });
|
|
123
|
+
current = [];
|
|
124
|
+
size = 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
current.push(entry);
|
|
128
|
+
size += bytes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (current.length > 0) {
|
|
132
|
+
batches.push({ upsert: current, delete: [] });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
batches.push({ upsert: rest, delete: deletes });
|
|
136
|
+
|
|
137
|
+
return batches;
|
|
138
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const paint = (code) => (text) =>
|
|
3
|
+
color ? `\u001b[${code}m${text}\u001b[0m` : String(text);
|
|
4
|
+
|
|
5
|
+
export const bold = paint(1);
|
|
6
|
+
export const dim = paint(2);
|
|
7
|
+
export const red = paint(31);
|
|
8
|
+
export const green = paint(32);
|
|
9
|
+
export const yellow = paint(33);
|
|
10
|
+
export const cyan = paint(36);
|
|
11
|
+
|
|
12
|
+
export function info(message) {
|
|
13
|
+
console.log(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function success(message) {
|
|
17
|
+
console.log(green('✔ ') + message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function warn(message) {
|
|
21
|
+
console.warn(yellow('! ') + message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Prints an API error, with the theme's problems when the platform listed them. */
|
|
25
|
+
export function problem(error) {
|
|
26
|
+
console.error(red('✖ ') + error.message);
|
|
27
|
+
|
|
28
|
+
for (const item of error.problems ?? []) {
|
|
29
|
+
console.error(red(' • ') + item);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { createZip } from './zip.js';
|
|
5
|
+
import { readThemeFiles } from './files.js';
|
|
6
|
+
|
|
7
|
+
/** A theme project folder: the theme files plus its build setup. */
|
|
8
|
+
export class Project {
|
|
9
|
+
constructor(root = process.cwd()) {
|
|
10
|
+
this.root = resolve(root);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
assertTheme() {
|
|
14
|
+
if (!existsSync(join(this.root, 'layout', 'theme.liquid'))) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`${this.root} isn't a theme folder (no layout/theme.liquid). Run this in your theme's folder, or create one with \`mattersimple-theme init\`.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
themeJson() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(
|
|
26
|
+
readFileSync(join(this.root, 'theme.json'), 'utf8'),
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
packageJson() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(
|
|
36
|
+
readFileSync(join(this.root, 'package.json'), 'utf8'),
|
|
37
|
+
);
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
hasScript(name) {
|
|
44
|
+
return Boolean(this.packageJson().scripts?.[name]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
files() {
|
|
48
|
+
return readThemeFiles(this.root);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
zip() {
|
|
52
|
+
return createZip(this.files().files);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/rules.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"allowed": [
|
|
3
|
+
"^layout/[a-z0-9_-]+\\.liquid$",
|
|
4
|
+
"^templates/(customers/)?[a-z0-9_-]+(\\.[a-z0-9_-]+)?\\.(json|liquid)$",
|
|
5
|
+
"^sections/[a-z0-9_-]+\\.(liquid|json)$",
|
|
6
|
+
"^snippets/[a-z0-9_-]+\\.liquid$",
|
|
7
|
+
"^assets/[A-Za-z0-9_.@-]+\\.(css|js|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf)$",
|
|
8
|
+
"^config/(settings_schema|settings_data)\\.json$",
|
|
9
|
+
"^locales/[a-z]{2}(-[A-Z]{2})?(\\.default)?(\\.schema)?\\.json$",
|
|
10
|
+
"^screenshots/(desktop|mobile)\\.(png|jpe?g|webp)$",
|
|
11
|
+
"^theme\\.json$",
|
|
12
|
+
"^(README|CHANGELOG|LICENSE)(\\.(md|txt))?$"
|
|
13
|
+
],
|
|
14
|
+
"ignored": [
|
|
15
|
+
"(^|/)(__MACOSX|\\.DS_Store|Thumbs\\.db|\\.git)(/|$)",
|
|
16
|
+
"(^|/)(\\.[^/]+|node_modules|src|dist|docs|package(-lock)?\\.json|pnpm-lock\\.yaml|yarn\\.lock|bun\\.lockb?|tailwind\\.config\\.[a-z]+|[^/]+\\.map)(/|$)"
|
|
17
|
+
],
|
|
18
|
+
"limits": {
|
|
19
|
+
"max_zip_bytes": 20971520,
|
|
20
|
+
"max_uncompressed_bytes": 41943040,
|
|
21
|
+
"max_files": 500,
|
|
22
|
+
"max_file_bytes": 5242880,
|
|
23
|
+
"max_compression_ratio": 100,
|
|
24
|
+
"screenshot_min_width": 1600,
|
|
25
|
+
"screenshot_min_height": 1000,
|
|
26
|
+
"screenshot_max_bytes": 5242880
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/run.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Runs a package.json script in the project, inheriting the terminal. */
|
|
4
|
+
export function runScript(root, script, { wait = true } = {}) {
|
|
5
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
6
|
+
const child = spawn(npm, ['run', '--silent', script], {
|
|
7
|
+
cwd: root,
|
|
8
|
+
stdio: 'inherit',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
if (!wait) {
|
|
12
|
+
return child;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
child.on('exit', (code) =>
|
|
17
|
+
code === 0
|
|
18
|
+
? resolve()
|
|
19
|
+
: reject(new Error(`npm run ${script} failed.`)),
|
|
20
|
+
);
|
|
21
|
+
child.on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function openInBrowser(url) {
|
|
26
|
+
const command =
|
|
27
|
+
process.platform === 'darwin'
|
|
28
|
+
? 'open'
|
|
29
|
+
: process.platform === 'win32'
|
|
30
|
+
? 'cmd'
|
|
31
|
+
: 'xdg-open';
|
|
32
|
+
const args =
|
|
33
|
+
process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
34
|
+
spawn(command, args, { stdio: 'ignore', detached: true })
|
|
35
|
+
.on('error', () => {})
|
|
36
|
+
.unref();
|
|
37
|
+
}
|
package/src/zip.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// A minimal zip reader and writer on node:zlib — enough for theme packages
|
|
2
|
+
// (no zip64, no encryption), so the CLI needs no dependencies.
|
|
3
|
+
import { deflateRawSync, inflateRawSync } from 'node:zlib';
|
|
4
|
+
|
|
5
|
+
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
|
|
6
|
+
let c = n;
|
|
7
|
+
|
|
8
|
+
for (let k = 0; k < 8; k++) {
|
|
9
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return c >>> 0;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function crc32(buffer) {
|
|
16
|
+
let crc = 0xffffffff;
|
|
17
|
+
|
|
18
|
+
for (const byte of buffer) {
|
|
19
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function dosDateTime(date) {
|
|
26
|
+
const time =
|
|
27
|
+
(date.getHours() << 11) |
|
|
28
|
+
(date.getMinutes() << 5) |
|
|
29
|
+
Math.floor(date.getSeconds() / 2);
|
|
30
|
+
const day =
|
|
31
|
+
((date.getFullYear() - 1980) << 9) |
|
|
32
|
+
((date.getMonth() + 1) << 5) |
|
|
33
|
+
date.getDate();
|
|
34
|
+
|
|
35
|
+
return { time, day };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {Map<string, Buffer>|Record<string, Buffer|string>} files contents keyed by path
|
|
40
|
+
* @returns {Buffer}
|
|
41
|
+
*/
|
|
42
|
+
export function createZip(files) {
|
|
43
|
+
const entries = files instanceof Map ? [...files] : Object.entries(files);
|
|
44
|
+
const { time, day } = dosDateTime(new Date());
|
|
45
|
+
const locals = [];
|
|
46
|
+
const centrals = [];
|
|
47
|
+
let offset = 0;
|
|
48
|
+
|
|
49
|
+
for (const [path, value] of entries) {
|
|
50
|
+
const data = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
51
|
+
const name = Buffer.from(path, 'utf8');
|
|
52
|
+
const deflated = deflateRawSync(data);
|
|
53
|
+
const stored = deflated.length >= data.length;
|
|
54
|
+
const body = stored ? data : deflated;
|
|
55
|
+
const crc = crc32(data);
|
|
56
|
+
|
|
57
|
+
const local = Buffer.alloc(30);
|
|
58
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
59
|
+
local.writeUInt16LE(20, 4);
|
|
60
|
+
local.writeUInt16LE(0x0800, 6);
|
|
61
|
+
local.writeUInt16LE(stored ? 0 : 8, 8);
|
|
62
|
+
local.writeUInt16LE(time, 10);
|
|
63
|
+
local.writeUInt16LE(day, 12);
|
|
64
|
+
local.writeUInt32LE(crc, 14);
|
|
65
|
+
local.writeUInt32LE(body.length, 18);
|
|
66
|
+
local.writeUInt32LE(data.length, 22);
|
|
67
|
+
local.writeUInt16LE(name.length, 26);
|
|
68
|
+
|
|
69
|
+
const central = Buffer.alloc(46);
|
|
70
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
71
|
+
central.writeUInt16LE(20, 4);
|
|
72
|
+
central.writeUInt16LE(20, 6);
|
|
73
|
+
central.writeUInt16LE(0x0800, 8);
|
|
74
|
+
central.writeUInt16LE(stored ? 0 : 8, 10);
|
|
75
|
+
central.writeUInt16LE(time, 12);
|
|
76
|
+
central.writeUInt16LE(day, 14);
|
|
77
|
+
central.writeUInt32LE(crc, 16);
|
|
78
|
+
central.writeUInt32LE(body.length, 20);
|
|
79
|
+
central.writeUInt32LE(data.length, 24);
|
|
80
|
+
central.writeUInt16LE(name.length, 28);
|
|
81
|
+
central.writeUInt32LE(offset, 42);
|
|
82
|
+
|
|
83
|
+
locals.push(local, name, body);
|
|
84
|
+
centrals.push(central, name);
|
|
85
|
+
offset += local.length + name.length + body.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const directory = Buffer.concat(centrals);
|
|
89
|
+
const end = Buffer.alloc(22);
|
|
90
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
91
|
+
end.writeUInt16LE(entries.length, 8);
|
|
92
|
+
end.writeUInt16LE(entries.length, 10);
|
|
93
|
+
end.writeUInt32LE(directory.length, 12);
|
|
94
|
+
end.writeUInt32LE(offset, 16);
|
|
95
|
+
|
|
96
|
+
return Buffer.concat([...locals, directory, end]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Buffer} zip
|
|
101
|
+
* @returns {Map<string, Buffer>} file contents keyed by path (folders skipped)
|
|
102
|
+
*/
|
|
103
|
+
export function readZip(zip) {
|
|
104
|
+
let end = -1;
|
|
105
|
+
|
|
106
|
+
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65557); i--) {
|
|
107
|
+
if (zip.readUInt32LE(i) === 0x06054b50) {
|
|
108
|
+
end = i;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (end === -1) {
|
|
114
|
+
throw new Error('Not a zip file.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const count = zip.readUInt16LE(end + 10);
|
|
118
|
+
let cursor = zip.readUInt32LE(end + 16);
|
|
119
|
+
const files = new Map();
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < count; i++) {
|
|
122
|
+
if (zip.readUInt32LE(cursor) !== 0x02014b50) {
|
|
123
|
+
throw new Error('Corrupt zip directory.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const method = zip.readUInt16LE(cursor + 10);
|
|
127
|
+
const compressedSize = zip.readUInt32LE(cursor + 20);
|
|
128
|
+
const nameLength = zip.readUInt16LE(cursor + 28);
|
|
129
|
+
const extraLength = zip.readUInt16LE(cursor + 30);
|
|
130
|
+
const commentLength = zip.readUInt16LE(cursor + 32);
|
|
131
|
+
const localOffset = zip.readUInt32LE(cursor + 42);
|
|
132
|
+
const name = zip.toString(
|
|
133
|
+
'utf8',
|
|
134
|
+
cursor + 46,
|
|
135
|
+
cursor + 46 + nameLength,
|
|
136
|
+
);
|
|
137
|
+
cursor += 46 + nameLength + extraLength + commentLength;
|
|
138
|
+
|
|
139
|
+
if (name.endsWith('/')) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const dataStart =
|
|
144
|
+
localOffset +
|
|
145
|
+
30 +
|
|
146
|
+
zip.readUInt16LE(localOffset + 26) +
|
|
147
|
+
zip.readUInt16LE(localOffset + 28);
|
|
148
|
+
const data = zip.subarray(dataStart, dataStart + compressedSize);
|
|
149
|
+
files.set(
|
|
150
|
+
name,
|
|
151
|
+
method === 8 ? inflateRawSync(data) : Buffer.from(data),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return files;
|
|
156
|
+
}
|