tempest-react-sdk 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +9 -10
  2. package/bin/create-tempest-app.e2e.test.mjs +31 -0
  3. package/bin/create-tempest-app.mjs +11 -1
  4. package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
  5. package/bin/lib/doctor/vitest.mjs +120 -0
  6. package/bin/lib/doctor/vitest.test.mjs +139 -0
  7. package/bin/lib/fs/contain.mjs +7 -0
  8. package/bin/lib/openapi/escape.mjs +68 -4
  9. package/bin/lib/openapi/generate.hostile.test.mjs +228 -0
  10. package/bin/lib/openapi/generate.mjs +119 -15
  11. package/bin/lib/openapi/pattern-risk.mjs +134 -0
  12. package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
  13. package/bin/tempest-gen.test.mjs +100 -0
  14. package/bin/tempest.mjs +24 -8
  15. package/dist/access/use-can.cjs +1 -1
  16. package/dist/access/use-can.cjs.map +1 -1
  17. package/dist/access/use-can.js +25 -28
  18. package/dist/access/use-can.js.map +1 -1
  19. package/dist/auth/create-tempest-auth.cjs +1 -1
  20. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  21. package/dist/auth/create-tempest-auth.js +189 -143
  22. package/dist/auth/create-tempest-auth.js.map +1 -1
  23. package/dist/auth/session-identity.cjs +2 -0
  24. package/dist/auth/session-identity.cjs.map +1 -0
  25. package/dist/auth/session-identity.js +12 -0
  26. package/dist/auth/session-identity.js.map +1 -0
  27. package/dist/br/boleto.cjs.map +1 -1
  28. package/dist/br/boleto.js.map +1 -1
  29. package/dist/components/AuthForm/AuthLinks.cjs +1 -1
  30. package/dist/components/AuthForm/AuthLinks.cjs.map +1 -1
  31. package/dist/components/AuthForm/AuthLinks.js +18 -15
  32. package/dist/components/AuthForm/AuthLinks.js.map +1 -1
  33. package/dist/components/AuthPage/AuthPage.cjs +1 -1
  34. package/dist/components/AuthPage/AuthPage.cjs.map +1 -1
  35. package/dist/components/AuthPage/AuthPage.js +58 -52
  36. package/dist/components/AuthPage/AuthPage.js.map +1 -1
  37. package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
  38. package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
  39. package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
  40. package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  41. package/dist/components/Button/Button.cjs +1 -1
  42. package/dist/components/Button/Button.cjs.map +1 -1
  43. package/dist/components/Button/Button.js +41 -40
  44. package/dist/components/Button/Button.js.map +1 -1
  45. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  46. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  47. package/dist/components/Chat/ChatAttachments.js +38 -38
  48. package/dist/components/Form/Form.cjs +1 -1
  49. package/dist/components/Form/Form.cjs.map +1 -1
  50. package/dist/components/Form/Form.js +30 -32
  51. package/dist/components/Form/Form.js.map +1 -1
  52. package/dist/components/Layout/Layout.cjs +1 -1
  53. package/dist/components/Layout/Layout.cjs.map +1 -1
  54. package/dist/components/Layout/Layout.js +25 -24
  55. package/dist/components/Layout/Layout.js.map +1 -1
  56. package/dist/components/Layout/gap.cjs +2 -0
  57. package/dist/components/Layout/gap.cjs.map +1 -0
  58. package/dist/components/Layout/gap.js +15 -0
  59. package/dist/components/Layout/gap.js.map +1 -0
  60. package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
  61. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  62. package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
  63. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  64. package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
  65. package/dist/components/Sidebar/Sidebar.js.map +1 -1
  66. package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
  67. package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
  68. package/dist/components/Sidebar/SidebarEntry.js +30 -29
  69. package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
  70. package/dist/data/create-data-provider.cjs +1 -1
  71. package/dist/data/create-data-provider.cjs.map +1 -1
  72. package/dist/data/create-data-provider.js +1 -2
  73. package/dist/data/create-data-provider.js.map +1 -1
  74. package/dist/forms/validate-form.cjs +1 -1
  75. package/dist/forms/validate-form.cjs.map +1 -1
  76. package/dist/forms/validate-form.js +6 -1
  77. package/dist/forms/validate-form.js.map +1 -1
  78. package/dist/forms/zod-resolver.cjs +1 -1
  79. package/dist/forms/zod-resolver.cjs.map +1 -1
  80. package/dist/forms/zod-resolver.js +8 -3
  81. package/dist/forms/zod-resolver.js.map +1 -1
  82. package/dist/http/api-client.cjs +1 -1
  83. package/dist/http/api-client.cjs.map +1 -1
  84. package/dist/http/api-client.js +90 -72
  85. package/dist/http/api-client.js.map +1 -1
  86. package/dist/http/credential-scope.cjs +1 -1
  87. package/dist/http/credential-scope.cjs.map +1 -1
  88. package/dist/http/credential-scope.js +33 -9
  89. package/dist/http/credential-scope.js.map +1 -1
  90. package/dist/http/presigned-post.cjs.map +1 -1
  91. package/dist/http/presigned-post.js.map +1 -1
  92. package/dist/http/resumable-upload.cjs +1 -1
  93. package/dist/http/resumable-upload.cjs.map +1 -1
  94. package/dist/http/resumable-upload.js +167 -112
  95. package/dist/http/resumable-upload.js.map +1 -1
  96. package/dist/http/upload-with-progress.cjs +1 -1
  97. package/dist/http/upload-with-progress.cjs.map +1 -1
  98. package/dist/http/upload-with-progress.js +100 -44
  99. package/dist/http/upload-with-progress.js.map +1 -1
  100. package/dist/oauth/GoogleSignIn.cjs +1 -1
  101. package/dist/oauth/GoogleSignIn.cjs.map +1 -1
  102. package/dist/oauth/GoogleSignIn.js +5 -4
  103. package/dist/oauth/GoogleSignIn.js.map +1 -1
  104. package/dist/offline/create-offline-sync.cjs +1 -1
  105. package/dist/offline/create-offline-sync.cjs.map +1 -1
  106. package/dist/offline/create-offline-sync.js +87 -69
  107. package/dist/offline/create-offline-sync.js.map +1 -1
  108. package/dist/query/offline-persistence.cjs +1 -1
  109. package/dist/query/offline-persistence.cjs.map +1 -1
  110. package/dist/query/offline-persistence.js +67 -44
  111. package/dist/query/offline-persistence.js.map +1 -1
  112. package/dist/sse/create-event-stream.cjs +1 -1
  113. package/dist/sse/create-event-stream.cjs.map +1 -1
  114. package/dist/sse/create-event-stream.js +48 -41
  115. package/dist/sse/create-event-stream.js.map +1 -1
  116. package/dist/sse/ticket-event-stream.cjs +1 -1
  117. package/dist/sse/ticket-event-stream.cjs.map +1 -1
  118. package/dist/sse/ticket-event-stream.js +48 -19
  119. package/dist/sse/ticket-event-stream.js.map +1 -1
  120. package/dist/sse/use-event-stream.cjs +1 -1
  121. package/dist/sse/use-event-stream.cjs.map +1 -1
  122. package/dist/sse/use-event-stream.js +19 -14
  123. package/dist/sse/use-event-stream.js.map +1 -1
  124. package/dist/sw/background-sync.cjs +1 -1
  125. package/dist/sw/background-sync.cjs.map +1 -1
  126. package/dist/sw/background-sync.js +10 -8
  127. package/dist/sw/background-sync.js.map +1 -1
  128. package/dist/sw/cache.cjs +1 -1
  129. package/dist/sw/cache.cjs.map +1 -1
  130. package/dist/sw/cache.js +111 -55
  131. package/dist/sw/cache.js.map +1 -1
  132. package/dist/sw/create-push-handler.cjs +1 -1
  133. package/dist/sw/create-push-handler.cjs.map +1 -1
  134. package/dist/sw/create-push-handler.js +20 -10
  135. package/dist/sw/create-push-handler.js.map +1 -1
  136. package/dist/sw.d.ts +65 -14
  137. package/dist/tabular/compact.cjs +1 -1
  138. package/dist/tabular/compact.cjs.map +1 -1
  139. package/dist/tabular/compact.js +35 -16
  140. package/dist/tabular/compact.js.map +1 -1
  141. package/dist/tabular/manifest.cjs +1 -1
  142. package/dist/tabular/manifest.cjs.map +1 -1
  143. package/dist/tabular/manifest.js +33 -15
  144. package/dist/tabular/manifest.js.map +1 -1
  145. package/dist/tabular.d.ts +11 -1
  146. package/dist/tempest-react-sdk.cjs +1 -1
  147. package/dist/tempest-react-sdk.d.ts +543 -70
  148. package/dist/tempest-react-sdk.js +224 -224
  149. package/dist/theme/initial-theme.cjs +4 -4
  150. package/dist/theme/initial-theme.cjs.map +1 -1
  151. package/dist/theme/initial-theme.js +8 -5
  152. package/dist/theme/initial-theme.js.map +1 -1
  153. package/dist/utils/compressed-storage.cjs +1 -1
  154. package/dist/utils/compressed-storage.cjs.map +1 -1
  155. package/dist/utils/compressed-storage.js +13 -7
  156. package/dist/utils/compressed-storage.js.map +1 -1
  157. package/dist/utils/objects.cjs +1 -1
  158. package/dist/utils/objects.cjs.map +1 -1
  159. package/dist/utils/objects.js +13 -7
  160. package/dist/utils/objects.js.map +1 -1
  161. package/dist/utils/safe-url.cjs +1 -1
  162. package/dist/utils/safe-url.cjs.map +1 -1
  163. package/dist/utils/safe-url.js +4 -1
  164. package/dist/utils/safe-url.js.map +1 -1
  165. package/dist/vite/base-url.cjs +1 -1
  166. package/dist/vite/base-url.cjs.map +1 -1
  167. package/dist/vite/base-url.js +5 -1
  168. package/dist/vite/base-url.js.map +1 -1
  169. package/dist/vite/tempest-pwa-dev-sw.cjs +1 -1
  170. package/dist/vite/tempest-pwa-dev-sw.cjs.map +1 -1
  171. package/dist/vite/tempest-pwa-dev-sw.js +96 -31
  172. package/dist/vite/tempest-pwa-dev-sw.js.map +1 -1
  173. package/dist/vite/tempest-pwa-manifest.cjs +2 -2
  174. package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
  175. package/dist/vite/tempest-pwa-manifest.js +32 -16
  176. package/dist/vite/tempest-pwa-manifest.js.map +1 -1
  177. package/dist/vite.cjs +1 -1
  178. package/dist/vite.d.ts +63 -12
  179. package/dist/vite.js +4 -4
  180. package/dist/webrtc/peer-mesh.cjs +1 -1
  181. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  182. package/dist/webrtc/peer-mesh.js +7 -1
  183. package/dist/webrtc/peer-mesh.js.map +1 -1
  184. package/dist/ws/create-web-socket.cjs +1 -1
  185. package/dist/ws/create-web-socket.cjs.map +1 -1
  186. package/dist/ws/create-web-socket.js +77 -61
  187. package/dist/ws/create-web-socket.js.map +1 -1
  188. package/dist/ws/use-web-socket.cjs +1 -1
  189. package/dist/ws/use-web-socket.cjs.map +1 -1
  190. package/dist/ws/use-web-socket.js +4 -3
  191. package/dist/ws/use-web-socket.js.map +1 -1
  192. package/package.json +1 -1
  193. package/template-pwa/src/pwa.tsx +42 -0
  194. package/template-pwa/src/sw.ts +5 -4
