@loopress/cli 0.29.0 → 0.31.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.
Files changed (154) hide show
  1. package/README.md +1369 -360
  2. package/dist/commands/acf/push.js +2 -1
  3. package/dist/commands/composer/push.d.ts +1 -0
  4. package/dist/commands/composer/push.js +13 -0
  5. package/dist/commands/content/copy.d.ts +28 -0
  6. package/dist/commands/content/copy.js +97 -0
  7. package/dist/commands/cpt/diff.js +5 -0
  8. package/dist/commands/cpt/list.d.ts +2 -0
  9. package/dist/commands/cpt/list.js +4 -0
  10. package/dist/commands/cpt/pull.d.ts +2 -0
  11. package/dist/commands/cpt/pull.js +4 -0
  12. package/dist/commands/cpt/push.d.ts +2 -0
  13. package/dist/commands/cpt/push.js +4 -0
  14. package/dist/commands/cpt/rm.d.ts +2 -0
  15. package/dist/commands/cpt/rm.js +3 -0
  16. package/dist/commands/cpt/rollback.js +5 -0
  17. package/dist/commands/dev.js +14 -4
  18. package/dist/commands/diff.js +1 -1
  19. package/dist/commands/doctor.d.ts +2 -0
  20. package/dist/commands/doctor.js +57 -2
  21. package/dist/commands/form/push.js +4 -1
  22. package/dist/commands/meta/diff.d.ts +2 -0
  23. package/dist/commands/meta/diff.js +5 -0
  24. package/dist/commands/meta/list.d.ts +19 -0
  25. package/dist/commands/meta/list.js +21 -0
  26. package/dist/commands/meta/pull.d.ts +2 -0
  27. package/dist/commands/meta/pull.js +4 -0
  28. package/dist/commands/meta/push.d.ts +2 -0
  29. package/dist/commands/meta/push.js +4 -0
  30. package/dist/commands/meta/rm.d.ts +2 -0
  31. package/dist/commands/meta/rm.js +3 -0
  32. package/dist/commands/meta/rollback.d.ts +2 -0
  33. package/dist/commands/meta/rollback.js +5 -0
  34. package/dist/commands/option/list.js +2 -8
  35. package/dist/commands/option/push.js +4 -2
  36. package/dist/commands/page/push.js +3 -2
  37. package/dist/commands/pattern/diff.d.ts +2 -0
  38. package/dist/commands/pattern/diff.js +5 -0
  39. package/dist/commands/pattern/list.d.ts +2 -0
  40. package/dist/commands/pattern/list.js +3 -0
  41. package/dist/commands/pattern/pull.d.ts +2 -0
  42. package/dist/commands/pattern/pull.js +4 -0
  43. package/dist/commands/pattern/push.d.ts +2 -0
  44. package/dist/commands/pattern/push.js +4 -0
  45. package/dist/commands/pattern/rm.d.ts +2 -0
  46. package/dist/commands/pattern/rm.js +3 -0
  47. package/dist/commands/pattern/rollback.d.ts +2 -0
  48. package/dist/commands/pattern/rollback.js +5 -0
  49. package/dist/commands/plugin/audit.js +43 -25
  50. package/dist/commands/project/config.d.ts +1 -0
  51. package/dist/commands/project/config.js +20 -1
  52. package/dist/commands/project/list.d.ts +1 -1
  53. package/dist/commands/project/list.js +6 -6
  54. package/dist/commands/project/pull.js +11 -8
  55. package/dist/commands/project/push.js +23 -18
  56. package/dist/commands/project/remove.js +1 -1
  57. package/dist/commands/project/switch.d.ts +0 -1
  58. package/dist/commands/project/switch.js +16 -36
  59. package/dist/commands/promote.d.ts +1 -0
  60. package/dist/commands/promote.js +7 -3
  61. package/dist/commands/pull.d.ts +2 -1
  62. package/dist/commands/pull.js +31 -6
  63. package/dist/commands/push.d.ts +4 -2
  64. package/dist/commands/push.js +51 -19
  65. package/dist/commands/role/diff.d.ts +2 -0
  66. package/dist/commands/role/diff.js +5 -0
  67. package/dist/commands/role/list.d.ts +2 -0
  68. package/dist/commands/role/list.js +4 -0
  69. package/dist/commands/role/pull.d.ts +2 -0
  70. package/dist/commands/role/pull.js +4 -0
  71. package/dist/commands/role/push.d.ts +2 -0
  72. package/dist/commands/role/push.js +4 -0
  73. package/dist/commands/role/rm.d.ts +2 -0
  74. package/dist/commands/role/rm.js +3 -0
  75. package/dist/commands/role/rollback.d.ts +2 -0
  76. package/dist/commands/role/rollback.js +5 -0
  77. package/dist/commands/snippet/push.d.ts +1 -0
  78. package/dist/commands/snippet/push.js +24 -3
  79. package/dist/commands/status.d.ts +2 -3
  80. package/dist/commands/status.js +21 -53
  81. package/dist/commands/taxonomy/diff.d.ts +2 -0
  82. package/dist/commands/taxonomy/diff.js +5 -0
  83. package/dist/commands/taxonomy/list.d.ts +2 -0
  84. package/dist/commands/taxonomy/list.js +5 -0
  85. package/dist/commands/taxonomy/pull.d.ts +2 -0
  86. package/dist/commands/taxonomy/pull.js +4 -0
  87. package/dist/commands/taxonomy/push.d.ts +2 -0
  88. package/dist/commands/taxonomy/push.js +4 -0
  89. package/dist/commands/taxonomy/rm.d.ts +2 -0
  90. package/dist/commands/taxonomy/rm.js +3 -0
  91. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  92. package/dist/commands/taxonomy/rollback.js +5 -0
  93. package/dist/commands/theme/template/push.d.ts +2 -0
  94. package/dist/commands/theme/template/push.js +26 -5
  95. package/dist/commands/theme/version/pull.js +6 -1
  96. package/dist/config/project-config.manager.d.ts +3 -3
  97. package/dist/config/project-config.manager.js +22 -31
  98. package/dist/hooks/finally.js +11 -3
  99. package/dist/lib/api-client.js +2 -1
  100. package/dist/lib/app-build.js +3 -1
  101. package/dist/lib/base.d.ts +2 -2
  102. package/dist/lib/base.js +33 -38
  103. package/dist/lib/declared-type-command.d.ts +35 -0
  104. package/dist/lib/declared-type-command.js +192 -0
  105. package/dist/lib/dev-watch.d.ts +1 -1
  106. package/dist/lib/dev-watch.js +7 -3
  107. package/dist/lib/diff-command.d.ts +2 -0
  108. package/dist/lib/diff-command.js +24 -3
  109. package/dist/lib/github-release.js +4 -0
  110. package/dist/lib/load-snippets.js +4 -0
  111. package/dist/lib/local-callback-server.js +3 -1
  112. package/dist/lib/php-files-command.d.ts +8 -0
  113. package/dist/lib/php-files-command.js +5 -0
  114. package/dist/lib/push-command.d.ts +5 -2
  115. package/dist/lib/push-command.js +96 -15
  116. package/dist/lib/put-or-create.js +3 -4
  117. package/dist/lib/resource-state.js +38 -85
  118. package/dist/lib/rollback-command.js +9 -2
  119. package/dist/lib/rollback-materialize.js +13 -24
  120. package/dist/lib/spinner.d.ts +2 -0
  121. package/dist/lib/spinner.js +72 -0
  122. package/dist/lib/temp-admin.d.ts +10 -1
  123. package/dist/lib/temp-admin.js +27 -2
  124. package/dist/lib/validate-local.js +40 -12
  125. package/dist/lib/wp-client.d.ts +1 -0
  126. package/dist/lib/wp-client.js +21 -2
  127. package/dist/lib/wp-site-diagnostic.js +3 -2
  128. package/dist/types/global-config.generated.d.ts +1 -5
  129. package/dist/types/project-config.generated.d.ts +28 -4
  130. package/dist/types/snippet.d.ts +1 -0
  131. package/dist/utils/composer.js +2 -1
  132. package/dist/utils/declared-type-format.d.ts +52 -0
  133. package/dist/utils/declared-type-format.js +233 -0
  134. package/dist/utils/resource-dirs.d.ts +9 -3
  135. package/dist/utils/resource-dirs.js +16 -4
  136. package/dist/utils/template-format.d.ts +1 -0
  137. package/dist/utils/theme-css.d.ts +19 -0
  138. package/dist/utils/theme-css.js +100 -0
  139. package/oclif.manifest.json +3294 -1213
  140. package/package.json +32 -16
  141. package/schemas/global-config.schema.json +2 -6
  142. package/schemas/project-config.schema.json +37 -7
  143. package/dist/commands/seo/diff.js +0 -5
  144. package/dist/commands/seo/list.d.ts +0 -18
  145. package/dist/commands/seo/list.js +0 -58
  146. package/dist/commands/seo/pull.d.ts +0 -24
  147. package/dist/commands/seo/pull.js +0 -89
  148. package/dist/commands/seo/push.d.ts +0 -29
  149. package/dist/commands/seo/push.js +0 -178
  150. package/dist/commands/seo/rollback.js +0 -5
  151. package/dist/utils/seo-format.d.ts +0 -29
  152. package/dist/utils/seo-format.js +0 -18
  153. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  154. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -0,0 +1,233 @@
