@wp-typia/project-tools 0.22.0 → 0.22.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.
@@ -1,1401 +1,35 @@
1
- import fs from "node:fs";
2
- import { promises as fsp } from "node:fs";
3
- import { createRequire } from "node:module";
4
- import path from "node:path";
5
- import { resolveWorkspaceProject, } from "./workspace-project.js";
6
- import { readWorkspaceInventory, appendWorkspaceInventoryEntries } from "./workspace-inventory.js";
7
- import { PROJECT_TOOLS_PACKAGE_ROOT } from "./template-registry.js";
8
- import { toPascalCase, toTitleCase } from "./string-case.js";
9
- import { findPhpFunctionRange, hasPhpFunctionDefinition, quotePhpString, replacePhpFunctionDefinition, } from "./php-utils.js";
10
- import { assertAdminViewDoesNotExist, assertValidGeneratedSlug, getWorkspaceBootstrapPath, normalizeBlockSlug, patchFile, quoteTsString, rollbackWorkspaceMutation, snapshotWorkspaceFiles, } from "./cli-add-shared.js";
11
- import { CLI_DIAGNOSTIC_CODES, createCliDiagnosticCodeError, } from "./cli-diagnostics.js";
12
- import { DEFAULT_WORDPRESS_DATAVIEWS_VERSION, DEFAULT_WORDPRESS_CORE_DATA_VERSION, DEFAULT_WORDPRESS_DATA_VERSION, DEFAULT_WP_TYPIA_DATAVIEWS_VERSION, } from "./package-versions.js";
13
- const ADMIN_VIEW_REST_SOURCE_KIND = "rest-resource";
14
- const ADMIN_VIEW_CORE_DATA_SOURCE_KIND = "core-data";
15
- const ADMIN_VIEW_CORE_DATA_ENTITY_KIND_IDS = ["postType", "taxonomy"];
16
- const ADMIN_VIEW_CORE_DATA_ENTITY_SEGMENT_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/u;
17
- const ADMIN_VIEW_CORE_DATA_ENTITY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
18
- const ADMIN_VIEW_SOURCE_USAGE = "wp-typia add admin-view <name> --source <rest-resource:slug|core-data:kind/name>";
19
- const ADMIN_VIEWS_SCRIPT = "build/admin-views/index.js";
20
- const ADMIN_VIEWS_ASSET = "build/admin-views/index.asset.php";
21
- const ADMIN_VIEWS_STYLE = "build/admin-views/style-index.css";
22
- const ADMIN_VIEWS_STYLE_RTL = "build/admin-views/style-index-rtl.css";
23
- const ADMIN_VIEWS_PHP_GLOB = "/inc/admin-views/*.php";
24
- const ADMIN_VIEW_ALLOW_UNPUBLISHED_DATAVIEWS_ENV = "WP_TYPIA_ALLOW_UNPUBLISHED_DATAVIEWS";
25
- // Lift this gate in the same release that publishes @wp-typia/dataviews.
26
- const ADMIN_VIEW_PUBLIC_INSTALLS_ENABLED = false;
27
- const require = createRequire(import.meta.url);
28
- function toCamelCase(input) {
29
- const pascalCase = toPascalCase(input);
30
- return `${pascalCase.charAt(0).toLowerCase()}${pascalCase.slice(1)}`;
31
- }
32
- function normalizeVersionRange(value, fallback) {
33
- const trimmed = value?.trim();
34
- if (!trimmed || trimmed.startsWith("workspace:")) {
35
- return fallback;
36
- }
37
- return /^[~^<>=]/u.test(trimmed) ? trimmed : `^${trimmed}`;
38
- }
39
- function readPackageManifest(packageJsonPath) {
40
- try {
41
- return JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
42
- }
43
- catch {
44
- return undefined;
45
- }
46
- }
47
- function readPackageManifestVersion(packageJsonPath) {
48
- return readPackageManifest(packageJsonPath)?.version;
49
- }
50
- function isAdminViewUnpublishedDataViewsOverrideEnabled() {
51
- return process.env[ADMIN_VIEW_ALLOW_UNPUBLISHED_DATAVIEWS_ENV]?.trim() === "1";
52
- }
53
- function assertAdminViewPackageAvailability() {
54
- if (isAdminViewUnpublishedDataViewsOverrideEnabled() || ADMIN_VIEW_PUBLIC_INSTALLS_ENABLED) {
55
- return;
56
- }
57
- throw createCliDiagnosticCodeError(CLI_DIAGNOSTIC_CODES.INVALID_ARGUMENT, "`wp-typia add admin-view` is temporarily unavailable because `@wp-typia/dataviews` is not published to npm for public installs yet.");
58
- }
59
- function detectJsonIndent(source) {
60
- const indentMatch = /\n([ \t]+)"/u.exec(source);
61
- return indentMatch?.[1] ?? 2;
62
- }
63
- function resolvePackageVersionRange(packageName, fallback, workspacePackageDirName) {
64
- if (workspacePackageDirName) {
65
- const workspaceVersion = readPackageManifestVersion(path.join(PROJECT_TOOLS_PACKAGE_ROOT, "..", workspacePackageDirName, "package.json"));
66
- if (workspaceVersion) {
67
- return normalizeVersionRange(workspaceVersion, fallback);
68
- }
69
- }
70
- try {
71
- return normalizeVersionRange(readPackageManifestVersion(require.resolve(`${packageName}/package.json`)), fallback);
72
- }
73
- catch {
74
- return fallback;
75
- }
76
- }
77
- function getAdminViewRelativeModuleSpecifier(adminViewSlug, workspaceFile) {
78
- const adminViewDir = `src/admin-views/${adminViewSlug}`;
79
- const normalizedFile = workspaceFile.replace(/\\/gu, "/");
80
- const modulePath = normalizedFile.replace(/\.[cm]?[jt]sx?$/u, "");
81
- const relativeModulePath = path.posix.relative(adminViewDir, modulePath);
82
- return relativeModulePath.startsWith(".") ? relativeModulePath : `./${relativeModulePath}`;
83
- }
84
- function isAdminViewCoreDataSource(source) {
85
- return source?.kind === ADMIN_VIEW_CORE_DATA_SOURCE_KIND;
86
- }
87
- function isAdminViewRestResourceSource(source) {
88
- return source?.kind === ADMIN_VIEW_REST_SOURCE_KIND;
89
- }
90
- function assertValidCoreDataEntitySegment(label, value) {
91
- const trimmed = value.trim();
92
- if (!trimmed) {
93
- throw new Error(`${label} is required. Use \`${ADMIN_VIEW_SOURCE_USAGE}\`.`);
94
- }
95
- if (!ADMIN_VIEW_CORE_DATA_ENTITY_SEGMENT_PATTERN.test(trimmed)) {
96
- throw new Error(`${label} must start with a letter and contain only letters, numbers, underscores, or hyphens.`);
97
- }
98
- return trimmed;
99
- }
100
- function assertValidCoreDataEntityName(value) {
101
- const normalized = value.trim();
102
- if (!normalized) {
103
- throw new Error(`Admin view source entity name is required. Use \`${ADMIN_VIEW_SOURCE_USAGE}\`.`);
104
- }
105
- if (!ADMIN_VIEW_CORE_DATA_ENTITY_NAME_PATTERN.test(normalized)) {
106
- throw new Error("Admin view source entity name must start with a lowercase letter or number and contain only lowercase letters, numbers, underscores, or hyphens.");
107
- }
108
- return normalized;
109
- }
110
- function assertValidCoreDataEntityKind(value) {
111
- const normalized = assertValidCoreDataEntitySegment("Admin view source entity kind", value);
112
- if (!ADMIN_VIEW_CORE_DATA_ENTITY_KIND_IDS.includes(normalized)) {
113
- throw new Error(`Admin view core-data sources currently support only: ${ADMIN_VIEW_CORE_DATA_ENTITY_KIND_IDS.join(", ")}.`);
114
- }
115
- return normalized;
116
- }
117
- function formatAdminViewSourceLocator(source) {
118
- if (isAdminViewCoreDataSource(source)) {
119
- return `${source.kind}:${source.entityKind}/${source.entityName}`;
120
- }
121
- return `${source.kind}:${source.slug}`;
122
- }
123
- function parseAdminViewSource(source) {
124
- const trimmed = source?.trim();
125
- if (!trimmed) {
126
- return undefined;
127
- }
128
- const separatorIndex = trimmed.indexOf(":");
129
- const kind = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex);
130
- const locator = separatorIndex === -1 ? "" : trimmed.slice(separatorIndex + 1);
131
- if (!locator) {
132
- throw new Error("Admin view source must use `rest-resource:<slug>` or `core-data:<kind>/<name>`.");
133
- }
134
- if (kind === ADMIN_VIEW_REST_SOURCE_KIND) {
135
- return {
136
- kind,
137
- slug: assertValidGeneratedSlug("Admin view source slug", locator, ADMIN_VIEW_SOURCE_USAGE),
138
- };
139
- }
140
- if (kind === ADMIN_VIEW_CORE_DATA_SOURCE_KIND) {
141
- const [entityKind, entityName, extra] = locator.split("/");
142
- if (!entityKind || !entityName || extra !== undefined) {
143
- throw new Error("Admin view core-data sources must use `core-data:<kind>/<name>`, for example `core-data:postType/post`.");
144
- }
145
- return {
146
- entityKind: assertValidCoreDataEntityKind(entityKind),
147
- entityName: assertValidCoreDataEntityName(entityName),
148
- kind,
149
- };
150
- }
151
- throw new Error("Admin view source must use `rest-resource:<slug>` or `core-data:<kind>/<name>`.");
152
- }
153
- function resolveRestResourceSource(restResources, source) {
154
- if (!isAdminViewRestResourceSource(source)) {
155
- return undefined;
156
- }
157
- const restResource = restResources.find((entry) => entry.slug === source.slug);
158
- if (!restResource) {
159
- throw new Error(`Unknown REST resource source "${source.slug}". Choose one of: ${restResources
160
- .map((entry) => entry.slug)
161
- .join(", ") || "<none>"}.`);
162
- }
163
- if (!restResource.methods.includes("list")) {
164
- throw new Error(`REST resource source "${source.slug}" must include the list method for DataViews pagination.`);
165
- }
166
- return restResource;
167
- }
168
- function buildAdminViewConfigEntry(adminViewSlug, source) {
169
- return [
170
- "\t{",
171
- `\t\tfile: ${quoteTsString(`src/admin-views/${adminViewSlug}/index.tsx`)},`,
172
- `\t\tphpFile: ${quoteTsString(`inc/admin-views/${adminViewSlug}.php`)},`,
173
- `\t\tslug: ${quoteTsString(adminViewSlug)},`,
174
- source
175
- ? `\t\tsource: ${quoteTsString(formatAdminViewSourceLocator(source))},`
176
- : null,
177
- "\t},",
178
- ]
179
- .filter((line) => typeof line === "string")
180
- .join("\n");
181
- }
182
- function buildAdminViewRegistrySource(adminViewSlugs) {
183
- const importLines = adminViewSlugs
184
- .map((adminViewSlug) => `import './${adminViewSlug}';`)
185
- .join("\n");
186
- return `${importLines}${importLines ? "\n\n" : ""}// wp-typia add admin-view entries\n`;
187
- }
188
- /**
189
- * Build the generated admin-view item and dataset types for the selected source.
190
- */
191
- function buildAdminViewTypesSource(adminViewSlug, restResource, coreDataSource) {
192
- const pascalName = toPascalCase(adminViewSlug);
193
- const coreDataRecordTypeName = `${pascalName}CoreDataRecord`;
194
- const itemTypeName = `${pascalName}AdminViewItem`;
195
- const dataSetTypeName = `${pascalName}AdminViewDataSet`;
196
- if (restResource) {
197
- const restPascalName = toPascalCase(restResource.slug);
198
- const restTypesModule = getAdminViewRelativeModuleSpecifier(adminViewSlug, restResource.typesFile);
199
- return `import type { ${restPascalName}Record } from ${quoteTsString(restTypesModule)};
200
-
201
- export type ${itemTypeName} = ${restPascalName}Record;
202
-
203
- export interface ${dataSetTypeName} {
204
- \titems: ${itemTypeName}[];
205
- \tpaginationInfo: {
206
- \t\ttotalItems: number;
207
- \t\ttotalPages: number;
208
- \t};
209
- }
210
- `;
211
- }
212
- if (coreDataSource) {
213
- if (coreDataSource.entityKind === "taxonomy") {
214
- return `export interface ${coreDataRecordTypeName} {
215
- \tcount?: number;
216
- \tdescription?: string;
217
- \tid: number;
218
- \tlink?: string;
219
- \tmeta?: Record<string, unknown>;
220
- \tname?: string;
221
- \tparent?: number;
222
- \tslug?: string;
223
- \ttaxonomy?: string;
224
- \t[key: string]: unknown;
225
- }
226
-
227
- export interface ${itemTypeName} {
228
- \tcount: number;
229
- \tdescription: string;
230
- \tid: number;
231
- \tlink: string;
232
- \tname: string;
233
- \tparent: number;
234
- \traw: ${coreDataRecordTypeName};
235
- \tslug: string;
236
- \ttaxonomy: string;
237
- }
238
-
239
- export interface ${dataSetTypeName} {
240
- \titems: ${itemTypeName}[];
241
- \tpaginationInfo: {
242
- \t\ttotalItems: number;
243
- \t\ttotalPages: number;
244
- \t};
245
- }
246
- `;
247
- }
248
- return `export interface ${coreDataRecordTypeName} {
249
- \tid: number;
250
- \tdate?: string;
251
- \tmodified?: string;
252
- \tname?: string;
253
- \tslug?: string;
254
- \tstatus?: string;
255
- \ttitle?: string | {
256
- \t\traw?: string;
257
- \t\trendered?: string;
258
- \t};
259
- \t[key: string]: unknown;
260
- }
261
-
262
- export interface ${itemTypeName} {
263
- \tid: number;
264
- \traw: ${coreDataRecordTypeName};
265
- \tslug: string;
266
- \tstatus: string;
267
- \ttitle: string;
268
- \tupdatedAt: string;
269
- }
270
-
271
- export interface ${dataSetTypeName} {
272
- \titems: ${itemTypeName}[];
273
- \tpaginationInfo: {
274
- \t\ttotalItems: number;
275
- \t\ttotalPages: number;
276
- \t};
277
- }
278
- `;
279
- }
280
- return `export type ${pascalName}AdminViewStatus = 'draft' | 'published';
281
-
282
- export interface ${itemTypeName} {
283
- \tid: number;
284
- \towner: string;
285
- \tstatus: ${pascalName}AdminViewStatus;
286
- \ttitle: string;
287
- \tupdatedAt: string;
288
- }
289
-
290
- export interface ${dataSetTypeName} {
291
- \titems: ${itemTypeName}[];
292
- \tpaginationInfo: {
293
- \t\ttotalItems: number;
294
- \t\ttotalPages: number;
295
- \t};
296
- }
297
- `;
298
- }
299
- /**
300
- * Build the generated DataViews config source for an admin-view scaffold.
301
- */
302
- function buildAdminViewConfigSource(adminViewSlug, textDomain, source, restResource) {
303
- const pascalName = toPascalCase(adminViewSlug);
304
- const camelName = toCamelCase(adminViewSlug);
305
- const itemTypeName = `${pascalName}AdminViewItem`;
306
- const dataViewsName = `${camelName}AdminDataViews`;
307
- const isCoreDataSource = source?.kind === ADMIN_VIEW_CORE_DATA_SOURCE_KIND;
308
- const isTaxonomyCoreDataSource = source?.kind === ADMIN_VIEW_CORE_DATA_SOURCE_KIND &&
309
- source.entityKind === "taxonomy";
310
- const defaultViewFields = restResource
311
- ? "['id']"
312
- : isTaxonomyCoreDataSource
313
- ? "['name', 'slug', 'count']"
314
- : isCoreDataSource
315
- ? "['title', 'slug', 'status', 'updatedAt']"
316
- : "['title', 'status', 'updatedAt']";
317
- const searchEnabled = restResource ? "false" : "true";
318
- const titleFieldSource = restResource
319
- ? ""
320
- : isTaxonomyCoreDataSource
321
- ? "\ttitleField: 'name',\n"
322
- : "\ttitleField: 'title',\n";
323
- const defaultViewEnhancementsSource = restResource
324
- ? ""
325
- : isTaxonomyCoreDataSource
326
- ? "\t\ttitleField: 'name',\n"
327
- : isCoreDataSource
328
- ? "\t\ttitleField: 'title',\n"
329
- : `\t\tsort: {
330
- \t\t\tdirection: 'desc',
331
- \t\t\tfield: 'updatedAt',
332
- \t\t},
333
- \t\ttitleField: 'title',
334
- `;
335
- const additionalFieldsSource = restResource
336
- ? "\t\t// REST-backed screens start with the guaranteed ID column. Add project-owned fields here once they are declared on the REST record type."
337
- : isTaxonomyCoreDataSource
338
- ? `\t\tcount: {
339
- \t\t\tlabel: __( 'Count', ${quoteTsString(textDomain)} ),
340
- \t\t\tschema: { type: 'integer' },
341
- \t\t},
342
- \t\tdescription: {
343
- \t\t\tlabel: __( 'Description', ${quoteTsString(textDomain)} ),
344
- \t\t\tschema: { type: 'string' },
345
- \t\t},
346
- \t\tlink: {
347
- \t\t\tlabel: __( 'Link', ${quoteTsString(textDomain)} ),
348
- \t\t\tschema: { format: 'uri', type: 'string' },
349
- \t\t},
350
- \t\tname: {
351
- \t\t\tenableGlobalSearch: true,
352
- \t\t\tlabel: __( 'Name', ${quoteTsString(textDomain)} ),
353
- \t\t\tschema: { type: 'string' },
354
- \t\t},
355
- \t\tparent: {
356
- \t\t\tlabel: __( 'Parent', ${quoteTsString(textDomain)} ),
357
- \t\t\tschema: { type: 'integer' },
358
- \t\t},
359
- \t\tslug: {
360
- \t\t\tenableGlobalSearch: true,
361
- \t\t\tlabel: __( 'Slug', ${quoteTsString(textDomain)} ),
362
- \t\t\tschema: { type: 'string' },
363
- \t\t},
364
- \t\ttaxonomy: {
365
- \t\t\tlabel: __( 'Taxonomy', ${quoteTsString(textDomain)} ),
366
- \t\t\tschema: { type: 'string' },
367
- \t\t},`
368
- : isCoreDataSource
369
- ? `\t\tslug: {
370
- \t\t\tenableGlobalSearch: true,
371
- \t\t\tlabel: __( 'Slug', ${quoteTsString(textDomain)} ),
372
- \t\t\tschema: { type: 'string' },
373
- \t\t},
374
- \t\tstatus: {
375
- \t\t\tlabel: __( 'Status', ${quoteTsString(textDomain)} ),
376
- \t\t\tschema: { type: 'string' },
377
- \t\t},
378
- \t\ttitle: {
379
- \t\t\tenableGlobalSearch: true,
380
- \t\t\tlabel: __( 'Name', ${quoteTsString(textDomain)} ),
381
- \t\t\tschema: { type: 'string' },
382
- \t\t},
383
- \t\tupdatedAt: {
384
- \t\t\tlabel: __( 'Updated', ${quoteTsString(textDomain)} ),
385
- \t\t\tschema: { format: 'date-time', type: 'string' },
386
- \t\t\ttype: 'datetime',
387
- \t\t},`
388
- : `\t\towner: {
389
- \t\t\tlabel: __( 'Owner', ${quoteTsString(textDomain)} ),
390
- \t\t\tschema: { type: 'string' },
391
- \t\t},
392
- \t\tstatus: {
393
- \t\t\tfilterBy: { operators: ['isAny', 'isNone'] },
394
- \t\t\tlabel: __( 'Status', ${quoteTsString(textDomain)} ),
395
- \t\t\tschema: {
396
- \t\t\t\tenum: ['draft', 'published'],
397
- \t\t\t\tenumLabels: {
398
- \t\t\t\t\tdraft: __( 'Draft', ${quoteTsString(textDomain)} ),
399
- \t\t\t\t\tpublished: __( 'Published', ${quoteTsString(textDomain)} ),
400
- \t\t\t\t},
401
- \t\t\t\ttype: 'string',
402
- \t\t\t},
403
- \t\t},
404
- \t\ttitle: {
405
- \t\t\tenableGlobalSearch: true,
406
- \t\t\tenableSorting: true,
407
- \t\t\tlabel: __( 'Title', ${quoteTsString(textDomain)} ),
408
- \t\t\tschema: { type: 'string' },
409
- \t\t},
410
- \t\tupdatedAt: {
411
- \t\t\tenableSorting: true,
412
- \t\t\tlabel: __( 'Updated', ${quoteTsString(textDomain)} ),
413
- \t\t\tschema: { format: 'date-time', type: 'string' },
414
- \t\t\ttype: 'datetime',
415
- \t\t},`;
416
- return `import { defineDataViews } from '@wp-typia/dataviews';
417
- import { __ } from '@wordpress/i18n';
418
-
419
- import type { ${itemTypeName} } from './types';
420
-
421
- export const ${dataViewsName} = defineDataViews<${itemTypeName}>({
422
- \tidField: 'id',
423
- \tsearch: ${searchEnabled},
424
- \tsearchLabel: __( 'Search records', ${quoteTsString(textDomain)} ),
425
- ${titleFieldSource}
426
- \tdefaultView: {
427
- \t\tfields: ${defaultViewFields},
428
- \t\tpage: 1,
429
- \t\tperPage: 10,
430
- ${defaultViewEnhancementsSource}
431
- \t\ttype: 'table',
432
- \t},
433
- \tfields: {
434
- \t\tid: {
435
- \t\t\tenableHiding: false,
436
- \t\t\tlabel: __( 'ID', ${quoteTsString(textDomain)} ),
437
- \t\t\treadOnly: true,
438
- \t\t\tschema: { type: 'integer' },
439
- \t\t},
440
- ${additionalFieldsSource}
441
- \t},
442
- });
443
- `;
444
- }
445
- function buildDefaultAdminViewDataSource(adminViewSlug) {
446
- const pascalName = toPascalCase(adminViewSlug);
447
- const camelName = toCamelCase(adminViewSlug);
448
- const title = toTitleCase(adminViewSlug);
449
- const itemTypeName = `${pascalName}AdminViewItem`;
450
- const dataSetTypeName = `${pascalName}AdminViewDataSet`;
451
- const queryTypeName = `${pascalName}AdminViewQuery`;
452
- const dataViewsName = `${camelName}AdminDataViews`;
453
- const fetchName = `fetch${pascalName}AdminViewData`;
454
- return `import type { DataViewsView } from '@wp-typia/dataviews';
455
-
456
- import { ${dataViewsName} } from './config';
457
- import type { ${dataSetTypeName}, ${itemTypeName} } from './types';
458
-
459
- export interface ${queryTypeName} {
460
- \tpage?: number;
461
- \tperPage?: number;
462
- \tsearch?: string;
463
- }
464
-
465
- const STARTER_ITEMS: ${itemTypeName}[] = [
466
- \t{
467
- \t\tid: 1,
468
- \t\towner: 'Editorial',
469
- \t\tstatus: 'published',
470
- \t\ttitle: ${quoteTsString(`${title} launch checklist`)},
471
- \t\tupdatedAt: '2026-04-01T10:30:00Z',
472
- \t},
473
- \t{
474
- \t\tid: 2,
475
- \t\towner: 'Design',
476
- \t\tstatus: 'draft',
477
- \t\ttitle: ${quoteTsString(`${title} content refresh`)},
478
- \t\tupdatedAt: '2026-04-03T14:15:00Z',
479
- \t},
480
- \t{
481
- \t\tid: 3,
482
- \t\towner: 'Operations',
483
- \t\tstatus: 'published',
484
- \t\ttitle: ${quoteTsString(`${title} support handoff`)},
485
- \t\tupdatedAt: '2026-04-08T08:45:00Z',
486
- \t},
487
- ];
488
-
489
- function matchesSearch(item: ${itemTypeName}, search: string | undefined): boolean {
490
- \tif (!search) {
491
- \t\treturn true;
492
- \t}
493
-
494
- \tconst needle = search.toLowerCase();
495
- \treturn [item.title, item.owner, item.status].some((value) =>
496
- \t\tvalue.toLowerCase().includes(needle),
497
- \t);
498
- }
499
-
500
- export async function ${fetchName}(
501
- \tview: DataViewsView<${itemTypeName}>,
502
- ): Promise<${dataSetTypeName}> {
503
- \tconst query = ${dataViewsName}.toQueryArgs<${queryTypeName}>(view, {
504
- \t\tperPageParam: 'perPage',
505
- \t});
506
- \tconst requestedPage = query.page ?? 1;
507
- \tconst page = requestedPage > 0 ? requestedPage : 1;
508
- \tconst requestedPerPage = query.perPage ?? view.perPage ?? 10;
509
- \tconst perPage = requestedPerPage > 0 ? requestedPerPage : 10;
510
- \tconst filteredItems = STARTER_ITEMS.filter((item) =>
511
- \t\tmatchesSearch(item, query.search),
512
- \t);
513
- \tconst offset = (page - 1) * perPage;
514
- \tconst items = filteredItems.slice(offset, offset + perPage);
515
-
516
- \treturn {
517
- \t\titems,
518
- \t\tpaginationInfo: {
519
- \t\t\ttotalItems: filteredItems.length,
520
- \t\t\ttotalPages: Math.max(1, Math.ceil(filteredItems.length / perPage)),
521
- \t\t},
522
- \t};
523
- }
524
- `;
525
- }
526
- function buildRestAdminViewDataSource(adminViewSlug, restResource) {
527
- const pascalName = toPascalCase(adminViewSlug);
528
- const restPascalName = toPascalCase(restResource.slug);
529
- const camelName = toCamelCase(adminViewSlug);
530
- const itemTypeName = `${pascalName}AdminViewItem`;
531
- const dataSetTypeName = `${pascalName}AdminViewDataSet`;
532
- const dataViewsName = `${camelName}AdminDataViews`;
533
- const fetchName = `fetch${pascalName}AdminViewData`;
534
- const restApiModule = getAdminViewRelativeModuleSpecifier(adminViewSlug, restResource.apiFile);
535
- const restTypesModule = getAdminViewRelativeModuleSpecifier(adminViewSlug, restResource.typesFile);
536
- return `import type { DataViewsView } from '@wp-typia/dataviews';
537
-
538
- import { listResource } from ${quoteTsString(restApiModule)};
539
- import type { ${restPascalName}ListQuery } from ${quoteTsString(restTypesModule)};
540
- import { ${dataViewsName} } from './config';
541
- import type { ${dataSetTypeName}, ${itemTypeName} } from './types';
542
-
543
- function resolveTotalPages(total: number, perPage: number | undefined): number {
544
- \tconst resolvedPerPage = perPage && perPage > 0 ? perPage : 1;
545
- \treturn Math.max(1, Math.ceil(total / resolvedPerPage));
546
- }
547
-
548
- export async function ${fetchName}(
549
- \tview: DataViewsView<${itemTypeName}>,
550
- ): Promise<${dataSetTypeName}> {
551
- \tconst query = ${dataViewsName}.toQueryArgs<${restPascalName}ListQuery>(view, {
552
- \t\tperPageParam: 'perPage',
553
- \t\tsearchParam: false,
554
- \t});
555
- \tconst result = await listResource({
556
- \t\tpage: query.page,
557
- \t\tperPage: query.perPage,
558
- \t});
559
- \tif (!result.isValid || !result.data) {
560
- \t\tthrow new Error('Unable to load REST resource records.');
561
- \t}
562
-
563
- \tconst response = result.data;
564
-
565
- \treturn {
566
- \t\titems: response.items,
567
- \t\tpaginationInfo: {
568
- \t\t\ttotalItems: response.total,
569
- \t\t\ttotalPages: resolveTotalPages(response.total, response.perPage ?? query.perPage),
570
- \t\t},
571
- \t};
572
- }
573
- `;
574
- }
575
- /**
576
- * Build a core-data-backed admin-view data module for a supported entity family.
577
- */
578
- function buildCoreDataAdminViewDataSource(adminViewSlug, coreDataSource) {
579
- const pascalName = toPascalCase(adminViewSlug);
580
- const camelName = toCamelCase(adminViewSlug);
581
- const coreDataRecordTypeName = `${pascalName}CoreDataRecord`;
582
- const dataSetTypeName = `${pascalName}AdminViewDataSet`;
583
- const itemTypeName = `${pascalName}AdminViewItem`;
584
- const queryTypeName = `${pascalName}AdminViewQuery`;
585
- const dataViewsName = `${camelName}AdminDataViews`;
586
- const useEntityRecordName = `use${pascalName}EntityRecord`;
587
- const useEntityRecordsName = `use${pascalName}EntityRecords`;
588
- const useAdminViewDataName = `use${pascalName}AdminViewData`;
589
- if (coreDataSource.entityKind === "taxonomy") {
590
- return `import type { DataViewsView } from '@wp-typia/dataviews';
591
- import { useEntityRecord, useEntityRecords } from '@wordpress/core-data';
592
- import { useMemo } from '@wordpress/element';
593
-
594
- import { ${dataViewsName} } from './config';
595
- import type {
596
- \t${coreDataRecordTypeName},
597
- \t${dataSetTypeName},
598
- \t${itemTypeName},
599
- } from './types';
600
-
601
- export interface ${queryTypeName} {
602
- \tpage?: number;
603
- \tper_page?: number;
604
- \tsearch?: string;
605
- }
606
-
607
- const CORE_DATA_ENTITY_KIND = ${quoteTsString(coreDataSource.entityKind)};
608
- const CORE_DATA_ENTITY_NAME = ${quoteTsString(coreDataSource.entityName)};
609
-
610
- function normalizeCoreDataNumber(value: unknown): number {
611
- \treturn typeof value === 'number' && Number.isFinite(value) ? value : 0;
612
- }
613
-
614
- function normalizeCoreDataString(value: unknown): string {
615
- \treturn typeof value === 'string' ? value : '';
616
- }
617
-
618
- function normalizeTaxonomyRecord(record: ${coreDataRecordTypeName}): ${itemTypeName} {
619
- \treturn {
620
- \t\tcount: normalizeCoreDataNumber(record.count),
621
- \t\tdescription: normalizeCoreDataString(record.description),
622
- \t\tid: record.id,
623
- \t\tlink: normalizeCoreDataString(record.link),
624
- \t\tname: normalizeCoreDataString(record.name) || normalizeCoreDataString(record.slug),
625
- \t\tparent: normalizeCoreDataNumber(record.parent),
626
- \t\traw: record,
627
- \t\tslug: normalizeCoreDataString(record.slug),
628
- \t\ttaxonomy: normalizeCoreDataString(record.taxonomy),
629
- \t};
630
- \t}
631
-
632
- export function ${useEntityRecordName}(recordId: number | undefined) {
633
- \treturn useEntityRecord<${coreDataRecordTypeName}>(
634
- \t\tCORE_DATA_ENTITY_KIND,
635
- \t\tCORE_DATA_ENTITY_NAME,
636
- \t\trecordId ?? 0,
637
- \t\t{ enabled: typeof recordId === 'number' },
638
- \t);
639
- \t}
640
-
641
- export function ${useEntityRecordsName}(view: DataViewsView<${itemTypeName}>) {
642
- \tconst query = ${dataViewsName}.toQueryArgs<${queryTypeName}>(view, {
643
- \t\tperPageParam: 'per_page',
644
- \t});
645
-
646
- \treturn useEntityRecords<${coreDataRecordTypeName}>(
647
- \t\tCORE_DATA_ENTITY_KIND,
648
- \t\tCORE_DATA_ENTITY_NAME,
649
- \t\tquery,
650
- \t);
651
- \t}
652
-
653
- export function ${useAdminViewDataName}(view: DataViewsView<${itemTypeName}>) {
654
- \tconst { hasResolved, isResolving, records, totalItems, totalPages } =
655
- \t\t${useEntityRecordsName}(view);
656
- \tconst items = useMemo(
657
- \t\t() => (records ?? []).map((record) => normalizeTaxonomyRecord(record)),
658
- \t\t[records],
659
- \t);
660
- \tconst dataSet = useMemo<${dataSetTypeName}>(
661
- \t\t() => ({
662
- \t\t\titems,
663
- \t\t\tpaginationInfo: {
664
- \t\t\t\ttotalItems: totalItems ?? items.length,
665
- \t\t\t\ttotalPages: Math.max(1, totalPages ?? 1),
666
- \t\t\t},
667
- \t\t}),
668
- \t\t[items, totalItems, totalPages],
669
- \t);
670
- \tconst error =
671
- \t\t!isResolving && hasResolved && records === null
672
- \t\t\t? 'Unable to load core-data entity records.'
673
- \t\t\t: null;
674
-
675
- \treturn {
676
- \t\tdataSet,
677
- \t\terror,
678
- \t\tisLoading: isResolving,
679
- \t};
680
- \t}
681
- `;
682
- }
683
- return `import type { DataViewsView } from '@wp-typia/dataviews';
684
- import { useEntityRecord, useEntityRecords } from '@wordpress/core-data';
685
- import { useMemo } from '@wordpress/element';
686
-
687
- import { ${dataViewsName} } from './config';
688
- import type {
689
- \t${coreDataRecordTypeName},
690
- \t${dataSetTypeName},
691
- \t${itemTypeName},
692
- } from './types';
693
-
694
- export interface ${queryTypeName} {
695
- \tpage?: number;
696
- \tper_page?: number;
697
- \tsearch?: string;
698
- }
699
-
700
- const CORE_DATA_ENTITY_KIND = ${quoteTsString(coreDataSource.entityKind)};
701
- const CORE_DATA_ENTITY_NAME = ${quoteTsString(coreDataSource.entityName)};
702
-
703
- function normalizeCoreDataString(value: unknown): string {
704
- \treturn typeof value === 'string' ? value : '';
705
- }
706
-
707
- function normalizeCoreDataTitle(record: ${coreDataRecordTypeName}): string {
708
- \tif (typeof record.title === 'string') {
709
- \t\treturn record.title;
710
- \t}
711
- \tif (record.title && typeof record.title === 'object') {
712
- \t\tif (typeof record.title.rendered === 'string') {
713
- \t\t\treturn record.title.rendered;
714
- \t\t}
715
- \t\tif (typeof record.title.raw === 'string') {
716
- \t\t\treturn record.title.raw;
717
- \t\t}
718
- \t}
719
-
720
- \treturn normalizeCoreDataString(record.name) || normalizeCoreDataString(record.slug);
721
- }
722
-
723
- function normalizeCoreDataUpdatedAt(record: ${coreDataRecordTypeName}): string {
724
- \treturn normalizeCoreDataString(record.modified) || normalizeCoreDataString(record.date);
725
- }
726
-
727
- function normalizeCoreDataRecord(record: ${coreDataRecordTypeName}): ${itemTypeName} {
728
- \treturn {
729
- \t\tid: record.id,
730
- \t\traw: record,
731
- \t\tslug: normalizeCoreDataString(record.slug),
732
- \t\tstatus: normalizeCoreDataString(record.status),
733
- \t\ttitle: normalizeCoreDataTitle(record),
734
- \t\tupdatedAt: normalizeCoreDataUpdatedAt(record),
735
- \t};
736
- }
737
-
738
- export function ${useEntityRecordName}(recordId: number | undefined) {
739
- \treturn useEntityRecord<${coreDataRecordTypeName}>(
740
- \t\tCORE_DATA_ENTITY_KIND,
741
- \t\tCORE_DATA_ENTITY_NAME,
742
- \t\trecordId ?? 0,
743
- \t\t{ enabled: typeof recordId === 'number' },
744
- \t);
745
- }
746
-
747
- export function ${useEntityRecordsName}(view: DataViewsView<${itemTypeName}>) {
748
- \tconst query = ${dataViewsName}.toQueryArgs<${queryTypeName}>(view, {
749
- \t\tperPageParam: 'per_page',
750
- \t});
751
-
752
- \treturn useEntityRecords<${coreDataRecordTypeName}>(
753
- \t\tCORE_DATA_ENTITY_KIND,
754
- \t\tCORE_DATA_ENTITY_NAME,
755
- \t\tquery,
756
- \t);
757
- }
758
-
759
- export function ${useAdminViewDataName}(view: DataViewsView<${itemTypeName}>) {
760
- \tconst { hasResolved, isResolving, records, totalItems, totalPages } =
761
- \t\t${useEntityRecordsName}(view);
762
- \tconst items = useMemo(
763
- \t\t() => (records ?? []).map((record) => normalizeCoreDataRecord(record)),
764
- \t\t[records],
765
- \t);
766
- \tconst dataSet = useMemo<${dataSetTypeName}>(
767
- \t\t() => ({
768
- \t\t\titems,
769
- \t\t\tpaginationInfo: {
770
- \t\t\t\ttotalItems: totalItems ?? items.length,
771
- \t\t\t\ttotalPages: Math.max(1, totalPages ?? 1),
772
- \t\t\t},
773
- \t\t}),
774
- \t\t[items, totalItems, totalPages],
775
- \t);
776
- \tconst error =
777
- \t\t!isResolving && hasResolved && records === null
778
- \t\t\t? 'Unable to load core-data entity records.'
779
- \t\t\t: null;
780
-
781
- \treturn {
782
- \t\tdataSet,
783
- \t\terror,
784
- \t\tisLoading: isResolving,
785
- \t};
786
- }
787
- `;
788
- }
789
- function buildAdminViewScreenSource(adminViewSlug, textDomain) {
790
- const pascalName = toPascalCase(adminViewSlug);
791
- const camelName = toCamelCase(adminViewSlug);
792
- const itemTypeName = `${pascalName}AdminViewItem`;
793
- const dataSetTypeName = `${pascalName}AdminViewDataSet`;
794
- const componentName = `${pascalName}AdminViewScreen`;
795
- const dataViewsName = `${camelName}AdminDataViews`;
796
- const fetchName = `fetch${pascalName}AdminViewData`;
797
- const title = toTitleCase(adminViewSlug);
798
- return `import type { DataViewsConfig, DataViewsView } from '@wp-typia/dataviews';
799
- import { Button, Notice, Spinner } from '@wordpress/components';
800
- import { useEffect, useState } from '@wordpress/element';
801
- import { __ } from '@wordpress/i18n';
802
- import { DataViews } from '@wordpress/dataviews/wp';
803
-
804
- import { ${dataViewsName} } from './config';
805
- import { ${fetchName} } from './data';
806
- import type { ${dataSetTypeName}, ${itemTypeName} } from './types';
807
-
808
- const TypedDataViews = DataViews as unknown as <TItem extends object>(
809
- \tprops: DataViewsConfig<TItem>,
810
- ) => ReturnType<typeof DataViews>;
811
-
812
- const EMPTY_DATA_SET: ${dataSetTypeName} = {
813
- \titems: [],
814
- \tpaginationInfo: {
815
- \t\ttotalItems: 0,
816
- \t\ttotalPages: 1,
817
- \t},
818
- };
819
-
820
- export function ${componentName}() {
821
- \tconst [view, setView] = useState<DataViewsView<${itemTypeName}>>(
822
- \t\t${dataViewsName}.defaultView,
823
- \t);
824
- \tconst [dataSet, setDataSet] = useState<${dataSetTypeName}>(EMPTY_DATA_SET);
825
- \tconst [error, setError] = useState<string | null>(null);
826
- \tconst [isLoading, setIsLoading] = useState(true);
827
- \tconst [reloadToken, setReloadToken] = useState(0);
828
-
829
- \tuseEffect(() => {
830
- \t\tlet isCurrentRequest = true;
831
- \t\tsetIsLoading(true);
832
- \t\tsetError(null);
833
-
834
- \t\tvoid ${fetchName}(view)
835
- \t\t\t.then((nextDataSet) => {
836
- \t\t\t\tif (isCurrentRequest) {
837
- \t\t\t\t\tsetDataSet(nextDataSet);
838
- \t\t\t\t}
839
- \t\t\t})
840
- \t\t\t.catch((nextError: unknown) => {
841
- \t\t\t\tif (isCurrentRequest) {
842
- \t\t\t\t\tsetError(
843
- \t\t\t\t\t\tnextError instanceof Error
844
- \t\t\t\t\t\t\t? nextError.message
845
- \t\t\t\t\t\t\t: __( 'Unable to load records.', ${quoteTsString(textDomain)} ),
846
- \t\t\t\t\t);
847
- \t\t\t\t}
848
- \t\t\t})
849
- \t\t\t.finally(() => {
850
- \t\t\t\tif (isCurrentRequest) {
851
- \t\t\t\t\tsetIsLoading(false);
852
- \t\t\t\t}
853
- \t\t\t});
854
-
855
- \t\treturn () => {
856
- \t\t\tisCurrentRequest = false;
857
- \t\t};
858
- \t}, [reloadToken, view]);
859
-
860
- \tconst config = ${dataViewsName}.createConfig({
861
- \t\tdata: dataSet.items,
862
- \t\tisLoading,
863
- \t\tonChangeView: setView,
864
- \t\tpaginationInfo: dataSet.paginationInfo,
865
- \t\tview,
866
- \t});
867
-
868
- \treturn (
869
- \t\t<div className="wp-typia-admin-view-screen">
870
- \t\t\t<header className="wp-typia-admin-view-screen__header">
871
- \t\t\t\t<div>
872
- \t\t\t\t\t<p className="wp-typia-admin-view-screen__eyebrow">
873
- \t\t\t\t\t\t{ __( 'DataViews admin screen', ${quoteTsString(textDomain)} ) }
874
- \t\t\t\t\t</p>
875
- \t\t\t\t\t<h1>{ __( ${quoteTsString(title)}, ${quoteTsString(textDomain)} ) }</h1>
876
- \t\t\t\t\t<p>
877
- \t\t\t\t\t\t{ __( 'Replace the fetcher in data.ts with your project data source when this screen graduates from scaffold to product UI.', ${quoteTsString(textDomain)} ) }
878
- \t\t\t\t\t</p>
879
- \t\t\t\t</div>
880
- \t\t\t\t<div className="wp-typia-admin-view-screen__actions">
881
- \t\t\t\t\t{ isLoading ? <Spinner /> : null }
882
- \t\t\t\t\t<Button
883
- \t\t\t\t\t\tisBusy={ isLoading }
884
- \t\t\t\t\t\tonClick={ () => setReloadToken((token) => token + 1) }
885
- \t\t\t\t\t\tvariant="secondary"
886
- \t\t\t\t\t>
887
- \t\t\t\t\t\t{ __( 'Reload', ${quoteTsString(textDomain)} ) }
888
- \t\t\t\t\t</Button>
889
- \t\t\t\t</div>
890
- \t\t\t</header>
891
- \t\t\t{ error ? (
892
- \t\t\t\t<Notice isDismissible={ false } status="error">
893
- \t\t\t\t\t{ error }
894
- \t\t\t\t</Notice>
895
- \t\t\t) : null }
896
- \t\t\t<TypedDataViews<${itemTypeName}> { ...config } />
897
- \t\t</div>
898
- \t);
899
- }
900
- `;
901
- }
902
- function buildCoreDataAdminViewScreenSource(adminViewSlug, textDomain) {
903
- const pascalName = toPascalCase(adminViewSlug);
904
- const camelName = toCamelCase(adminViewSlug);
905
- const itemTypeName = `${pascalName}AdminViewItem`;
906
- const dataSetTypeName = `${pascalName}AdminViewDataSet`;
907
- const componentName = `${pascalName}AdminViewScreen`;
908
- const dataViewsName = `${camelName}AdminDataViews`;
909
- const useAdminViewDataName = `use${pascalName}AdminViewData`;
910
- const title = toTitleCase(adminViewSlug);
911
- return `import type { DataViewsConfig, DataViewsView } from '@wp-typia/dataviews';
912
- import { Notice, Spinner } from '@wordpress/components';
913
- import { useState } from '@wordpress/element';
914
- import { __ } from '@wordpress/i18n';
915
- import { DataViews } from '@wordpress/dataviews/wp';
916
-
917
- import { ${dataViewsName} } from './config';
918
- import { ${useAdminViewDataName} } from './data';
919
- import type { ${dataSetTypeName}, ${itemTypeName} } from './types';
920
-
921
- const TypedDataViews = DataViews as unknown as <TItem extends object>(
922
- \tprops: DataViewsConfig<TItem>,
923
- ) => ReturnType<typeof DataViews>;
924
-
925
- const EMPTY_DATA_SET: ${dataSetTypeName} = {
926
- \titems: [],
927
- \tpaginationInfo: {
928
- \t\ttotalItems: 0,
929
- \t\ttotalPages: 1,
930
- \t},
931
- };
932
-
933
- export function ${componentName}() {
934
- \tconst [view, setView] = useState<DataViewsView<${itemTypeName}>>(
935
- \t\t${dataViewsName}.defaultView,
936
- \t);
937
- \tconst {
938
- \t\tdataSet = EMPTY_DATA_SET,
939
- \t\terror,
940
- \t\tisLoading,
941
- \t} = ${useAdminViewDataName}(view);
942
- \tconst config = ${dataViewsName}.createConfig({
943
- \t\tdata: dataSet.items,
944
- \t\tisLoading,
945
- \t\tonChangeView: setView,
946
- \t\tpaginationInfo: dataSet.paginationInfo,
947
- \t\tview,
948
- \t});
949
-
950
- \treturn (
951
- \t\t<div className="wp-typia-admin-view-screen">
952
- \t\t\t<header className="wp-typia-admin-view-screen__header">
953
- \t\t\t\t<div>
954
- \t\t\t\t\t<p className="wp-typia-admin-view-screen__eyebrow">
955
- \t\t\t\t\t\t{ __( 'DataViews admin screen', ${quoteTsString(textDomain)} ) }
956
- \t\t\t\t\t</p>
957
- \t\t\t\t\t<h1>{ __( ${quoteTsString(title)}, ${quoteTsString(textDomain)} ) }</h1>
958
- \t\t\t\t\t<p>
959
- \t\t\t\t\t\t{ __( 'This screen reads from the WordPress core-data entity store. Extend data.ts when you need entity-specific field mapping or edit flows.', ${quoteTsString(textDomain)} ) }
960
- \t\t\t\t\t</p>
961
- \t\t\t\t</div>
962
- \t\t\t\t<div className="wp-typia-admin-view-screen__actions">
963
- \t\t\t\t\t{ isLoading ? <Spinner /> : null }
964
- \t\t\t\t</div>
965
- \t\t\t</header>
966
- \t\t\t{ error ? (
967
- \t\t\t\t<Notice isDismissible={ false } status="error">
968
- \t\t\t\t\t{ error }
969
- \t\t\t\t</Notice>
970
- \t\t\t) : null }
971
- \t\t\t<TypedDataViews<${itemTypeName}> { ...config } />
972
- \t\t</div>
973
- \t);
974
- }
975
- `;
976
- }
977
- function buildAdminViewEntrySource(adminViewSlug) {
978
- const pascalName = toPascalCase(adminViewSlug);
979
- const componentName = `${pascalName}AdminViewScreen`;
980
- const rootId = `wp-typia-admin-view-${adminViewSlug}`;
981
- return `import { createRoot } from '@wordpress/element';
982
-
983
- import '@wordpress/dataviews/build-style/style.css';
984
- import { ${componentName} } from './Screen';
985
- import './style.scss';
986
-
987
- const ROOT_ELEMENT_ID = ${quoteTsString(rootId)};
988
-
989
- function mountAdminView() {
990
- \tconst rootElement = document.getElementById(ROOT_ELEMENT_ID);
991
- \tif (!rootElement) {
992
- \t\treturn;
993
- \t}
994
-
995
- \tcreateRoot(rootElement).render(<${componentName} />);
996
- }
997
-
998
- if (document.readyState === 'loading') {
999
- \tdocument.addEventListener('DOMContentLoaded', mountAdminView);
1000
- } else {
1001
- \tmountAdminView();
1002
- }
1003
- `;
1004
- }
1005
- function buildAdminViewStyleSource() {
1006
- return `.wp-typia-admin-view-screen {
1007
- \tbox-sizing: border-box;
1008
- \tmax-width: 1180px;
1009
- \tpadding: 24px 24px 48px 0;
1010
- }
1011
-
1012
- .wp-typia-admin-view-screen__header {
1013
- \talign-items: flex-start;
1014
- \tdisplay: flex;
1015
- \tgap: 24px;
1016
- \tjustify-content: space-between;
1017
- \tmargin-bottom: 24px;
1018
- }
1019
-
1020
- .wp-typia-admin-view-screen__header h1 {
1021
- \tfont-size: 28px;
1022
- \tline-height: 1.2;
1023
- \tmargin: 0 0 8px;
1024
- }
1025
-
1026
- .wp-typia-admin-view-screen__header p {
1027
- \tmax-width: 680px;
1028
- }
1029
-
1030
- .wp-typia-admin-view-screen__eyebrow {
1031
- \tcolor: #3858e9;
1032
- \tfont-size: 11px;
1033
- \tfont-weight: 600;
1034
- \tletter-spacing: 0.08em;
1035
- \tmargin: 0 0 8px;
1036
- \ttext-transform: uppercase;
1037
- }
1038
-
1039
- .wp-typia-admin-view-screen__actions {
1040
- \talign-items: center;
1041
- \tdisplay: flex;
1042
- \tgap: 12px;
1043
- }
1044
- `;
1045
- }
1046
- function buildAdminViewPhpSource(adminViewSlug, workspace) {
1047
- const workspaceBaseName = workspace.packageName.split("/").pop() ?? workspace.packageName;
1048
- const phpSlug = adminViewSlug.replace(/-/g, "_");
1049
- const functionPrefix = `${workspace.workspace.phpPrefix}_${phpSlug}`;
1050
- const menuSlugFunctionName = `${functionPrefix}_admin_view_menu_slug`;
1051
- const renderFunctionName = `${functionPrefix}_render_admin_view`;
1052
- const registerFunctionName = `${functionPrefix}_register_admin_view`;
1053
- const enqueueFunctionName = `${functionPrefix}_enqueue_admin_view`;
1054
- const hookGlobalName = `${functionPrefix}_admin_view_hook`;
1055
- const rootId = `wp-typia-admin-view-${adminViewSlug}`;
1056
- const title = toTitleCase(adminViewSlug);
1057
- return `<?php
1058
- if ( ! defined( 'ABSPATH' ) ) {
1059
- \treturn;
1060
- }
1061
-
1062
- if ( ! function_exists( '${menuSlugFunctionName}' ) ) {
1063
- \tfunction ${menuSlugFunctionName}() : string {
1064
- \t\treturn '${workspaceBaseName}-${adminViewSlug}';
1065
- \t}
1066
- }
1067
-
1068
- if ( ! function_exists( '${renderFunctionName}' ) ) {
1069
- \tfunction ${renderFunctionName}() : void {
1070
- \t\t?>
1071
- \t\t<div class="wrap">
1072
- \t\t\t<div id="${rootId}"></div>
1073
- \t\t</div>
1074
- \t\t<?php
1075
- \t}
1076
- }
1077
-
1078
- if ( ! function_exists( '${registerFunctionName}' ) ) {
1079
- \tfunction ${registerFunctionName}() : void {
1080
- \t\t$GLOBALS['${hookGlobalName}'] = add_submenu_page(
1081
- \t\t\t'tools.php',
1082
- \t\t\t__( ${quotePhpString(title)}, ${quotePhpString(workspace.workspace.textDomain)} ),
1083
- \t\t\t__( ${quotePhpString(title)}, ${quotePhpString(workspace.workspace.textDomain)} ),
1084
- \t\t\t'edit_posts',
1085
- \t\t\t${menuSlugFunctionName}(),
1086
- \t\t\t'${renderFunctionName}'
1087
- \t\t);
1088
- \t}
1089
- }
1090
-
1091
- if ( ! function_exists( '${enqueueFunctionName}' ) ) {
1092
- \tfunction ${enqueueFunctionName}( string $hook_suffix ) : void {
1093
- \t\t$page_hook = isset( $GLOBALS['${hookGlobalName}'] ) && is_string( $GLOBALS['${hookGlobalName}'] )
1094
- \t\t\t? $GLOBALS['${hookGlobalName}']
1095
- \t\t\t: '';
1096
-
1097
- \t\tif ( $page_hook !== $hook_suffix ) {
1098
- \t\t\treturn;
1099
- \t\t}
1100
-
1101
- \t\t$plugin_file = dirname( __DIR__, 2 ) . '/${workspaceBaseName}.php';
1102
- \t\t$script_path = dirname( __DIR__, 2 ) . '/${ADMIN_VIEWS_SCRIPT}';
1103
- \t\t$asset_path = dirname( __DIR__, 2 ) . '/${ADMIN_VIEWS_ASSET}';
1104
- \t\t$style_path = dirname( __DIR__, 2 ) . '/${ADMIN_VIEWS_STYLE}';
1105
- \t\t$style_rtl_path = dirname( __DIR__, 2 ) . '/${ADMIN_VIEWS_STYLE_RTL}';
1106
-
1107
- \t\tif ( ! file_exists( $script_path ) || ! file_exists( $asset_path ) ) {
1108
- \t\t\treturn;
1109
- \t\t}
1110
-
1111
- \t\t$asset = require $asset_path;
1112
- \t\tif ( ! is_array( $asset ) ) {
1113
- \t\t\t$asset = array();
1114
- \t\t}
1115
-
1116
- \t\t$dependencies = isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] )
1117
- \t\t\t? $asset['dependencies']
1118
- \t\t\t: array();
1119
-
1120
- \t\twp_enqueue_script(
1121
- \t\t\t'${workspaceBaseName}-${adminViewSlug}-admin-view',
1122
- \t\t\tplugins_url( '${ADMIN_VIEWS_SCRIPT}', $plugin_file ),
1123
- \t\t\t$dependencies,
1124
- \t\t\tisset( $asset['version'] ) ? $asset['version'] : filemtime( $script_path ),
1125
- \t\t\ttrue
1126
- \t\t);
1127
-
1128
- \t\tif ( file_exists( $style_path ) ) {
1129
- \t\t\twp_enqueue_style(
1130
- \t\t\t\t'${workspaceBaseName}-${adminViewSlug}-admin-view',
1131
- \t\t\t\tplugins_url( '${ADMIN_VIEWS_STYLE}', $plugin_file ),
1132
- \t\t\t\tarray( 'wp-components' ),
1133
- \t\t\t\tisset( $asset['version'] ) ? $asset['version'] : filemtime( $style_path )
1134
- \t\t\t);
1135
- \t\t\tif ( file_exists( $style_rtl_path ) ) {
1136
- \t\t\t\twp_style_add_data( '${workspaceBaseName}-${adminViewSlug}-admin-view', 'rtl', 'replace' );
1137
- \t\t\t}
1138
- \t\t}
1139
- \t}
1140
- }
1141
-
1142
- add_action( 'admin_menu', '${registerFunctionName}' );
1143
- add_action( 'admin_enqueue_scripts', '${enqueueFunctionName}' );
1144
- `;
1145
- }
1146
- async function ensureAdminViewPackageDependencies(workspace, adminViewSource) {
1147
- const packageJsonPath = path.join(workspace.projectDir, "package.json");
1148
- const wpTypiaDataViewsVersion = resolvePackageVersionRange("@wp-typia/dataviews", DEFAULT_WP_TYPIA_DATAVIEWS_VERSION, "wp-typia-dataviews");
1149
- const wordpressDataViewsVersion = resolvePackageVersionRange("@wordpress/dataviews", DEFAULT_WORDPRESS_DATAVIEWS_VERSION);
1150
- const wordpressCoreDataVersion = resolvePackageVersionRange("@wordpress/core-data", DEFAULT_WORDPRESS_CORE_DATA_VERSION);
1151
- const wordpressDataVersion = resolvePackageVersionRange("@wordpress/data", DEFAULT_WORDPRESS_DATA_VERSION);
1152
- await patchFile(packageJsonPath, (source) => {
1153
- const packageJson = JSON.parse(source);
1154
- const coreDataDependencies = isAdminViewCoreDataSource(adminViewSource)
1155
- ? {
1156
- "@wordpress/core-data": packageJson.dependencies?.["@wordpress/core-data"] ??
1157
- wordpressCoreDataVersion,
1158
- "@wordpress/data": packageJson.dependencies?.["@wordpress/data"] ??
1159
- wordpressDataVersion,
1160
- }
1161
- : {};
1162
- const nextDependencies = {
1163
- ...(packageJson.dependencies ?? {}),
1164
- "@wordpress/dataviews": packageJson.dependencies?.["@wordpress/dataviews"] ?? wordpressDataViewsVersion,
1165
- ...coreDataDependencies,
1166
- };
1167
- const nextDevDependencies = {
1168
- ...(packageJson.devDependencies ?? {}),
1169
- "@wp-typia/dataviews": packageJson.devDependencies?.["@wp-typia/dataviews"] ??
1170
- wpTypiaDataViewsVersion,
1171
- };
1172
- if (JSON.stringify(nextDependencies) === JSON.stringify(packageJson.dependencies ?? {}) &&
1173
- JSON.stringify(nextDevDependencies) ===
1174
- JSON.stringify(packageJson.devDependencies ?? {})) {
1175
- return source;
1176
- }
1177
- packageJson.dependencies = nextDependencies;
1178
- packageJson.devDependencies = nextDevDependencies;
1179
- return `${JSON.stringify(packageJson, null, detectJsonIndent(source))}\n`;
1180
- });
1181
- }
1182
- async function ensureAdminViewBootstrapAnchors(workspace) {
1183
- const bootstrapPath = getWorkspaceBootstrapPath(workspace);
1184
- await patchFile(bootstrapPath, (source) => {
1185
- let nextSource = source;
1186
- const loadFunctionName = `${workspace.workspace.phpPrefix}_load_admin_views`;
1187
- const loadHook = `add_action( 'plugins_loaded', '${loadFunctionName}' );`;
1188
- const loadFunction = `
1189
-
1190
- function ${loadFunctionName}() {
1191
- \tforeach ( glob( __DIR__ . '${ADMIN_VIEWS_PHP_GLOB}' ) ?: array() as $admin_view_module ) {
1192
- \t\trequire_once $admin_view_module;
1193
- \t}
1194
- }
1195
- `;
1196
- const insertionAnchors = [
1197
- /add_action\(\s*["']init["']\s*,\s*["'][^"']+_load_textdomain["']\s*\);\s*\n/u,
1198
- /\?>\s*$/u,
1199
- ];
1200
- const insertPhpSnippet = (snippet) => {
1201
- for (const anchor of insertionAnchors) {
1202
- const candidate = nextSource.replace(anchor, (match) => `${snippet}\n${match}`);
1203
- if (candidate !== nextSource) {
1204
- nextSource = candidate;
1205
- return;
1206
- }
1207
- }
1208
- nextSource = `${nextSource.trimEnd()}\n${snippet}\n`;
1209
- };
1210
- const appendPhpSnippet = (snippet) => {
1211
- const closingTagPattern = /\?>\s*$/u;
1212
- if (closingTagPattern.test(nextSource)) {
1213
- nextSource = nextSource.replace(closingTagPattern, `${snippet}\n?>`);
1214
- return;
1215
- }
1216
- nextSource = `${nextSource.trimEnd()}\n${snippet}\n`;
1217
- };
1218
- if (!hasPhpFunctionDefinition(nextSource, loadFunctionName)) {
1219
- insertPhpSnippet(loadFunction);
1220
- }
1221
- else {
1222
- const functionRange = findPhpFunctionRange(nextSource, loadFunctionName);
1223
- const functionSource = functionRange
1224
- ? nextSource.slice(functionRange.start, functionRange.end)
1225
- : "";
1226
- if (!functionSource.includes(ADMIN_VIEWS_PHP_GLOB)) {
1227
- const replacedSource = replacePhpFunctionDefinition(nextSource, loadFunctionName, loadFunction);
1228
- if (!replacedSource) {
1229
- throw new Error(`Unable to repair ${path.basename(bootstrapPath)} for ${loadFunctionName}.`);
1230
- }
1231
- nextSource = replacedSource;
1232
- }
1233
- }
1234
- if (!nextSource.includes(loadHook)) {
1235
- appendPhpSnippet(loadHook);
1236
- }
1237
- return nextSource;
1238
- });
1239
- }
1240
- async function ensureAdminViewBuildScriptAnchors(workspace) {
1241
- const buildScriptPath = path.join(workspace.projectDir, "scripts", "build-workspace.mjs");
1242
- await patchFile(buildScriptPath, (source) => {
1243
- if (/['"]src\/admin-views\/index\.(?:ts|js)['"]/u.test(source)) {
1244
- return source;
1245
- }
1246
- const currentSharedEntriesPattern = /(\r?\n\s*['"]src\/editor-plugins\/index\.js['"])\s*,?/u;
1247
- let nextSource = source.replace(currentSharedEntriesPattern, `$1,
1248
- \t\t'src/admin-views/index.ts',
1249
- \t\t'src/admin-views/index.js',`);
1250
- if (nextSource !== source) {
1251
- return nextSource;
1252
- }
1253
- const legacySharedEntriesPattern = /\[\s*['"]src\/bindings\/index\.ts['"]\s*,\s*['"]src\/bindings\/index\.js['"]\s*(?:,\s*)?\]/u;
1254
- nextSource = source.replace(legacySharedEntriesPattern, `[
1255
- \t\t'src/bindings/index.ts',
1256
- \t\t'src/bindings/index.js',
1257
- \t\t'src/editor-plugins/index.ts',
1258
- \t\t'src/editor-plugins/index.js',
1259
- \t\t'src/admin-views/index.ts',
1260
- \t\t'src/admin-views/index.js',
1261
- \t]`);
1262
- if (nextSource !== source) {
1263
- return nextSource;
1264
- }
1265
- throw new Error(`Unable to update ${path.relative(workspace.projectDir, buildScriptPath)} for admin view shared entries.`);
1266
- });
1267
- }
1268
- async function ensureAdminViewWebpackAnchors(workspace) {
1269
- const webpackConfigPath = path.join(workspace.projectDir, "webpack.config.js");
1270
- await patchFile(webpackConfigPath, (source) => {
1271
- if (/['"]admin-views\/index['"]/u.test(source)) {
1272
- return source;
1273
- }
1274
- const editorPluginEntryPattern = /(\n\s*\[\s*['"]editor-plugins\/index['"][\s\S]*?['"]src\/editor-plugins\/index\.js['"][\s\S]*?\]\s*\])\s*,?/u;
1275
- let nextSource = source.replace(editorPluginEntryPattern, `$1,
1276
- \t\t[
1277
- \t\t\t'admin-views/index',
1278
- \t\t\t[ 'src/admin-views/index.ts', 'src/admin-views/index.js' ],
1279
- \t\t],`);
1280
- if (nextSource !== source) {
1281
- return nextSource;
1282
- }
1283
- const legacySharedEntriesBlockPattern = /for\s*\(\s*const\s+relativePath\s+of\s+\[\s*['"]src\/bindings\/index\.ts['"]\s*,\s*['"]src\/bindings\/index\.js['"]\s*(?:,\s*)?\]\s*\)\s*\{[\s\S]*?entries\.push\(\s*\[\s*['"]bindings\/index['"]\s*,\s*entryPath\s*\]\s*\);\s*break;\s*\}/u;
1284
- const nextSharedEntriesBlock = `\tfor ( const [ entryName, candidates ] of [\n\t\t[\n\t\t\t'bindings/index',\n\t\t\t[ 'src/bindings/index.ts', 'src/bindings/index.js' ],\n\t\t],\n\t\t[\n\t\t\t'editor-plugins/index',\n\t\t\t[ 'src/editor-plugins/index.ts', 'src/editor-plugins/index.js' ],\n\t\t],\n\t\t[\n\t\t\t'admin-views/index',\n\t\t\t[ 'src/admin-views/index.ts', 'src/admin-views/index.js' ],\n\t\t],\n\t] ) {\n\t\tfor ( const relativePath of candidates ) {\n\t\t\tconst entryPath = path.resolve( process.cwd(), relativePath );\n\t\t\tif ( ! fs.existsSync( entryPath ) ) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tentries.push( [ entryName, entryPath ] );\n\t\t\tbreak;\n\t\t}\n\t}`;
1285
- nextSource = source.replace(legacySharedEntriesBlockPattern, nextSharedEntriesBlock);
1286
- if (nextSource === source) {
1287
- throw new Error(`Unable to update ${path.relative(workspace.projectDir, webpackConfigPath)} for admin view shared entries.`);
1288
- }
1289
- return nextSource;
1290
- });
1291
- }
1292
- function resolveAdminViewRegistryPath(projectDir) {
1293
- const adminViewsDir = path.join(projectDir, "src", "admin-views");
1294
- return [
1295
- path.join(adminViewsDir, "index.ts"),
1296
- path.join(adminViewsDir, "index.js"),
1297
- ].find((candidatePath) => fs.existsSync(candidatePath)) ?? path.join(adminViewsDir, "index.ts");
1298
- }
1299
- function readAdminViewRegistrySlugs(registryPath) {
1300
- if (!fs.existsSync(registryPath)) {
1301
- return [];
1302
- }
1303
- const source = fs.readFileSync(registryPath, "utf8");
1304
- return Array.from(source.matchAll(/^\s*import\s+['"]\.\/([^/'"]+)(?:\/index(?:\.[cm]?[jt]sx?)?)?['"];?\s*$/gmu)).map((match) => match[1]);
1305
- }
1306
- async function writeAdminViewRegistry(projectDir, adminViewSlug) {
1307
- const adminViewsDir = path.join(projectDir, "src", "admin-views");
1308
- const registryPath = resolveAdminViewRegistryPath(projectDir);
1309
- await fsp.mkdir(adminViewsDir, { recursive: true });
1310
- const existingAdminViewSlugs = readWorkspaceInventory(projectDir).adminViews.map((entry) => entry.slug);
1311
- const existingRegistrySlugs = readAdminViewRegistrySlugs(registryPath);
1312
- const nextAdminViewSlugs = Array.from(new Set([...existingAdminViewSlugs, ...existingRegistrySlugs, adminViewSlug])).sort();
1313
- await fsp.writeFile(registryPath, buildAdminViewRegistrySource(nextAdminViewSlugs), "utf8");
1314
- }
1
+ import { assertAdminViewDoesNotExist, assertValidGeneratedSlug, normalizeBlockSlug, } from './cli-add-shared.js';
2
+ import { assertAdminViewPackageAvailability, parseAdminViewSource, resolveAdminViewCoreDataSource, resolveRestResourceSource, } from './cli-add-workspace-admin-view-source.js';
3
+ import { scaffoldAdminViewWorkspace } from './cli-add-workspace-admin-view-scaffold.js';
4
+ import { formatAdminViewSourceLocator } from './cli-add-workspace-admin-view-types.js';
5
+ import { readWorkspaceInventory } from './workspace-inventory.js';
6
+ import { resolveWorkspaceProject } from './workspace-project.js';
7
+ const ADD_ADMIN_VIEW_USAGE = 'wp-typia add admin-view <name> [--source <rest-resource:slug|core-data:kind/name>]';
1315
8
  /**
1316
9
  * Add one DataViews-powered WordPress admin screen scaffold to an official
1317
10
  * workspace project.
1318
- *
1319
- * @param options Command options for the admin-view scaffold workflow.
1320
- * @param options.adminViewName Human-entered admin screen name that will be
1321
- * normalized and validated before files are written.
1322
- * @param options.cwd Working directory used to resolve the nearest official workspace.
1323
- * Defaults to `process.cwd()`.
1324
- * @param options.source Optional data source locator. `rest-resource:<slug>`
1325
- * wires the screen to an existing list-capable REST resource.
1326
- * `core-data:<kind>/<name>` binds the screen to a supported WordPress-owned
1327
- * core-data entity collection.
1328
- * @returns A promise that resolves with the normalized `adminViewSlug`, optional
1329
- * `source`, and owning `projectDir` after scaffold files and inventory entries
1330
- * are written successfully.
1331
- * @throws {Error} When the command is run outside an official workspace, when
1332
- * the slug/source is invalid, or when a conflicting file or inventory entry exists.
1333
11
  */
1334
12
  export async function runAddAdminViewCommand({ adminViewName, cwd = process.cwd(), source, }) {
1335
13
  const workspace = resolveWorkspaceProject(cwd);
1336
14
  assertAdminViewPackageAvailability();
1337
- const adminViewSlug = assertValidGeneratedSlug("Admin view name", normalizeBlockSlug(adminViewName), "wp-typia add admin-view <name> [--source <rest-resource:slug|core-data:kind/name>]");
15
+ const adminViewSlug = assertValidGeneratedSlug('Admin view name', normalizeBlockSlug(adminViewName), ADD_ADMIN_VIEW_USAGE);
1338
16
  const parsedSource = parseAdminViewSource(source);
1339
17
  const inventory = readWorkspaceInventory(workspace.projectDir);
1340
18
  const restResource = resolveRestResourceSource(inventory.restResources, parsedSource);
1341
- const coreDataSource = isAdminViewCoreDataSource(parsedSource)
1342
- ? parsedSource
1343
- : undefined;
19
+ const coreDataSource = resolveAdminViewCoreDataSource(parsedSource);
1344
20
  assertAdminViewDoesNotExist(workspace.projectDir, adminViewSlug, inventory);
1345
- const blockConfigPath = path.join(workspace.projectDir, "scripts", "block-config.ts");
1346
- const bootstrapPath = getWorkspaceBootstrapPath(workspace);
1347
- const buildScriptPath = path.join(workspace.projectDir, "scripts", "build-workspace.mjs");
1348
- const packageJsonPath = path.join(workspace.projectDir, "package.json");
1349
- const webpackConfigPath = path.join(workspace.projectDir, "webpack.config.js");
1350
- const adminViewsIndexPath = resolveAdminViewRegistryPath(workspace.projectDir);
1351
- const adminViewDir = path.join(workspace.projectDir, "src", "admin-views", adminViewSlug);
1352
- const adminViewPhpPath = path.join(workspace.projectDir, "inc", "admin-views", `${adminViewSlug}.php`);
1353
- const mutationSnapshot = {
1354
- fileSources: await snapshotWorkspaceFiles([
1355
- adminViewsIndexPath,
1356
- blockConfigPath,
1357
- bootstrapPath,
1358
- buildScriptPath,
1359
- packageJsonPath,
1360
- webpackConfigPath,
1361
- ]),
1362
- snapshotDirs: [],
1363
- targetPaths: [adminViewDir, adminViewPhpPath],
21
+ await scaffoldAdminViewWorkspace({
22
+ adminViewSlug,
23
+ coreDataSource,
24
+ parsedSource,
25
+ restResource,
26
+ workspace,
27
+ });
28
+ return {
29
+ adminViewSlug,
30
+ projectDir: workspace.projectDir,
31
+ source: parsedSource
32
+ ? formatAdminViewSourceLocator(parsedSource)
33
+ : undefined,
1364
34
  };
1365
- try {
1366
- await fsp.mkdir(adminViewDir, { recursive: true });
1367
- await fsp.mkdir(path.dirname(adminViewPhpPath), { recursive: true });
1368
- await ensureAdminViewPackageDependencies(workspace, parsedSource);
1369
- await ensureAdminViewBootstrapAnchors(workspace);
1370
- await ensureAdminViewBuildScriptAnchors(workspace);
1371
- await ensureAdminViewWebpackAnchors(workspace);
1372
- await fsp.writeFile(path.join(adminViewDir, "types.ts"), buildAdminViewTypesSource(adminViewSlug, restResource, coreDataSource), "utf8");
1373
- await fsp.writeFile(path.join(adminViewDir, "config.ts"), buildAdminViewConfigSource(adminViewSlug, workspace.workspace.textDomain, parsedSource, restResource), "utf8");
1374
- await fsp.writeFile(path.join(adminViewDir, "data.ts"), coreDataSource
1375
- ? buildCoreDataAdminViewDataSource(adminViewSlug, coreDataSource)
1376
- : restResource
1377
- ? buildRestAdminViewDataSource(adminViewSlug, restResource)
1378
- : buildDefaultAdminViewDataSource(adminViewSlug), "utf8");
1379
- await fsp.writeFile(path.join(adminViewDir, "Screen.tsx"), coreDataSource
1380
- ? buildCoreDataAdminViewScreenSource(adminViewSlug, workspace.workspace.textDomain)
1381
- : buildAdminViewScreenSource(adminViewSlug, workspace.workspace.textDomain), "utf8");
1382
- await fsp.writeFile(path.join(adminViewDir, "index.tsx"), buildAdminViewEntrySource(adminViewSlug), "utf8");
1383
- await fsp.writeFile(path.join(adminViewDir, "style.scss"), buildAdminViewStyleSource(), "utf8");
1384
- await fsp.writeFile(adminViewPhpPath, buildAdminViewPhpSource(adminViewSlug, workspace), "utf8");
1385
- await writeAdminViewRegistry(workspace.projectDir, adminViewSlug);
1386
- await appendWorkspaceInventoryEntries(workspace.projectDir, {
1387
- adminViewEntries: [buildAdminViewConfigEntry(adminViewSlug, parsedSource)],
1388
- });
1389
- return {
1390
- adminViewSlug,
1391
- projectDir: workspace.projectDir,
1392
- source: parsedSource
1393
- ? formatAdminViewSourceLocator(parsedSource)
1394
- : undefined,
1395
- };
1396
- }
1397
- catch (error) {
1398
- await rollbackWorkspaceMutation(mutationSnapshot);
1399
- throw error;
1400
- }
1401
35
  }