create-packkit 2.9.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/package.json +23 -4
- package/src/cli/args.js +0 -1
- package/src/cli/index.js +55 -14
- package/src/cli/upgrade.js +147 -0
- package/src/core/features/bundler.js +13 -7
- package/src/core/features/checks.js +4 -3
- package/src/core/features/env.js +2 -1
- package/src/core/features/frameworks.js +15 -14
- package/src/core/features/githooks.js +7 -6
- package/src/core/features/index.js +7 -0
- package/src/core/features/lint.js +7 -6
- package/src/core/features/meta.js +2 -1
- package/src/core/features/release.js +4 -3
- package/src/core/features/service.js +6 -5
- package/src/core/features/sizelimit.js +3 -2
- package/src/core/features/storybook.js +5 -4
- package/src/core/features/test.js +14 -15
- package/src/core/features/typescript.js +2 -1
- package/src/core/features/vite.js +9 -8
- package/src/core/index.js +41 -7
- package/src/core/monorepo.js +75 -101
- package/src/core/options.js +67 -22
- package/src/core/render.js +6 -0
- package/src/core/versions.js +121 -0
- package/src/embedded/contract.js +66 -0
- package/src/embedded/index.js +506 -0
- package/src/embedded/paths.js +88 -0
- package/src/embedded/pkg-merge.js +89 -0
- package/src/embedded/upgrade.js +148 -0
- package/src/embedded/writer.js +151 -0
- package/src/index.js +12 -0
- package/types/core.d.ts +96 -0
- package/types/embedded.d.ts +112 -0
- package/types/index.d.ts +6 -0
- package/types/writer.d.ts +25 -0
package/src/core/options.js
CHANGED
|
@@ -36,6 +36,7 @@ export const OPTIONS = {
|
|
|
36
36
|
},
|
|
37
37
|
serviceFramework: {
|
|
38
38
|
group: 'core', type: 'select', label: 'Service framework (HTTP service)', default: 'hono',
|
|
39
|
+
when: (cfg) => cfg.target?.includes('service'),
|
|
39
40
|
choices: [
|
|
40
41
|
{ value: 'hono', label: 'Hono (fast, web-standard)' },
|
|
41
42
|
{ value: 'fastify', label: 'Fastify' },
|
|
@@ -259,21 +260,42 @@ export function defaultConfig() {
|
|
|
259
260
|
return cfg;
|
|
260
261
|
}
|
|
261
262
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Merge partial input over the defaults and coerce a few fields.
|
|
265
|
+
*
|
|
266
|
+
* Pass a `diagnostics` array to have every coercion that overrides an
|
|
267
|
+
* explicitly-supplied value recorded there (the embedded API surfaces these so
|
|
268
|
+
* a host application learns when Packkit changed its requested config, instead
|
|
269
|
+
* of the change happening silently). Existing callers omit it and see identical
|
|
270
|
+
* behavior — the coercions run exactly the same either way.
|
|
271
|
+
*/
|
|
272
|
+
export function normalizeConfig(input = {}, diagnostics = null) {
|
|
264
273
|
const cfg = { ...defaultConfig(), ...input };
|
|
274
|
+
const requested = new Set(Object.keys(input));
|
|
265
275
|
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
276
|
+
// Record a coercion only when it actually changes an explicitly-requested
|
|
277
|
+
// value — a field left at its default that "changes" to the same default is
|
|
278
|
+
// not news. Derived helper flags (isTs, hasApp…) go through plain assignment
|
|
279
|
+
// below; they are computed, not overrides, so they never emit a diagnostic.
|
|
280
|
+
const coerce = (field, value, code, message, severity = 'warning') => {
|
|
281
|
+
const same = JSON.stringify(cfg[field]) === JSON.stringify(value);
|
|
282
|
+
if (same) return;
|
|
283
|
+
const previousValue = cfg[field];
|
|
284
|
+
cfg[field] = value;
|
|
285
|
+
if (diagnostics && requested.has(field)) {
|
|
286
|
+
diagnostics.push({ severity, code, field, previousValue, resolvedValue: value, message, source: 'normalize' });
|
|
287
|
+
}
|
|
288
|
+
};
|
|
269
289
|
|
|
270
|
-
//
|
|
271
|
-
if (cfg.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
290
|
+
// A target is required; default it if missing.
|
|
291
|
+
if (!Array.isArray(cfg.target) || cfg.target.length === 0) {
|
|
292
|
+
coerce('target', ['library'], 'TARGET_DEFAULTED', 'No target was given, so "library" was used.', 'info');
|
|
293
|
+
}
|
|
294
|
+
if (!Array.isArray(cfg.workflows)) coerce('workflows', [], 'WORKFLOWS_DEFAULTED', 'workflows was not a list, so it was reset to none.', 'info');
|
|
295
|
+
|
|
296
|
+
if (cfg.bundler === 'none') coerce('minify', false, 'MINIFY_REQUIRES_BUNDLER', 'Minify was disabled because no bundler produces output to minify.');
|
|
297
|
+
if (cfg.test === 'none' || cfg.test === 'node') coerce('coverage', false, 'COVERAGE_UNSUPPORTED_RUNNER', `Coverage was disabled because the "${cfg.test}" test runner does not report it.`);
|
|
298
|
+
if (cfg.workflows.includes('codecov')) coerce('coverage', true, 'COVERAGE_FORCED_BY_CODECOV', 'Coverage was enabled because the Codecov workflow requires it.', 'info');
|
|
277
299
|
|
|
278
300
|
cfg.isReact = cfg.framework === 'react';
|
|
279
301
|
cfg.isVue = cfg.framework === 'vue';
|
|
@@ -283,7 +305,7 @@ export function normalizeConfig(input = {}) {
|
|
|
283
305
|
cfg.hasApp = cfg.target.includes('app');
|
|
284
306
|
// A component-framework package that isn't an app is a library.
|
|
285
307
|
if (cfg.hasFramework && !cfg.hasApp && !cfg.target.includes('library')) {
|
|
286
|
-
|
|
308
|
+
coerce('target', ['library', ...cfg.target], 'TARGET_LIBRARY_ADDED', 'A "library" target was added because a component framework needs something to build.', 'info');
|
|
287
309
|
}
|
|
288
310
|
|
|
289
311
|
cfg.isTs = cfg.language === 'ts';
|
|
@@ -305,31 +327,54 @@ export function normalizeConfig(input = {}) {
|
|
|
305
327
|
cfg.hasBuild = cfg.viteBuild || (!cfg.svelteLib && (cfg.bundler !== 'none' || cfg.isTs));
|
|
306
328
|
|
|
307
329
|
// Apps aren't published packages.
|
|
308
|
-
if (cfg.hasApp)
|
|
330
|
+
if (cfg.hasApp) coerce('moduleFormat', 'esm', 'MODULE_FORMAT_FORCED_FOR_APP', 'An app is bundled, not published, so its module format is ESM.', 'info');
|
|
309
331
|
cfg.hasEsm = cfg.moduleFormat === 'esm' || cfg.moduleFormat === 'dual';
|
|
310
332
|
cfg.hasCjs = cfg.moduleFormat === 'cjs' || cfg.moduleFormat === 'dual';
|
|
311
333
|
|
|
312
334
|
// Storybook only applies to component libraries.
|
|
313
|
-
if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary)
|
|
335
|
+
if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary) coerce('storybook', false, 'STORYBOOK_REQUIRES_COMPONENT_LIBRARY', 'Storybook was disabled because it only applies to a component library.');
|
|
314
336
|
|
|
315
337
|
// Playwright E2E only applies to app targets.
|
|
316
|
-
if (!cfg.hasApp)
|
|
338
|
+
if (!cfg.hasApp) coerce('e2e', false, 'E2E_REQUIRES_APP', 'End-to-end tests were disabled because they only apply to an app target.');
|
|
317
339
|
|
|
318
340
|
// A monorepo is its own generation path (see buildMonorepo); it has a build.
|
|
319
341
|
if (cfg.monorepo) cfg.hasBuild = true;
|
|
320
342
|
|
|
321
343
|
cfg.publishable = (cfg.hasLibrary || cfg.hasCli) && !cfg.hasApp && !cfg.hasService;
|
|
322
344
|
// Package-correctness checks only make sense for a publishable package.
|
|
323
|
-
if (!cfg.publishable)
|
|
345
|
+
if (!cfg.publishable) coerce('pkgChecks', false, 'PKG_CHECKS_REQUIRES_PUBLISHABLE', 'Package-correctness checks were disabled because this project is not published to npm.');
|
|
324
346
|
// Sourcemaps + shipped source only matter for a published package.
|
|
325
|
-
if (!cfg.publishable)
|
|
347
|
+
if (!cfg.publishable) coerce('sourcemaps', false, 'SOURCEMAPS_REQUIRES_PUBLISHABLE', 'Sourcemaps were disabled because this project is not published to npm.');
|
|
326
348
|
// A bundle-size budget needs a published package with a built entry.
|
|
327
|
-
if (!(cfg.publishable && cfg.hasBuild))
|
|
349
|
+
if (!(cfg.publishable && cfg.hasBuild)) coerce('sizeLimit', false, 'SIZE_LIMIT_REQUIRES_BUILT_LIBRARY', 'The bundle-size budget was disabled because it needs a published package with a build.');
|
|
328
350
|
// Env validation is for server-side runtimes (services / CLIs), not libs/apps.
|
|
329
|
-
if (!(cfg.hasService || cfg.hasCli))
|
|
351
|
+
if (!(cfg.hasService || cfg.hasCli)) coerce('env', false, 'ENV_REQUIRES_SERVICE_OR_CLI', 'Env validation was disabled because it only applies to a service or CLI.');
|
|
330
352
|
// Canary snapshots ride on the Changesets flow.
|
|
331
|
-
if (cfg.release !== 'changesets')
|
|
353
|
+
if (cfg.release !== 'changesets') coerce('canary', false, 'CANARY_REQUIRES_CHANGESETS', 'Canary releases were disabled because they require the Changesets release flow.');
|
|
332
354
|
// JSR is TypeScript-first, ESM, and for plain (non-framework) libraries.
|
|
333
|
-
if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp))
|
|
355
|
+
if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) coerce('jsr', false, 'JSR_REQUIRES_PLAIN_TS_LIBRARY', 'JSR publishing was disabled because it applies only to a plain TypeScript library.');
|
|
356
|
+
|
|
357
|
+
cfg.resolved = resolvedView(cfg);
|
|
334
358
|
return cfg;
|
|
335
359
|
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A grouped, read-only view of the derived state — the same booleans the flat
|
|
363
|
+
* `cfg.has*`/`is*` flags expose, organized by concern. Consumers that prefer
|
|
364
|
+
* structured access (`cfg.resolved.targets.app`) can use it instead of
|
|
365
|
+
* re-interpreting raw selections; the flat flags remain for existing code.
|
|
366
|
+
*
|
|
367
|
+
* It is derived purely from the flags computed above, and test/invariants.test.js
|
|
368
|
+
* asserts the two never disagree and that the state is internally consistent —
|
|
369
|
+
* so the derived model can't silently contradict itself.
|
|
370
|
+
*/
|
|
371
|
+
function resolvedView(cfg) {
|
|
372
|
+
return {
|
|
373
|
+
targets: { library: cfg.hasLibrary, cli: cfg.hasCli, service: cfg.hasService, app: cfg.hasApp },
|
|
374
|
+
language: { typescript: cfg.isTs, ext: cfg.ext, srcExt: cfg.srcExt },
|
|
375
|
+
framework: { name: cfg.framework, react: cfg.isReact, vue: cfg.isVue, svelte: cfg.isSvelte, any: cfg.hasFramework },
|
|
376
|
+
build: { vite: cfg.viteBuild, custom: cfg.customBuild, usesVite: cfg.usesVite, has: cfg.hasBuild },
|
|
377
|
+
module: { format: cfg.moduleFormat, esm: cfg.hasEsm, cjs: cfg.hasCjs },
|
|
378
|
+
package: { publishable: cfg.publishable, monorepo: cfg.monorepo },
|
|
379
|
+
};
|
|
380
|
+
}
|
package/src/core/render.js
CHANGED
|
@@ -9,6 +9,11 @@ export function render(template, vars = {}) {
|
|
|
9
9
|
});
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Keys that would let a merged-in object reach an object's prototype. The
|
|
13
|
+
// embedded API deep-merges host-supplied data (extension package.json), so
|
|
14
|
+
// these are skipped defensively even though the merge is immutable.
|
|
15
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
16
|
+
|
|
12
17
|
/** Deep-merge plain objects (arrays are concatenated + de-duped). Used to fold
|
|
13
18
|
* each feature's package.json fragment into the accumulator. */
|
|
14
19
|
export function deepMerge(target, source) {
|
|
@@ -18,6 +23,7 @@ export function deepMerge(target, source) {
|
|
|
18
23
|
if (isPlainObject(target) && isPlainObject(source)) {
|
|
19
24
|
const out = { ...target };
|
|
20
25
|
for (const [k, v] of Object.entries(source)) {
|
|
26
|
+
if (UNSAFE_KEYS.has(k)) continue;
|
|
21
27
|
out[k] = k in target ? deepMerge(target[k], v) : v;
|
|
22
28
|
}
|
|
23
29
|
return out;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The single source of truth for every dependency version Packkit writes into
|
|
2
|
+
// generated projects. Feature modules reference these by name instead of
|
|
3
|
+
// hard-coding a spec, so a bump happens in one place and the freshness workflow
|
|
4
|
+
// (scripts/check-template-deps.mjs) has one authoritative list to check.
|
|
5
|
+
//
|
|
6
|
+
// A conformance test (test/versions.test.js) asserts that every version in the
|
|
7
|
+
// generated output comes from this catalog, so nothing can drift back into a
|
|
8
|
+
// feature module unnoticed.
|
|
9
|
+
//
|
|
10
|
+
// When you bump anything here, update LAST_REVIEWED.
|
|
11
|
+
|
|
12
|
+
export const LAST_REVIEWED = '2026-07-26';
|
|
13
|
+
|
|
14
|
+
// name → npm spec. Deps that ship at one version everywhere.
|
|
15
|
+
export const V = {
|
|
16
|
+
// toolchain
|
|
17
|
+
typescript: '^5.9.3',
|
|
18
|
+
'typescript-eslint': '^8.0.0',
|
|
19
|
+
eslint: '^10.0.0',
|
|
20
|
+
'@eslint/js': '^10.0.0',
|
|
21
|
+
prettier: '^3.3.0',
|
|
22
|
+
'@biomejs/biome': '^2.0.0',
|
|
23
|
+
oxlint: '^1.0.0',
|
|
24
|
+
turbo: '^2.0.0',
|
|
25
|
+
rimraf: '^6.0.0',
|
|
26
|
+
'@types/node': '^24.0.0',
|
|
27
|
+
|
|
28
|
+
// build
|
|
29
|
+
tsup: '^8.0.0',
|
|
30
|
+
tsdown: '^0.6.0',
|
|
31
|
+
unbuild: '^3.0.0',
|
|
32
|
+
rollup: '^4.0.0',
|
|
33
|
+
'@rollup/plugin-typescript': '^12.0.0',
|
|
34
|
+
'@rollup/plugin-terser': '^1.0.0',
|
|
35
|
+
tslib: '^2.6.0',
|
|
36
|
+
tsx: '^4.0.0',
|
|
37
|
+
vite: '^8.0.0',
|
|
38
|
+
'vite-plugin-dts': '^5.0.0',
|
|
39
|
+
|
|
40
|
+
// test
|
|
41
|
+
vitest: '^4.0.0',
|
|
42
|
+
'@vitest/coverage-v8': '^4.0.0',
|
|
43
|
+
jsdom: '^29.0.0',
|
|
44
|
+
jest: '^30.0.0',
|
|
45
|
+
'@types/jest': '^30.0.0',
|
|
46
|
+
'ts-jest': '^29.0.0',
|
|
47
|
+
supertest: '^7.0.0',
|
|
48
|
+
'@types/supertest': '^6.0.0',
|
|
49
|
+
'@playwright/test': '^1.50.0',
|
|
50
|
+
|
|
51
|
+
// release / package checks
|
|
52
|
+
'@changesets/cli': '^2.27.0',
|
|
53
|
+
'release-it': '^21.0.0',
|
|
54
|
+
np: '^12.0.0',
|
|
55
|
+
publint: '^0.3.0',
|
|
56
|
+
'@arethetypeswrong/cli': '^0.18.0',
|
|
57
|
+
knip: '^5.0.0',
|
|
58
|
+
'size-limit': '^11.0.0',
|
|
59
|
+
'@size-limit/preset-small-lib': '^11.0.0',
|
|
60
|
+
|
|
61
|
+
// git hooks
|
|
62
|
+
'simple-git-hooks': '^2.11.0',
|
|
63
|
+
husky: '^9.1.0',
|
|
64
|
+
lefthook: '^2.0.0',
|
|
65
|
+
'lint-staged': '^16.2.0',
|
|
66
|
+
|
|
67
|
+
// react
|
|
68
|
+
react: '^19.0.0',
|
|
69
|
+
'react-dom': '^19.0.0',
|
|
70
|
+
'@types/react': '^19.0.0',
|
|
71
|
+
'@types/react-dom': '^19.0.0',
|
|
72
|
+
'@vitejs/plugin-react': '^6.0.0',
|
|
73
|
+
'@testing-library/react': '^16.0.0',
|
|
74
|
+
'@testing-library/dom': '^10.0.0',
|
|
75
|
+
|
|
76
|
+
// vue
|
|
77
|
+
vue: '^3.4.0',
|
|
78
|
+
'vue-tsc': '^3.0.0',
|
|
79
|
+
'@vitejs/plugin-vue': '^6.0.0',
|
|
80
|
+
'@testing-library/vue': '^8.1.0',
|
|
81
|
+
|
|
82
|
+
// svelte
|
|
83
|
+
svelte: '^5.0.0',
|
|
84
|
+
'svelte-check': '^4.0.0',
|
|
85
|
+
'@sveltejs/vite-plugin-svelte': '^7.0.0',
|
|
86
|
+
'@testing-library/svelte': '^5.2.0',
|
|
87
|
+
|
|
88
|
+
// storybook
|
|
89
|
+
storybook: '^10.0.0',
|
|
90
|
+
'@storybook/react': '^10.0.0',
|
|
91
|
+
'@storybook/react-vite': '^10.0.0',
|
|
92
|
+
'@storybook/vue3': '^10.0.0',
|
|
93
|
+
'@storybook/vue3-vite': '^10.0.0',
|
|
94
|
+
'@storybook/svelte': '^10.0.0',
|
|
95
|
+
'@storybook/svelte-vite': '^10.0.0',
|
|
96
|
+
|
|
97
|
+
// services
|
|
98
|
+
hono: '^4.5.0',
|
|
99
|
+
'@hono/node-server': '^2.0.0',
|
|
100
|
+
fastify: '^5.0.0',
|
|
101
|
+
express: '^5.0.0',
|
|
102
|
+
'@types/express': '^5.0.0',
|
|
103
|
+
zod: '^4.0.0',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Peer-dependency ranges — deliberately looser than the direct-dependency spec,
|
|
107
|
+
// so a component library declares broad compatibility rather than a hard pin.
|
|
108
|
+
export const PEER = {
|
|
109
|
+
react: '>=18',
|
|
110
|
+
'react-dom': '>=18',
|
|
111
|
+
vue: '>=3',
|
|
112
|
+
svelte: '>=5',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Deps intentionally held below their latest major, with the reason. The
|
|
116
|
+
// freshness check reads this so a held-back dep is reported, not failed.
|
|
117
|
+
export const HELD = {
|
|
118
|
+
typescript: 'typescript-eslint peers typescript <6.1.0 (no TS 7 support yet)',
|
|
119
|
+
knip: 'v6 crashes on the oxc-parser native binding',
|
|
120
|
+
'lint-staged': 'v17 requires Node >=22.22.1, above our Node 22 engines floor; hold at 16 to keep the Maintenance-LTS line working',
|
|
121
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Provider-neutral deployment contract.
|
|
2
|
+
//
|
|
3
|
+
// A host application deploying a generated project needs to know how to build
|
|
4
|
+
// and run it — but Packkit must not know or care whether that host is Vercel,
|
|
5
|
+
// a Kubernetes cluster, or a Raspberry Pi. So this describes build/runtime
|
|
6
|
+
// requirements in provider-agnostic terms, derived purely from the resolved
|
|
7
|
+
// config. No AWS/Netlify/Vercel/Cloudflare/GitHub fields, by design.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Derive the deployment contract from a resolved config.
|
|
11
|
+
* @returns {import('./index.js').DeploymentContract}
|
|
12
|
+
*/
|
|
13
|
+
export function deriveDeploymentContract(cfg) {
|
|
14
|
+
const run = (script) => (cfg.packageManager === 'npm' ? `npm run ${script}` : `${cfg.packageManager} ${script}`);
|
|
15
|
+
const start = cfg.packageManager === 'npm' ? 'npm start' : `${cfg.packageManager} start`;
|
|
16
|
+
// Read the structured resolved view rather than re-interpreting raw selections.
|
|
17
|
+
const { targets, build } = cfg.resolved;
|
|
18
|
+
|
|
19
|
+
if (targets.service) {
|
|
20
|
+
// The generated server reads PORT but defaults to 3000, so PORT is optional,
|
|
21
|
+
// not required — `port` communicates the default and the `PORT` env var
|
|
22
|
+
// overrides it. `healthCheckPath` is only asserted because every service
|
|
23
|
+
// framework Packkit generates (Hono/Fastify/Express) defines /health.
|
|
24
|
+
return prune({
|
|
25
|
+
type: 'node-service',
|
|
26
|
+
buildCommand: build.has ? run('build') : undefined,
|
|
27
|
+
startCommand: start,
|
|
28
|
+
port: 3000,
|
|
29
|
+
healthCheckPath: '/health',
|
|
30
|
+
requiredEnvironmentVariables: [],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (targets.app) {
|
|
35
|
+
return prune({
|
|
36
|
+
type: 'static',
|
|
37
|
+
buildCommand: run('build'),
|
|
38
|
+
// Vite's default build output.
|
|
39
|
+
outputDirectory: 'dist',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (targets.cli) {
|
|
44
|
+
return prune({
|
|
45
|
+
type: 'cli',
|
|
46
|
+
buildCommand: build.has ? run('build') : undefined,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return prune({
|
|
51
|
+
type: 'library',
|
|
52
|
+
buildCommand: build.has ? run('build') : undefined,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Drop undefined fields and empty required-env arrays so the contract stays
|
|
57
|
+
// minimal and deterministic — the same config always yields the same object.
|
|
58
|
+
function prune(contract) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [k, v] of Object.entries(contract)) {
|
|
61
|
+
if (v === undefined) continue;
|
|
62
|
+
if (k === 'requiredEnvironmentVariables' && Array.isArray(v) && v.length === 0) continue;
|
|
63
|
+
out[k] = v;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|