1
+ import { basename } from 'node:path';
2
+ import { parseHtmlHeader, titleFromSlug } from './page-format.js';
3
+ export const CPT_SPEC = {
4
+ arrayArgs: { capabilities: [], labels: [], rewrite: [true, false], supports: [false], taxonomies: [], template: [] },
5
+ cliName: 'cpt',
6
+ codeArgs: ['register_meta_box_cb', 'rest_controller_class', 'autosave_rest_controller_class', 'revisions_rest_controller_class'],
7
+ dir: 'cpt',
8
+ dirKind: 'cpt',
9
+ endpoint: 'loopress/v1/post-types',
10
+ noun: 'post type',
11
+ plural: 'post types',
12
+ registeredEndpoint: 'loopress/v1/registered-post-types',
13
+ registerFunction: 'register_post_type()',
14
+ slugMaxLength: 20,
15
+ termArgs: [],
16
+ };
17
+ // `object_type` is the one key that isn't a register_taxonomy() argument: its second parameter,
18
+ // the post types the taxonomy attaches to. Kept in the same file, split off by the plugin.
19
+ export const TAXONOMY_SPEC = {
20
+ arrayArgs: { capabilities: [], labels: [], object_type: [], rewrite: [true, false] },
21
+ cliName: 'taxonomy',
22
+ codeArgs: ['meta_box_cb', 'meta_box_sanitize_cb', 'update_count_callback', 'rest_controller_class'],
23
+ dir: 'taxonomies',
24
+ dirKind: 'taxonomy',
25
+ endpoint: 'loopress/v1/taxonomies',
26
+ noun: 'taxonomy',
27
+ plural: 'taxonomies',
28
+ registeredEndpoint: 'loopress/v1/registered-taxonomies',
29
+ registerFunction: 'register_taxonomy()',
30
+ slugMaxLength: 32,
31
+ // register_taxonomy() creates the default term when it doesn't exist yet.
32
+ termArgs: ['default_term'],
33
+ };
34
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ // register_post_meta()'s `type` values, each with the check its `default` must pass.
36
+ const META_TYPES = {
37
+ array: Array.isArray,
38
+ boolean: (value) => typeof value === 'boolean',
39
+ integer: Number.isSafeInteger,
40
+ number: (value) => typeof value === 'number',
41
+ object: isObject,
42
+ string: (value) => typeof value === 'string',
43
+ };
44
+ function checkMetaRest(key, type, rest) {
45
+ if (typeof rest !== 'boolean' && !isObject(rest))
46
+ throw new Error(`"${key}": "show_in_rest" must be true, false or an object`);
47
+ // WordPress calls it with each value on every REST read (WP_REST_Meta_Fields).
48
+ if (isObject(rest) && Object.hasOwn(rest, 'prepare_callback')) {
49
+ throw new Error(`"${key}": "show_in_rest.prepare_callback" runs PHP code, use a hook instead of a file for it`);
50
+ }
51
+ // WordPress silently leaves an array or object key out of REST without a schema.
52
+ if (rest !== false && (type === 'array' || type === 'object') && !(isObject(rest) && isObject(rest.schema))) {
53
+ throw new Error(`"${key}": a ${type} exposed in REST needs "show_in_rest": { "schema": {...} }`);
54
+ }
55
+ }
56
+ function checkMetaKey(key, meta) {
57
+ if (!/^[a-zA-Z0-9_-]{1,255}$/.test(key))
58
+ throw new Error(`"${key}" is not a valid meta key: 1 to 255 letters, digits, "_" or "-"`);
59
+ if (!isObject(meta))
60
+ throw new Error(`"${key}" must be a JSON object of register_post_meta() arguments`);
61
+ const code = ['sanitize_callback', 'auth_callback'].find((name) => Object.hasOwn(meta, name));
62
+ if (code)
63
+ throw new Error(`"${key}": "${code}" runs PHP code, use a hook instead of a file for it`);
64
+ const { type } = meta;
65
+ if (typeof type !== 'string' || !Object.hasOwn(META_TYPES, type)) {
66
+ throw new Error(`"${key}": "type" must be one of ${Object.keys(META_TYPES).join(', ')}`);
67
+ }
68
+ if (Object.hasOwn(meta, 'single') && typeof meta.single !== 'boolean')
69
+ throw new Error(`"${key}": "single" must be true or false`);
70
+ checkMetaRest(key, type, meta.show_in_rest ?? false);
71
+ if (Object.hasOwn(meta, 'default') && !META_TYPES[type](meta.default))
72
+ throw new Error(`"${key}": "default" must be a ${type}`);
73
+ }
74
+ // Same checks as the plugin's PostMetaService::assertArgs(): the file is `key => register_post_meta()
75
+ // arguments`, so the code and type checks are one level deeper than for post types.
76
+ function checkMetaArgs(args) {
77
+ for (const [key, meta] of Object.entries(args))
78
+ checkMetaKey(key, meta);
79
+ }
80
+ // The file name is the post type (`post` and `page` included), the keys are meta keys. Only the
81
+ // keys are declared, never their values.
82
+ export const META_SPEC = {
83
+ arrayArgs: {},
84
+ checkArgs: checkMetaArgs,
85
+ cliName: 'meta',
86
+ codeArgs: [],
87
+ dir: 'meta',
88
+ dirKind: 'meta',
89
+ endpoint: 'loopress/v1/post-meta',
90
+ noun: 'post meta',
91
+ plural: 'post meta',
92
+ registeredEndpoint: 'loopress/v1/registered-post-meta',
93
+ registerFunction: 'register_post_meta()',
94
+ slugMaxLength: 20,
95
+ termArgs: [],
96
+ };
97
+ // Same checks as the plugin's RoleService::assertArgs().
98
+ function checkRoleArgs(args) {
99
+ if (typeof args.name !== 'string' || args.name.trim() === '')
100
+ throw new Error('"name" must be a non-empty string');
101
+ const capabilities = args.capabilities ?? {};
102
+ if (!isObject(capabilities) || Object.values(capabilities).some((granted) => typeof granted !== 'boolean')) {
103
+ throw new Error('"capabilities" must map each capability name to true or false');
104
+ }
105
+ }
106
+ // `{"name": ..., "capabilities": {cap: bool}}`, added in memory on every request, never written
107
+ // to the roles option. Built-in roles are refused by the plugin.
108
+ export const ROLE_SPEC = {
109
+ arrayArgs: { capabilities: [] },
110
+ checkArgs: checkRoleArgs,
111
+ cliName: 'role',
112
+ codeArgs: [],
113
+ dir: 'roles',
114
+ dirKind: 'role',
115
+ endpoint: 'loopress/v1/roles',
116
+ noun: 'role',
117
+ plural: 'roles',
118
+ registeredEndpoint: 'loopress/v1/registered-roles',
119
+ registerFunction: 'add_role()',
120
+ slugMaxLength: 50,
121
+ termArgs: [],
122
+ };
123
+ // Header key => register_block_pattern() property, and how its text is read.
124
+ const PATTERN_HEADER = {
125
+ 'block-types': { key: 'blockTypes', type: 'list' },
126
+ categories: { key: 'categories', type: 'list' },
127
+ description: { key: 'description', type: 'text' },
128
+ inserter: { key: 'inserter', type: 'boolean' },
129
+ keywords: { key: 'keywords', type: 'list' },
130
+ 'post-types': { key: 'postTypes', type: 'list' },
131
+ 'template-types': { key: 'templateTypes', type: 'list' },
132
+ title: { key: 'title', type: 'text' },
133
+ 'viewport-width': { key: 'viewportWidth', type: 'integer' },
134
+ };
135
+ function readPatternValue(header, type, value) {
136
+ if (type === 'list')
137
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
138
+ if (type === 'boolean') {
139
+ if (value !== 'true' && value !== 'false')
140
+ throw new Error(`"${header}" must be "true" or "false", got "${value}"`);
141
+ return value === 'true';
142
+ }
143
+ if (type === 'integer') {
144
+ if (!/^\d+$/.test(value))
145
+ throw new Error(`"${header}" must be a whole number, got "${value}"`);
146
+ return Number(value);
147
+ }
148
+ return value;
149
+ }
150
+ // A pattern file is its block markup, opened by the same `key: value` comment header as a page.
151
+ export function parsePatternFile(slug, raw) {
152
+ const { body, meta } = parseHtmlHeader(raw, Object.keys(PATTERN_HEADER));
153
+ const args = { title: titleFromSlug(slug) };
154
+ for (const [header, value] of Object.entries(meta)) {
155
+ const { key, type } = PATTERN_HEADER[header];
156
+ args[key] = readPatternValue(header, type, value);
157
+ }
158
+ args.content = body;
159
+ return args;
160
+ }
161
+ export function formatPatternFile(args) {
162
+ const lines = Object.entries(PATTERN_HEADER)
163
+ .filter(([, { key }]) => args[key] !== undefined)
164
+ .map(([header, { key }]) => `${header}: ${Array.isArray(args[key]) ? args[key].join(', ') : String(args[key])}`);
165
+ return `<!--\n${lines.join('\n')}\n-->\n${typeof args.content === 'string' ? args.content : ''}`;
166
+ }
167
+ function checkPatternArgs(args) {
168
+ if (typeof args.title !== 'string' || args.title.trim() === '')
169
+ throw new Error('"title" must be a non-empty string');
170
+ }
171
+ // Registered as `loopress/<slug>`, referenced with `<!-- wp:pattern {"slug":"loopress/<slug>"} /-->`.
172
+ export const PATTERN_SPEC = {
173
+ arrayArgs: { blockTypes: [], categories: [], keywords: [], postTypes: [], templateTypes: [] },
174
+ checkArgs: checkPatternArgs,
175
+ cliName: 'pattern',
176
+ codeArgs: ['filePath'],
177
+ dir: 'patterns',
178
+ dirKind: 'pattern',
179
+ endpoint: 'loopress/v1/patterns',
180
+ file: { extension: '.html', format: formatPatternFile, parse: parsePatternFile },
181
+ noun: 'pattern',
182
+ plural: 'patterns',
183
+ registeredEndpoint: 'loopress/v1/registered-patterns',
184
+ registerFunction: 'register_block_pattern()',
185
+ slugMaxLength: 100,
186
+ termArgs: [],
187
+ };
188
+ export function fileExtension(spec) {
189
+ return spec.file?.extension ?? '.json';
190
+ }
191
+ export function parseDeclaredFile(spec, filePath, raw) {
192
+ const slug = basename(filePath, fileExtension(spec));
193
+ return { args: spec.file ? spec.file.parse(slug, raw) : parseDeclaredArgs(raw), slug };
194
+ }
195
+ export function formatDeclaredFile(spec, args) {
196
+ return spec.file ? spec.file.format(args) : JSON.stringify(args, null, 2) + '\n';
197
+ }
198
+ export function itemEndpoint(spec, slug) {
199
+ return `${spec.endpoint}/${encodeURIComponent(slug)}`;
200
+ }
201
+ export function parseDeclaredArgs(raw) {
202
+ const parsed = JSON.parse(raw);
203
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
204
+ throw new Error('not a JSON object of arguments');
205
+ }
206
+ return parsed;
207
+ }
208
+ // `lps validate`'s check of one file: the file name is a valid slug, array arguments are arrays,
209
+ // and no argument is code.
210
+ export function declaredFileChecker(spec) {
211
+ const slugPattern = new RegExp(`^[a-z0-9_-]{1,${spec.slugMaxLength}}$`);
212
+ return (raw, filePath) => {
213
+ const slug = basename(filePath, fileExtension(spec));
214
+ if (!slugPattern.test(slug)) {
215
+ throw new Error(`"${slug}" is not a valid ${spec.noun} slug: 1 to ${spec.slugMaxLength} lowercase letters, digits, "_" or "-"`);
216
+ }
217
+ const { args } = parseDeclaredFile(spec, filePath, raw);
218
+ for (const [key, alsoAllowed] of Object.entries(spec.arrayArgs)) {
219
+ const value = args[key];
220
+ if (Object.hasOwn(args, key) && (typeof value !== 'object' || value === null) && !alsoAllowed.includes(value)) {
221
+ const or = alsoAllowed.length > 0 ? ' (or ' + alsoAllowed.join(', ') + ')' : '';
222
+ throw new Error(`"${key}" must be a JSON object or array${or}`);
223
+ }
224
+ }
225
+ const term = spec.termArgs.find((key) => Object.hasOwn(args, key));
226
+ if (term)
227
+ throw new Error(`"${term}" creates a term, and Loopress doesn't sync terms: remove it and create the term in wp-admin`);
228
+ const code = spec.codeArgs.find((key) => Object.hasOwn(args, key));
229
+ if (code)
230
+ throw new Error(`"${code}" runs PHP code, use a hook instead of a file for it`);
231
+ spec.checkArgs?.(args);
232
+ };
233
+ }
@@ -3,15 +3,21 @@ export declare const RESOURCE_DIR_DEFAULTS: {
3
3
  readonly acf: "acf";
4
4
  readonly api: "api";
5
5
  readonly apps: "apps";
6
+ readonly blockCss: "theme/blocks";
7
+ readonly cpt: "cpt";
8
+ readonly css: "theme/css";
6
9
  readonly form: "forms";
7
10
  readonly hooks: "hooks";
8
11
  readonly menu: "menus";
12
+ readonly meta: "meta";
9
13
  readonly options: "options";
10
14
  readonly page: "pages";
11
- readonly part: "parts";
12
- readonly seo: "seo";
15
+ readonly part: "theme/parts";
16
+ readonly pattern: "patterns";
17
+ readonly role: "roles";
13
18
  readonly snippets: "snippets";
14
- readonly template: "templates";
19
+ readonly taxonomy: "taxonomies";
20
+ readonly template: "theme/templates";
15
21
  readonly themeStyles: "theme";
16
22
  };
