@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 +21 -0
- package/README.md +25 -2
- package/bin/mattersimple-theme.js +4 -0
- package/package.json +21 -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
|
@@ -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
|
+
}
|