@wp-typia/project-tools 0.22.5 → 0.22.6
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/dist/runtime/ai-feature-capability.js +20 -0
- package/dist/runtime/cli-add-block.js +16 -11
- package/dist/runtime/cli-add-collision.js +213 -136
- package/dist/runtime/cli-add-help.js +1 -1
- package/dist/runtime/cli-add-kind-ids.d.ts +11 -0
- package/dist/runtime/cli-add-kind-ids.js +20 -0
- package/dist/runtime/cli-add-types.d.ts +2 -8
- package/dist/runtime/cli-add-types.js +1 -17
- package/dist/runtime/cli-add-workspace-ability-scaffold.d.ts +3 -1
- package/dist/runtime/cli-add-workspace-ability-scaffold.js +22 -5
- package/dist/runtime/cli-add-workspace-ability.d.ts +4 -0
- package/dist/runtime/cli-add-workspace-ability.js +5 -1
- package/dist/runtime/cli-add-workspace-admin-view-source.d.ts +7 -0
- package/dist/runtime/cli-add-workspace-admin-view-source.js +9 -10
- package/dist/runtime/cli-add-workspace-admin-view-types.d.ts +0 -2
- package/dist/runtime/cli-add-workspace-admin-view-types.js +0 -3
- package/dist/runtime/cli-add-workspace-ai-scaffold.js +14 -6
- package/dist/runtime/cli-doctor-workspace-bindings.js +2 -3
- package/dist/runtime/cli-doctor-workspace-blocks.js +2 -3
- package/dist/runtime/cli-doctor-workspace-features.js +6 -11
- package/dist/runtime/cli-doctor-workspace-shared.d.ts +8 -0
- package/dist/runtime/cli-doctor-workspace-shared.js +10 -0
- package/dist/runtime/cli-help.js +1 -1
- package/dist/runtime/cli-init-apply.d.ts +15 -0
- package/dist/runtime/cli-init-apply.js +99 -0
- package/dist/runtime/cli-init-package-json.d.ts +19 -0
- package/dist/runtime/cli-init-package-json.js +191 -0
- package/dist/runtime/cli-init-plan.d.ts +39 -0
- package/dist/runtime/cli-init-plan.js +375 -0
- package/dist/runtime/cli-init-templates.d.ts +27 -0
- package/dist/runtime/cli-init-templates.js +244 -0
- package/dist/runtime/cli-init-types.d.ts +84 -0
- package/dist/runtime/cli-init-types.js +3 -0
- package/dist/runtime/cli-init.d.ts +4 -100
- package/dist/runtime/cli-init.js +6 -878
- package/dist/runtime/fs-async.d.ts +28 -0
- package/dist/runtime/fs-async.js +53 -0
- package/dist/runtime/package-managers.js +1 -1
- package/dist/runtime/php-utils.d.ts +16 -0
- package/dist/runtime/php-utils.js +321 -1
- package/dist/runtime/scaffold-apply-utils.js +10 -20
- package/dist/runtime/scaffold-bootstrap.js +6 -8
- package/dist/runtime/scaffold-compatibility.d.ts +15 -3
- package/dist/runtime/scaffold-compatibility.js +42 -11
- package/dist/runtime/scaffold-documents.js +12 -0
- package/dist/runtime/scaffold-package-manager-files.js +4 -3
- package/dist/runtime/string-case.d.ts +5 -0
- package/dist/runtime/string-case.js +52 -2
- package/dist/runtime/template-source-cache.d.ts +19 -0
- package/dist/runtime/template-source-cache.js +164 -28
- package/dist/runtime/template-source-external.d.ts +7 -0
- package/dist/runtime/template-source-external.js +22 -5
- package/dist/runtime/template-source-normalization.d.ts +1 -1
- package/dist/runtime/template-source-normalization.js +12 -12
- package/dist/runtime/template-source-remote.d.ts +14 -0
- package/dist/runtime/template-source-remote.js +91 -15
- package/dist/runtime/template-source.js +35 -25
- package/dist/runtime/typia-llm.js +7 -0
- package/dist/runtime/version-floor.js +8 -2
- package/dist/runtime/workspace-inventory.d.ts +16 -14
- package/dist/runtime/workspace-inventory.js +58 -14
- package/package.json +6 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* metadata aligned when optional or required AI-capable features are added.
|
|
6
6
|
*/
|
|
7
7
|
import { AI_FEATURE_DEFINITIONS, resolveAiFeatureCapabilityPlan, } from './ai-feature-capability.js';
|
|
8
|
-
import { pickHigherVersionFloor } from './version-floor.js';
|
|
8
|
+
import { parseVersionFloorParts, pickHigherVersionFloor, } from './version-floor.js';
|
|
9
9
|
/**
|
|
10
10
|
* Baseline headers used by scaffold output before optional features are added.
|
|
11
11
|
*/
|
|
@@ -39,7 +39,7 @@ export const REQUIRED_WORKSPACE_ABILITY_COMPATIBILITY = [
|
|
|
39
39
|
function pickHigherScaffoldVersionFloor(current, candidate) {
|
|
40
40
|
return pickHigherVersionFloor(current, candidate) ?? current;
|
|
41
41
|
}
|
|
42
|
-
function pickHigherHeaderVersionFloor(policyValue, currentValue) {
|
|
42
|
+
function pickHigherHeaderVersionFloor(policyValue, currentValue, { headerName, onWarning, }) {
|
|
43
43
|
const normalizedCurrentValue = currentValue.trim();
|
|
44
44
|
if (!normalizedCurrentValue) {
|
|
45
45
|
return policyValue;
|
|
@@ -47,10 +47,35 @@ function pickHigherHeaderVersionFloor(policyValue, currentValue) {
|
|
|
47
47
|
try {
|
|
48
48
|
return pickHigherScaffoldVersionFloor(policyValue, normalizedCurrentValue);
|
|
49
49
|
}
|
|
50
|
-
catch {
|
|
50
|
+
catch (error) {
|
|
51
|
+
const warning = [
|
|
52
|
+
`Invalid plugin header version floor for ${headerName}: "${normalizedCurrentValue}".`,
|
|
53
|
+
'Expected dotted numeric segments such as "6.7" or "8.1.2".',
|
|
54
|
+
`Replacing it with compatibility policy value "${policyValue}".`,
|
|
55
|
+
].join(' ');
|
|
56
|
+
if (!onWarning) {
|
|
57
|
+
throw new Error(warning, { cause: error });
|
|
58
|
+
}
|
|
59
|
+
onWarning(warning);
|
|
51
60
|
return policyValue;
|
|
52
61
|
}
|
|
53
62
|
}
|
|
63
|
+
function assertPolicyVersionFloor(headerName, value) {
|
|
64
|
+
try {
|
|
65
|
+
parseVersionFloorParts(value);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error([
|
|
69
|
+
`Invalid scaffold compatibility policy floor for ${headerName}: "${value}".`,
|
|
70
|
+
'Expected dotted numeric segments such as "6.7" or "8.1.2".',
|
|
71
|
+
].join(' '), { cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function assertPluginHeaderPolicyVersionFloors(pluginHeader) {
|
|
75
|
+
assertPolicyVersionFloor('Requires at least', pluginHeader.requiresAtLeast);
|
|
76
|
+
assertPolicyVersionFloor('Tested up to', pluginHeader.testedUpTo);
|
|
77
|
+
assertPolicyVersionFloor('Requires PHP', pluginHeader.requiresPhp);
|
|
78
|
+
}
|
|
54
79
|
function formatRuntimeGate(feature) {
|
|
55
80
|
return (feature.runtimeGates ?? []).map((gate) => `${feature.label}: ${gate.kind} ${gate.value}`);
|
|
56
81
|
}
|
|
@@ -108,21 +133,27 @@ export function renderScaffoldCompatibilityConfig(policy, indent = '\t\t') {
|
|
|
108
133
|
.map((line, index) => (index === 0 ? line : `${indent}${line}`))
|
|
109
134
|
.join('\n');
|
|
110
135
|
}
|
|
111
|
-
function replacePluginHeaderVersionFloor(source, pattern, policyValue) {
|
|
136
|
+
function replacePluginHeaderVersionFloor(source, pattern, policyValue, headerName, options) {
|
|
112
137
|
return source.replace(pattern, (_match, prefix, currentValue, lineEnding) => {
|
|
113
138
|
const versionPrefix = prefix.endsWith(':') ? `${prefix} ` : prefix;
|
|
114
|
-
return `${versionPrefix}${pickHigherHeaderVersionFloor(policyValue, currentValue
|
|
139
|
+
return `${versionPrefix}${pickHigherHeaderVersionFloor(policyValue, currentValue, {
|
|
140
|
+
headerName,
|
|
141
|
+
onWarning: options.onWarning,
|
|
142
|
+
})}${lineEnding}`;
|
|
115
143
|
});
|
|
116
144
|
}
|
|
117
145
|
/**
|
|
118
146
|
* Patch a generated plugin bootstrap header without lowering custom floors.
|
|
119
147
|
*
|
|
120
|
-
* Preserves the original header line endings while replacing empty
|
|
121
|
-
*
|
|
148
|
+
* Preserves the original header line endings while replacing empty version
|
|
149
|
+
* strings with policy values. Malformed user-authored values are reported
|
|
150
|
+
* through `options.onWarning`; without a warning handler they throw instead of
|
|
151
|
+
* falling back silently.
|
|
122
152
|
*/
|
|
123
|
-
export function updatePluginHeaderCompatibility(source, policy) {
|
|
153
|
+
export function updatePluginHeaderCompatibility(source, policy, options = {}) {
|
|
124
154
|
const { pluginHeader } = policy;
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
155
|
+
assertPluginHeaderPolicyVersionFloors(pluginHeader);
|
|
156
|
+
const nextSource = replacePluginHeaderVersionFloor(source, /(\* Requires at least:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.requiresAtLeast, 'Requires at least', options);
|
|
157
|
+
const nextSourceWithTestedUpTo = replacePluginHeaderVersionFloor(nextSource, /(\* Tested up to:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.testedUpTo, 'Tested up to', options);
|
|
158
|
+
return replacePluginHeaderVersionFloor(nextSourceWithTestedUpTo, /(\* Requires PHP:[^\S\r\n]*)([^\r\n]*)(\r?)/u, pluginHeader.requiresPhp, 'Requires PHP', options);
|
|
128
159
|
}
|
|
@@ -21,6 +21,17 @@ function formatReadmeTemplateIdentity(templateId) {
|
|
|
21
21
|
}
|
|
22
22
|
return [`- Template id: ${templateId}`, '- Type: custom or external scaffold'].join('\n');
|
|
23
23
|
}
|
|
24
|
+
function getPackageManagerInstallGuidance(packageManager) {
|
|
25
|
+
if (packageManager !== 'npm') {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
const installCommand = formatInstallCommand(packageManager);
|
|
29
|
+
return [
|
|
30
|
+
'',
|
|
31
|
+
`> npm note: the scaffold uses \`${installCommand}\` for the first install so npm does not spend the initial create flow in the audit resolver. Run \`npm audit\` separately when you want npm vulnerability output.`,
|
|
32
|
+
'> If npm prints React peer dependency noise from WordPress block-editor packages, validate with `npm run typecheck` and `npm run build` before changing WordPress package ranges.',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
24
35
|
/**
|
|
25
36
|
* Builds the generated README markdown for one scaffolded project.
|
|
26
37
|
*
|
|
@@ -99,6 +110,7 @@ ${formatReadmeTemplateIdentity(templateId)}
|
|
|
99
110
|
${formatInstallCommand(packageManager)}
|
|
100
111
|
${formatRunScript(packageManager, developmentScript)}
|
|
101
112
|
\`\`\`
|
|
113
|
+
${getPackageManagerInstallGuidance(packageManager)}
|
|
102
114
|
|
|
103
115
|
${getQuickStartWorkflowNote(packageManager, templateId, {
|
|
104
116
|
compoundPersistenceEnabled,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { promises as fsp } from "node:fs";
|
|
3
2
|
import { execSync } from "node:child_process";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { formatInstallCommand, getPackageManager, transformPackageManagerText, } from "./package-managers.js";
|
|
5
|
+
import { readOptionalUtf8File } from "./fs-async.js";
|
|
6
6
|
const LOCKFILES = {
|
|
7
7
|
bun: ["bun.lock", "bun.lockb"],
|
|
8
8
|
npm: ["package-lock.json"],
|
|
@@ -33,11 +33,12 @@ export async function normalizePackageManagerFiles(targetDir, packageManagerId)
|
|
|
33
33
|
*/
|
|
34
34
|
export async function normalizePackageJson(targetDir, packageManagerId) {
|
|
35
35
|
const packageJsonPath = path.join(targetDir, "package.json");
|
|
36
|
-
|
|
36
|
+
const packageJsonSource = await readOptionalUtf8File(packageJsonPath);
|
|
37
|
+
if (packageJsonSource === null) {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
const packageManager = getPackageManager(packageManagerId);
|
|
40
|
-
const packageJson = JSON.parse(
|
|
41
|
+
const packageJson = JSON.parse(packageJsonSource);
|
|
41
42
|
if (packageManagerId === "npm") {
|
|
42
43
|
delete packageJson.packageManager;
|
|
43
44
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Normalize arbitrary text into a kebab-case identifier.
|
|
3
|
+
* Common acronym runs stay grouped, with a boundary before the next
|
|
4
|
+
* capitalized word.
|
|
5
|
+
* Ambiguous acronym+lowercase inputs like `URLslug` intentionally stay as one
|
|
6
|
+
* word because there is no PascalCase boundary marker before the lowercase
|
|
7
|
+
* suffix.
|
|
3
8
|
*
|
|
4
9
|
* @param input Raw text that may contain spaces, punctuation, or camelCase.
|
|
5
10
|
* @returns A lowercase kebab-case string with collapsed separators.
|
|
@@ -1,15 +1,65 @@
|
|
|
1
|
+
const COMMON_ACRONYM_PREFIXES = [
|
|
2
|
+
'HTML',
|
|
3
|
+
'HTTP',
|
|
4
|
+
'JSON',
|
|
5
|
+
'REST',
|
|
6
|
+
'UUID',
|
|
7
|
+
'API',
|
|
8
|
+
'CSS',
|
|
9
|
+
'CTA',
|
|
10
|
+
'DOM',
|
|
11
|
+
'PHP',
|
|
12
|
+
'SQL',
|
|
13
|
+
'SVG',
|
|
14
|
+
'URL',
|
|
15
|
+
'XML',
|
|
16
|
+
'ID',
|
|
17
|
+
'JS',
|
|
18
|
+
'UI',
|
|
19
|
+
'WP',
|
|
20
|
+
];
|
|
1
21
|
function capitalizeSegment(segment) {
|
|
2
22
|
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
3
23
|
}
|
|
24
|
+
function findCommonAcronymPrefix(segment) {
|
|
25
|
+
return COMMON_ACRONYM_PREFIXES.find((prefix) => segment.startsWith(prefix));
|
|
26
|
+
}
|
|
27
|
+
function splitKnownAcronymSegment(segment) {
|
|
28
|
+
const prefixes = [];
|
|
29
|
+
let remaining = segment;
|
|
30
|
+
while (remaining.length > 0) {
|
|
31
|
+
const prefix = findCommonAcronymPrefix(remaining);
|
|
32
|
+
if (!prefix) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
const suffix = remaining.slice(prefix.length);
|
|
36
|
+
if (/^[A-Z][a-z]/.test(suffix)) {
|
|
37
|
+
return [...prefixes, prefix, suffix].join('-');
|
|
38
|
+
}
|
|
39
|
+
if (!findCommonAcronymPrefix(suffix)) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
prefixes.push(prefix);
|
|
43
|
+
remaining = suffix;
|
|
44
|
+
}
|
|
45
|
+
return segment;
|
|
46
|
+
}
|
|
47
|
+
function splitAcronymBoundary(value) {
|
|
48
|
+
return value.replace(/[A-Z]{2,}[a-z]+/g, splitKnownAcronymSegment);
|
|
49
|
+
}
|
|
4
50
|
/**
|
|
5
51
|
* Normalize arbitrary text into a kebab-case identifier.
|
|
52
|
+
* Common acronym runs stay grouped, with a boundary before the next
|
|
53
|
+
* capitalized word.
|
|
54
|
+
* Ambiguous acronym+lowercase inputs like `URLslug` intentionally stay as one
|
|
55
|
+
* word because there is no PascalCase boundary marker before the lowercase
|
|
56
|
+
* suffix.
|
|
6
57
|
*
|
|
7
58
|
* @param input Raw text that may contain spaces, punctuation, or camelCase.
|
|
8
59
|
* @returns A lowercase kebab-case string with collapsed separators.
|
|
9
60
|
*/
|
|
10
61
|
export function toKebabCase(input) {
|
|
11
|
-
return input
|
|
12
|
-
.trim()
|
|
62
|
+
return splitAcronymBoundary(input.trim())
|
|
13
63
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
14
64
|
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
15
65
|
.replace(/^-+|-+$/g, '')
|
|
@@ -14,6 +14,13 @@ export declare const EXTERNAL_TEMPLATE_CACHE_DIR_ENV = "WP_TYPIA_EXTERNAL_TEMPLA
|
|
|
14
14
|
* Unset, empty, zero, negative, and non-numeric values keep pruning disabled.
|
|
15
15
|
*/
|
|
16
16
|
export declare const EXTERNAL_TEMPLATE_CACHE_TTL_DAYS_ENV = "WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_TTL_DAYS";
|
|
17
|
+
/**
|
|
18
|
+
* Environment variable that overrides how often TTL pruning may scan the cache.
|
|
19
|
+
*
|
|
20
|
+
* Unset values use the default interval. Zero, negative, and non-numeric values
|
|
21
|
+
* disable scan throttling.
|
|
22
|
+
*/
|
|
23
|
+
export declare const EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS_ENV = "WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS";
|
|
17
24
|
/**
|
|
18
25
|
* Serializable metadata recorded in the cache marker for diagnostics.
|
|
19
26
|
*/
|
|
@@ -72,10 +79,18 @@ export interface ExternalTemplateCachePruneOptions {
|
|
|
72
79
|
* Environment object to inspect, defaulting to `process.env`.
|
|
73
80
|
*/
|
|
74
81
|
env?: NodeJS.ProcessEnv;
|
|
82
|
+
/**
|
|
83
|
+
* Force a full prune scan even when the last-pruned marker is still fresh.
|
|
84
|
+
*/
|
|
85
|
+
force?: boolean;
|
|
75
86
|
/**
|
|
76
87
|
* Clock override for deterministic tests.
|
|
77
88
|
*/
|
|
78
89
|
now?: Date | number;
|
|
90
|
+
/**
|
|
91
|
+
* Minimum interval between full prune scans. Omit for the environment/default.
|
|
92
|
+
*/
|
|
93
|
+
pruneIntervalMs?: number;
|
|
79
94
|
/**
|
|
80
95
|
* TTL override in days. When omitted, the TTL environment variable is used.
|
|
81
96
|
*/
|
|
@@ -101,6 +116,10 @@ export interface ExternalTemplateCachePruneResult {
|
|
|
101
116
|
* Candidate directories skipped because they were malformed or unsafe.
|
|
102
117
|
*/
|
|
103
118
|
skippedEntries: number;
|
|
119
|
+
/**
|
|
120
|
+
* Whether a recent last-pruned marker skipped the full cache directory scan.
|
|
121
|
+
*/
|
|
122
|
+
skippedByThrottle: boolean;
|
|
104
123
|
/**
|
|
105
124
|
* Resolved TTL in milliseconds, or `null` when pruning was disabled.
|
|
106
125
|
*/
|
|
@@ -19,14 +19,29 @@ export const EXTERNAL_TEMPLATE_CACHE_DIR_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE
|
|
|
19
19
|
* Unset, empty, zero, negative, and non-numeric values keep pruning disabled.
|
|
20
20
|
*/
|
|
21
21
|
export const EXTERNAL_TEMPLATE_CACHE_TTL_DAYS_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_TTL_DAYS';
|
|
22
|
+
/**
|
|
23
|
+
* Environment variable that overrides how often TTL pruning may scan the cache.
|
|
24
|
+
*
|
|
25
|
+
* Unset values use the default interval. Zero, negative, and non-numeric values
|
|
26
|
+
* disable scan throttling.
|
|
27
|
+
*/
|
|
28
|
+
export const EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS_ENV = 'WP_TYPIA_EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS';
|
|
22
29
|
/**
|
|
23
30
|
* Marker file written after a cache entry is fully populated.
|
|
24
31
|
*/
|
|
25
32
|
const CACHE_MARKER_FILE = 'wp-typia-template-cache.json';
|
|
33
|
+
/**
|
|
34
|
+
* Marker file written after a full TTL prune scan completes.
|
|
35
|
+
*/
|
|
36
|
+
const CACHE_PRUNE_MARKER_FILE = 'wp-typia-template-cache-prune.json';
|
|
26
37
|
/**
|
|
27
38
|
* Milliseconds in one TTL day.
|
|
28
39
|
*/
|
|
29
40
|
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
41
|
+
/**
|
|
42
|
+
* Default minimum interval between full external template cache prune scans.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_CACHE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
30
45
|
/**
|
|
31
46
|
* Private directory mode used for cache roots and entries on POSIX platforms.
|
|
32
47
|
*/
|
|
@@ -119,6 +134,27 @@ function resolveExternalTemplateCacheTtlMs(options = {}) {
|
|
|
119
134
|
const ttlMs = ttlDays * MILLISECONDS_PER_DAY;
|
|
120
135
|
return Number.isFinite(ttlMs) ? ttlMs : null;
|
|
121
136
|
}
|
|
137
|
+
function parseExternalTemplateCachePruneIntervalMs(value) {
|
|
138
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const intervalMs = typeof value === 'number' ? value : Number(value.trim());
|
|
142
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return intervalMs;
|
|
146
|
+
}
|
|
147
|
+
function resolveExternalTemplateCachePruneIntervalMs(options = {}) {
|
|
148
|
+
if (options.pruneIntervalMs !== undefined) {
|
|
149
|
+
return parseExternalTemplateCachePruneIntervalMs(options.pruneIntervalMs);
|
|
150
|
+
}
|
|
151
|
+
const env = options.env ?? process.env;
|
|
152
|
+
const envValue = env[EXTERNAL_TEMPLATE_CACHE_PRUNE_INTERVAL_MS_ENV];
|
|
153
|
+
if (envValue === undefined) {
|
|
154
|
+
return DEFAULT_CACHE_PRUNE_INTERVAL_MS;
|
|
155
|
+
}
|
|
156
|
+
return parseExternalTemplateCachePruneIntervalMs(envValue);
|
|
157
|
+
}
|
|
122
158
|
/**
|
|
123
159
|
* Creates a deterministic cache key from source identity and integrity parts.
|
|
124
160
|
*
|
|
@@ -313,6 +349,14 @@ function parseCacheMarkerMetadata(markerText) {
|
|
|
313
349
|
metadata,
|
|
314
350
|
};
|
|
315
351
|
}
|
|
352
|
+
async function readCacheEntryMarker(markerPath) {
|
|
353
|
+
try {
|
|
354
|
+
return parseCacheMarkerMetadata(await fsp.readFile(markerPath, 'utf8'));
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
316
360
|
function cacheMetadataMatches(actual, expected) {
|
|
317
361
|
return Object.entries(expected).every(([key, value]) => actual[key] === value);
|
|
318
362
|
}
|
|
@@ -324,6 +368,19 @@ function getExternalTemplateCacheNowMs(now) {
|
|
|
324
368
|
: Date.now();
|
|
325
369
|
return Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
326
370
|
}
|
|
371
|
+
function isCacheEntryFreshForTtl(createdAtMs, nowMs, ttlMs) {
|
|
372
|
+
return ttlMs === null || createdAtMs >= nowMs - ttlMs;
|
|
373
|
+
}
|
|
374
|
+
async function getReusableCacheEntryMarker(entryDir, markerPath, sourceDir) {
|
|
375
|
+
if (!(await isReusableCacheEntry(entryDir, markerPath, sourceDir))) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
return readCacheEntryMarker(markerPath);
|
|
379
|
+
}
|
|
380
|
+
async function isReusableFreshCacheEntry(entryDir, markerPath, sourceDir, nowMs, ttlMs) {
|
|
381
|
+
const marker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
|
|
382
|
+
return (marker !== null && isCacheEntryFreshForTtl(marker.createdAtMs, nowMs, ttlMs));
|
|
383
|
+
}
|
|
327
384
|
function isPathInsideDirectory(directory, candidatePath) {
|
|
328
385
|
const relativePath = path.relative(directory, candidatePath);
|
|
329
386
|
return (relativePath.length > 0 &&
|
|
@@ -342,6 +399,74 @@ async function removeCacheEntryWithinRoot(cacheRoot, entryDir) {
|
|
|
342
399
|
return false;
|
|
343
400
|
}
|
|
344
401
|
}
|
|
402
|
+
function getCachePruneMarkerPath(cacheRoot) {
|
|
403
|
+
return path.join(cacheRoot, CACHE_PRUNE_MARKER_FILE);
|
|
404
|
+
}
|
|
405
|
+
function parseCachePruneMarker(markerText) {
|
|
406
|
+
let marker;
|
|
407
|
+
try {
|
|
408
|
+
marker = JSON.parse(markerText);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const rawPrunedAt = marker.prunedAt;
|
|
417
|
+
const prunedAtMs = typeof rawPrunedAt === 'string' ? Date.parse(rawPrunedAt) : Number.NaN;
|
|
418
|
+
const rawPruneIntervalMs = marker
|
|
419
|
+
.pruneIntervalMs;
|
|
420
|
+
const rawTtlMs = marker.ttlMs;
|
|
421
|
+
if (typeof rawTtlMs !== 'number' || !Number.isFinite(rawTtlMs)) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
if (!Number.isFinite(prunedAtMs)) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
if (rawPruneIntervalMs !== null &&
|
|
428
|
+
(typeof rawPruneIntervalMs !== 'number' ||
|
|
429
|
+
!Number.isFinite(rawPruneIntervalMs))) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
prunedAtMs,
|
|
434
|
+
pruneIntervalMs: rawPruneIntervalMs ?? null,
|
|
435
|
+
ttlMs: rawTtlMs,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async function shouldSkipExternalTemplateCachePrune({ cacheRoot, force, nowMs, pruneIntervalMs, ttlMs, }) {
|
|
439
|
+
if (force || pruneIntervalMs === null) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
let markerText;
|
|
443
|
+
try {
|
|
444
|
+
markerText = await fsp.readFile(getCachePruneMarkerPath(cacheRoot), 'utf8');
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
const marker = parseCachePruneMarker(markerText);
|
|
450
|
+
if (!marker ||
|
|
451
|
+
marker.ttlMs !== ttlMs ||
|
|
452
|
+
marker.pruneIntervalMs !== pruneIntervalMs) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
const elapsedMs = nowMs - marker.prunedAtMs;
|
|
456
|
+
return elapsedMs >= 0 && elapsedMs < pruneIntervalMs;
|
|
457
|
+
}
|
|
458
|
+
async function writeExternalTemplateCachePruneMarker({ cacheRoot, nowMs, pruneIntervalMs, ttlMs, }) {
|
|
459
|
+
try {
|
|
460
|
+
await fsp.writeFile(getCachePruneMarkerPath(cacheRoot), `${JSON.stringify({
|
|
461
|
+
prunedAt: new Date(nowMs).toISOString(),
|
|
462
|
+
pruneIntervalMs,
|
|
463
|
+
ttlMs,
|
|
464
|
+
}, null, 2)}\n`, 'utf8');
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// Prune markers are an optimization; failing to write one keeps TTL safe.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
345
470
|
/**
|
|
346
471
|
* Removes stale external template cache entries when a positive TTL is configured.
|
|
347
472
|
*
|
|
@@ -359,16 +484,32 @@ export async function pruneExternalTemplateCache(options = {}) {
|
|
|
359
484
|
env,
|
|
360
485
|
ttlDays: options.ttlDays,
|
|
361
486
|
});
|
|
487
|
+
const pruneIntervalMs = resolveExternalTemplateCachePruneIntervalMs({
|
|
488
|
+
env,
|
|
489
|
+
pruneIntervalMs: options.pruneIntervalMs,
|
|
490
|
+
});
|
|
362
491
|
const result = {
|
|
363
492
|
cacheRoot,
|
|
364
493
|
prunedEntries: 0,
|
|
365
494
|
scannedEntries: 0,
|
|
366
495
|
skippedEntries: 0,
|
|
496
|
+
skippedByThrottle: false,
|
|
367
497
|
ttlMs,
|
|
368
498
|
};
|
|
369
499
|
if (ttlMs === null || !(await isPrivateCacheDirectory(cacheRoot))) {
|
|
370
500
|
return result;
|
|
371
501
|
}
|
|
502
|
+
const nowMs = getExternalTemplateCacheNowMs(options.now);
|
|
503
|
+
if (await shouldSkipExternalTemplateCachePrune({
|
|
504
|
+
cacheRoot,
|
|
505
|
+
force: options.force,
|
|
506
|
+
nowMs,
|
|
507
|
+
pruneIntervalMs,
|
|
508
|
+
ttlMs,
|
|
509
|
+
})) {
|
|
510
|
+
result.skippedByThrottle = true;
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
372
513
|
let namespaceEntries;
|
|
373
514
|
try {
|
|
374
515
|
namespaceEntries = await fsp.readdir(cacheRoot, { withFileTypes: true });
|
|
@@ -376,7 +517,7 @@ export async function pruneExternalTemplateCache(options = {}) {
|
|
|
376
517
|
catch {
|
|
377
518
|
return result;
|
|
378
519
|
}
|
|
379
|
-
const expiresBeforeMs =
|
|
520
|
+
const expiresBeforeMs = nowMs - ttlMs;
|
|
380
521
|
for (const namespaceEntry of namespaceEntries) {
|
|
381
522
|
if (!namespaceEntry.isDirectory()) {
|
|
382
523
|
continue;
|
|
@@ -410,19 +551,7 @@ export async function pruneExternalTemplateCache(options = {}) {
|
|
|
410
551
|
}
|
|
411
552
|
const markerPath = path.join(entryDir, CACHE_MARKER_FILE);
|
|
412
553
|
const sourceDir = path.join(entryDir, 'source');
|
|
413
|
-
|
|
414
|
-
result.skippedEntries += 1;
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
let markerText;
|
|
418
|
-
try {
|
|
419
|
-
markerText = await fsp.readFile(markerPath, 'utf8');
|
|
420
|
-
}
|
|
421
|
-
catch {
|
|
422
|
-
result.skippedEntries += 1;
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
const marker = parseCacheMarkerMetadata(markerText);
|
|
554
|
+
const marker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
|
|
426
555
|
if (!marker) {
|
|
427
556
|
result.skippedEntries += 1;
|
|
428
557
|
continue;
|
|
@@ -437,6 +566,12 @@ export async function pruneExternalTemplateCache(options = {}) {
|
|
|
437
566
|
}
|
|
438
567
|
}
|
|
439
568
|
}
|
|
569
|
+
await writeExternalTemplateCachePruneMarker({
|
|
570
|
+
cacheRoot,
|
|
571
|
+
nowMs,
|
|
572
|
+
pruneIntervalMs,
|
|
573
|
+
ttlMs,
|
|
574
|
+
});
|
|
440
575
|
return result;
|
|
441
576
|
}
|
|
442
577
|
/**
|
|
@@ -462,6 +597,8 @@ export async function findReusableExternalTemplateSourceCache(descriptor) {
|
|
|
462
597
|
!(await isPrivateCacheDirectory(namespaceDir))) {
|
|
463
598
|
return null;
|
|
464
599
|
}
|
|
600
|
+
const ttlMs = resolveExternalTemplateCacheTtlMs();
|
|
601
|
+
const nowMs = getExternalTemplateCacheNowMs(undefined);
|
|
465
602
|
await pruneExternalTemplateCache();
|
|
466
603
|
let entries;
|
|
467
604
|
try {
|
|
@@ -478,18 +615,10 @@ export async function findReusableExternalTemplateSourceCache(descriptor) {
|
|
|
478
615
|
const entryDir = path.join(namespaceDir, entry.name);
|
|
479
616
|
const markerPath = path.join(entryDir, CACHE_MARKER_FILE);
|
|
480
617
|
const sourceDir = path.join(entryDir, 'source');
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
markerText = await fsp.readFile(markerPath, 'utf8');
|
|
487
|
-
}
|
|
488
|
-
catch {
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
const marker = parseCacheMarkerMetadata(markerText);
|
|
492
|
-
if (!marker || !cacheMetadataMatches(marker.metadata, descriptor.metadata)) {
|
|
618
|
+
const marker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
|
|
619
|
+
if (!marker ||
|
|
620
|
+
!isCacheEntryFreshForTtl(marker.createdAtMs, nowMs, ttlMs) ||
|
|
621
|
+
!cacheMetadataMatches(marker.metadata, descriptor.metadata)) {
|
|
493
622
|
continue;
|
|
494
623
|
}
|
|
495
624
|
if (!bestEntry || marker.createdAtMs > bestEntry.createdAtMs) {
|
|
@@ -530,13 +659,20 @@ export async function resolveExternalTemplateSourceCache(descriptor, populateSou
|
|
|
530
659
|
!(await ensurePrivateCacheDirectory(namespaceDir))) {
|
|
531
660
|
return null;
|
|
532
661
|
}
|
|
662
|
+
const ttlMs = resolveExternalTemplateCacheTtlMs();
|
|
663
|
+
const nowMs = getExternalTemplateCacheNowMs(undefined);
|
|
533
664
|
await pruneExternalTemplateCache();
|
|
534
|
-
|
|
665
|
+
const existingMarker = await getReusableCacheEntryMarker(entryDir, markerPath, sourceDir);
|
|
666
|
+
if (existingMarker &&
|
|
667
|
+
isCacheEntryFreshForTtl(existingMarker.createdAtMs, nowMs, ttlMs)) {
|
|
535
668
|
return {
|
|
536
669
|
cacheHit: true,
|
|
537
670
|
sourceDir,
|
|
538
671
|
};
|
|
539
672
|
}
|
|
673
|
+
if (existingMarker) {
|
|
674
|
+
await removeCacheEntryWithinRoot(cacheRoot, entryDir);
|
|
675
|
+
}
|
|
540
676
|
const temporaryEntryDir = path.join(namespaceDir, `.tmp-${cacheKey}-${process.pid}-${Date.now()}-${Math.random()
|
|
541
677
|
.toString(16)
|
|
542
678
|
.slice(2)}`);
|
|
@@ -579,7 +715,7 @@ export async function resolveExternalTemplateSourceCache(descriptor, populateSou
|
|
|
579
715
|
}
|
|
580
716
|
const errorCode = getNodeErrorCode(error);
|
|
581
717
|
if (CACHE_PUBLISH_RACE_ERROR_CODES.has(errorCode) &&
|
|
582
|
-
(await
|
|
718
|
+
(await isReusableFreshCacheEntry(entryDir, markerPath, sourceDir, nowMs, ttlMs))) {
|
|
583
719
|
return {
|
|
584
720
|
cacheHit: true,
|
|
585
721
|
sourceDir,
|
|
@@ -11,6 +11,13 @@ export declare const EXTERNAL_TEMPLATE_TRUST_WARNING = "External template config
|
|
|
11
11
|
* @returns The first matching entry path, or null when no supported entry exists.
|
|
12
12
|
*/
|
|
13
13
|
export declare function getExternalTemplateEntry(sourceDir: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Search a source directory for a supported external template entry asynchronously.
|
|
16
|
+
*
|
|
17
|
+
* @param sourceDir Directory that may contain an external template config entry.
|
|
18
|
+
* @returns The first matching entry path, or null when no supported entry exists.
|
|
19
|
+
*/
|
|
20
|
+
export declare function findExternalTemplateEntry(sourceDir: string): Promise<string | null>;
|
|
14
21
|
/**
|
|
15
22
|
* Load an official external template config and render its seed.
|
|
16
23
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference path="./external-template-modules.d.ts" />
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { promises as fsp } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { assertExternalTemplateFileSize, getExternalTemplateConfigMaxBytes, withExternalTemplateTimeout, } from './external-template-guards.js';
|
|
@@ -7,6 +8,7 @@ import { isPlainObject } from './object-utils.js';
|
|
|
7
8
|
import { toSegmentPascalCase } from './string-case.js';
|
|
8
9
|
import { createManagedTempRoot } from './temp-roots.js';
|
|
9
10
|
import { copyRawDirectory, copyRenderedDirectory } from './template-render.js';
|
|
11
|
+
import { pathExists } from './fs-async.js';
|
|
10
12
|
/**
|
|
11
13
|
* Candidate filenames for official external template config entrypoints.
|
|
12
14
|
*/
|
|
@@ -43,8 +45,23 @@ export function getExternalTemplateEntry(sourceDir) {
|
|
|
43
45
|
}
|
|
44
46
|
return null;
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Search a source directory for a supported external template entry asynchronously.
|
|
50
|
+
*
|
|
51
|
+
* @param sourceDir Directory that may contain an external template config entry.
|
|
52
|
+
* @returns The first matching entry path, or null when no supported entry exists.
|
|
53
|
+
*/
|
|
54
|
+
export async function findExternalTemplateEntry(sourceDir) {
|
|
55
|
+
for (const filename of EXTERNAL_TEMPLATE_ENTRY_CANDIDATES) {
|
|
56
|
+
const candidate = path.join(sourceDir, filename);
|
|
57
|
+
if (await pathExists(candidate)) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
46
63
|
async function loadExternalTemplateConfig(sourceDir) {
|
|
47
|
-
const entryPath =
|
|
64
|
+
const entryPath = await findExternalTemplateEntry(sourceDir);
|
|
48
65
|
if (!entryPath) {
|
|
49
66
|
throw new Error(`No external template config entry found in ${sourceDir}.`);
|
|
50
67
|
}
|
|
@@ -52,7 +69,8 @@ async function loadExternalTemplateConfig(sourceDir) {
|
|
|
52
69
|
label: `External template config "${entryPath}"`,
|
|
53
70
|
maxBytes: getExternalTemplateConfigMaxBytes(),
|
|
54
71
|
});
|
|
55
|
-
const
|
|
72
|
+
const entryStats = await fsp.stat(entryPath);
|
|
73
|
+
const moduleUrl = `${pathToFileURL(entryPath).href}?mtime=${entryStats.mtimeMs}`;
|
|
56
74
|
const loadedModule = (await withExternalTemplateTimeout(`loading external template config "${entryPath}"`, () => import(moduleUrl)));
|
|
57
75
|
const loadedConfig = loadedModule.default ?? loadedModule;
|
|
58
76
|
if (!isPlainObject(loadedConfig)) {
|
|
@@ -200,10 +218,9 @@ export async function renderCreateBlockExternalTemplate(sourceDir, context, requ
|
|
|
200
218
|
if (typeof assetsPath === 'string' && assetsPath.trim().length > 0) {
|
|
201
219
|
await copyRawDirectory(resolveSourceSubpath(sourceDir, assetsPath), path.join(tempRoot, 'assets'));
|
|
202
220
|
}
|
|
221
|
+
const assetsDir = path.join(tempRoot, 'assets');
|
|
203
222
|
return {
|
|
204
|
-
assetsDir:
|
|
205
|
-
? path.join(tempRoot, 'assets')
|
|
206
|
-
: undefined,
|
|
223
|
+
assetsDir: (await pathExists(assetsDir)) ? assetsDir : undefined,
|
|
207
224
|
blockDir,
|
|
208
225
|
cleanup,
|
|
209
226
|
formatHint,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { renderCreateBlockExternalTemplate } from './template-source-external.js';
|
|
2
|
-
export { getTemplateProjectType, getDefaultCategory, normalizeCreateBlockSubset, normalizeWpTypiaTemplateSeed, } from './template-source-remote.js';
|
|
2
|
+
export { getTemplateProjectType, getTemplateProjectTypeAsync, getDefaultCategory, getDefaultCategoryAsync, normalizeCreateBlockSubset, normalizeWpTypiaTemplateSeed, } from './template-source-remote.js';
|
|
3
3
|
import type { TemplateSourceFormat, TemplateVariableContext } from './template-source-contracts.js';
|
|
4
4
|
export declare function getTemplateVariableContext(variables: {
|
|
5
5
|
[key: string]: string;
|