17
23
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
@@ -5,29 +5,41 @@ export const RESOURCE_DIR_DEFAULTS = {
5
5
  acf: 'acf',
6
6
  api: 'api',
7
7
  apps: 'apps',
8
+ blockCss: 'theme/blocks',
9
+ cpt: 'cpt',
10
+ css: 'theme/css',
8
11
  form: 'forms',
9
12
  hooks: 'hooks',
10
13
  menu: 'menus',
14
+ meta: 'meta',
11
15
  options: 'options',
12
16
  page: 'pages',
13
- part: 'parts',
14
- seo: 'seo',
17
+ part: 'theme/parts',
18
+ pattern: 'patterns',
19
+ role: 'roles',
15
20
  snippets: 'snippets',
16
- template: 'templates',
21
+ taxonomy: 'taxonomies',
22
+ template: 'theme/templates',
17
23
  themeStyles: 'theme',
18
24
  };
19
25
  const CONFIG_KEY = {
20
26
  acf: 'acfDir',
21
27
  api: 'apiDir',
22
28
  apps: 'appsDir',
29
+ blockCss: 'blockCssDir',
30
+ cpt: 'cptDir',
31
+ css: 'cssDir',
23
32
  form: 'formDir',
24
33
  hooks: 'hooksDir',
25
34
  menu: 'menuDir',
35
+ meta: 'metaDir',
26
36
  options: 'optionsDir',
27
37
  page: 'pageDir',
28
38
  part: 'partDir',
29
- seo: 'seoDir',
39
+ pattern: 'patternDir',
40
+ role: 'roleDir',
30
41
  snippets: 'snippetsDir',
42
+ taxonomy: 'taxonomyDir',
31
43
  template: 'templateDir',
32
44
  themeStyles: 'themeStylesDir',
33
45
  };
