diff --git a/.changeset/brave-pandas-smile.md b/.changeset/brave-pandas-smile.md new file mode 100644 index 0000000..6d5a85e --- /dev/null +++ b/.changeset/brave-pandas-smile.md @@ -0,0 +1,34 @@ +--- +'@dynamic-field-kit/vue': patch +--- + +Stop `MultiFieldInput` from pinning the whole package into every consumer +bundle. + +`MultiFieldInput` renders itself recursively for repeatable groups, and reached +itself through a module-scope `multiFieldInputSelfRef = MultiFieldInput` +assignment. A bare top-level assignment is a side effect no bundler is allowed +to drop, so it anchored `MultiFieldInput` → `FieldInput` → `DynamicInput` → +`getDefaultRenderer` → every default renderer, even in an app that imported none +of them. + +The self-reference is now a hoisted function declaration returning +`MultiFieldInput`. Its body is not evaluated until a group actually renders, so +nothing is retained until something uses it. The explicit return type keeps +TypeScript from having to infer `MultiFieldInput` from inside its own +initializer, which is what the forward-declared `let` was working around. + +Measured with esbuild, minified, `vue` external: + +| App imports | Before | After | +| ---------------------- | -------- | ---------------- | +| one core helper | 13,934 B | 2,479 B (−82%) | +| `DynamicInput` only | 13,934 B | 9,196 B (−34%) | +| `MultiFieldInput` only | 13,944 B | 13,958 B (+14 B) | +| everything | 20,530 B | 20,543 B (+13 B) | + +Apps that pull in the whole surface pay 13–14 bytes for the wrapper function, +which is the honest cost of the change. + +No behaviour or type change: `dist/index.d.ts` is byte-identical, and the vue +suite (115 tests, including the repeatable-group recursion) passes unchanged. diff --git a/.changeset/clever-donkeys-repeat.md b/.changeset/clever-donkeys-repeat.md new file mode 100644 index 0000000..35d7575 --- /dev/null +++ b/.changeset/clever-donkeys-repeat.md @@ -0,0 +1,32 @@ +--- +'@dynamic-field-kit/angular': patch +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Close the remaining documentation gaps in each package README, so every public +export is described somewhere. README ships in the npm tarball, so this reaches +the package pages only through a release. + +- **Sync vs async validation** is now spelled out in core, with the consequence + that was previously implicit: `validateField` / `validateFields` cannot await, + so a `validate` hook returning a Promise is treated as valid on the sync path. + `useDynamicForm` and `createDynamicFormStore` validate synchronously + (including on submit), so async rules have to run through + `validateFieldsAsync` explicitly. Each adapter README repeats the caveat and + links to the core section. +- Document `validateFieldAsync`, `validateFieldsAsync`, `resolveOptions` and + `validators` in the react, vue and angular export lists, separated from each + adapter's own exports so it is clear they are core re-exports. +- Document the core types that appear in every signature but had no definition + in the README: `Properties`, `ValidatorFn`, `FieldValidatorResult`, + `FieldValidatorFunction`, and `FormStep` alongside `WizardState`. +- Angular: document `layoutRegistry` / `LayoutRegistry`, the `ColumnLayout` / + `RowLayout` / `GridLayout` components and `BaseInputComponent`, with a custom + layout example. Its layout registry holds standalone components rather than + render functions, which is the one place the three adapters genuinely differ, + and it was the only adapter not documenting its registry at all. +- Rename angular's `## What it exports` to `## Exports` to match react and vue. +- Link the demo sub-routes: react's enterprise-features and wizard pages, and + from core the per-framework demos plus the wizard it documents. diff --git a/.changeset/olive-pumas-shave.md b/.changeset/olive-pumas-shave.md new file mode 100644 index 0000000..959908e --- /dev/null +++ b/.changeset/olive-pumas-shave.md @@ -0,0 +1,35 @@ +--- +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Let consumer bundlers drop the parts of the adapters an app does not use. + +Components were declared as bare top-level calls — `defineComponent({...})` in +vue, `React.memo(...)` in react. A bundler cannot prove such a call is +side-effect free, so it has to evaluate it even when the result is unused, which +kept every default renderer and every component in an app's bundle no matter how +little of the package it imported. Marking those calls `/* @__PURE__ */` makes +them droppable. Measured with esbuild, minified, framework external: + +| App imports | Before | After | +| -------------------------- | -------- | --------------- | +| react: one core helper | 11,149 B | 3,258 B (−71%) | +| react: `DynamicInput` only | 11,150 B | 6,830 B (−39%) | +| react: everything | 17,601 B | unchanged | +| vue: one core helper | 17,379 B | 13,934 B (−20%) | +| vue: `DynamicInput` only | 17,379 B | 13,934 B (−20%) | +| vue: everything | 20,530 B | unchanged | + +Apps that use the whole surface are unchanged, which is the expected result — +there is nothing to drop. The shipped `dist` grows slightly (react +0.03 KB, vue ++0.29 KB) because the annotations are comments in the bundle; the trade is a +bigger published file for a smaller consumer bundle. + +`@dynamic-field-kit/core` now declares `"sideEffects": false`. It has no +top-level execution at all — the only module-scope work is `new FieldRegistry()` +assigned to an export — so the claim is accurate, and it lets bundlers that rely +on the flag rather than their own analysis skip core entirely when it is unused. +The adapters deliberately do not set it: their entry side-effect-imports the +default layouts in order to register them. diff --git a/.changeset/shiny-otters-repeat.md b/.changeset/shiny-otters-repeat.md new file mode 100644 index 0000000..14441bd --- /dev/null +++ b/.changeset/shiny-otters-repeat.md @@ -0,0 +1,30 @@ +--- +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Stop publishing sourcemaps, roughly halving what each package installs. + +tsup was emitting sourcemaps with `sourcesContent`, so every `.map` carried a +full copy of the TypeScript source. That is what made them work at all — `files` +only publishes `dist`, so a map referencing `../src/*.ts` would otherwise +resolve to nothing — but it also made them about half of each tarball, shipped +to every consumer on every install. + +| Package | Unpacked | Tarball | Files | +| ------- | ----------------------- | --------------------- | ----- | +| core | 157.0 → 83.0 KB (−47%) | 33.3 → 17.5 KB (−47%) | 8 → 6 | +| react | 207.6 → 90.1 KB (−57%) | 46.8 → 19.2 KB (−59%) | 8 → 6 | +| vue | 250.4 → 109.4 KB (−56%) | 47.6 → 20.5 KB (−57%) | 8 → 6 | + +Nothing that ends up in an application bundle changes — sourcemaps never do. +What changes is install size, and the ability to step into the library's +TypeScript source while debugging a consuming app. + +This is a deliberate trade, not a free win: the maps worked. Each package's +`tsup.config.ts` carries the reasoning next to a `sourcemap: false` that is one +word away from restoring them. + +`@dynamic-field-kit/angular` is unaffected; ng-packagr's published output does +not carry them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8dc2b0..97e73ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,13 +23,13 @@ jobs: github.event_name == 'pull_request' && !startsWith(github.head_ref, 'changeset-release/') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # changeset status diffs against the base branch, so it needs history. fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 0f38d8b..8d9bb07 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -31,10 +31,10 @@ jobs: # prefixed or they 404. SITE_BASE: /${{ github.event.repository.name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -86,7 +86,7 @@ jobs: find _site -maxdepth 2 -name index.html - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: _site @@ -99,4 +99,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index f937858..9ab69a8 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -9,10 +9,10 @@ jobs: lint-and-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -38,7 +38,7 @@ jobs: # Common ancestor of the four paths is `packages`, so the artifact stores # `/dist/...`. - name: Upload built dist - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: package-dist path: | @@ -55,6 +55,11 @@ jobs: - name: Type tests run: npm run test:types --workspace=@dynamic-field-kit/core + # Runs after the build so the suite's "every package reports a size" + # assertion has a dist to look at. + - name: Test build scripts + run: npm run test:scripts + - name: Show bundle sizes run: node scripts/show-sizes.js @@ -85,10 +90,10 @@ jobs: coverage-file: packages/angular/coverage/lcov.info coverage-name: angular steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -105,7 +110,7 @@ jobs: - name: Upload coverage if: matrix.coverage-file != '' && success() - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: files: ${{ matrix.coverage-file }} flags: unittests @@ -121,10 +126,10 @@ jobs: matrix: app: [react-app, vue-app, angular-app] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -132,7 +137,7 @@ jobs: # The example apps depend on the packages via `file:` paths, so the dist # has to exist before their install resolves them. - name: Download built dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: package-dist path: packages @@ -160,10 +165,10 @@ jobs: runs-on: ubuntu-latest needs: [lint-and-build, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -174,7 +179,7 @@ jobs: # Reuse the dist built by lint-and-build instead of rebuilding all four # packages. Extract into `packages/` so paths become packages//dist. - name: Download built dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: package-dist path: packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 741cfe6..5948442 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,13 +53,13 @@ jobs: needs: [gates] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: ref: ${{ github.ref_name }} fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.gitignore b/.gitignore index 6cc2f4c..3e603fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ node_modules/ packages/*/dist dist/ +# vite's transient config bundle, left behind when a vitest run is interrupted +*.timestamp-*.mjs + # test coverage packages/*/coverage coverage/ diff --git a/.prettierignore b/.prettierignore index 7ea4d6b..c2a57a0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,12 @@ build/ out/ coverage/ +# vite's transient config bundle, left behind when a vitest run is interrupted. +# Must stay ignored here as well as in .gitignore: `prettier --check .` walks +# untracked files too, so an orphaned one fails format-check after a local +# `npm run test --workspace=@dynamic-field-kit/angular`. +*.timestamp-*.mjs + # Framework caches .angular/ .next/ diff --git a/example/react-app/app/layout.tsx b/example/react-app/app/layout.tsx index 8da647e..833365f 100644 --- a/example/react-app/app/layout.tsx +++ b/example/react-app/app/layout.tsx @@ -13,8 +13,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Dynamic Field Kit - React Example', + description: + 'Schema-driven forms rendered by @dynamic-field-kit/react: renderer registry, layouts, conditional fields, repeatable groups, form state and a multi-step wizard.', }; export default function RootLayout({ diff --git a/example/vue-app/index.html b/example/vue-app/index.html index 0011a17..c0468dc 100644 --- a/example/vue-app/index.html +++ b/example/vue-app/index.html @@ -4,7 +4,11 @@ - vue-app + Dynamic Field Kit - Vue Example +
diff --git a/package-lock.json b/package-lock.json index 32be7b4..370a80d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,16 +12,12 @@ "dependencies": { "@dynamic-field-kit/core": "workspace:*", "@dynamic-field-kit/react": "workspace:*", - "@dynamic-field-kit/vue": "workspace:*", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "vue": "^3.5.32" + "@dynamic-field-kit/vue": "workspace:*" }, "devDependencies": { "@changesets/cli": "^2.31.1", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", - "@types/react": "^19.2.8", "@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/parser": "^5.60.0", "eslint": "^8.44.0", @@ -65,7 +61,6 @@ "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "19.2.27", "rxjs": "7.8.1" @@ -99,7 +94,6 @@ "integrity": "sha512-63dLzZeRAZiDOWegZHDG1L3Su4m7tfh2n4uV4fXhJYQeyblAQIrmz/dsyjn2cwR021neDbteLCn7MeysKj47/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.1902.27", @@ -1098,7 +1092,6 @@ "integrity": "sha512-kMcuTUxDcnTa+JF9SaxdY+b6pkGjtefxGp/VYblSbvlzQ1rVX4wH4V9LHHyXsTOxRRNGqRJ01g0Enhq1iA19mQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.1902.27", @@ -2138,7 +2131,6 @@ "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -2755,7 +2747,6 @@ "integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2780,7 +2771,6 @@ "integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2794,7 +2784,6 @@ "integrity": "sha512-tYYQk8AUz2sEkVl0a3uebduDUXPuKiGEKl2Jryrbn0xh9i1EsxoCjt1VvHnGnksGp3mz4DQihFVEnte0KeVQ5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -2845,7 +2834,6 @@ "integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2896,7 +2884,6 @@ "integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2992,7 +2979,6 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -3377,6 +3363,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3386,6 +3373,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3434,6 +3422,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -4676,6 +4665,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -5525,7 +5515,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -5549,7 +5538,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -6421,6 +6409,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -7636,6 +7625,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7657,6 +7647,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7678,6 +7669,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7699,6 +7691,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7720,6 +7713,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7741,6 +7735,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7762,6 +7757,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7783,6 +7779,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7804,6 +7801,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7825,6 +7823,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7846,6 +7845,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7867,6 +7867,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7888,6 +7889,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -8415,7 +8417,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -8677,8 +8678,7 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai-subset": { "version": "1.3.6", @@ -8810,7 +8810,6 @@ "integrity": "sha512-grSwveyouVpXDwUvnpIb5noOpZQGOzbVdZXdjw8P9WOjnrUenKj2YuIh35OpXQ+UCmMQEgyvRobT5uuK9iDCUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -8845,7 +8844,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8982,7 +8980,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -9416,6 +9413,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", @@ -9429,6 +9427,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.32", @@ -9439,6 +9438,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", @@ -9456,6 +9456,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.32", @@ -9466,6 +9467,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.32" @@ -9475,6 +9477,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.32", @@ -9485,6 +9488,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.32", @@ -9497,6 +9501,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.32", @@ -9510,6 +9515,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -9738,7 +9744,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9854,7 +9859,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10694,7 +10698,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -11025,7 +11028,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -11653,7 +11655,6 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -11829,6 +11830,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -12403,6 +12405,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -12633,7 +12636,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12719,7 +12721,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12913,7 +12914,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13200,6 +13200,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/esutils": { @@ -15439,7 +15440,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -15730,7 +15730,6 @@ "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^3.0.5", "parse-node-version": "^1.0.1" @@ -15785,6 +15784,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -15800,6 +15800,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver" } @@ -15811,6 +15812,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16395,6 +16397,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -16905,9 +16908,10 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -17743,6 +17747,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -17883,6 +17888,7 @@ "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -17898,7 +17904,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -18090,7 +18095,6 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -18337,8 +18341,8 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18347,8 +18351,8 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18777,7 +18781,6 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -18867,7 +18870,6 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -18968,7 +18970,6 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -19053,6 +19054,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/schema-utils": { @@ -19580,6 +19582,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -20130,7 +20133,6 @@ "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -20461,8 +20463,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsup": { "version": "8.5.1", @@ -20682,9 +20683,8 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20969,7 +20969,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -21490,7 +21489,6 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -21562,8 +21560,8 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", @@ -21658,7 +21656,6 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21738,7 +21735,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -22424,8 +22420,7 @@ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "packages/angular": { "name": "@dynamic-field-kit/angular", @@ -22466,7 +22461,6 @@ "integrity": "sha512-FhgT3MwDIXu8vzrQHFlQnwAOFENQY8FlIHkQf4WnJc6km4L0uUqQnm7iQPmX5z6dFFDTIsPDwHRDc73CoUaNVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ts-morph": "^21.0.0", "vfile": "^6.0.3" @@ -23168,7 +23162,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23477,7 +23470,6 @@ "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^4.3.5", "@types/chai-subset": "^1.3.3", @@ -23573,6 +23565,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.0", + "@types/react": "^19.2.8", "@vitejs/plugin-react": "^4.2.0", "@vitest/coverage-v8": "^1.6.0", "jsdom": "^24.0.0", @@ -23598,7 +23591,8 @@ "@vue/test-utils": "^2.4.6", "jsdom": "^24.0.0", "tsup": "^8.0.1", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vue": "^3.5.32" }, "peerDependencies": { "@dynamic-field-kit/core": "^1.3.0", diff --git a/package.json b/package.json index 2aafb23..872c27e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build:changed": "node scripts/build-changed.js", "dev": "npm run dev --workspaces", "test": "npm run test --workspaces --if-present", - "lint": "eslint packages/core/src packages/react/src packages/vue/src packages/angular/src --ext .ts,.tsx,.js --ignore-path .eslintignore", + "test:scripts": "vitest run", + "lint": "eslint packages/core/src packages/react/src packages/vue/src packages/angular/src scripts --ext .ts,.tsx,.js --ignore-path .eslintignore", "lint:cross-framework": "node scripts/check-cross-framework-imports.js", "verify-framework-deps": "node scripts/verify-framework-deps.js", "lint:fix": "eslint . --ext .ts,.tsx,.js --fix", @@ -28,7 +29,6 @@ "@changesets/cli": "^2.31.1", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", - "@types/react": "^19.2.8", "@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/parser": "^5.60.0", "eslint": "^8.44.0", @@ -44,13 +44,15 @@ "typescript": "~5.5.0", "vitest": "^1.6.0" }, + "//dependencies": "the root is private and ships nothing, so it declares no runtime dependencies. react/react-dom/vue/@types/react used to sit here for the packages to borrow; each package now declares its own (enforced by scripts/verify-framework-deps.js), which is also what keeps `npm audit --omit=dev` from failing on advisories in a build-time tree, e.g. vue -> @vue/compiler-sfc -> postcss -> nanoid.", "dependencies": { "@dynamic-field-kit/core": "workspace:*", "@dynamic-field-kit/react": "workspace:*", - "@dynamic-field-kit/vue": "workspace:*", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "vue": "^3.5.32" + "@dynamic-field-kit/vue": "workspace:*" + }, + "//overrides": "nanoid <3.3.18 is GHSA-2v37-7h3g-55p8. It reaches us only through vue -> @vue/compiler-sfc -> postcss (build-time), but pin it forward so the toolchain we actually run is patched, not merely excluded from the audit.", + "overrides": { + "nanoid": "^3.3.18" }, "//lint-staged": "globs must stay a superset of what `prettier --check .` sees, or a file can pass pre-commit and fail CI format-check. eslint only runs on the extensions the lint script targets.", "lint-staged": { diff --git a/packages/angular/README.md b/packages/angular/README.md index c47711a..ebe487f 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -4,7 +4,9 @@ Angular adapter for `@dynamic-field-kit/core`. This package provides Angular components and a convenience module that render field schemas defined with `@dynamic-field-kit/core`. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/angular/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/angular/ — tabs for the +basic schema, the enterprise features (`createDynamicFormStore`, HTML5 renderers, +blur wiring, DevTools) and the multi-step wizard. ## Install @@ -20,7 +22,7 @@ If you need to pin versions explicitly: npm install @dynamic-field-kit/core@^1.0.12 @dynamic-field-kit/angular@^1.2.3 ``` -## What it exports +## Exports - `DynamicInput` - `FieldInput` @@ -29,9 +31,27 @@ npm install @dynamic-field-kit/core@^1.0.12 @dynamic-field-kit/angular@^1.2.3 - `fieldRegistry` - `FieldRegistry` (class, for scoped registries) - `FIELD_REGISTRY` (injection token) -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` - `createDynamicFormStore` (signal-based form state) - `DynamicFormDevToolsComponent` +- `layoutRegistry` / `LayoutRegistry` (class, for a scoped layout registry) +- `ColumnLayout` / `RowLayout` / `GridLayout` (the standalone layout components, registered for you) +- `BaseInputComponent` — the abstract base your custom renderers extend + +Re-exported from `@dynamic-field-kit/core` so a consumer app rarely has to import +both packages: + +- `validateField` / `validateFieldAsync` — one field, returns `string[]` +- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options +- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) +- `ValidationResult` + +`createDynamicFormStore` validates **synchronously** via `validateFields`, +including on submit. Fields whose `validate` hook returns a Promise are treated +as valid on that path, so run async rules through `validateFieldsAsync` +yourself. See the +[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) +for the full rules. ## Basic setup (Angular 19+) @@ -194,6 +214,29 @@ fields in error. > ``` +Those four names resolve through `layoutRegistry`, which holds standalone +components rather than render functions — the Angular equivalent of the React +and Vue layout registries. `ColumnLayout`, `RowLayout` and `GridLayout` are +registered for you when you import the package root; register your own the same +way: + +```ts +import { Component } from '@angular/core'; +import { layoutRegistry } from '@dynamic-field-kit/angular'; + +@Component({ + standalone: true, + selector: 'app-stack-tight', + template: `
`, +}) +export class StackTightLayout {} + +layoutRegistry.register('stack-tight', StackTightLayout); +``` + +`LayoutRegistry` is the class behind that singleton, for when you want an +isolated set of layouts instead of the shared one. + ## Derived fields with `computeValue` Give a field a `computeValue` to derive its value from the rest of the form data whenever any field changes: diff --git a/packages/angular/test/DynamicFormDevTools.spec.ts b/packages/angular/test/DynamicFormDevTools.spec.ts index e9f20b8..4e16fc2 100644 --- a/packages/angular/test/DynamicFormDevTools.spec.ts +++ b/packages/angular/test/DynamicFormDevTools.spec.ts @@ -73,6 +73,22 @@ describe('DynamicFormDevToolsComponent', () => { expect(badge?.textContent?.trim()).toBe('2'); }); + it('survives a null errors input instead of throwing', () => { + // `errors` defaults to {}, but a form store that has not validated yet can + // hand down null. errorCount() guards with `this.errors || {}`; without it + // the overlay would throw on Object.keys(null) and take the host app's + // render down with it. + fixture.componentRef.setInput('errors', null); + fixture.detectChanges(); + + expect(fixture.componentInstance.errorCount()).toBe(0); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.dfk-devtools-badge' + ) + ).toBeNull(); + }); + it('shows the error count in the errors tab label', () => { fixture.componentInstance.errors = { name: ['required'] }; open(fixture); diff --git a/packages/angular/test/FieldInput.spec.ts b/packages/angular/test/FieldInput.spec.ts index d7b56b2..10276c2 100644 --- a/packages/angular/test/FieldInput.spec.ts +++ b/packages/angular/test/FieldInput.spec.ts @@ -1,4 +1,5 @@ -import { ChangeDetectorRef } from '@angular/core'; +import { NgFor } from '@angular/common'; +import { ChangeDetectorRef, Component, Input } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseInputComponent } from '../src/components/BaseInput'; @@ -10,6 +11,23 @@ import { TextRendererComponent, } from './helpers/renderers'; +/** + * Renders whatever `options` it is handed, so the tests below can assert on + * what actually reached the renderer instead of reading FieldInput's getter. + * `.opt` absent means the renderer received no options at all. + */ +@Component({ + selector: 'dfk-test-options', + standalone: true, + imports: [NgFor], + template: `{{ + $any(o).label + }}`, +}) +class OptionsRendererComponent { + @Input() options?: Record[]; +} + describe('FieldInput', () => { let registry: ReturnType; @@ -132,6 +150,84 @@ describe('FieldInput', () => { }); }); +describe('FieldInput option resolution', () => { + let registry: ReturnType; + + function mount(inputs: Record) { + const fixture = TestBed.createComponent(FieldInput); + for (const [key, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(key, value); + } + fixture.detectChanges(); + return fixture; + } + + function renderedOptions(fixture: { nativeElement: HTMLElement }): string[] { + return Array.from(fixture.nativeElement.querySelectorAll('.opt')).map((n) => + (n.textContent || '').trim() + ); + } + + beforeEach(() => { + registry = makeRegistry(); + registry.register('select', OptionsRendererComponent as never); + TestBed.configureTestingModule({ + imports: [FieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + it("prefers the explicit options input over the field's own options", () => { + // MultiFieldInput resolves options against the live form data and passes + // the result down, so the input has to win over whatever the schema says. + const fixture = mount({ + fieldDescription: { + name: 'city', + type: 'select', + options: [{ label: 'from schema' }], + }, + options: [{ label: 'from parent' }], + }); + + expect(renderedOptions(fixture)).toEqual(['from parent']); + }); + + it('passes a static options array from the field description straight down', () => { + const fixture = mount({ + fieldDescription: { + name: 'city', + type: 'select', + options: [{ label: 'Hanoi' }, { label: 'HCM' }], + }, + }); + + expect(renderedOptions(fixture)).toEqual(['Hanoi', 'HCM']); + }); + + it('withholds a dynamic options callback instead of passing the function down', () => { + // A callback needs the form data to resolve, which FieldInput does not + // have -- only MultiFieldInput does, and it passes the result via the + // `options` input. Handing the raw function to a renderer would make it + // try to iterate a function. + const resolve = vi.fn(() => [{ label: 'never called here' }]); + + const fixture = mount({ + fieldDescription: { name: 'city', type: 'select', options: resolve }, + }); + + expect(renderedOptions(fixture)).toEqual([]); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('passes nothing when the field declares no options at all', () => { + const fixture = mount({ + fieldDescription: { name: 'city', type: 'select' }, + }); + + expect(renderedOptions(fixture)).toEqual([]); + }); +}); + describe('BaseInputComponent', () => { class TestInput extends BaseInputComponent {} diff --git a/packages/angular/test/MultiFieldInputLayout.spec.ts b/packages/angular/test/MultiFieldInputLayout.spec.ts new file mode 100644 index 0000000..f96c91e --- /dev/null +++ b/packages/angular/test/MultiFieldInputLayout.spec.ts @@ -0,0 +1,230 @@ +import { TestBed } from '@angular/core/testing'; +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MultiFieldInput } from '../src/components/MultiFieldInput'; +import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; +import { makeRegistry, TextRendererComponent } from './helpers/renderers'; + +const FIELDS: FieldDescription[] = [ + { name: 'first', type: 'text' }, + { name: 'second', type: 'text' }, +]; + +/** + * Installs a matchMedia stub that reports "mobile" for the given viewport + * width, and records every media query the component asks about so a test can + * assert on the breakpoint it derived. jsdom ships no matchMedia at all, which + * is why the component's own `typeof window.matchMedia === 'function'` guard + * exists. + */ +function stubMatchMedia(viewportWidth: number) { + const queries: string[] = []; + const stub = vi.fn((query: string) => { + queries.push(query); + const max = Number(/max-width:\s*(\d+)px/.exec(query)?.[1] ?? NaN); + return { matches: viewportWidth <= max } as MediaQueryList; + }); + (window as unknown as { matchMedia: unknown }).matchMedia = stub; + return queries; +} + +describe('MultiFieldInput responsive layout', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + afterEach(() => { + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + }); + + function mount(layout: unknown) { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', FIELDS); + fixture.componentRef.setInput('properties', {}); + fixture.componentRef.setInput('layout', layout); + fixture.detectChanges(); + return fixture; + } + + const RESPONSIVE = { + type: 'responsive', + mobile: 'column', + desktop: { type: 'grid', columns: 3, gap: 16 }, + }; + + it('resolves to the desktop layout above the breakpoint', () => { + stubMatchMedia(1280); + + const fixture = mount(RESPONSIVE); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('grid'); + expect(fixture.componentInstance.columns).toBe(3); + expect(fixture.componentInstance.gap).toBe(16); + }); + + it('resolves to the mobile layout below the breakpoint', () => { + stubMatchMedia(375); + + const fixture = mount(RESPONSIVE); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('column'); + }); + + it('honours a custom breakpoint, querying one pixel below it', () => { + // The component asks for `max-width: px`, so a viewport + // exactly at the breakpoint counts as desktop rather than mobile. + const queries = stubMatchMedia(900); + + const fixture = mount({ ...RESPONSIVE, breakpoint: 900 }); + + expect(queries).toContain('(max-width: 899px)'); + expect(fixture.componentInstance.resolvedLayoutType).toBe('grid'); + }); + + it('falls back to the default breakpoint when none is given', () => { + const queries = stubMatchMedia(1280); + + mount(RESPONSIVE); + + expect(queries).toEqual([expect.stringMatching(/^\(max-width: \d+px\)$/)]); + expect(queries).not.toContain('(max-width: 899px)'); + }); + + it('re-renders on resize only when the mobile state actually flips', () => { + stubMatchMedia(1280); + const fixture = mount(RESPONSIVE); + const cdr = ( + fixture.componentInstance as unknown as { + cdr: { markForCheck: () => void }; + } + ).cdr; + const markForCheck = vi.spyOn(cdr, 'markForCheck'); + + // Still desktop: nothing changed, so no work should be scheduled. + fixture.componentInstance.onWindowResize(); + expect(markForCheck).not.toHaveBeenCalled(); + + // Crossing the breakpoint is the case that must schedule a re-render. + stubMatchMedia(375); + fixture.componentInstance.onWindowResize(); + expect(markForCheck).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.resolvedLayoutType).toBe('column'); + }); + + it('treats the layout as desktop when the environment has no matchMedia', () => { + // jsdom without the stub: the guard has to keep the component usable + // rather than throwing on `window.matchMedia(...)`. + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + + const fixture = mount(RESPONSIVE); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('grid'); + }); +}); + +describe('MultiFieldInput layout defaults', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + function mount(layout: unknown) { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', FIELDS); + fixture.componentRef.setInput('properties', {}); + fixture.componentRef.setInput('layout', layout); + fixture.detectChanges(); + return fixture; + } + + it('defaults a grid without an explicit column count to two columns', () => { + const fixture = mount({ type: 'grid' }); + + expect(fixture.componentInstance.columns).toBe(2); + }); + + it('defaults the gap when the layout config omits it', () => { + const fixture = mount({ type: 'grid', columns: 4 }); + + expect(fixture.componentInstance.gap).toBe(12); + expect(fixture.componentInstance.columns).toBe(4); + }); + + it('reports two columns for a non-grid layout', () => { + const fixture = mount('column'); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('column'); + expect(fixture.componentInstance.columns).toBe(2); + }); +}); + +describe('MultiFieldInput group bounds', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + function mountGroup(field: FieldDescription, items: unknown) { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', [field]); + fixture.componentRef.setInput('properties', { [field.name]: items }); + fixture.detectChanges(); + return fixture; + } + + const GROUP: FieldDescription = { + name: 'contacts', + type: 'text', + fields: [{ name: 'email', type: 'text' }], + minItems: 1, + maxItems: 2, + }; + + it('refuses to add past maxItems', () => { + const changes: unknown[] = []; + const fixture = mountGroup(GROUP, [{ email: 'a' }, { email: 'b' }]); + fixture.componentInstance.onChange.subscribe((next) => changes.push(next)); + + fixture.componentInstance.onGroupItemAdd(GROUP); + + expect(changes).toEqual([]); + }); + + it('refuses to remove below minItems', () => { + const changes: unknown[] = []; + const fixture = mountGroup(GROUP, [{ email: 'a' }]); + fixture.componentInstance.onChange.subscribe((next) => changes.push(next)); + + fixture.componentInstance.onGroupItemRemove(GROUP, 0); + + expect(changes).toEqual([]); + }); + + it('treats a non-array group value as empty rather than throwing', () => { + // A schema can declare a group whose data has not been initialised yet, or + // arrives as a scalar from a stale payload. + const fixture = mountGroup(GROUP, 'not-an-array'); + + expect(fixture.componentInstance.canRemoveItem(GROUP)).toBe(false); + expect(fixture.componentInstance.canAddItem(GROUP)).toBe(true); + }); +}); diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json index 7b911f8..8379ab2 100644 --- a/packages/angular/tsconfig.json +++ b/packages/angular/tsconfig.json @@ -1,13 +1,18 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": false, - "declaration": true, - "outDir": "dist", - "target": "ES2019", - "module": "ES2020", - "experimentalDecorators": true, - "emitDecoratorMetadata": true - }, - "include": ["src/**/*"] -} +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "outDir": "dist", + // Matches what ng-packagr actually emits: the published fesm2022 bundle + // uses ES2022 class fields (`value;` declarations, define semantics). This + // said ES2019 for a while, which ng-packagr ignored, so the build shipped + // ES2022 while the config claimed otherwise - that mismatch is why + // tsconfig.spec.json had to override the target to test what really ships. + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ES2020", + "experimentalDecorators": true + }, + "include": ["src/**/*"] +} diff --git a/packages/angular/tsconfig.spec.json b/packages/angular/tsconfig.spec.json index 602e058..2f60d5b 100644 --- a/packages/angular/tsconfig.spec.json +++ b/packages/angular/tsconfig.spec.json @@ -1,9 +1,10 @@ { "extends": "./tsconfig.json", + // target/useDefineForClassFields are inherited from tsconfig.json, which now + // states the ES2022 emit ng-packagr actually produces. Specs must keep + // matching it or they exercise class-field semantics that never ship. "compilerOptions": { - "outDir": "./out-tsc/spec", - "target": "ES2022", - "useDefineForClassFields": true + "outDir": "./out-tsc/spec" }, "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/core/README.md b/packages/core/README.md index 9323706..50fd64d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,12 @@ Core types and shared registries for `dynamic-field-kit`. `@dynamic-field-kit/core` is intentionally framework-agnostic. It does not import React, Vue, or Angular types in its public API. Applications define field schemas in `core`, then register framework-specific renderers through an adapter package such as `@dynamic-field-kit/react`, `@dynamic-field-kit/vue`, or `@dynamic-field-kit/angular`. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/ — the same schema +rendered by [React](https://vannt-dev.github.io/dynamic-field-kit/react/), +[Vue](https://vannt-dev.github.io/dynamic-field-kit/vue/) and +[Angular](https://vannt-dev.github.io/dynamic-field-kit/angular/), including the +[wizard engine](https://vannt-dev.github.io/dynamic-field-kit/react/wizard/) +documented below. ## What this package provides @@ -14,7 +19,7 @@ Live demo: https://vannt-dev.github.io/dynamic-field-kit/ - `fieldRegistry` as the shared runtime registry instance, plus the `FieldRegistry` class for isolated (scoped) registries - Layout config types (`LayoutConfig`, `BaseLayout`, `ResponsiveLayout`, `ColumnLayoutConfig`, `RowLayoutConfig`, `GridLayoutConfig`) - the single source of truth re-exported by every adapter - `applyComputedValues` to resolve `computeValue` fields against form data -- `validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly` and the `ValidationResult` type for opt-in, app-supplied validation and dynamic disabled/readOnly conditions +- `validateField` / `validateFieldAsync`, `validateFields` / `validateFieldsAsync`, `resolveDisabled`, `resolveReadOnly`, `resolveOptions` and the `ValidationResult` type for opt-in, app-supplied validation and dynamic disabled/readOnly/options conditions - `isFieldGroup`, `createGroupItem`, `canAddGroupItem`, `canRemoveGroupItem` to work with repeatable field groups (`FieldDescription.fields`), plus `moveGroupItem`, `swapGroupItems`, `insertGroupItem` and `focusFirstInvalidField` for driving a group's array yourself - `zodValidator`, `yupValidator`, `valibotValidator` / `standardSchemaValidator` to validate with an existing schema library - A multi-step wizard state machine: `createWizardState`, `validateStep`, `canGoNext` / `canGoPrev`, `goNext` / `goPrev` / `goToStep`, `markStepCompleted` / `isStepCompleted` @@ -129,6 +134,39 @@ export interface FieldDescription { } ``` +The value types that show up throughout that shape, and in every signature in +this README: + +```ts +// Any form-data object. `data` and `rootData` are always this. +export type Properties = Record; + +// What `validators.*` helpers return: one message, or undefined when valid. +export type ValidatorFn = ( + value: unknown, + data?: Properties, + rootData?: Properties +) => string | undefined; + +// What a `FieldDescription.validate` hook may return. The Promise arm is what +// makes a field async-only -- see "Sync vs async validation" below. +export type FieldValidatorResult = + | string + | string[] + | undefined + | Promise; + +export type FieldValidatorFunction = ( + value: unknown, + data: Properties, + rootData?: Properties +) => FieldValidatorResult; +``` + +The schema adapters (`zodValidator` and friends) each return a +`FieldValidatorFunction`, which is why their result drops straight into +`validate`. + ## Derived fields with `computeValue` `computeValue` derives a field's value from the rest of the form data (e.g. a `fullName` computed from `firstName` + `lastName`). It is called as `(data, rootData)` - `rootData` is the top-level form even for fields nested in a group. Every adapter's `MultiFieldInput` re-evaluates it once per change, against the post-change data - it is not re-run to a fixed point, so avoid chaining `computeValue` fields into a cycle, and return a primitive or a stable reference (returning a fresh object/array each call defeats the render-skipping optimisation). In development, `applyComputedValues` warns when a `computeValue` chain does not converge in one pass. @@ -213,10 +251,43 @@ const fields: FieldDescription[] = [ `validateFields(fields, data, rootData?)` returns `{ valid, errors }`, recursing into repeatable groups (keys like `contacts[0].email`) and skipping fields that -are hidden by `appearCondition` or disabled. Use `validateFieldsAsync(fields, data, rootData?)` -when fields define async `validate` functions. Adapters call `validateField` / -`resolveDisabled` / `resolveReadOnly` per field to surface `error`, `disabled`, -and `readOnly` to renderers reactively. +are hidden by `appearCondition` or disabled. Adapters call `validateField` / +`resolveDisabled` / `resolveReadOnly` / `resolveOptions` per field to surface +`error`, `disabled`, `readOnly` and the resolved `options` to renderers +reactively. + +### Sync vs async validation + +`validateField` and `validateFields` are synchronous, and that has a consequence +worth knowing: **when a `validate` hook returns a Promise, the sync path treats +the field as valid.** It cannot await, so it discards the pending result rather +than blocking. Any field whose `validate` is `async` — or whose schema has async +refinements — must go through the async pair: + +```ts +import { + validateField, + validateFieldAsync, + validateFields, + validateFieldsAsync, +} from '@dynamic-field-kit/core'; + +// One field. Both always return string[] (empty when valid). +const errors = validateField(field, value, data, rootData); // string[] +const errorsAsync = await validateFieldAsync(field, value, data, rootData); + +// A whole schema. Both return ValidationResult -> { valid, errors }. +const result = validateFields(fields, data); // sync hooks only +const resultAsync = await validateFieldsAsync(fields, data); // awaits each hook +``` + +The framework form hooks (`useDynamicForm`, `createDynamicFormStore`) validate +synchronously, so wire async rules up through `validateFieldsAsync` yourself — +for example on submit — rather than expecting them to surface on change. + +`resolveOptions(field, data, rootData?)` returns `Properties[] | undefined`, +calling `field.options` when it is a callback and passing it through when it is a +static array. ### Schema adapters (Zod, Yup, Valibot / Standard Schema) @@ -288,10 +359,30 @@ if (valid) { | `markStepCompleted(state, index?)` | Record a step as done, defaulting to the current one | | `isStepCompleted(state, index)` | For rendering a step indicator | -`WizardState` carries `currentStep`, `currentStepIndex`, `totalSteps`, -`isFirstStep`, `isLastStep`, `steps` and `completedSteps`. `goNext` does not -validate — call `validateStep` yourself so a "save draft and come back" flow -stays possible. +Each step is a `FormStep`, and `WizardState` is what every helper above takes +and returns: + +```ts +export interface FormStep { + id: string; + title: string; + description?: string; + fields: FieldDescription[]; +} + +export interface WizardState { + currentStepIndex: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + currentStep: FormStep; + steps: FormStep[]; + completedSteps: number[]; +} +``` + +`goNext` does not validate — call `validateStep` yourself so a "save draft and +come back" flow stays possible. ## Group array helpers diff --git a/packages/core/package.json b/packages/core/package.json index 478295b..a0e7ab3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,8 @@ "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "//sideEffects": "core has no top-level execution - the only module-scope work is `new FieldRegistry()` assigned to an export, which is pure. The adapters cannot say this: their entry side-effect-imports the default layouts to register them.", + "sideEffects": false, "publishConfig": { "access": "public" }, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 7933f0c..4a9f280 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -5,5 +5,9 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, - sourcemap: true, + // Sourcemaps are not published: tsup inlines the full TS source via + // sourcesContent, which made the maps ~48% of the tarball (core: 75.7 KB of + // 157 KB unpacked) on every consumer install. Flip back to `sourcemap: true` + // if stepping into the library source is worth that. + sourcemap: false, }); diff --git a/packages/react/README.md b/packages/react/README.md index a9d845d..56ecddf 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,7 +4,10 @@ React adapter for `@dynamic-field-kit/core`. This package provides React components for rendering `FieldDescription[]` and exports a React-typed `fieldRegistry`, so registered renderers can be used directly as JSX components. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/react/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/react/ — plus +[enterprise features](https://vannt-dev.github.io/dynamic-field-kit/react/new-features/) +(`useDynamicForm`, HTML5 renderers, blur wiring, DevTools) and a +[multi-step wizard](https://vannt-dev.github.io/dynamic-field-kit/react/wizard/). ## Install @@ -31,9 +34,23 @@ Note: `@dynamic-field-kit/core`, `react`, and `react-dom` are **peer dependencie - `FieldTypeKey` - `FieldRendererProps` - `LayoutConfig` -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` - `defaultRenderersMap` / `getDefaultRenderer` +Re-exported from `@dynamic-field-kit/core` so a consumer app rarely has to import +both packages: + +- `validateField` / `validateFieldAsync` — one field, returns `string[]` +- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options +- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) +- `ValidationResult` + +`useDynamicForm` validates **synchronously** via `validateFields`, including on +submit. Fields whose `validate` hook returns a Promise are treated as valid on +that path, so run async rules through `validateFieldsAsync` yourself. See the +[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) +for the full rules. + `FieldGroupInput` (repeatable field groups) is used internally by `FieldInput` and doesn't need to be imported directly - see "Repeatable field groups" below. Default layouts are registered automatically when you import the package root. diff --git a/packages/react/package.json b/packages/react/package.json index 8a13084..3f1edee 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -29,6 +29,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.0", + "@types/react": "^19.2.8", "@vitest/coverage-v8": "^1.6.0", "@vitejs/plugin-react": "^4.2.0", "jsdom": "^24.0.0", diff --git a/packages/react/src/components/DynamicInput.tsx b/packages/react/src/components/DynamicInput.tsx index e74895f..dc7c52e 100644 --- a/packages/react/src/components/DynamicInput.tsx +++ b/packages/react/src/components/DynamicInput.tsx @@ -91,7 +91,7 @@ const DynamicInputInner = ({ // unaffected fields don't re-render every time a sibling field's value changes. // React.memo erases the generic signature, so restore it via an `unknown` // round-trip (plain `as typeof DynamicInputInner` fails dts generation). -const DynamicInput = React.memo( +const DynamicInput = /* @__PURE__ */ React.memo( DynamicInputInner ) as unknown as typeof DynamicInputInner; diff --git a/packages/react/src/components/FieldInput.tsx b/packages/react/src/components/FieldInput.tsx index 950713b..949a84a 100644 --- a/packages/react/src/components/FieldInput.tsx +++ b/packages/react/src/components/FieldInput.tsx @@ -103,7 +103,7 @@ const FieldInputInner = ({ // `renderInfos` covers every field, so the default shallow-prop compare would // re-render all fields whenever any one of them changes. Compare only the // slice this field actually reads instead. -const FieldInput = React.memo(FieldInputInner, (prev, next) => { +const FieldInput = /* @__PURE__ */ React.memo(FieldInputInner, (prev, next) => { const name = prev.fieldDescription.name; return ( prev.fieldDescription === next.fieldDescription && diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 91aaa2c..0bfed6f 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -5,7 +5,11 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, - sourcemap: true, + // Sourcemaps are not published: tsup inlines the full TS source via + // sourcesContent, which made the maps ~48% of the tarball (core: 75.7 KB of + // 157 KB unpacked) on every consumer install. Flip back to `sourcemap: true` + // if stepping into the library source is worth that. + sourcemap: false, // QUAN TRỌNG external: ['react', 'react/jsx-runtime', '@dynamic-field-kit/core'], }); diff --git a/packages/vue/README.md b/packages/vue/README.md index 64e0f13..77ec142 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -4,7 +4,9 @@ Vue 3 adapter for `@dynamic-field-kit/core`. This package provides Vue components that render `FieldDescription[]` and resolve field renderers through the shared registry used by `dynamic-field-kit`. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ — tabs for the +basic schema, the enterprise features (`useDynamicForm`, HTML5 renderers, blur +wiring, DevTools) and the multi-step wizard. ## Install @@ -28,11 +30,25 @@ Note: `@dynamic-field-kit/core` and `vue` are **peer dependencies** — this ada - `FieldRendererProps` - `Properties` - `LayoutConfig` -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` - `useDynamicForm` - `DynamicFormDevTools` - `defaultRenderersMap` / `getDefaultRenderer` +Re-exported from `@dynamic-field-kit/core` so a consumer app rarely has to import +both packages: + +- `validateField` / `validateFieldAsync` — one field, returns `string[]` +- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options +- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) +- `ValidationResult` + +`useDynamicForm` validates **synchronously** via `validateFields`, including on +submit. Fields whose `validate` hook returns a Promise are treated as valid on +that path, so run async rules through `validateFieldsAsync` yourself. See the +[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) +for the full rules. + Default layouts are registered automatically when you import the package root. Built-in layouts: diff --git a/packages/vue/package.json b/packages/vue/package.json index cac7d8a..371a7c7 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -30,7 +30,8 @@ "@vue/test-utils": "^2.4.6", "jsdom": "^24.0.0", "tsup": "^8.0.1", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vue": "^3.5.32" }, "exports": { ".": { diff --git a/packages/vue/src/components/DynamicFormDevTools.ts b/packages/vue/src/components/DynamicFormDevTools.ts index 396f526..e8ada7f 100644 --- a/packages/vue/src/components/DynamicFormDevTools.ts +++ b/packages/vue/src/components/DynamicFormDevTools.ts @@ -1,7 +1,7 @@ import { FieldDescription, Properties } from '@dynamic-field-kit/core'; import { defineComponent, h, PropType, ref } from 'vue'; -export const DynamicFormDevTools = defineComponent({ +export const DynamicFormDevTools = /* @__PURE__ */ defineComponent({ name: 'DynamicFormDevTools', props: { data: { diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts index afe71ba..1d84b45 100644 --- a/packages/vue/src/components/DynamicInput.ts +++ b/packages/vue/src/components/DynamicInput.ts @@ -4,7 +4,7 @@ import { FieldTypeKey, Properties } from '@dynamic-field-kit/core'; import { getDefaultRenderer } from '../defaultRenderers'; import { useFieldRegistry } from '../fieldRegistryContext'; -const DynamicInput = defineComponent({ +const DynamicInput = /* @__PURE__ */ defineComponent({ name: 'DynamicInput', props: { diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts index d4d25eb..99382cd 100644 --- a/packages/vue/src/components/FieldInput.ts +++ b/packages/vue/src/components/FieldInput.ts @@ -9,7 +9,7 @@ import { import { defineComponent, h, PropType } from 'vue'; import DynamicInput from './DynamicInput'; -const FieldInput = defineComponent({ +const FieldInput = /* @__PURE__ */ defineComponent({ name: 'FieldInput', props: { fieldDescription: { diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index eecb661..5a40834 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -24,22 +24,33 @@ function resolveLayout(layout?: LayoutConfig) { return { type: layout.type, config: layout }; } -// Forward-declared with an explicit type so the recursive h() call inside -// renderGroupField doesn't force TypeScript to infer MultiFieldInput's type -// from within its own initializer (which fails to build: "implicitly has -// type 'any' because it does not have a type annotation and is referenced -// ... in its own initializer"). Assigned once MultiFieldInput exists below. -// Must be declared before, and assigned after, MultiFieldInput is defined, -// so it can't be a `const` despite only ever being assigned once. -// eslint-disable-next-line prefer-const -let multiFieldInputSelfRef: Component; +// The recursive h() call inside renderGroupField cannot name MultiFieldInput +// directly: that forces TypeScript to infer its type from within its own +// initializer, which fails to build with "implicitly has type 'any' because it +// does not have a type annotation and is referenced ... in its own +// initializer". A function declaration solves it twice over -- the explicit +// return type breaks the inference cycle, and the declaration is hoisted, so +// the initializer below can call it. +// +// It must stay a function rather than a module-scope +// `selfRef = MultiFieldInput` assignment. A bare top-level assignment is a side +// effect no bundler can drop, and it anchored MultiFieldInput -> FieldInput -> +// DynamicInput -> every default renderer into consumer bundles that imported +// none of them (~11.5 KB minified). A function body is not evaluated until it +// is called, so nothing is retained until something actually renders a group. +function selfRef(): Component { + // Safe despite the forward reference: this body only runs during a render, + // long after the module has finished evaluating. + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return MultiFieldInput; +} // Repeatable field groups render a nested MultiFieldInput per item, so this // component renders itself recursively (see renderGroupField below) rather // than delegating to a separate group component. A separate file importing // both MultiFieldInput and FieldInput (which would need to import it back) // breaks Vue's declaration-file generation with a circular type alias. -const MultiFieldInput = defineComponent({ +const MultiFieldInput = /* @__PURE__ */ defineComponent({ name: 'MultiFieldInput', props: { @@ -207,7 +218,7 @@ const MultiFieldInput = defineComponent({ }, [ h('div', { style: { flex: 1 } }, [ - h(multiFieldInputSelfRef, { + h(selfRef(), { fieldDescriptions: fields, properties: item, rootData: props.rootData ?? data, @@ -268,6 +279,4 @@ const MultiFieldInput = defineComponent({ }, }); -multiFieldInputSelfRef = MultiFieldInput; - export default MultiFieldInput; diff --git a/packages/vue/src/defaultRenderers.ts b/packages/vue/src/defaultRenderers.ts index e750184..0ffd122 100644 --- a/packages/vue/src/defaultRenderers.ts +++ b/packages/vue/src/defaultRenderers.ts @@ -2,7 +2,7 @@ import { Properties } from '@dynamic-field-kit/core'; import { defineComponent, h, PropType } from 'vue'; -export const DefaultTextRenderer = defineComponent({ +export const DefaultTextRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultTextRenderer', props: { value: null, @@ -46,7 +46,7 @@ export const DefaultTextRenderer = defineComponent({ }, }); -export const DefaultNumberRenderer = defineComponent({ +export const DefaultNumberRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultNumberRenderer', props: { value: null, @@ -88,7 +88,7 @@ export const DefaultNumberRenderer = defineComponent({ }, }); -export const DefaultPasswordRenderer = defineComponent({ +export const DefaultPasswordRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultPasswordRenderer', setup(props, { attrs }) { return () => @@ -100,7 +100,7 @@ export const DefaultPasswordRenderer = defineComponent({ }, }); -export const DefaultEmailRenderer = defineComponent({ +export const DefaultEmailRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultEmailRenderer', setup(props, { attrs }) { return () => @@ -112,7 +112,7 @@ export const DefaultEmailRenderer = defineComponent({ }, }); -export const DefaultTextareaRenderer = defineComponent({ +export const DefaultTextareaRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultTextareaRenderer', props: { value: null, @@ -151,7 +151,7 @@ export const DefaultTextareaRenderer = defineComponent({ }, }); -export const DefaultCheckboxRenderer = defineComponent({ +export const DefaultCheckboxRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultCheckboxRenderer', props: { value: null, @@ -188,7 +188,7 @@ export const DefaultCheckboxRenderer = defineComponent({ }, }); -export const DefaultSelectRenderer = defineComponent({ +export const DefaultSelectRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultSelectRenderer', props: { value: null, @@ -245,7 +245,7 @@ export const DefaultSelectRenderer = defineComponent({ }, }); -export const DefaultRadioRenderer = defineComponent({ +export const DefaultRadioRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultRadioRenderer', props: { value: null, @@ -313,7 +313,7 @@ export const DefaultRadioRenderer = defineComponent({ }, }); -export const DefaultRangeRenderer = defineComponent({ +export const DefaultRangeRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultRangeRenderer', props: { value: null, @@ -354,7 +354,7 @@ export const DefaultRangeRenderer = defineComponent({ }, }); -export const DefaultFileRenderer = defineComponent({ +export const DefaultFileRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultFileRenderer', props: { value: null, @@ -397,7 +397,7 @@ export const DefaultFileRenderer = defineComponent({ }, }); -export const DefaultDateRenderer = defineComponent({ +export const DefaultDateRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultDateRenderer', setup(props, { attrs }) { return () => @@ -409,7 +409,7 @@ export const DefaultDateRenderer = defineComponent({ }, }); -export const DefaultTimeRenderer = defineComponent({ +export const DefaultTimeRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultTimeRenderer', setup(props, { attrs }) { return () => @@ -421,7 +421,7 @@ export const DefaultTimeRenderer = defineComponent({ }, }); -export const DefaultDateTimeLocalRenderer = defineComponent({ +export const DefaultDateTimeLocalRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultDateTimeLocalRenderer', setup(props, { attrs }) { return () => @@ -433,7 +433,7 @@ export const DefaultDateTimeLocalRenderer = defineComponent({ }, }); -export const DefaultSwitchRenderer = defineComponent({ +export const DefaultSwitchRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultSwitchRenderer', setup(props, { attrs }) { return () => diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts index 170dcb8..0daf56c 100644 --- a/packages/vue/tsup.config.ts +++ b/packages/vue/tsup.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, - sourcemap: true, + // Sourcemaps are not published: tsup inlines the full TS source via + // sourcesContent, which made the maps ~48% of the tarball (core: 75.7 KB of + // 157 KB unpacked) on every consumer install. Flip back to `sourcemap: true` + // if stepping into the library source is worth that. + sourcemap: false, external: ['vue', '@vue/runtime-core', '@dynamic-field-kit/core'], }); diff --git a/scripts/build-changed.js b/scripts/build-changed.js index 3e5c69c..4902ca8 100644 --- a/scripts/build-changed.js +++ b/scripts/build-changed.js @@ -1,8 +1,8 @@ #!/usr/bin/env node // Build only changed packages based on git diff +const { execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { execSync, spawnSync } = require('child_process'); function readJSON(p) { try { diff --git a/scripts/check-cross-framework-imports.js b/scripts/check-cross-framework-imports.js index 48c054a..a9abb84 100644 --- a/scripts/check-cross-framework-imports.js +++ b/scripts/check-cross-framework-imports.js @@ -5,9 +5,18 @@ const fs = require('fs'); const path = require('path'); +const FRAMEWORK_PACKAGES = ['react', 'vue', 'angular']; + +const IMPORT_PATTERN = /from\s+['"]@dynamic-field-kit\/(vue|angular|react)['"]/; +const REQUIRE_PATTERN = + /require\(['"]@dynamic-field-kit\/(vue|angular|react)['"]\)/; + function walk(dir, cb) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { + if (!fs.existsSync(dir)) { + return; + } + + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) { walk(full, cb); @@ -18,52 +27,48 @@ function walk(dir, cb) { } function isSourceFile(p) { - return /\\.(ts|tsx|js|jsx)$/.test(p); + return /\.(ts|tsx|js|jsx)$/.test(p); } -const repoRoot = path.resolve(__dirname, '..'); -const roots = [ - path.resolve(repoRoot, 'packages', 'react', 'src'), - path.resolve(repoRoot, 'packages', 'vue', 'src'), - path.resolve(repoRoot, 'packages', 'angular', 'src'), -]; - -let violations = []; +function findCrossFrameworkImports(root = path.resolve(__dirname, '..')) { + const violations = []; -for (const root of roots) { - walk(root, (file) => { - if (!isSourceFile(file)) return; - const content = fs.readFileSync(file, 'utf8'); - const lines = content.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const m = line.match( - /from\s+['"]@dynamic-field-kit\/(vue|angular|react)['"]/ - ); - if (m) { - violations.push({ file, line: i + 1, framework: m[1] }); - } - const m2 = line.match( - /require\(['"]@dynamic-field-kit\/(vue|angular|react)['"]\)/ - ); - if (m2) { - violations.push({ file, line: i + 1, framework: m2[1] }); + for (const pkg of FRAMEWORK_PACKAGES) { + walk(path.join(root, 'packages', pkg, 'src'), (file) => { + if (!isSourceFile(file)) { + return; } - } - }); + + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + + lines.forEach((line, i) => { + const m = line.match(IMPORT_PATTERN) || line.match(REQUIRE_PATTERN); + if (m) { + violations.push({ file, line: i + 1, framework: m[1] }); + } + }); + }); + } + + return violations; } -if (violations.length) { - console.error('Cross-framework imports detected:'); - violations.forEach((v) => { - console.error( - ` - ${v.file}:${v.line} (importing @dynamic-field-kit/${v.framework})` - ); - }); - process.exit(1); -} else { +if (require.main === module) { + const violations = findCrossFrameworkImports(); + + if (violations.length) { + console.error('Cross-framework imports detected:'); + violations.forEach((v) => { + console.error( + ` - ${v.file}:${v.line} (importing @dynamic-field-kit/${v.framework})` + ); + }); + process.exit(1); + } + console.log( 'OK: No cross-framework imports found in src of react/vue/angular packages.' ); - process.exit(0); } + +module.exports = { findCrossFrameworkImports, FRAMEWORK_PACKAGES }; diff --git a/scripts/check-cross-framework-imports.test.js b/scripts/check-cross-framework-imports.test.js new file mode 100644 index 0000000..5d88717 --- /dev/null +++ b/scripts/check-cross-framework-imports.test.js @@ -0,0 +1,108 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { findCrossFrameworkImports } from './check-cross-framework-imports.js'; + +const tempRoots = []; + +/** Writes `files` (relative path -> contents) under packages//src. */ +function makeWorkspace(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cross-imports-')); + tempRoots.push(root); + + for (const pkg of ['react', 'vue', 'angular']) { + fs.mkdirSync(path.join(root, 'packages', pkg, 'src'), { recursive: true }); + } + + for (const [relative, contents] of Object.entries(files)) { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + } + + return root; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('findCrossFrameworkImports', () => { + it('detects an import of a sibling framework package', () => { + const root = makeWorkspace({ + 'packages/vue/src/index.ts': + "import { DynamicInput } from '@dynamic-field-kit/react';\n", + }); + + expect(findCrossFrameworkImports(root)).toEqual([ + { + file: path.join(root, 'packages', 'vue', 'src', 'index.ts'), + line: 1, + framework: 'react', + }, + ]); + }); + + it('detects a require() of a sibling framework package', () => { + const root = makeWorkspace({ + 'packages/react/src/legacy.js': + "const vue = require('@dynamic-field-kit/vue');\n", + }); + + expect(findCrossFrameworkImports(root)).toMatchObject([ + { line: 1, framework: 'vue' }, + ]); + }); + + it('reports the line number of a violation further down a file', () => { + const root = makeWorkspace({ + 'packages/angular/src/index.ts': [ + "import { defineField } from '@dynamic-field-kit/core';", + '', + "import { x } from '@dynamic-field-kit/vue';", + ].join('\n'), + }); + + expect(findCrossFrameworkImports(root)).toMatchObject([ + { line: 3, framework: 'vue' }, + ]); + }); + + it('scans nested directories', () => { + const root = makeWorkspace({ + 'packages/vue/src/layout/deep/nested.tsx': + "import x from '@dynamic-field-kit/angular';\n", + }); + + expect(findCrossFrameworkImports(root)).toHaveLength(1); + }); + + it('allows importing core', () => { + const root = makeWorkspace({ + 'packages/vue/src/index.ts': + "import { defineField } from '@dynamic-field-kit/core';\n", + }); + + expect(findCrossFrameworkImports(root)).toEqual([]); + }); + + it('ignores files that are not source files', () => { + const root = makeWorkspace({ + 'packages/vue/src/README.md': + "import { x } from '@dynamic-field-kit/react';\n", + 'packages/vue/src/data.json': '{}', + }); + + expect(findCrossFrameworkImports(root)).toEqual([]); + }); + + it('finds nothing in this repo', () => { + expect(findCrossFrameworkImports(path.resolve(__dirname, '..'))).toEqual( + [] + ); + }); +}); diff --git a/scripts/diagnose-hoist.js b/scripts/diagnose-hoist.js index 820327a..f173a18 100644 --- a/scripts/diagnose-hoist.js +++ b/scripts/diagnose-hoist.js @@ -7,7 +7,9 @@ packages.forEach((p) => { try { const dir = `./packages/${p}`; console.log(`\n=== ${p.toUpperCase()} ===`); - const out = execSync(`npm ls @dynamic-field-kit/core --depth=0`, { + // stdio: 'inherit' prints straight to our stdout, so there is nothing to + // capture from the return value. + execSync(`npm ls @dynamic-field-kit/core --depth=0`, { cwd: dir, stdio: 'inherit', }); diff --git a/scripts/show-sizes.js b/scripts/show-sizes.js index 90f0273..932b517 100644 --- a/scripts/show-sizes.js +++ b/scripts/show-sizes.js @@ -1,29 +1,83 @@ const fs = require('fs'); const path = require('path'); -const root = process.cwd(); -const packages = ['core', 'react', 'vue']; +const DEFAULT_PACKAGES = ['core', 'react', 'vue', 'angular']; -console.log('=== Bundle Sizes ===\n'); +const stripDotSlash = (entry) => entry.replace(/^\.\//, ''); -for (const pkg of packages) { - const distPath = path.join(root, 'packages', pkg, 'dist'); +/** + * Resolves a package's ESM and CJS entry points from its own manifest rather + * than guessing from file extensions. The `.js`/`.mjs` pair that tsup emits for + * core and react inverts for vue, which is `"type": "module"` and so ships ESM + * as `.js` and CommonJS as `.cjs`. + */ +function entryPoints(pkgJson) { + const conditions = (pkgJson.exports && pkgJson.exports['.']) || {}; + const esm = conditions.import || pkgJson.module; + const cjs = conditions.require || pkgJson.main; - if (!fs.existsSync(distPath)) { - console.log(`${pkg}: dist not found`); - continue; - } + // ng-packagr ships ESM only and points both `main` and `module` at the same + // fesm2022 bundle, so treating `main` as CJS there would invent a bundle. + const hasDistinctCjs = + cjs && (!esm || stripDotSlash(cjs) !== stripDotSlash(esm)); + + return { esm, cjs: hasDistinctCjs ? cjs : null }; +} + +function collectBundleSizes(root, packages = DEFAULT_PACKAGES) { + const results = []; + + for (const pkg of packages) { + const pkgDir = path.join(root, 'packages', pkg); + const manifest = path.join(pkgDir, 'package.json'); + + if (!fs.existsSync(manifest)) { + results.push({ pkg, format: null, bytes: null, missing: true }); + continue; + } + + const { esm, cjs } = entryPoints( + JSON.parse(fs.readFileSync(manifest, 'utf8')) + ); + const found = []; - const mainFile = path.join(distPath, 'index.js'); - const esmFile = path.join(distPath, 'index.mjs'); + for (const [format, entry] of [ + ['ESM', esm], + ['CJS', cjs], + ]) { + if (!entry) { + continue; + } - if (fs.existsSync(mainFile)) { - const size = fs.statSync(mainFile).size; - console.log(`${pkg} (CJS): ${(size / 1024).toFixed(2)} KB`); + const file = path.join(pkgDir, stripDotSlash(entry)); + if (fs.existsSync(file)) { + found.push({ pkg, format, bytes: fs.statSync(file).size }); + } + } + + if (found.length === 0) { + results.push({ pkg, format: null, bytes: null, missing: true }); + } else { + results.push(...found); + } } - if (fs.existsSync(esmFile)) { - const size = fs.statSync(esmFile).size; - console.log(`${pkg} (ESM): ${(size / 1024).toFixed(2)} KB`); + return results; +} + +function formatSizes(sizes) { + return sizes.map((entry) => + entry.missing + ? `${entry.pkg}: not built` + : `${entry.pkg} (${entry.format}): ${(entry.bytes / 1024).toFixed(2)} KB` + ); +} + +if (require.main === module) { + console.log('=== Bundle Sizes ===\n'); + for (const line of formatSizes(collectBundleSizes(process.cwd()))) { + console.log(line); } } + +module.exports = { collectBundleSizes, formatSizes, DEFAULT_PACKAGES }; diff --git a/scripts/show-sizes.test.js b/scripts/show-sizes.test.js new file mode 100644 index 0000000..5bd93a8 --- /dev/null +++ b/scripts/show-sizes.test.js @@ -0,0 +1,149 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { collectBundleSizes } from './show-sizes.js'; + +const tempRoots = []; + +/** + * Builds a throwaway workspace root containing one package, so the collector + * runs against a real filesystem instead of a mocked one. + */ +function makeWorkspace(pkg, packageJson, distFiles) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'show-sizes-')); + tempRoots.push(root); + + const pkgDir = path.join(root, 'packages', pkg); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify(packageJson) + ); + + for (const [relative, contents] of Object.entries(distFiles)) { + const file = path.join(pkgDir, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + } + + return root; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('collectBundleSizes', () => { + it('labels a "type: module" package by its exports map, not by file extension', () => { + // The vue package's shape: `.js` is the ESM build and `.cjs` is CommonJS, + // the opposite of the `.js`/`.mjs` convention core and react use. + const root = makeWorkspace( + 'vue', + { + name: '@dynamic-field-kit/vue', + type: 'module', + main: './dist/index.cjs', + module: './dist/index.js', + exports: { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.cjs', + }, + }, + }, + { + 'dist/index.js': 'e'.repeat(2048), + 'dist/index.cjs': 'c'.repeat(1024), + } + ); + + expect(collectBundleSizes(root, ['vue'])).toEqual([ + { pkg: 'vue', format: 'ESM', bytes: 2048 }, + { pkg: 'vue', format: 'CJS', bytes: 1024 }, + ]); + }); + + it('labels a `.js`/`.mjs` package correctly too', () => { + const root = makeWorkspace( + 'core', + { + name: '@dynamic-field-kit/core', + main: 'dist/index.js', + module: 'dist/index.mjs', + exports: { + '.': { + import: './dist/index.mjs', + require: './dist/index.js', + }, + }, + }, + { + 'dist/index.mjs': 'e'.repeat(512), + 'dist/index.js': 'c'.repeat(256), + } + ); + + expect(collectBundleSizes(root, ['core'])).toEqual([ + { pkg: 'core', format: 'ESM', bytes: 512 }, + { pkg: 'core', format: 'CJS', bytes: 256 }, + ]); + }); + + it('reports an ESM-only package once when main and module are the same file', () => { + // The angular package's shape: ng-packagr ships fesm2022 only, and points + // both `main` and `module` at it. Reporting that file as CJS would be a lie. + const root = makeWorkspace( + 'angular', + { + name: '@dynamic-field-kit/angular', + type: 'module', + main: 'dist/fesm2022/dynamic-field-kit-angular.mjs', + module: 'dist/fesm2022/dynamic-field-kit-angular.mjs', + }, + { 'dist/fesm2022/dynamic-field-kit-angular.mjs': 'a'.repeat(4096) } + ); + + expect(collectBundleSizes(root, ['angular'])).toEqual([ + { pkg: 'angular', format: 'ESM', bytes: 4096 }, + ]); + }); + + it('marks a package as unbuilt when its entry file is missing', () => { + const root = makeWorkspace( + 'core', + { + name: '@dynamic-field-kit/core', + main: 'dist/index.js', + module: 'dist/index.mjs', + }, + {} + ); + + expect(collectBundleSizes(root, ['core'])).toEqual([ + { pkg: 'core', format: null, bytes: null, missing: true }, + ]); + }); + + it('covers every published package by default', () => { + const root = path.resolve(__dirname, '..'); + const reported = new Set( + collectBundleSizes(root).map((entry) => entry.pkg) + ); + + expect([...reported].sort()).toEqual(['angular', 'core', 'react', 'vue']); + }); + + it('resolves the real vue package entries to the right formats', () => { + const root = path.resolve(__dirname, '..'); + const vue = collectBundleSizes(root, ['vue']); + + // Regression guard: `dist/index.js` in the vue package is ESM. An + // extension-based guess reports it as CJS and never reports `dist/index.cjs`. + expect(vue.map((entry) => entry.format)).toEqual(['ESM', 'CJS']); + }); +}); diff --git a/scripts/verify-framework-deps.js b/scripts/verify-framework-deps.js index 5a4664a..02b580f 100644 --- a/scripts/verify-framework-deps.js +++ b/scripts/verify-framework-deps.js @@ -1,53 +1,77 @@ #!/usr/bin/env node -// Verify that each framework package (react, vue, angular) depends only on core -// and does not depend on the other framework packages. +// Verify that each framework package (react, vue, angular) depends only on core, +// and that it dev-installs every framework it peer-depends on. -const path = require('path'); const fs = require('fs'); +const path = require('path'); + +const FRAMEWORK_PACKAGES = ['react', 'vue', 'angular']; function readJson(p) { const data = fs.readFileSync(p, 'utf8'); return JSON.parse(data); } -const base = path.resolve(__dirname, '..'); -const pkgs = { - react: path.join(base, 'packages', 'react', 'package.json'), - vue: path.join(base, 'packages', 'vue', 'package.json'), - angular: path.join(base, 'packages', 'angular', 'package.json'), -}; - -let problems = []; - -function check(pkgName, pjson) { - const other = Object.keys(pkgs).filter((n) => n !== pkgName); +function crossFrameworkProblems(pkgName, pjson) { const deps = Object.assign( {}, pjson.dependencies || {}, pjson.peerDependencies || {}, pjson.devDependencies || {} ); - other.forEach((o) => { - const alias = `@dynamic-field-kit/${o}`; - if (deps && Object.prototype.hasOwnProperty.call(deps, alias)) { - problems.push( + + return FRAMEWORK_PACKAGES.filter((other) => other !== pkgName) + .map((other) => `@dynamic-field-kit/${other}`) + .filter((alias) => Object.prototype.hasOwnProperty.call(deps, alias)) + .map( + (alias) => `${pkgName}: depends on ${alias} (should depend only on core) (in dependencies/peer/dev)` - ); - } - }); + ); } -for (const [name, p] of Object.entries(pkgs)) { - const pjson = readJson(p); - check(name, pjson); +// A peer that is never dev-installed still resolves during a local build or +// test run, but only by borrowing whatever the workspace root happens to hoist. +// That makes the package's own manifest a lie about what it was verified +// against, and it breaks the moment the root stops declaring it. +function undeclaredPeerProblems(pkgName, pjson) { + const devDeps = pjson.devDependencies || {}; + + return Object.keys(pjson.peerDependencies || {}) + .filter((peer) => !Object.prototype.hasOwnProperty.call(devDeps, peer)) + .map( + (peer) => + `${pkgName}: peer-depends on ${peer} but does not declare it in devDependencies, ` + + `so it builds and tests against whatever the workspace root happens to hoist` + ); } -if (problems.length) { - console.error('Framework dependency issues found:'); - problems.forEach((m) => console.error(' -', m)); - process.exit(1); -} else { +function findFrameworkDepProblems(root = path.resolve(__dirname, '..')) { + const problems = []; + + for (const pkgName of FRAMEWORK_PACKAGES) { + const pjson = readJson( + path.join(root, 'packages', pkgName, 'package.json') + ); + + problems.push(...crossFrameworkProblems(pkgName, pjson)); + problems.push(...undeclaredPeerProblems(pkgName, pjson)); + } + + return problems; +} + +if (require.main === module) { + const problems = findFrameworkDepProblems(); + + if (problems.length) { + console.error('Framework dependency issues found:'); + problems.forEach((m) => console.error(' -', m)); + process.exit(1); + } + console.log( - 'OK: No cross-framework dependencies detected in react/vue/angular packages.' + 'OK: react/vue/angular depend only on core and dev-install their own peers.' ); } + +module.exports = { findFrameworkDepProblems, FRAMEWORK_PACKAGES }; diff --git a/scripts/verify-framework-deps.test.js b/scripts/verify-framework-deps.test.js new file mode 100644 index 0000000..e559409 --- /dev/null +++ b/scripts/verify-framework-deps.test.js @@ -0,0 +1,116 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { findFrameworkDepProblems } from './verify-framework-deps.js'; + +const tempRoots = []; + +const FRAMEWORKS = ['react', 'vue', 'angular']; + +/** A manifest that satisfies every rule, so each test can break one thing. */ +function healthyManifest(pkg, overrides = {}) { + return { + name: `@dynamic-field-kit/${pkg}`, + peerDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + devDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + ...overrides, + }; +} + +function makeWorkspace(manifests) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'framework-deps-')); + tempRoots.push(root); + + for (const pkg of FRAMEWORKS) { + const pkgDir = path.join(root, 'packages', pkg); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify(manifests[pkg] || healthyManifest(pkg)) + ); + } + + return root; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('findFrameworkDepProblems', () => { + it('accepts packages that declare every peer in devDependencies', () => { + const root = makeWorkspace({}); + + expect(findFrameworkDepProblems(root)).toEqual([]); + }); + + it('flags a package that does not dev-install a framework it peer-depends on', () => { + // A package that peer-depends on vue but never dev-installs it can only be + // built and tested by borrowing vue from the workspace root. + const root = makeWorkspace({ + vue: healthyManifest('vue', { + peerDependencies: { + '@dynamic-field-kit/core': '^1.3.0', + vue: '^3.0.0', + }, + devDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + }), + }); + + expect(findFrameworkDepProblems(root)).toEqual([ + 'vue: peer-depends on vue but does not declare it in ' + + 'devDependencies, so it builds and tests against whatever the ' + + 'workspace root happens to hoist', + ]); + }); + + it('still flags a dependency on a sibling framework package', () => { + const root = makeWorkspace({ + react: healthyManifest('react', { + dependencies: { '@dynamic-field-kit/vue': '^1.4.0' }, + }), + }); + + expect(findFrameworkDepProblems(root)).toEqual([ + 'react: depends on @dynamic-field-kit/vue (should depend only on core) (in dependencies/peer/dev)', + ]); + }); + + it('reports both kinds of problem at once', () => { + const root = makeWorkspace({ + react: healthyManifest('react', { + peerDependencies: { + '@dynamic-field-kit/core': '^1.3.0', + '@dynamic-field-kit/angular': '^1.4.0', + react: '^19.0.0', + }, + devDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + }), + }); + + // One cross-framework dependency, plus two peers left out of + // devDependencies (@dynamic-field-kit/angular and react). The sibling + // package is genuinely both problems at once, so it is reported twice. + expect(findFrameworkDepProblems(root)).toHaveLength(3); + }); + + it('applies the devDependencies rule to the core peer too', () => { + const root = makeWorkspace({ + angular: healthyManifest('angular', { devDependencies: {} }), + }); + + expect(findFrameworkDepProblems(root)).toEqual([ + 'angular: peer-depends on @dynamic-field-kit/core but does not ' + + 'declare it in devDependencies, so it builds and tests against ' + + 'whatever the workspace root happens to hoist', + ]); + }); + + it('holds for the real packages in this repo', () => { + expect(findFrameworkDepProblems(path.resolve(__dirname, '..'))).toEqual([]); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..ff50d9f --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Root-level suite for the repo's own build/CI scripts in `scripts/`. The four +// packages each run their own vitest with their own config and coverage floor; +// this one deliberately covers nothing but `scripts/**`. +export default defineConfig({ + test: { + include: ['scripts/**/*.test.js'], + environment: 'node', + }, +});