package/README.md CHANGED
@@ -159,7 +159,7 @@ Via `package.json`:
159
159
 
160
160
  Requires React `>=18` and Node `>=22.12` to build.
161
161
 
162
- **A bundler is assumed — outside one, load the shipped `.css` loader.** Every component module imports its own stylesheet (see [CSS import](#css-import)), and `.css` is not a format Node can load — `node -e "import('tempest-react-sdk')"` fails with `ERR_UNKNOWN_FILE_EXTENSION`. Vite, webpack, Rspack, Parcel and esbuild all resolve that import, and the SDK is client-side only, so no real app consumer is affected. For everything that is not a bundler — a script, a Jest suite, reading the surface by introspection — the package ships the loader: `node --import tempest-react-sdk/node-css-loader your-script.mjs` resolves every stylesheet to an empty module, for `import` and `require` both. A **Jest** suite can use it or the usual `moduleNameMapper: { "\\.css$": "<rootDir>/test/css-stub.js" }`. Vitest needs nothing.
162
+ **A bundler is assumed — outside one, load the shipped `.css` loader.** Every component module imports its own stylesheet (see [CSS import](#css-import)), and `.css` is not a format Node can load — `node -e "import('tempest-react-sdk')"` fails with `ERR_UNKNOWN_FILE_EXTENSION`. Vite, webpack, Rspack, Parcel and esbuild all resolve that import, and the SDK is client-side only, so no real app consumer is affected. For everything that is not a bundler — a script, a Jest suite, reading the surface by introspection — the package ships the loader: `node --import tempest-react-sdk/node-css-loader your-script.mjs` resolves every stylesheet to an empty module, for `import` and `require` both. A **Jest** suite can use it or the usual `moduleNameMapper: { "\\.css$": "<rootDir>/test/css-stub.js" }`. **Vitest** needs `tempestVitest()` from `tempest-react-sdk/vite`, because it hands every ESM package from `node_modules` to Node: `createViteConfig` already wires it, but a separate `vitest.config.ts` (or a plain `vite.config.ts`) must add it to `plugins`, or the first import of the SDK fails with `Unknown file extension ".css"` — see [Testing with Vitest](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/vite-config/#testing-with-vitest).
163
163
 
164
164
  ### Peer & bundled dependencies
165
165
 
@@ -276,7 +276,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
276
276
  | `router` _(dep: `react-router`)_ | `defineRoutes`, `AppRouter`, `RouteGuard`, + re-exports of React Router's whole client-side surface (`Link`, `NavLink`, `Outlet`, `useNavigate`, `useParams`, `useSearchParams`, `matchPath`, `generatePath`, `createBrowserRouter`, `useLoaderData`, …; its `Form` as `RouterForm`), types: `TempestRouteObject`, `RouterKind`, `AppRouterProps`, `RouteGuardProps` |
277
277
  | `store` _(dep: `zustand`)_ | `createStore`, `createSelectors`, types: `CreateStoreOptions`, `CreateStorePersistOptions`, `WithSelectors` |
278
278
  | `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
279
- | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestCsp` (writes a Content-Security-Policy whose `connect-src` is derived from the app's `VITE_*` URLs, enforced in dev too), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `TempestCspOptions`, `TempestCspPolicy`, `TempestCspSource`, `AppleSplashSpec`, `TempestVitePlugin` |
279
+ | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev` and bundles it into `vite build`), `tempestCsp` (writes a Content-Security-Policy whose `connect-src` is derived from the app's `VITE_*` URLs, enforced in dev too), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `TempestCspOptions`, `TempestCspPolicy`, `TempestCspSource`, `AppleSplashSpec`, `TempestVitePlugin` |
280
280
  | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
281
281
  | `sse` | `createEventStream`, `useEventStream` (`schema` valida cada frame — zod/valibot/arktype via Standard Schema — e `onParseError` para frame que não é JSON) |
282
282
  | `ws` | `createWebSocket` (handshake timeout, silence watchdog, jittered backoff, `navigator.onLine` suspend, close-code classification, `opened` promise), `useWebSocket`, `schema` + `onValidationError` (frame fora do contrato não é entregue; o `pong` do heartbeat continua saindo), `onParseError` (frame que não é JSON: descarta em vez de entregar `string` como `T`), `isRejectionCloseCode`, `HEARTBEAT_CLOSE_CODE` |
@@ -312,7 +312,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
312
312
  | `router` _(dep: `react-router`)_ | `defineRoutes`, `AppRouter`, `RouteGuard`, + re-exports of React Router's whole client-side surface (`Link`, `NavLink`, `Outlet`, `useNavigate`, `useParams`, `useSearchParams`, `matchPath`, `generatePath`, `createBrowserRouter`, `useLoaderData`, …; its `Form` as `RouterForm`), types: `TempestRouteObject`, `RouterKind`, `AppRouterProps`, `RouteGuardProps` |
313
313
  | `store` _(dep: `zustand`)_ | `createStore`, `createSelectors`, types: `CreateStoreOptions`, `CreateStorePersistOptions`, `WithSelectors` |
314
314
  | `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
315
- | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
315
+ | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev` and bundles it into `vite build`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
316
316
  | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
317
317
  | `sse` | `createEventStream`, `useEventStream` (`schema` valida cada frame — zod/valibot/arktype via Standard Schema — e `onParseError` para frame que não é JSON) |
318
318
  | `ws` | `createWebSocket` (handshake timeout, silence watchdog, jittered backoff, `navigator.onLine` suspend, close-code classification, `opened` promise), `useWebSocket`, `schema` + `onValidationError` (frame fora do contrato não é entregue; o `pong` do heartbeat continua saindo), `onParseError` (frame que não é JSON: descarta em vez de entregar `string` como `T`), `isRejectionCloseCode`, `HEARTBEAT_CLOSE_CODE` |
@@ -343,7 +343,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
343
343
 
344
344
  Full per-module docs are published as a bilingual MkDocs site on GitHub Pages — **[Português (BR)](https://mauriciobenjamin700.github.io/tempest-react-sdk/)** / **[English (US)](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/)** (one page per module + draw.io diagrams in [`docs/diagrams/`](./docs/diagrams)). The source markdown lives in [`docs/`](./docs) (PT-BR base files + `.en.md` translations).
345
345
 
346
- > **Local preview:** `pip install -r docs/requirements.txt && mkdocs serve` (the published site is built and deployed automatically by `.github/workflows/docs.yml`).
346
+ > **Local preview:** `pip install -r docs/requirements.txt && mkdocs serve` (the published site is built and pushed to `gh-pages` by `make docs-deploy`, from the maintainer's machine).
347
347
 
348
348
  A demo app exercising every module lives in [`examples/gallery`](./examples/gallery) — `cd examples/gallery && npm install && npm run dev`.
349
349
 
@@ -1321,7 +1321,7 @@ installNotificationClickHandler({
1321
1321
  });
1322
1322
  ```
1323
1323
 
1324
- The SDK does **not** ship the worker file or the bundler config — pair it with `vite-plugin-pwa` (`injectManifest`) or a separately bundled worker.
1324
+ The SDK does **not** ship the worker file — you write `src/sw.ts`. `tempestPwaDevSw()` from `tempest-react-sdk/vite` bundles it to `dist/sw.js` in `vite build` (and serves it in dev), and `tempestPwaManifest()` fails the build when the manifest would ship without a worker. See [Vite Config › PWA](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/vite-config/#pwa-the-service-worker-in-the-production-build).
1325
1325
 
1326
1326
  ### Audio playback recipe
1327
1327
 
@@ -1995,15 +1995,14 @@ Every gallery section maps 1-to-1 with a docs page — see [`docs/gallery.md`](.
1995
1995
 
1996
1996
  ## Release
1997
1997
 
1998
- Versioning is managed with [Changesets](https://github.com/changesets/changesets):
1998
+ Two commands, with the release PR's review in between:
1999
1999
 
2000
2000
  ```bash
2001
- npx changeset # describe the change (patch / minor / major)
2002
- npx changeset version # bump versions + update CHANGELOG.md
2003
- npm run release # build + changeset publish
2001
+ make release TAG=X.Y.Z # opens the release PR (no tag, no publish)
2002
+ make release-tag TAG=X.Y.Z # after the merge: build + smokes + npm publish + tag + GitHub Release
2004
2003
  ```
2005
2004
 
2006
- CI workflow `.github/workflows/release.yml` publishes to npm whenever a "Version Packages" PR merges into `main`. The first publish is manual — set the `NPM_TOKEN` secret in _Settings → Secrets and variables → Actions_ and run `npm publish` once.
2005
+ There is no GitHub Actions in this repository: `make release-tag` runs the ref guard, builds and smokes the merged commit, publishes the smoked tarball with the maintainer's npm credential (no provenance — that needs a CI runner's OIDC), pushes the tag and creates the GitHub Release. A bare `npm publish` is refused (`make publish` exits 1). Pipeline and the repository owner's settings checklist: [Release pipeline](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/release/).
2007
2006
 
2008
2007
  Tags follow `vMAJOR.MINOR.PATCH`. CSS tokens (`--tempest-*`) are treated as public API — renames bump the minor (or major, on removal).
2009
2008
 
@@ -49,6 +49,37 @@ function scaffoldHere() {
49
49
  return { status: result.status, stdout: result.stdout + result.stderr };
50
50
  }
51
51
 
52
+ describe("create-tempest-app new-folder mode refuses a destination that is a symlink (#533)", () => {
53
+ it("my-app -> ../outside is refused and nothing lands at the link target", () => {
54
+ symlinkSync("../outside", join(proj, "my-app"));
55
+
56
+ const result = spawnSync(process.execPath, [CLI, "my-app"], {
57
+ cwd: proj,
58
+ encoding: "utf8",
59
+ env: { ...process.env, NO_COLOR: "1" },
60
+ });
61
+
62
+ expect(result.status).toBe(1);
63
+ expect(result.stderr).toMatch(/"my-app" is a symbolic link/);
64
+ expect(readdirSync(outside)).toEqual([]);
65
+ expect(lstatSync(join(proj, "my-app")).isSymbolicLink()).toBe(true);
66
+ });
67
+
68
+ it("a plain new folder name is still scaffolded", () => {
69
+ const result = spawnSync(process.execPath, [CLI, "my-app"], {
70
+ cwd: proj,
71
+ encoding: "utf8",
72
+ env: { ...process.env, NO_COLOR: "1" },
73
+ });
74
+
75
+ expect(result.status).toBe(0);
76
+ expect(existsSync(join(proj, "my-app", "src", "main.tsx"))).toBe(true);
77
+ expect(JSON.parse(readFileSync(join(proj, "my-app", "package.json"), "utf8")).name).toBe(
78
+ "my-app",
79
+ );
80
+ });
81
+ });
82
+
52
83
  describe("create-tempest-app merge mode never writes through a symlink (#436)", () => {
53
84
  it("a symlinked src/ pointing outside the project is skipped, not written into", () => {
54
85
  writeFileSync(join(outside, "main.tsx"), "OUTSIDE-USER-FILE");
@@ -10,7 +10,8 @@
10
10
  // package.json has the Tempest scripts/deps merged in (your name/version are
11
11
  // preserved). A symlink already in the destination is never written through:
12
12
  // it is skipped and reported, so a link to a folder outside the project cannot
13
- // redirect the template there. The `--pwa` flag overlays the PWA template
13
+ // redirect the template there. A named destination that is itself a symlink is
14
+ // refused for the same reason. The `--pwa` flag overlays the PWA template
14
15
  // (manifest, service worker, push-subscribe wiring) on top of the base.
15
16
  import { cp, lstat, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
16
17
  import { existsSync } from "node:fs";
@@ -239,6 +240,15 @@ async function main() {
239
240
  process.exit(1);
240
241
  }
241
242
  destDir = resolve(process.cwd(), arg);
243
+ if (await isSymlink(destDir)) {
244
+ console.error(
245
+ `${c.red}✗ "${arg}" is a symbolic link; the project would be created where it points.${c.reset}`,
246
+ );
247
+ console.error(
248
+ `${c.dim} Create the project at the real path, or remove the link.${c.reset}`,
249
+ );
250
+ process.exit(1);
251
+ }
242
252
  if (!(await isEmptyDir(destDir))) {
243
253
  console.error(`${c.red}✗ Directory "${arg}" exists and is not empty.${c.reset}`);
244
254
  console.error(
@@ -98,6 +98,11 @@ describe("tempest doctor — a third-party project that does not use the SDK", (
98
98
  expect(out).not.toContain('add import "tempest-react-sdk/styles.css"');
99
99
  });
100
100
 
101
+ it("does not judge the Vitest config of a project without the SDK", () => {
102
+ installed("vitest", "4.1.12");
103
+ expect(doctor().out).not.toContain("inline the SDK");
104
+ });
105
+
101
106
  it("does not demand a src/main.tsx it has no reason to expect", () => {
102
107
  const { out } = doctor();
103
108
  expect(out).not.toContain("app entry");
@@ -336,6 +341,30 @@ describe("tempest doctor — a project that does use the SDK", () => {
336
341
  expect(doctor().out).toContain("leaflet missing");
337
342
  });
338
343
 
344
+ it("warns when Vitest would load the SDK without the plugin", () => {
345
+ installed("vitest", "4.1.12");
346
+ const { out, code } = doctor();
347
+ expect(out).toContain("vite.config.ts does not inline the SDK for Vitest");
348
+ expect(out).toContain("tempestVitest()");
349
+ expect(code).toBe(0);
350
+ });
351
+
352
+ it("accepts a vitest.config that adds tempestVitest", () => {
353
+ installed("vitest", "4.1.12");
354
+ write(
355
+ "vitest.config.ts",
356
+ 'import { tempestVitest } from "tempest-react-sdk/vite";\n' +
357
+ "export default defineConfig({ plugins: [tempestVitest()] });",
358
+ );
359
+ const { out } = doctor();
360
+ expect(out).toContain("vitest.config.ts lets Vitest load the SDK");
361
+ expect(out).not.toContain("does not inline the SDK");
362
+ });
363
+
364
+ it("says nothing about Vitest in a project that does not install it", () => {
365
+ expect(doctor().out).not.toContain("Vitest");
366
+ });
367
+
339
368
  it("warns when the SDK is installed but undeclared", () => {
340
369
  write("package.json", {
341
370
  name: "sdk-app",
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The Vitest config check for `tempest doctor`.
3
+ *
4
+ * Every SDK component module imports its own stylesheet, and Vitest hands an ESM
5
+ * package from `node_modules` to plain Node, which has no loader for `.css`. A
6
+ * consumer suite that imports the SDK therefore dies before running with
7
+ * `Unknown file extension ".css"` unless the config inlines the SDK — which is what
8
+ * `tempestVitest()` does, and what `createViteConfig` includes. Measured in #548
9
+ * with the 0.81.0 tarball on Vitest 4.1.12: an own `vitest.config.ts`, a plain
10
+ * `vite.config.ts` and no config at all fail; only the plugin passes.
11
+ *
12
+ * The check is a text heuristic over the config file Vitest would load, not an
13
+ * evaluation of it: running the user's config would execute their code and pull
14
+ * their plugins in, which a health check has no business doing. The markers are the
15
+ * plugin and helper names, an explicit `inline: true` (which inlines everything,
16
+ * the SDK included), and an inline list naming the package — the three shapes that
17
+ * make the suite pass.
18
+ */
19
+
20
+ /**
21
+ * Config file names Vitest looks for, in the order it prefers them.
22
+ *
23
+ * Ported from Vitest 4.1.12's `configFiles` (`CONFIG_NAMES` × `CONFIG_EXTENSIONS`
24
+ * in `dist/chunks/constants.*.js`, not exported): a `vitest.config.*` wins over a
25
+ * `vite.config.*`, and within each the extensions are tried in this order. The
26
+ * test reads the installed Vitest and fails when the list drifts.
27
+ */
28
+ export const VITEST_CONFIG_FILES = [
29
+ "vitest.config.ts",
30
+ "vitest.config.mts",
31
+ "vitest.config.cts",
32
+ "vitest.config.js",
33
+ "vitest.config.mjs",
34
+ "vitest.config.cjs",
35
+ "vite.config.ts",
36
+ "vite.config.mts",
37
+ "vite.config.cts",
38
+ "vite.config.js",
39
+ "vite.config.mjs",
40
+ "vite.config.cjs",
41
+ ];
42
+
43
+ /** Text that, once comments are gone, means the config already inlines the SDK. */
44
+ const INLINES_SDK = [
45
+ /\btempestVitest\b/,
46
+ /\bcreateViteConfig\b/,
47
+ /\binline\s*:\s*true\b/,
48
+ /\binline\s*:\s*\[[^\]]*["'`]tempest-react-sdk["'`]/,
49
+ ];
50
+
51
+ /** An import of the project's own `vite.config`, as `mergeConfig` setups do. */
52
+ const IMPORTS_VITE_CONFIG = /from\s*["']\.\/vite\.config(?:\.[cm]?[jt]s)?["']/;
53
+
54
+ /**
55
+ * Drop JavaScript comments so a marker that only appears in prose does not count.
56
+ *
57
+ * A line comment is only removed when `//` is not preceded by `:`, which keeps
58
+ * `https://` inside a string intact; a missed edge case here costs one false "ok",
59
+ * never a crash.
60
+ *
61
+ * @param {string} text - Source text of a config file.
62
+ * @returns {string} The text without block and line comments.
63
+ */
64
+ function stripComments(text) {
65
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
66
+ }
67
+
68
+ /**
69
+ * Whether a config file's text makes Vitest inline the SDK.
70
+ *
71
+ * @param {string} text - Source text of the config.
72
+ * @returns {boolean} Whether one of the passing shapes is present.
73
+ */
74
+ function inlinesSdk(text) {
75
+ const code = stripComments(text);
76
+ return INLINES_SDK.some((marker) => marker.test(code));
77
+ }
78
+
79
+ /**
80
+ * Audit whether the project's Vitest setup can import the SDK.
81
+ *
82
+ * Vitest loads the first `vitest.config.*`, else the first `vite.config.*`, else
83
+ * runs with defaults; this mirrors that choice and judges only the file it would
84
+ * load. A `vitest.config.*` that imports `./vite.config` (the `mergeConfig`
85
+ * pattern) passes when that `vite.config.*` does, so a project using
86
+ * `createViteConfig` there is not flagged.
87
+ *
88
+ * @param {object} params
89
+ * @param {boolean} params.vitestInstalled - Whether `vitest` resolves in the project.
90
+ * @param {Record<string, string>} params.configs - Text of each config file present,
91
+ * keyed by its name from {@link VITEST_CONFIG_FILES}.
92
+ * @returns {Array<[status: string, label: string, detail?: string]>} Check rows;
93
+ * empty when the project does not use Vitest.
94
+ */
95
+ export function checkVitest({ vitestInstalled, configs }) {
96
+ if (!vitestInstalled) return [];
97
+
98
+ const loaded = VITEST_CONFIG_FILES.find((name) => name in configs);
99
+ const fix =
100
+ "add `plugins: [tempestVitest()]` from tempest-react-sdk/vite, or build the config " +
101
+ "with createViteConfig — otherwise any test importing the SDK fails with " +
102
+ '`Unknown file extension ".css"`';
103
+
104
+ if (!loaded) {
105
+ return [["warn", "Vitest runs with no config", fix]];
106
+ }
107
+
108
+ const text = configs[loaded];
109
+ const viaViteConfig =
110
+ loaded.startsWith("vitest.") &&
111
+ IMPORTS_VITE_CONFIG.test(stripComments(text)) &&
112
+ VITEST_CONFIG_FILES.some(
113
+ (name) => name.startsWith("vite.") && name in configs && inlinesSdk(configs[name]),
114
+ );
115
+
116
+ if (inlinesSdk(text) || viaViteConfig) {
117
+ return [["ok", `${loaded} lets Vitest load the SDK`]];
118
+ }
119
+ return [["warn", `${loaded} does not inline the SDK for Vitest`, fix]];
120
+ }
@@ -0,0 +1,139 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { VITEST_CONFIG_FILES, checkVitest } from "./vitest.mjs";
7
+
8
+ const VITEST_CHUNKS = join(
9
+ dirname(fileURLToPath(import.meta.url)),
10
+ "..",
11
+ "..",
12
+ "..",
13
+ "node_modules",
14
+ "vitest",
15
+ "dist",
16
+ "chunks",
17
+ );
18
+
19
+ /** Statuses of the returned rows, in order. */
20
+ const statuses = (rows) => rows.map(([status]) => status);
21
+
22
+ /** Run the check for a project with Vitest and the given config files. */
23
+ const check = (configs) => checkVitest({ vitestInstalled: true, configs });
24
+
25
+ /**
26
+ * The config file list the installed Vitest really searches, read from its source.
27
+ *
28
+ * @returns {string[]} `CONFIG_NAMES` × `CONFIG_EXTENSIONS`, in Vitest's order.
29
+ */
30
+ function installedVitestConfigFiles() {
31
+ const file = readdirSync(VITEST_CHUNKS).find((name) => /^constants\..*\.js$/.test(name));
32
+ const text = readFileSync(join(VITEST_CHUNKS, file), "utf8");
33
+ const array = (name) =>
34
+ JSON.parse(
35
+ new RegExp(`const ${name} = (\\[[^\\]]*\\])`)
36
+ .exec(text)[1]
37
+ .replace(/\s+/g, "")
38
+ .replace(/,]/, "]"),
39
+ );
40
+ return array("CONFIG_NAMES").flatMap((name) =>
41
+ array("CONFIG_EXTENSIONS").map((ext) => name + ext),
42
+ );
43
+ }
44
+
45
+ describe("checkVitest — which file it judges", () => {
46
+ it("searches the same files, in the same order, as the installed Vitest", () => {
47
+ expect(VITEST_CONFIG_FILES).toEqual(installedVitestConfigFiles());
48
+ });
49
+
50
+ it("judges vitest.config over vite.config, the one Vitest loads", () => {
51
+ const rows = check({
52
+ "vite.config.ts": "export default createViteConfig();",
53
+ "vitest.config.ts": "export default defineConfig({ test: {} });",
54
+ });
55
+ expect(statuses(rows)).toEqual(["warn"]);
56
+ expect(rows[0][1]).toContain("vitest.config.ts");
57
+ });
58
+
59
+ it("falls back to vite.config when there is no vitest.config", () => {
60
+ const rows = check({ "vite.config.mjs": "export default defineConfig({});" });
61
+ expect(rows[0][1]).toContain("vite.config.mjs");
62
+ });
63
+ });
64
+
65
+ describe("checkVitest — setups that fail the suite", () => {
66
+ it("says nothing when the project does not use Vitest", () => {
67
+ expect(checkVitest({ vitestInstalled: false, configs: {} })).toEqual([]);
68
+ });
69
+
70
+ it("warns when Vitest would run with no config at all", () => {
71
+ const rows = check({});
72
+ expect(statuses(rows)).toEqual(["warn"]);
73
+ expect(rows[0][2]).toContain("tempestVitest()");
74
+ expect(rows[0][2]).toContain('Unknown file extension ".css"');
75
+ });
76
+
77
+ it("warns on a plain vitest.config without the plugin", () => {
78
+ const rows = check({
79
+ "vitest.config.ts":
80
+ 'import { defineConfig } from "vitest/config";\n' +
81
+ 'export default defineConfig({ test: { environment: "jsdom" } });',
82
+ });
83
+ expect(statuses(rows)).toEqual(["warn"]);
84
+ });
85
+
86
+ it("does not count the plugin named only in a comment", () => {
87
+ const rows = check({
88
+ "vitest.config.ts":
89
+ "// TODO: add tempestVitest()\n/* createViteConfig later */\n" +
90
+ "export default defineConfig({});",
91
+ });
92
+ expect(statuses(rows)).toEqual(["warn"]);
93
+ });
94
+
95
+ it("does not trust an import of a vite.config that lacks the plugin too", () => {
96
+ const rows = check({
97
+ "vite.config.ts": "export default defineConfig({});",
98
+ "vitest.config.ts":
99
+ 'import viteConfig from "./vite.config";\n' +
100
+ "export default mergeConfig(viteConfig, defineConfig({ test: {} }));",
101
+ });
102
+ expect(statuses(rows)).toEqual(["warn"]);
103
+ });
104
+ });
105
+
106
+ describe("checkVitest — setups that pass", () => {
107
+ it.each([
108
+ ["tempestVitest()", "export default defineConfig({ plugins: [tempestVitest()] });"],
109
+ ["createViteConfig", "export default createViteConfig({ overrides: {} });"],
110
+ [
111
+ "inline: true",
112
+ "export default defineConfig({ test: { server: { deps: { inline: true } } } });",
113
+ ],
114
+ [
115
+ "an inline list naming the SDK",
116
+ 'export default defineConfig({ test: { server: { deps: { inline: ["x", "tempest-react-sdk"] } } } });',
117
+ ],
118
+ ])("accepts %s", (_, text) => {
119
+ expect(statuses(check({ "vitest.config.ts": text }))).toEqual(["ok"]);
120
+ });
121
+
122
+ it("accepts a vitest.config merged over a vite.config that uses createViteConfig", () => {
123
+ const rows = check({
124
+ "vite.config.ts": "export default createViteConfig();",
125
+ "vitest.config.ts":
126
+ 'import viteConfig from "./vite.config.ts";\n' +
127
+ "export default mergeConfig(viteConfig, defineConfig({ test: {} }));",
128
+ });
129
+ expect(statuses(rows)).toEqual(["ok"]);
130
+ });
131
+
132
+ it("keeps a URL inside a string from hiding a marker after it", () => {
133
+ const rows = check({
134
+ "vitest.config.ts":
135
+ 'const docs = "https://example.com"; export default defineConfig({ plugins: [tempestVitest()] });',
136
+ });
137
+ expect(statuses(rows)).toEqual(["ok"]);
138
+ });
139
+ });
@@ -28,6 +28,13 @@ export function lstatOrNull(path) {
28
28
  * not skipped over: its `realpath` throws and the path counts as outside, since
29
29
  * `mkdir` or `writeFile` would create whatever the link names.
30
30
  *
31
+ * The answer is about the tree as it is now; nothing makes it and the write that
32
+ * follows one atomic step. A process swapping a folder for a symlink between the
33
+ * two can still redirect the write, and Node has no `openat`/`O_NOFOLLOW` walk
34
+ * to close that. Such a process is already writing inside the user's project, so
35
+ * it could edit the generated files or `package.json` directly: the window is an
36
+ * accepted risk, the same one `create-tempest-app` documents (#533).
37
+ *
31
38
  * @param {string} root - Project root.
32
39
  * @param {string} path - Absolute path, existing or about to be created.
33
40
  * @returns {boolean}
@@ -16,7 +16,11 @@
16
16
  * {@link assertNoCollision}, never silently merged;
17
17
  * - string position → `JSON.stringify` at the call site;
18
18
  * - doc comment → {@link commentText};
19
- * - URL template literal → {@link urlTemplate}.
19
+ * - URL template literal → {@link urlTemplate};
20
+ * - the developer's terminal → {@link terminalText}.
21
+ *
22
+ * One more check covers the text that becomes a URL rather than being escaped
23
+ * into one: {@link assertSafePath}.
20
24
  */
21
25
 
22
26
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
@@ -150,19 +154,79 @@ export function assertNoCollision(entries, what) {
150
154
  }
151
155
  }
152
156
 
157
+ /**
158
+ * C0 and C1 control characters except tab and line feed: everything a terminal
159
+ * may read as the start of an escape sequence (`ESC`, the 8-bit `CSI` and
160
+ * `OSC`), plus `CR` and backspace, which rewrite what is already on the line.
161
+ */
162
+ const TERMINAL_CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
163
+
164
+ /**
165
+ * Make spec text safe to print to the developer's terminal.
166
+ *
167
+ * `tempest gen api` echoes the spec's tag names, and its errors quote spec
168
+ * names. `JSON.stringify` escapes the C0 range but leaves the C1 range raw, so
169
+ * neither path was safe on its own: a tag carrying `ESC ] 0 ; ... BEL` retitled
170
+ * the terminal window, and an OSC 8 sequence printed a clickable link whose
171
+ * target the developer never saw (#533). Every control character a terminal
172
+ * acts on is dropped; tab and line feed are kept so a multi-line error still
173
+ * reads as one.
174
+ *
175
+ * @param {unknown} text - Text that may come from the spec.
176
+ * @returns {string} The text without terminal control characters.
177
+ */
178
+ export function terminalText(text) {
179
+ return String(text).replace(TERMINAL_CONTROLS, "");
180
+ }
181
+
153
182
  /**
154
183
  * Make spec text safe inside a `/** ... *\/` block comment.
155
184
  *
156
185
  * Line terminators collapse to a space, so the text cannot start a new line of
157
186
  * the comment, and every `*\/` is broken as `*\\/` so it cannot close it.
187
+ * Terminal control characters are dropped too ({@link terminalText}): they are
188
+ * not code, but printing the generated file with `cat` would replay them.
158
189
  *
159
190
  * @param {unknown} text - The spec text (tag, summary, path).
160
191
  * @returns {string} The neutralized single-line text.
161
192
  */
162
193
  export function commentText(text) {
163
- return String(text)
164
- .replace(/[\r\n\u2028\u2029]+/g, " ")
165
- .replace(/\*\//g, "*\\/");
194
+ return terminalText(String(text).replace(/[\r\n\u2028\u2029]+/g, " ")).replace(/\*\//g, "*\\/");
195
+ }
196
+
197
+ /**
198
+ * Throw when an OpenAPI path key would not stay a path under the client's
199
+ * `baseURL`.
200
+ *
201
+ * The key becomes the URL the generated method requests, and the client passes
202
+ * an absolute URL through untouched: a key `"https://other.example/x"` made the
203
+ * method call another origin \u2014 the Bearer is withheld there, but the body and,
204
+ * with `credentials: "include"`, the cookies were not. A static `.` or `..`
205
+ * segment (`"/../x"`) climbs out of the `baseURL`'s path prefix once the URL is
206
+ * normalized. OpenAPI requires a path key to start with `/` and to be relative
207
+ * to the server URL, so neither is a valid spec; both are refused before any
208
+ * file is written instead of becoming a call the developer never reads. `\` is
209
+ * a separator and `%2e` a dot here because the WHATWG URL parser normalizes them
210
+ * that way.
211
+ *
212
+ * @param {unknown} path - The key from `paths`.
213
+ * @throws {Error} When the key does not start with `/` or has a dot segment.
214
+ */
215
+ export function assertSafePath(path) {
216
+ if (typeof path !== "string" || !path.startsWith("/")) {
217
+ throw new Error(
218
+ `OpenAPI path ${JSON.stringify(path)} does not start with "/". A path key is relative to the server URL; an absolute URL here would send the request to another origin.`,
219
+ );
220
+ }
221
+ const dotSegment = path
222
+ .replace(/[?#][\s\S]*$/, "")
223
+ .split(/[/\\]/)
224
+ .some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment));
225
+ if (dotSegment) {
226
+ throw new Error(
227
+ `OpenAPI path ${JSON.stringify(path)} has a "." or ".." segment, which leaves the baseURL's path once the URL is normalized. Rename the path in the spec.`,
228
+ );
229
+ }
166
230
  }
167
231
 
168
232
  /**