@@ -21,6 +21,7 @@ export type ChildTheme = {
21
21
  parts: Part[];
22
22
  stylesheet: string;
23
23
  templates: Template[];
24
+ unknownBlocks?: string[];
24
25
  };
25
26
  export declare function parseTemplateFile(slug: string, raw: string): Template;
26
27
  export declare function parsePartFile(slug: string, raw: string): Part;
@@ -0,0 +1,19 @@
1
+ import { type PageProblem } from './page-format.js';
2
+ export type BlockStyle = {
3
+ css: string;
4
+ name: string;
5
+ };
6
+ export type ThemeAsset = {
7
+ content: string;
8
+ path: string;
9
+ };
10
+ export type ThemeCss = {
11
+ assets: ThemeAsset[];
12
+ blocks: BlockStyle[];
13
+ style: string;
14
+ };
15
+ export declare const STYLE_ENTRY = "style.css";
16
+ export declare function bundleThemeCss(cssDir: string, blocksDir: string): Promise<{
17
+ css: ThemeCss;
18
+ problems: PageProblem[];
19
+ }>;
@@ -0,0 +1,100 @@
1
+ import { build } from 'esbuild';
2
+ import { Buffer } from 'node:buffer';
3
+ import { existsSync } from 'node:fs';
4
+ import { readdir } from 'node:fs/promises';
5
+ import { extname, join, relative, resolve, sep } from 'node:path';
6
+ // The global stylesheet's entry: it @imports the rest of theme/css/ in cascade order.
7
+ export const STYLE_ENTRY = 'style.css';
8
+ // WordPress's own block name rule (register_block_type): lowercase letters, digits and dashes,
9
+ // starting with a letter, on both sides of the slash.
10
+ const BLOCK_SEGMENT = /^[a-z][a-z0-9-]*$/;
11
+ const ASSET_EXTENSIONS = ['.avif', '.eot', '.gif', '.jpeg', '.jpg', '.otf', '.png', '.svg', '.ttf', '.webp', '.woff', '.woff2'];
12
+ async function listFiles(dir) {
13
+ try {
14
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
15
+ return entries
16
+ .filter((entry) => entry.isFile() && !entry.name.startsWith('.'))
17
+ .map((entry) => join(entry.parentPath, entry.name))
18
+ .toSorted((a, b) => a.localeCompare(b));
19
+ }
20
+ catch (error) {
21
+ if (error.code === 'ENOENT')
22
+ return [];
23
+ throw error;
24
+ }
25
+ }
26
+ async function readBlockEntries(blocksDir, problems) {
27
+ const entries = [];
28
+ for (const file of await listFiles(blocksDir)) {
29
+ const segments = relative(blocksDir, file).split(sep);
30
+ const [namespace, block] = [segments[0], segments[1]?.replace(/\.css$/, '')];
31
+ if (segments.length !== 2 || extname(file) !== '.css' || !BLOCK_SEGMENT.test(namespace) || !BLOCK_SEGMENT.test(block)) {
32
+ problems.push({ file, message: `must be <namespace>/<block>.css, named after a block (e.g. ${join(blocksDir, 'core', 'button.css')})` });
33
+ continue;
34
+ }
35
+ entries.push({ file, name: `${namespace}/${block}` });
36
+ }
37
+ return entries;
38
+ }
39
+ function buildProblems(error) {
40
+ const { errors } = error;
41
+ if (!Array.isArray(errors))
42
+ throw error;
43
+ return errors.map((message) => ({
44
+ file: message.location ? `${message.location.file}:${message.location.line}` : 'esbuild',
45
+ message: message.text,
46
+ }));
47
+ }
48
+ // Bundles theme/css/style.css and every theme/blocks/<namespace>/<block>.css with esbuild:
49
+ // @import resolved, relative url() copied to assets/ and rewritten, minified. Paths in the result
50
+ // are the child theme's: style.css, blocks/core/button.css, assets/inter-HASH.woff2.
51
+ export async function bundleThemeCss(cssDir, blocksDir) {
52
+ const problems = [];
53
+ const empty = { assets: [], blocks: [], style: '' };
54
+ const styleEntry = join(cssDir, STYLE_ENTRY);
55
+ const hasStyle = existsSync(styleEntry);
56
+ if (!hasStyle && (await listFiles(cssDir)).some((file) => extname(file) === '.css')) {
57
+ problems.push({ file: cssDir, message: `has CSS files but no ${STYLE_ENTRY}, the entry that @imports them: nothing in it would be loaded` });
58
+ }
59
+ const blocks = await readBlockEntries(blocksDir, problems);
60
+ if (problems.length > 0)
61
+ return { css: empty, problems };
62
+ const entryPoints = [
63
+ ...(hasStyle ? [{ in: resolve(styleEntry), out: 'style' }] : []),
64
+ ...blocks.map((block) => ({ in: resolve(block.file), out: `blocks/${block.name}` })),
65
+ ];
66
+ if (entryPoints.length === 0)
67
+ return { css: empty, problems };
68
+ // Never written: outdir only anchors the output paths and the relative url() rewrites.
69
+ const outdir = resolve('/loopress-child');
70
+ let outputFiles;
71
+ try {
72
+ ;
73
+ ({ outputFiles } = await build({
74
+ assetNames: 'assets/[name]-[hash]',
75
+ bundle: true,
76
+ charset: 'utf8',
77
+ entryPoints,
78
+ loader: Object.fromEntries(ASSET_EXTENSIONS.map((extension) => [extension, 'file'])),
79
+ logLevel: 'silent',
80
+ minify: true,
81
+ outdir,
82
+ write: false,
83
+ }));
84
+ }
85
+ catch (error) {
86
+ return { css: empty, problems: buildProblems(error) };
87
+ }
88
+ const css = { assets: [], blocks: [], style: '' };
89
+ for (const output of outputFiles) {
90
+ const path = relative(outdir, output.path).split(sep).join('/');
91
+ if (path === STYLE_ENTRY)
92
+ css.style = output.text;
93
+ else if (path.startsWith('blocks/') && path.endsWith('.css'))
94
+ css.blocks.push({ css: output.text, name: path.slice('blocks/'.length, -'.css'.length) });
95
+ else
96
+ css.assets.push({ content: Buffer.from(output.contents).toString('base64'), path });
97
+ }
98
+ css.blocks.sort((a, b) => a.name.localeCompare(b.name));
99
+ return { css, problems };
100
+ }