tempest-react-sdk 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/create-tempest-app.e2e.test.mjs +112 -0
- package/bin/create-tempest-app.mjs +69 -28
- package/bin/lib/component/generate.mjs +191 -0
- package/bin/lib/component/generate.test.mjs +336 -0
- package/bin/lib/openapi/escape.mjs +210 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
- package/bin/lib/openapi/generate.mjs +93 -17
- package/bin/lib/openapi/schema-to-zod.mjs +41 -4
- package/bin/tempest.mjs +184 -3
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +127 -55
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +13 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +72 -62
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +12 -4
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +46 -38
- package/dist/components/Chat/ChatAttachments.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs +7 -5
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +151 -87
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Toast/Toast.module.cjs +1 -1
- package/dist/components/Toast/Toast.module.cjs.map +1 -1
- package/dist/components/Toast/Toast.module.js +7 -3
- package/dist/components/Toast/Toast.module.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +77 -51
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +32 -21
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +81 -73
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +39 -35
- package/dist/http/errors.js.map +1 -1
- package/dist/neural.cjs +2 -0
- package/dist/neural.cjs.map +1 -0
- package/dist/neural.d.ts +110 -0
- package/dist/neural.js +48 -0
- package/dist/neural.js.map +1 -0
- package/dist/oauth/callback.cjs +2 -0
- package/dist/oauth/callback.cjs.map +1 -0
- package/dist/oauth/callback.js +42 -0
- package/dist/oauth/callback.js.map +1 -0
- package/dist/oauth/use-oauth-callback.cjs +1 -1
- package/dist/oauth/use-oauth-callback.cjs.map +1 -1
- package/dist/oauth/use-oauth-callback.js +13 -20
- package/dist/oauth/use-oauth-callback.js.map +1 -1
- package/dist/push/use-push-subscription.cjs +1 -1
- package/dist/push/use-push-subscription.cjs.map +1 -1
- package/dist/push/use-push-subscription.js +42 -42
- package/dist/push/use-push-subscription.js.map +1 -1
- package/dist/push/web-push-client.cjs +1 -1
- package/dist/push/web-push-client.cjs.map +1 -1
- package/dist/push/web-push-client.js +23 -3
- package/dist/push/web-push-client.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +68 -23
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/styles/component/Toast.css +6 -1
- package/dist/styles/feedback.css +6 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +271 -60
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache-inspect.cjs.map +1 -1
- package/dist/sw/cache-inspect.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +91 -64
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/credential-headers.cjs +2 -0
- package/dist/sw/credential-headers.cjs.map +1 -0
- package/dist/sw/credential-headers.js +17 -0
- package/dist/sw/credential-headers.js.map +1 -0
- package/dist/sw/session-data.cjs +2 -0
- package/dist/sw/session-data.cjs.map +1 -0
- package/dist/sw/session-data.js +17 -0
- package/dist/sw/session-data.js.map +1 -0
- package/dist/sw.cjs +1 -1
- package/dist/sw.d.ts +271 -23
- package/dist/sw.js +7 -5
- package/dist/telemetry/app-error-owner.cjs +2 -0
- package/dist/telemetry/app-error-owner.cjs.map +1 -0
- package/dist/telemetry/app-error-owner.js +18 -0
- package/dist/telemetry/app-error-owner.js.map +1 -0
- package/dist/telemetry/app-error-queue.cjs +1 -1
- package/dist/telemetry/app-error-queue.cjs.map +1 -1
- package/dist/telemetry/app-error-queue.js +19 -14
- package/dist/telemetry/app-error-queue.js.map +1 -1
- package/dist/telemetry/app-error-reporter.cjs +1 -1
- package/dist/telemetry/app-error-reporter.cjs.map +1 -1
- package/dist/telemetry/app-error-reporter.js +75 -34
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/telemetry/app-error-transport.cjs +1 -1
- package/dist/telemetry/app-error-transport.cjs.map +1 -1
- package/dist/telemetry/app-error-transport.js +16 -3
- package/dist/telemetry/app-error-transport.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +979 -62
- package/dist/tempest-react-sdk.js +160 -156
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +16 -13
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/safe-url.cjs +2 -0
- package/dist/utils/safe-url.cjs.map +1 -0
- package/dist/utils/safe-url.js +39 -0
- package/dist/utils/safe-url.js.map +1 -0
- package/dist/vite/tempest-csp.cjs +2 -2
- package/dist/vite/tempest-csp.cjs.map +1 -1
- package/dist/vite/tempest-csp.js +118 -63
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +21 -5
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +2 -10
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +110 -72
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +11 -1
- package/template-pwa/src/pwa.tsx +22 -0
- package/template-pwa/src/sw.ts +27 -14
- package/dist/components/Markdown/markdown-url.cjs +0 -2
- package/dist/components/Markdown/markdown-url.cjs.map +0 -1
- package/dist/components/Markdown/markdown-url.js +0 -28
- package/dist/components/Markdown/markdown-url.js.map +0 -1
package/README.md
CHANGED
|
@@ -284,9 +284,10 @@ Every module is re-exported from the package root — `import { Button, useDebou
|
|
|
284
284
|
| `geo` _(opt. peer `leaflet`)_ | `haversineKm`, `pathLengthKm`, `bearingDeg`, `estimateTravel`, `createOSRMBackend`, `boundingBox`, `projectMercator`, `fitProjection`, `createPositionTracker`, `usePositionTracker`, `TrajectoryMap` — tile-free SVG map + trajectory tracking (no external/paid API; Leaflet is an opt-in, lazy-loaded tile layer for self-hosted tiles), types: `Coordinate`, `TrackPoint`, `TravelEstimate`, `TravelMode`, `GeoBounds`, `RoutingBackend` |
|
|
285
285
|
| `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
|
|
286
286
|
| `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
|
|
287
|
-
| `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync
|
|
287
|
+
| `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`, credentials never stored), `provideBackgroundSyncCredentials`, `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability), `clearSessionData`/`clearBackgroundSyncQueue` (logout cleanup) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
|
|
288
288
|
| `charts` _(subpath `tempest-react-sdk/charts`, opt. peer `recharts`)_ | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DEFAULT_CHART_COLORS` — themed recharts wrappers (recharts externalized; install it only if you use charts) |
|
|
289
289
|
| `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2065 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one of 46 balanced shards** (40 slugs each) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces — sharding by range rather than by initial letter took the worst fetch from 19.10 KB to 4.78 KB brotli. Unknown slug renders `fallback` and never throws; lucide's 258 deprecated aliases keep resolving |
|
|
290
|
+
| `neural` _(subpath `tempest-react-sdk/neural`, opt. peer `@sapphi-red/web-noise-suppressor`)_ | `isNeuralSuppressionSupported`, `prepareNeuralSuppressor`, `createNeuralSuppressor`, types `NeuralSuppressorAssets`, `NeuralSuppressor` — RNNoise noise suppression: a recurrent model in an `AudioWorklet` separates speech from non-speech on a 48 kHz context and hands back a denoised `MediaStreamTrack` to publish in place of the capture (peer externalized; install it only if you use neural) |
|
|
290
291
|
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
|
|
291
292
|
| `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
|
|
292
293
|
| `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
|
|
@@ -320,11 +321,12 @@ Every module is re-exported from the package root — `import { Button, useDebou
|
|
|
320
321
|
| `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
|
|
321
322
|
| `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2065 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one of 46 balanced shards** (40 slugs each) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces — sharding by range rather than by initial letter took the worst fetch from 19.10 KB to 4.78 KB brotli. Unknown slug renders `fallback` and never throws; lucide's 258 deprecated aliases keep resolving |
|
|
322
323
|
| `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
|
|
323
|
-
| `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync
|
|
324
|
+
| `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`, credentials never stored), `provideBackgroundSyncCredentials`, `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability), `clearSessionData`/`clearBackgroundSyncQueue` (logout cleanup) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
|
|
324
325
|
| `editor` _(subpath `tempest-react-sdk/editor`, opt. peers `@tiptap/react`+`@tiptap/starter-kit`)_ | `RichTextEditor` — themed tiptap v3 rich-text editor with toolbar (tiptap externalized; install it only if you use the editor) |
|
|
325
326
|
| `vision` _(subpath `tempest-react-sdk/vision`, opt. peer `onnxruntime-web`)_ | `Classifier`, `Detector`, `Segmenter`, `DetectClassify` (fused detect→classify pipeline, optionally with a segmentation stage) + `warmup()`, FP16 models, `cache` (Cache Storage), video/`VideoFrame` inputs, `raiseOnEmpty`/`NoDetectionsError`, `LetterboxPipeline`, `readFusionSpec` and results/labels/preprocess — on-device ONNX vision inference, vendored from `@mauriciobenjamin700/ort-vision-sdk-web` (onnxruntime-web externalized; install it only if you use vision). Plus browser capture primitives: `useCameraStream` (rear-camera `getUserMedia` + classified errors + `retry()`), `computeImageLuminance`/`isLuminanceAcceptable`/`LowLuminanceError`/`LUMINANCE_SAMPLE_MAX_EDGE` (BT.709 brightness), `useLiveLuminance` (live rAF sampling) |
|
|
326
327
|
| `tabular` _(subpath `tempest-react-sdk/tabular`, opt. peer `onnxruntime-web`)_ | `TabularPredictor`, `useTabularPredictor` — scikit-learn models exported by `tempest-fastapi-sdk`'s `export_sklearn_to_onnx`, running in the browser with no inference server. Offline by design: `fetchModelBytes`/`isModelCached`/`cacheModelBytes`/`clearModelCache` (Cache Storage, cache-first) plus `configureOrtAssets`/`ortAssetUrls`/`ORT_WASM_ASSETS` for the runtime's WebAssembly binaries, which ONNX Runtime Web fetches at session creation. Import the default `onnxruntime-web` entry — the WebGPU build has no `ai.onnx.ml` kernels |
|
|
327
328
|
| `imaging` _(subpath `tempest-react-sdk/imaging`, no deps)_ | `resizeImage`, `cropImage`, `rotateImage`, `flipImage`, `compressToTarget` (binary-searches quality into a byte budget), `createThumbnails` (several sizes from one decode), `captureFrame` (one frame out of a `<video>`, at the instant you ask for), `decodeImage`/`readImageInfo`, `encodeImage`, `supportsImageType`/`bestSupportedType`, plus `useImagePreview`/`useImageProcessing`. Canvas only — decodes with EXIF orientation applied (phone photos arrive upright), reports the format actually produced (asking for AVIF where there is no encoder silently returns PNG), and re-encoding drops EXIF, which is the privacy step as much as the bandwidth one. `captureFrame` is the one with teeth: `seeked` does not mean the new frame is readable, so the naive seek-then-draw returns the neighbouring frame with no error — and a `MediaRecorder` blob reports no duration until it is probed for one, which it does for you |
|
|
329
|
+
| `neural` _(subpath `tempest-react-sdk/neural`, opt. peer `@sapphi-red/web-noise-suppressor`)_ | `isNeuralSuppressionSupported`, `prepareNeuralSuppressor`, `createNeuralSuppressor`, types `NeuralSuppressorAssets`, `NeuralSuppressor` — RNNoise noise suppression: a recurrent model in an `AudioWorklet` separates speech from non-speech on a 48 kHz context and hands back a denoised `MediaStreamTrack` to publish in place of the capture (peer externalized; install it only if you use neural) |
|
|
328
330
|
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
|
|
329
331
|
| `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
|
|
330
332
|
| `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "create-tempest-app.mjs");
|
|
20
|
+
|
|
21
|
+
let root;
|
|
22
|
+
let proj;
|
|
23
|
+
let outside;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
root = mkdtempSync(join(tmpdir(), "cta-"));
|
|
27
|
+
proj = join(root, "proj");
|
|
28
|
+
outside = join(root, "outside");
|
|
29
|
+
mkdirSync(proj);
|
|
30
|
+
mkdirSync(outside);
|
|
31
|
+
writeFileSync(join(proj, "package.json"), JSON.stringify({ name: "mine" }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(root, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `create-tempest-app .` in `proj` (merge mode) and return its output.
|
|
40
|
+
*
|
|
41
|
+
* @returns {{ status: number | null, stdout: string }}
|
|
42
|
+
*/
|
|
43
|
+
function scaffoldHere() {
|
|
44
|
+
const result = spawnSync(process.execPath, [CLI, "."], {
|
|
45
|
+
cwd: proj,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
48
|
+
});
|
|
49
|
+
return { status: result.status, stdout: result.stdout + result.stderr };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("create-tempest-app merge mode never writes through a symlink (#436)", () => {
|
|
53
|
+
it("a symlinked src/ pointing outside the project is skipped, not written into", () => {
|
|
54
|
+
writeFileSync(join(outside, "main.tsx"), "OUTSIDE-USER-FILE");
|
|
55
|
+
symlinkSync("../outside", join(proj, "src"));
|
|
56
|
+
|
|
57
|
+
const { status, stdout } = scaffoldHere();
|
|
58
|
+
|
|
59
|
+
expect(status).toBe(0);
|
|
60
|
+
expect(readFileSync(join(outside, "main.tsx"), "utf8")).toBe("OUTSIDE-USER-FILE");
|
|
61
|
+
expect(readdirSync(outside)).toEqual(["main.tsx"]);
|
|
62
|
+
expect(stdout).toMatch(/· src\b/);
|
|
63
|
+
expect(lstatSync(join(proj, "src")).isSymbolicLink()).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("a symlinked nested directory is skipped while its siblings are still written", () => {
|
|
67
|
+
mkdirSync(join(proj, "src"));
|
|
68
|
+
symlinkSync("../../outside", join(proj, "src", "pages"));
|
|
69
|
+
|
|
70
|
+
const { status, stdout } = scaffoldHere();
|
|
71
|
+
|
|
72
|
+
expect(status).toBe(0);
|
|
73
|
+
expect(readdirSync(outside)).toEqual([]);
|
|
74
|
+
expect(stdout).toMatch(/· src[/\\]pages\b/);
|
|
75
|
+
expect(existsSync(join(proj, "src", "main.tsx"))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a symlinked package.json is not merged through", () => {
|
|
79
|
+
rmSync(join(proj, "package.json"));
|
|
80
|
+
writeFileSync(join(outside, "package.json"), '{"name":"outside"}');
|
|
81
|
+
symlinkSync("../outside/package.json", join(proj, "package.json"));
|
|
82
|
+
|
|
83
|
+
const { status, stdout } = scaffoldHere();
|
|
84
|
+
|
|
85
|
+
expect(status).toBe(0);
|
|
86
|
+
expect(readFileSync(join(outside, "package.json"), "utf8")).toBe('{"name":"outside"}');
|
|
87
|
+
expect(stdout).toMatch(/· package\.json\b/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("leaves the user's own _gitignore alone instead of renaming it over .gitignore", () => {
|
|
91
|
+
mkdirSync(join(proj, "notes"));
|
|
92
|
+
writeFileSync(join(proj, "notes", "_gitignore"), "user-underscore");
|
|
93
|
+
writeFileSync(join(proj, "notes", ".gitignore"), "user-dot");
|
|
94
|
+
|
|
95
|
+
const { status } = scaffoldHere();
|
|
96
|
+
|
|
97
|
+
expect(status).toBe(0);
|
|
98
|
+
expect(readFileSync(join(proj, "notes", "_gitignore"), "utf8")).toBe("user-underscore");
|
|
99
|
+
expect(readFileSync(join(proj, "notes", ".gitignore"), "utf8")).toBe("user-dot");
|
|
100
|
+
expect(existsSync(join(proj, ".gitignore"))).toBe(true);
|
|
101
|
+
expect(existsSync(join(proj, "_gitignore"))).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("still scaffolds the full template into a plain directory", () => {
|
|
105
|
+
const { status } = scaffoldHere();
|
|
106
|
+
|
|
107
|
+
expect(status).toBe(0);
|
|
108
|
+
expect(existsSync(join(proj, "src", "main.tsx"))).toBe(true);
|
|
109
|
+
expect(existsSync(join(proj, ".env.example"))).toBe(true);
|
|
110
|
+
expect(JSON.parse(readFileSync(join(proj, "package.json"), "utf8")).name).toBe("mine");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
//
|
|
9
9
|
// In merge mode, files the user already has are left untouched and an existing
|
|
10
10
|
// package.json has the Tempest scripts/deps merged in (your name/version are
|
|
11
|
-
// preserved).
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// preserved). A symlink already in the destination is never written through:
|
|
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
|
|
14
|
+
// (manifest, service worker, push-subscribe wiring) on top of the base.
|
|
15
|
+
import { cp, lstat, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
14
16
|
import { existsSync } from "node:fs";
|
|
15
|
-
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
18
|
import { fileURLToPath } from "node:url";
|
|
17
19
|
|
|
18
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -86,23 +88,73 @@ async function readSdkVersion() {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether `path` exists in the destination as a symbolic link.
|
|
93
|
+
*
|
|
94
|
+
* Checked with `lstat`, which does not follow the link: `mkdir(recursive)` and
|
|
95
|
+
* `cp` both would, and that is how a `src -> ../elsewhere` link turned a merge
|
|
96
|
+
* into writes outside the project (#436).
|
|
97
|
+
*
|
|
98
|
+
* @param {string} path - Absolute destination path.
|
|
99
|
+
* @returns {Promise<boolean>}
|
|
100
|
+
*/
|
|
101
|
+
async function isSymlink(path) {
|
|
102
|
+
try {
|
|
103
|
+
return (await lstat(path)).isSymbolicLink();
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether the real location of `path`'s parent directory sits inside `root`.
|
|
111
|
+
*
|
|
112
|
+
* Second line behind {@link isSymlink}: it catches a link that appears in an
|
|
113
|
+
* ancestor the copy did not create itself. A folder whose name merely starts
|
|
114
|
+
* with two dots (`..cache`) is inside; only `..` itself or a `../` prefix is not.
|
|
115
|
+
*
|
|
116
|
+
* Both checks run immediately before each `mkdir`/`cp`, but nothing makes the
|
|
117
|
+
* check and the write one atomic step: a process that swaps a folder for a
|
|
118
|
+
* symlink in that window can still redirect the write. Node offers no
|
|
119
|
+
* `openat`/`O_NOFOLLOW` walk to close it, and the attacker would need to be
|
|
120
|
+
* racing the scaffold inside the user's own project folder, so this residual
|
|
121
|
+
* TOCTOU window is an accepted risk.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} realRoot - `realpath` of the destination root.
|
|
124
|
+
* @param {string} path - Absolute destination path about to be written.
|
|
125
|
+
* @returns {Promise<boolean>}
|
|
126
|
+
*/
|
|
127
|
+
async function parentInside(realRoot, path) {
|
|
128
|
+
const parent = await realpath(dirname(path));
|
|
129
|
+
const rel = relative(realRoot, parent);
|
|
130
|
+
return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
/**
|
|
90
134
|
* Copy a template tree into `destDir`. `package.json` is handled separately;
|
|
91
135
|
* any path already present in `protect` (the user's own files) is skipped and
|
|
92
|
-
* recorded
|
|
136
|
+
* recorded, and so is any symlink in the destination, which is never written
|
|
137
|
+
* through. Everything else is overwritten, so a later overlay wins over the
|
|
93
138
|
* base it sits on.
|
|
139
|
+
*
|
|
140
|
+
* Template entries are written straight under their final name
|
|
141
|
+
* (`_gitignore` → `.gitignore`), so no rename pass over the destination is
|
|
142
|
+
* needed; the one that existed renamed the user's own `_gitignore` files too.
|
|
94
143
|
*/
|
|
95
|
-
async function copyTree(srcDir, destDir, protect, skipped, relBase = "") {
|
|
144
|
+
async function copyTree(srcDir, destDir, protect, skipped, realRoot, relBase = "") {
|
|
96
145
|
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
97
146
|
for (const entry of entries) {
|
|
98
147
|
const rel = join(relBase, finalName(entry.name));
|
|
99
148
|
const from = join(srcDir, entry.name);
|
|
100
149
|
const to = join(destDir, finalName(entry.name));
|
|
150
|
+
if (entry.name === "package.json" && !entry.isDirectory()) continue;
|
|
151
|
+
if ((await isSymlink(to)) || !(await parentInside(realRoot, to))) {
|
|
152
|
+
skipped.add(rel);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
101
155
|
if (entry.isDirectory()) {
|
|
102
156
|
await mkdir(to, { recursive: true });
|
|
103
|
-
await copyTree(from, to, protect, skipped, rel);
|
|
104
|
-
} else if (entry.name === "package.json") {
|
|
105
|
-
// handled separately by writeFreshPackageJson / mergePackageJson
|
|
157
|
+
await copyTree(from, to, protect, skipped, realRoot, rel);
|
|
106
158
|
} else if (protect.has(rel)) {
|
|
107
159
|
skipped.add(rel);
|
|
108
160
|
} else {
|
|
@@ -111,19 +163,6 @@ async function copyTree(srcDir, destDir, protect, skipped, relBase = "") {
|
|
|
111
163
|
}
|
|
112
164
|
}
|
|
113
165
|
|
|
114
|
-
/** Recursively rename underscore-prefixed dotfiles after a directory copy. */
|
|
115
|
-
async function fixDotfiles(dir) {
|
|
116
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
117
|
-
for (const entry of entries) {
|
|
118
|
-
const full = join(dir, entry.name);
|
|
119
|
-
if (entry.isDirectory()) {
|
|
120
|
-
await fixDotfiles(full);
|
|
121
|
-
} else if (RENAME_ON_COPY[entry.name]) {
|
|
122
|
-
await rename(full, join(dir, RENAME_ON_COPY[entry.name]));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
166
|
/** Stamp the SDK version into a fresh package.json. */
|
|
128
167
|
async function writeFreshPackageJson(destDir, name, sdkVersion) {
|
|
129
168
|
const pkg = JSON.parse(await readFile(join(TEMPLATE_DIR, "package.json"), "utf8"));
|
|
@@ -216,20 +255,22 @@ async function main() {
|
|
|
216
255
|
const protect = await listFiles(destDir);
|
|
217
256
|
const hadPkg = protect.has("package.json");
|
|
218
257
|
const skipped = new Set();
|
|
258
|
+
const realRoot = await realpath(destDir);
|
|
219
259
|
|
|
220
|
-
await copyTree(TEMPLATE_DIR, destDir, protect, skipped);
|
|
221
|
-
if (pwa) await copyTree(TEMPLATE_PWA_DIR, destDir, protect, skipped);
|
|
222
|
-
await fixDotfiles(destDir);
|
|
260
|
+
await copyTree(TEMPLATE_DIR, destDir, protect, skipped, realRoot);
|
|
261
|
+
if (pwa) await copyTree(TEMPLATE_PWA_DIR, destDir, protect, skipped, realRoot);
|
|
223
262
|
|
|
224
|
-
|
|
225
|
-
if (
|
|
263
|
+
const pkgIsLink = await isSymlink(join(destDir, "package.json"));
|
|
264
|
+
if (pkgIsLink) {
|
|
265
|
+
skipped.add("package.json");
|
|
266
|
+
} else if (hadPkg) {
|
|
226
267
|
await mergePackageJson(destDir, sdkVersion);
|
|
227
268
|
console.log(`${c.dim}Merged scripts + deps into existing package.json.${c.reset}`);
|
|
228
269
|
} else {
|
|
229
270
|
const name = mergeMode ? (destDir.split("/").pop() || "tempest-app").toLowerCase() : arg;
|
|
230
271
|
await writeFreshPackageJson(destDir, name, sdkVersion);
|
|
231
272
|
}
|
|
232
|
-
if (pwa) await applyPwaPackageJson(destDir);
|
|
273
|
+
if (pwa && !pkgIsLink) await applyPwaPackageJson(destDir);
|
|
233
274
|
|
|
234
275
|
if (skipped.size) {
|
|
235
276
|
console.log(`\n${c.yellow}Skipped ${skipped.size} existing file(s):${c.reset}`);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold for `tempest gen component <Name>`: one folder per component with
|
|
3
|
+
* `index.tsx`, `styles.module.css`, `constants.ts`, `utils.ts` and `hooks.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Pure: takes a name, returns `{ name, files }`. The CLI does the I/O. The output
|
|
6
|
+
* is written to pass the app template's own gates as generated — `tsc` under its
|
|
7
|
+
* `strict` + `noUnusedLocals` tsconfig and ESLint under its `eslint.config.js`
|
|
8
|
+
* (sorted imports, type-only imports, no unused code) — and follows the
|
|
9
|
+
* template's Prettier config (double quotes, two-space indent, trailing commas).
|
|
10
|
+
* Every file is wired into `index.tsx`, so none starts out as dead code.
|
|
11
|
+
*
|
|
12
|
+
* `hooks.ts` reaches React through `import * as React` rather than a named
|
|
13
|
+
* import, so no generated name can ever redeclare what the file imports. The
|
|
14
|
+
* hook is `use<Name>`, or `use<Name>Component` when that would be a React
|
|
15
|
+
* built-in (see {@link REACT_BUILTIN_HOOKS}).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const WORD = /[A-Za-z0-9]+/g;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Folder names Windows reserves for devices, matched case-insensitively. A
|
|
22
|
+
* folder called `Con` or `Lpt1` cannot be created or cloned there, so a project
|
|
23
|
+
* with one breaks for every Windows teammate.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* React's built-in hook names. Ported from the `react` and `react-dom` hook
|
|
27
|
+
* exports (React 19). A component whose `use<Name>` lands on one of them gets
|
|
28
|
+
* `use<Name>Component` instead: the `react-hooks` lint rules match hooks by name,
|
|
29
|
+
* so a custom `useMemo(title)` would be linted as React's and warned about.
|
|
30
|
+
*/
|
|
31
|
+
export const REACT_BUILTIN_HOOKS = new Set([
|
|
32
|
+
"use",
|
|
33
|
+
"useActionState",
|
|
34
|
+
"useCallback",
|
|
35
|
+
"useContext",
|
|
36
|
+
"useDebugValue",
|
|
37
|
+
"useDeferredValue",
|
|
38
|
+
"useEffect",
|
|
39
|
+
"useEffectEvent",
|
|
40
|
+
"useFormStatus",
|
|
41
|
+
"useId",
|
|
42
|
+
"useImperativeHandle",
|
|
43
|
+
"useInsertionEffect",
|
|
44
|
+
"useLayoutEffect",
|
|
45
|
+
"useMemo",
|
|
46
|
+
"useOptimistic",
|
|
47
|
+
"useReducer",
|
|
48
|
+
"useRef",
|
|
49
|
+
"useState",
|
|
50
|
+
"useSyncExternalStore",
|
|
51
|
+
"useTransition",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a component name to PascalCase.
|
|
58
|
+
*
|
|
59
|
+
* Words are split on any non-alphanumeric character and on lower→upper case
|
|
60
|
+
* boundaries, so `"user card"`, `"user-card"`, `"user_card"`, `"userCard"` and
|
|
61
|
+
* `"UserCard"` all become `UserCard`. A word already in capitals (`"API"`) keeps
|
|
62
|
+
* them after its first letter: `"api client"` → `ApiClient`, `"APIClient"` →
|
|
63
|
+
* `APIClient`.
|
|
64
|
+
*
|
|
65
|
+
* @param {unknown} raw - The name as typed on the command line.
|
|
66
|
+
* @returns {string} The PascalCase component name.
|
|
67
|
+
* @throws {Error} When the name is empty, has characters other than ASCII
|
|
68
|
+
* letters, digits, spaces, `-` and `_`, does not start with a letter, or is a
|
|
69
|
+
* device name Windows reserves (`Con`, `Nul`, `Com1`…).
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeComponentName(raw) {
|
|
72
|
+
const input = typeof raw === "string" ? raw.trim() : "";
|
|
73
|
+
if (!input) {
|
|
74
|
+
throw new Error("Missing component name. Usage: tempest gen component <Name>");
|
|
75
|
+
}
|
|
76
|
+
if (!/^[A-Za-z0-9 _-]+$/.test(input)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Invalid component name ${JSON.stringify(input)}: use ASCII letters, digits, spaces, "-" or "_".`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!/^[A-Za-z]/.test(input)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Invalid component name ${JSON.stringify(input)}: it must start with a letter.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const words = input.replace(/([a-z0-9])([A-Z])/g, "$1 $2").match(WORD) ?? [];
|
|
87
|
+
const name = words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
88
|
+
if (WINDOWS_RESERVED.test(name)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Invalid component name ${JSON.stringify(input)}: "${name}" is a reserved device name on Windows and cannot be a folder there.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Turn a PascalCase name into `UPPER_SNAKE_CASE` (`UserCard` → `USER_CARD`).
|
|
98
|
+
*
|
|
99
|
+
* @param {string} name - A PascalCase name.
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function upperSnake(name) {
|
|
103
|
+
return name
|
|
104
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
105
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
106
|
+
.toUpperCase();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the five files of a component folder.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} rawName - The name as typed; normalized with
|
|
113
|
+
* {@link normalizeComponentName}.
|
|
114
|
+
* @returns {{ name: string, files: Record<string, string> }} The PascalCase name
|
|
115
|
+
* (also the folder name) and a `{ fileName: contents }` map.
|
|
116
|
+
* @throws {Error} When the name is invalid.
|
|
117
|
+
*/
|
|
118
|
+
export function componentFiles(rawName) {
|
|
119
|
+
const name = normalizeComponentName(rawName);
|
|
120
|
+
const constant = `${upperSnake(name)}_DEFAULT_TITLE`;
|
|
121
|
+
const hook = REACT_BUILTIN_HOOKS.has(`use${name}`) ? `use${name}Component` : `use${name}`;
|
|
122
|
+
const props = `${name}Props`;
|
|
123
|
+
|
|
124
|
+
const index = [
|
|
125
|
+
`import { ${hook} } from "./hooks";`,
|
|
126
|
+
`import styles from "./styles.module.css";`,
|
|
127
|
+
`import { joinClassNames } from "./utils";`,
|
|
128
|
+
``,
|
|
129
|
+
`export interface ${props} {`,
|
|
130
|
+
` title?: string;`,
|
|
131
|
+
` className?: string;`,
|
|
132
|
+
`}`,
|
|
133
|
+
``,
|
|
134
|
+
`export function ${name}({ title, className }: ${props}) {`,
|
|
135
|
+
` const { heading } = ${hook}(title);`,
|
|
136
|
+
``,
|
|
137
|
+
` return (`,
|
|
138
|
+
` <section className={joinClassNames(styles.root, className)}>`,
|
|
139
|
+
` <h2 className={styles.title}>{heading}</h2>`,
|
|
140
|
+
` </section>`,
|
|
141
|
+
` );`,
|
|
142
|
+
`}`,
|
|
143
|
+
``,
|
|
144
|
+
].join("\n");
|
|
145
|
+
|
|
146
|
+
const css = [
|
|
147
|
+
`.root {`,
|
|
148
|
+
` display: flex;`,
|
|
149
|
+
` flex-direction: column;`,
|
|
150
|
+
` gap: var(--tempest-space-2);`,
|
|
151
|
+
`}`,
|
|
152
|
+
``,
|
|
153
|
+
`.title {`,
|
|
154
|
+
` margin: 0;`,
|
|
155
|
+
`}`,
|
|
156
|
+
``,
|
|
157
|
+
].join("\n");
|
|
158
|
+
|
|
159
|
+
const constants = [`export const ${constant} = ${JSON.stringify(name)};`, ``].join("\n");
|
|
160
|
+
|
|
161
|
+
const utils = [
|
|
162
|
+
`export function joinClassNames(...names: Array<string | false | null | undefined>): string {`,
|
|
163
|
+
` return names.filter(Boolean).join(" ");`,
|
|
164
|
+
`}`,
|
|
165
|
+
``,
|
|
166
|
+
].join("\n");
|
|
167
|
+
|
|
168
|
+
const hooks = [
|
|
169
|
+
`import * as React from "react";`,
|
|
170
|
+
``,
|
|
171
|
+
`import { ${constant} } from "./constants";`,
|
|
172
|
+
``,
|
|
173
|
+
`export function ${hook}(title?: string): { heading: string } {`,
|
|
174
|
+
` const heading = React.useMemo(() => title?.trim() || ${constant}, [title]);`,
|
|
175
|
+
``,
|
|
176
|
+
` return { heading };`,
|
|
177
|
+
`}`,
|
|
178
|
+
``,
|
|
179
|
+
].join("\n");
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
name,
|
|
183
|
+
files: {
|
|
184
|
+
"index.tsx": index,
|
|
185
|
+
"styles.module.css": css,
|
|
186
|
+
"constants.ts": constants,
|
|
187
|
+
"utils.ts": utils,
|
|
188
|
+
"hooks.ts": hooks,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|