diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 033ff05..efa55de 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,15 +27,24 @@ npm run build --workspace=@localess/react npm run build --workspace=@localess/cli ``` -All packages use `tsup` for building with the command: +All packages use `tsup` for building: +- `@localess/client` and `@localess/react`: `tsup src/index.ts --format cjs,esm --dts` → `dist/index.js`, `dist/index.mjs`, `dist/index.d.ts` +- `@localess/cli`: `tsup src/index.ts --format cjs,esm --dts --shims` → ESM only (`dist/index.mjs`), `--shims` adds Node.js polyfills for the CLI executable + +## Test Commands + +Tests only exist in `@localess/cli`. Run from the workspace root: + ```bash -tsup src/index.ts --format cjs,esm --dts -``` +# Run all CLI tests +npm test --workspace=@localess/cli -This generates: -- `dist/index.js` (CommonJS) -- `dist/index.mjs` (ESM) -- `dist/index.d.ts` (TypeScript declarations) +# Run a single test file +npx vitest run packages/cli/src/commands/login/login.test.ts + +# Run tests in watch mode +npx vitest --workspace=@localess/cli +``` ### Requirements @@ -140,8 +149,8 @@ The component: ```tsx const MyComponent = ({ data, links }) => { return ( -
-

{data.title}

+
+

{data.title}

{data.children?.map(child => ( ))} @@ -151,9 +160,10 @@ const MyComponent = ({ data, links }) => { ``` **Visual Editor Integration**: -- `llEditable(content)` - Adds `data-ll-id` and `data-ll-schema` attributes to root element -- `llEditableField(fieldName)` - Adds `data-ll-field` attribute to specific fields +- `localessEditable(content)` - Adds `data-ll-id` and `data-ll-schema` attributes to root element +- `localessEditableField(fieldName)` - Adds `data-ll-field` attribute to specific fields; generic type `T` restricts `fieldName` to valid content keys - Only applied when `enableSync: true` +- `llEditable` / `llEditableField` are deprecated aliases — prefer `localessEditable` / `localessEditableField` - Listen to editor events: `window.localess.on(['input', 'change'], callback)` **Rich Text Rendering**: @@ -180,9 +190,16 @@ resolveAsset(asset) // Returns: {origin}/api/v1/spaces/{spaceId}/assets/{uri} ### CLI Package (@localess/cli) -**Status**: Early development - only `login` command implemented. +**Status**: Early development (v0.0.6). Built with Commander.js. Entry point: `src/index.ts` with shebang `#!/usr/bin/env node`. -Built with Commander.js. Entry point: `src/index.ts` with shebang `#!/usr/bin/env node`. +**Implemented commands**: +- `login --origin --space --token ` — persists credentials to `.localess/credentials.json` +- `logout` — clears stored credentials +- `translations push --path [--format flat|nested] [--type add-missing|replace]` +- `translations pull --path [--format flat|nested]` +- `types generate [--path ]` — generates TypeScript types from OpenAPI schema (default output: `.localess/localess.d.ts`) + +**CLI-specific client** (`src/client.ts`) extends the base client with retry logic (`fetchWithRetry`, 3 retries/500ms delay on 5xx), `getSpace()`, `updateTranslations()`, and `getOpenApi()`. Uses `zod` for input validation and `openapi-typescript` for type generation. ## Content Data Model @@ -220,10 +237,10 @@ components: { ### Editable Attributes Pattern -When `enableSync: true`, always spread editable attributes on the root element and field elements: +When `enableSync: true`, always spread editable attributes on the root element and field elements. Use the preferred (non-deprecated) API: ```tsx -
-

{data.title}

+
+

{data.title}

``` @@ -251,8 +268,9 @@ All packages use dual exports (CJS + ESM): ### Version Management Packages follow semantic versioning: -- `@localess/client` and `@localess/react`: v0.9.2 -- `@localess/cli`: v0.0.1 (early stage) +- `@localess/client`: v0.9.3 +- `@localess/react`: v0.9.5 +- `@localess/cli`: v0.0.6 (early stage) ## Common Patterns @@ -288,16 +306,17 @@ useEffect(() => { } } window.localess.on(['input', 'change'], handler) - return () => window.localess.off(['input', 'change'], handler) } }, []) ``` +Note: `window.localess` only provides `.on()` and `.onChange()` — there is no `.off()` method. + ### Nested Components Pattern Content often has nested structures. Use recursive `LocalessComponent`: ```tsx -
+
{data.sections?.map(section => ( ))} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8597416..b5df62e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,13 +12,13 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x] + node-version: [20.x, 22.x, 24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: 'npm' diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml index 2b0a7c6..2bdbcb9 100644 --- a/.github/workflows/dev-publish.yml +++ b/.github/workflows/dev-publish.yml @@ -8,10 +8,10 @@ jobs: publish-snapshot: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Use Node.js 20.x - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20.x registry-url: 'https://registry.npmjs.org/' @@ -20,26 +20,35 @@ jobs: - name: Install dependencies run: npm ci - - name: Build all packages - run: npm run build - - - name: Set snapshot version for all packages + - name: Set snapshot version from root package.json run: | DATE=$(date +"%Y%m%d%H%M%S") - for pkg in packages/*/package.json; do - jq ".version |= sub("-dev\\.[0-9]+"; "") | .version += "-dev.$DATE"" $pkg > tmp.$$.json && mv tmp.$$.json $pkg + VERSION="$(node -p "require('./package.json').version")-dev.${DATE}" + echo "Publishing snapshot version: $VERSION" + for pkgdir in packages/*/; do + pkg="${pkgdir}package.json" + jq --arg v "$VERSION" ' + .version = $v | + if .dependencies then + .dependencies |= with_entries(if .key | startswith("@localess/") then .value = $v else . end) + else . end | + if .peerDependencies then + .peerDependencies |= with_entries(if .key | startswith("@localess/") then .value = $v else . end) + else . end + ' "$pkg" > tmp.json && mv tmp.json "$pkg" done shell: bash - - name: Publish snapshot to npm with tag 'dev' + - name: Build all packages + run: npm run build + + - name: Publish all packages to npm with tag 'dev' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - for pkg in packages/*/package.json; do - pkgdir=$(dirname $pkg) - if ! grep -q '"private"\s*:\s*true' $pkg; then - npm publish --tag dev --access public --workspace $(basename $pkgdir) + for pkgdir in packages/*/; do + if ! grep -q '"private"\s*:\s*true' "${pkgdir}package.json"; then + npm publish --tag dev --access public --workspace "$pkgdir" fi done shell: bash - diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2cb6437..77c8524 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,41 +1,53 @@ -name: Publish to npm +name: Publish to npm (master branch) on: push: - tags: - - 'v*' + branches: [master] jobs: publish: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20.x, 22.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - name: Use Node.js 20.x + uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version: 20.x registry-url: 'https://registry.npmjs.org/' cache: 'npm' - name: Install dependencies run: npm ci + - name: Sync versions from root package.json + run: | + VERSION=$(node -p "require('./package.json').version") + echo "Publishing version: $VERSION" + for pkgdir in packages/*/; do + pkg="${pkgdir}package.json" + jq --arg v "$VERSION" ' + .version = $v | + if .dependencies then + .dependencies |= with_entries(if .key | startswith("@localess/") then .value = $v else . end) + else . end | + if .peerDependencies then + .peerDependencies |= with_entries(if .key | startswith("@localess/") then .value = $v else . end) + else . end + ' "$pkg" > tmp.json && mv tmp.json "$pkg" + done + shell: bash + - name: Build all packages run: npm run build - - name: Publish packages to npm + - name: Publish all packages to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - npm publish --workspaces --access public - - - name: Verify published versions - run: | - npm info @localess/client | grep version - npm info @localess/react | grep version - npm info @localess/cli | grep version - + for pkgdir in packages/*/; do + if ! grep -q '"private"\s*:\s*true' "${pkgdir}package.json"; then + npm publish --access public --workspace "$pkgdir" + fi + done + shell: bash diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f803b6 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +
+
+Localess logo +
+
+ +---- + +# localess-js + +Official JavaScript/TypeScript SDK monorepo for the [Localess](https://github.com/Lessify/localess) headless CMS platform. + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Node.js >= 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) + +--- + +## Overview + +Localess is a headless CMS designed for teams that need flexible content management with multi-locale support, a Visual Editor, and a developer-friendly API. This repository houses all official JavaScript and TypeScript integrations as a single npm workspaces monorepo. + +Keeping all packages together in one repository ensures that shared types and interfaces remain consistent, changes to the core SDK are immediately reflected in framework-specific packages, and versioning stays synchronized across the entire SDK surface. + +--- + +## Packages + +| Package | Version | Description | +|---------|---------|-------------| +| [`@localess/client`](packages/client) | 3.0.0 | Core JavaScript/TypeScript SDK. Fetch content, translations, and assets from the Localess API. **Server-side only.** | +| [`@localess/react`](packages/react) | 3.0.0 | React integration. Dynamic component mapping, rich text rendering, and Visual Editor sync. | +| [`@localess/cli`](packages/cli) | 3.0.0 | Command-line interface. Manage translations and generate TypeScript types from your content schemas. | + +### Package Dependency Graph + +``` +@localess/react ──┐ + ├──▶ @localess/client (core SDK) +@localess/cli ──┘ +``` + +`@localess/client` is the foundational layer. Both `@localess/react` and `@localess/cli` depend on it for API communication, caching, and shared type definitions. + +--- + +## Quick Start + +Choose the package that fits your use case: + +### Server-side / Framework-agnostic + +```bash +npm install @localess/client +``` + +```ts +import { localessClient } from "@localess/client"; + +const client = localessClient({ + origin: 'https://my-localess.web.app', + spaceId: 'YOUR_SPACE_ID', + token: 'YOUR_API_TOKEN', // Keep secret — server-side only +}); + +const content = await client.getContentBySlug('home'); +const translations = await client.getTranslations('en'); +``` + +→ See the full [`@localess/client` documentation](packages/client/README.md) + +--- + +### React (including Next.js) + +```bash +npm install @localess/react +``` + +```tsx +import { localessInit, getLocalessClient, LocalessComponent } from "@localess/react"; + +localessInit({ + origin: process.env.LOCALESS_ORIGIN, + spaceId: process.env.LOCALESS_SPACE_ID, + token: process.env.LOCALESS_TOKEN, + enableSync: true, + components: { 'hero': HeroBlock, 'footer': Footer }, +}); +``` + +→ See the full [`@localess/react` documentation](packages/react/README.md) + +--- + +### CLI (Translations & Type Generation) + +```bash +npm install @localess/cli -D + +localess login +localess translations pull en --path ./locales/en.json +localess types generate +``` + +→ See the full [`@localess/cli` documentation](packages/cli/README.md) + +--- + +## Repository Structure + +``` +localess-js/ +├── packages/ +│ ├── client/ # @localess/client +│ ├── react/ # @localess/react +│ └── cli/ # @localess/cli +├── package.json # Workspace root (npm workspaces) +└── LICENSE +``` + +--- + +## Development + +### Requirements + +- Node.js >= 20.0.0 +- npm >= 10 (for workspaces support) + +### Install Dependencies + +```bash +npm install +``` + +### Build All Packages + +```bash +# Build all packages in dependency order +npm run build + +# Build individual packages +npm run build:client +npm run build:react +npm run build:cli +``` + +### Run Tests + +Tests are currently provided for `@localess/cli`: + +```bash +npm test --workspace=@localess/cli +``` + +--- + +## Contributing + +Contributions are welcome! Please open an issue or pull request on [GitHub](https://github.com/Lessify/localess-js/issues). + +--- + +## License + +[MIT](LICENSE) © [Lessify](https://github.com/Lessify) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d4ce8b6..23d1669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "localess-js", - "version": "1.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "localess-js", - "version": "1.0.0", + "version": "3.0.0", "license": "MIT", "workspaces": [ "packages/*", @@ -487,6 +487,334 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", + "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.0.tgz", + "integrity": "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.8.tgz", + "integrity": "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", + "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.8.tgz", + "integrity": "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/external-editor": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.8.tgz", + "integrity": "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.3.tgz", + "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.8.tgz", + "integrity": "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.8.tgz", + "integrity": "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.8.tgz", + "integrity": "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", + "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.0", + "@inquirer/confirm": "^6.0.8", + "@inquirer/editor": "^5.0.8", + "@inquirer/expand": "^5.0.8", + "@inquirer/input": "^5.0.8", + "@inquirer/number": "^4.0.8", + "@inquirer/password": "^5.0.8", + "@inquirer/rawlist": "^5.2.4", + "@inquirer/search": "^4.1.4", + "@inquirer/select": "^5.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.4.tgz", + "integrity": "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", + "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", + "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", + "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -946,9 +1274,9 @@ "license": "MIT" }, "node_modules/@tiptap/core": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz", - "integrity": "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz", + "integrity": "sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==", "license": "MIT", "peer": true, "funding": { @@ -956,52 +1284,52 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.18.0" + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.18.0.tgz", - "integrity": "sha512-xUgOvHCdGXh9Lfxd7DtgsSr0T/egIwBllWHIBWDjQEQQ0b+ICn+0+i703btHMB4hjdduZtgVDrhK8jAW3U6swA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.1.tgz", + "integrity": "sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.18.0.tgz", - "integrity": "sha512-8sEpY0nxAGGFDYlF+WVFPKX00X2dAAjmoi0+2eWvK990PdQqwXrQsRs7pkUbpE2mDtATV8+GlDXk9KDkK/ZXhA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.1.tgz", + "integrity": "sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.18.0" + "@tiptap/extension-list": "^3.20.1" } }, "node_modules/@tiptap/extension-code": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.18.0.tgz", - "integrity": "sha512-0SU53O0NRmdtRM2Hgzm372dVoHjs2F40o/dtB7ls4kocf4W89FyWeC2R6ZsFQqcXisNh9RTzLtYfbNyizGuZIw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.1.tgz", + "integrity": "sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-code-block": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.18.0.tgz", - "integrity": "sha512-fCx1oT95ikGfoizw+XCjeglQxlLK4lWgUcB4Dcn5TdaCoFBQMEaZs7Q0jVajxxxULnyArkg60uarc1ac/IF2Hw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.1.tgz", + "integrity": "sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA==", "license": "MIT", "peer": true, "funding": { @@ -1009,83 +1337,83 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.18.0.tgz", - "integrity": "sha512-euUvh9r1KNSua9X4VdMS6lcWgUkcd0YznCFhp4b5gSqT5/5F7tGlvEg5mNpBeNhOIreDQV6zfBc7HvLfh7cLEA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.20.1.tgz", + "integrity": "sha512-QJXZGN43HArGNl5HeiPF1fXZZs6FWJwG3wTr9v+OwsM8EX3ixyblIoeY0/nmFBlQqci49ZA/KfCqVwfGNlRj5A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/extension-code-block": "^3.18.0", - "@tiptap/pm": "^3.18.0", + "@tiptap/core": "^3.20.1", + "@tiptap/extension-code-block": "^3.20.1", + "@tiptap/pm": "^3.20.1", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "node_modules/@tiptap/extension-document": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.18.0.tgz", - "integrity": "sha512-e0hOGrjTMpCns8IC5p+c5CEiE1BBmFBFL+RpIxU/fjT2SaZ7q2xsFguBu94lQDT0cD6fdZokFRpGwEMxZNVGCg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.1.tgz", + "integrity": "sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-heading": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.18.0.tgz", - "integrity": "sha512-MTamVnYsFWVndLSq5PRQ7ZmbF6AExsFS9uIvGtUAwuhzvR4of/WHh6wpvWYjA+BLXTWRrfuGHaZTl7UXBN13fg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.1.tgz", + "integrity": "sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-history": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-3.18.0.tgz", - "integrity": "sha512-xIOxVPmQqqfVzt3zLTRahjTX0pAnfNVqIThYyNCP9/cgIjLJ8QuMjczurjVtVYHWdt6Fr0+d5KYUU6EmcyAmQQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-3.20.1.tgz", + "integrity": "sha512-ejETpNSByEZd6CtPIwOtACwG+FiCm0FD5hi/qMA01xWFkEO20B2EyGpc4NfKMQEfjbVOi+QY1QlXjqJXgOEx4w==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.18.0" + "@tiptap/extensions": "^3.20.1" } }, "node_modules/@tiptap/extension-italic": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.18.0.tgz", - "integrity": "sha512-1C4nB08psiRo0BPxAbpYq8peUOKnjQWtBCLPbE6B9ToTK3vmUk0AZTqLO11FvokuM1GF5l2Lg3sKrKFuC2hcjQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.1.tgz", + "integrity": "sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-link": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.18.0.tgz", - "integrity": "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.1.tgz", + "integrity": "sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA==", "license": "MIT", "dependencies": { "linkifyjs": "^4.3.2" @@ -1095,14 +1423,14 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-list": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.18.0.tgz", - "integrity": "sha512-9lQBo45HNqIFcLEHAk+CY3W51eMMxIJjWbthm2CwEWr4PB3+922YELlvq8JcLH1nVFkBVpmBFmQe/GxgnCkzwQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.1.tgz", + "integrity": "sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==", "license": "MIT", "peer": true, "funding": { @@ -1110,92 +1438,92 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-list-item": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.18.0.tgz", - "integrity": "sha512-auTSt+NXoUnT0xofzFa+FnXsrW1TPdT1OB3U1OqQCIWkumZqL45A8OK9kpvyQsWj/xJ8fy1iZwFlKXPtxjLd2w==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.1.tgz", + "integrity": "sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.18.0" + "@tiptap/extension-list": "^3.20.1" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.18.0.tgz", - "integrity": "sha512-5bUAfklYLS5o6qvLLfreGyGvD1JKXqOQF0YntLyPuCGrXv7+XjPWQL2BmEf59fOn2UPT2syXLQ1WN5MHTArRzg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.1.tgz", + "integrity": "sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.18.0" + "@tiptap/extension-list": "^3.20.1" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.18.0.tgz", - "integrity": "sha512-uvFhdwiur4NhhUdBmDsajxjGAIlg5qga55fYag2DzOXxIQE2M7/aVMRkRpuJzb88GY4EHSh8rY34HgMK2FJt2Q==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.1.tgz", + "integrity": "sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.18.0.tgz", - "integrity": "sha512-kl/fa68LZg8NWUqTkRTfgyCx+IGqozBmzJxQDc1zxurrIU+VFptDV9UuZim587sbM2KGjCi/PNPjPGk1Uu0PVg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.1.tgz", + "integrity": "sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-text": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.18.0.tgz", - "integrity": "sha512-9TvctdnBCwK/zyTi9kS7nGFNl5OvGM8xE0u38ZmQw5t79JOqJHgOroyqMjw8LHK/1PWrozfNCmsZbpq4IZuKXw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.1.tgz", + "integrity": "sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.18.0.tgz", - "integrity": "sha512-009IeXURNJ/sm1pBqbj+2YQgjQaBtNlJR3dbl6xu49C+qExqCmI7klhKQuwsVVGLR7ahsYlp7d9RlftnhCXIcQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.1.tgz", + "integrity": "sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extensions": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.18.0.tgz", - "integrity": "sha512-uSRIE9HGshBN6NRFR3LX2lZqBLvX92SgU5A9AvUbJD4MqU63E+HdruJnRjsVlX3kPrmbIDowxrzXlUcg3K0USQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.1.tgz", + "integrity": "sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==", "license": "MIT", "peer": true, "funding": { @@ -1203,29 +1531,29 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/html": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-3.18.0.tgz", - "integrity": "sha512-27A+N3im8pKxlirm7lDZRwgku6xMoDuGwt2cP/G59LrCh208G1oR/RTRBAKwrOQLfA1T/fTv08ZRt2enMYnmkw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-3.20.1.tgz", + "integrity": "sha512-vElmnCWIqIj8DIS1lIxKKlN8pQEZVNiRfh4RZ2TL4tdZDsfS29US2lbgWEeI8lZdgr5C5JuoC/X1Nv+N8tJn6A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0", + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1", "happy-dom": "^20.0.2" } }, "node_modules/@tiptap/pm": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.18.0.tgz", - "integrity": "sha512-8RoI5gW0xBVCsuxahpK8vx7onAw6k2/uR3hbGBBnH+HocDMaAZKot3nTyY546ij8ospIC1mnQ7k4BhVUZesZDQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.1.tgz", + "integrity": "sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==", "license": "MIT", "peer": true, "dependencies": { @@ -1254,17 +1582,17 @@ } }, "node_modules/@tiptap/static-renderer": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.18.0.tgz", - "integrity": "sha512-eC0GQ77mgeki224a6+nImfoXikoynfLZKcryw/PJ9LyUdpSy8F3U23jwgQyrfZo+mX6o4Px/eP7mGhaffDW2fA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.20.1.tgz", + "integrity": "sha512-zl5zlnQmUlEyfoboqiYm5okXbpT/kO5z7FiJSm+VQ2z22bduipirnQvfGVGDc0puc+IH77w5dhGaNlbKPWbqVA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0", + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -1606,6 +1934,12 @@ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", "license": "MIT" }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1622,6 +1956,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -1811,6 +2154,21 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -1827,6 +2185,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1913,6 +2280,22 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -2096,6 +2479,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2168,15 +2560,6 @@ "typescript": "^5.x" } }, - "node_modules/openapi3-ts": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", - "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", - "license": "MIT", - "dependencies": { - "yaml": "^2.8.0" - } - }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", @@ -2659,6 +3042,12 @@ "license": "MIT", "peer": true }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2672,6 +3061,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -3143,7 +3544,10 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -3169,14 +3573,25 @@ "node": ">=12" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/cli": { "name": "@localess/cli", - "version": "0.0.1", + "version": "3.0.0", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.3.0", "chalk": "^5.6.2", "commander": "^14.0.3", - "openapi-typescript": "^7.12.0" + "openapi-typescript": "^7.12.0", + "zod": "^4.3.6" }, "bin": { "localess": "dist/index.mjs" @@ -3201,11 +3616,8 @@ }, "packages/client": { "name": "@localess/client", - "version": "0.9.2", + "version": "3.0.0", "license": "MIT", - "dependencies": { - "openapi3-ts": "^4.5.0" - }, "devDependencies": { "@types/node": "^20", "tsup": "^8.5.1", @@ -3217,35 +3629,35 @@ }, "packages/react": { "name": "@localess/react", - "version": "0.9.2", + "version": "3.0.0", "license": "MIT", "dependencies": { "@localess/client": "*", - "@tiptap/extension-bold": "^3.14.0", - "@tiptap/extension-bullet-list": "^3.14.0", - "@tiptap/extension-code": "^3.14.0", - "@tiptap/extension-code-block-lowlight": "^3.14.0", - "@tiptap/extension-document": "^3.14.0", - "@tiptap/extension-heading": "^3.14.0", - "@tiptap/extension-history": "^3.14.0", - "@tiptap/extension-italic": "^3.14.0", - "@tiptap/extension-link": "^3.14.0", - "@tiptap/extension-list-item": "^3.14.0", - "@tiptap/extension-ordered-list": "^3.14.0", - "@tiptap/extension-paragraph": "^3.14.0", - "@tiptap/extension-strike": "^3.14.0", - "@tiptap/extension-text": "^3.14.0", - "@tiptap/extension-underline": "^3.14.0", - "@tiptap/html": "^3.14.0", - "@tiptap/static-renderer": "^3.14.0" + "@tiptap/extension-bold": "^3.20.1", + "@tiptap/extension-bullet-list": "^3.20.1", + "@tiptap/extension-code": "^3.20.1", + "@tiptap/extension-code-block-lowlight": "^3.20.1", + "@tiptap/extension-document": "^3.20.1", + "@tiptap/extension-heading": "^3.20.1", + "@tiptap/extension-history": "^3.20.1", + "@tiptap/extension-italic": "^3.20.1", + "@tiptap/extension-link": "^3.20.1", + "@tiptap/extension-list-item": "^3.20.1", + "@tiptap/extension-ordered-list": "^3.20.1", + "@tiptap/extension-paragraph": "^3.20.1", + "@tiptap/extension-strike": "^3.20.1", + "@tiptap/extension-text": "^3.20.1", + "@tiptap/extension-underline": "^3.20.1", + "@tiptap/html": "^3.20.1", + "@tiptap/static-renderer": "^3.20.1" }, "devDependencies": { - "@types/node": "^20.12.12", + "@types/node": "^20", "@types/react": "^19.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tsup": "^8.5.1", - "typescript": "^5.8.3" + "typescript": "^5.9.3" }, "engines": { "node": ">= 20.0.0" diff --git a/package.json b/package.json index 4862bd0..e5be57f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "localess-js", - "version": "1.0.0", + "version": "3.0.0", "description": "", "main": "index.js", "workspaces": [ @@ -25,9 +25,9 @@ "homepage": "https://github.com/Lessify/localess-js#readme", "devDependencies": { "@types/node": "^20.12.12", - "vitest": "^4.0.18", "tsup": "^8.5.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.0.18" }, "engines": { "node": ">= 20.0.0" diff --git a/packages/cli/README.md b/packages/cli/README.md index 7db3292..48224a8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -6,57 +6,249 @@ ---- -# Localess Command Line +# Localess CLI -This client SDK is designed to work with the Localess API. It provides a simple way to interact with the Localess API from your JavaScript or TypeScript application. +The `@localess/cli` package is the official command-line interface for the [Localess](https://github.com/Lessify/localess) headless CMS platform. It provides commands to authenticate with your Localess instance, synchronize translations, and generate TypeScript type definitions from your content schemas. -> **Important:** -> The Client is designed to be used on the server side only, as it requires your **Localess API Token** to be kept secret. -> Do not use this client in your frontend application, as it exposes your API Token to the public. +## Requirements + +- Node.js >= 20.0.0 ## Installation -### NPM -````bash -npm install @localess/js-client@latest -```` - -### Yarn -````bash -yarn add @localess/js-client@latest -```` - -## Client - -````ts -import {localessClient} from "@localess/js-client"; - -const llClient = localessClient({ - // A fully qualified domain name with protocol (http/https) and port. - origin: 'https://my-localess.web.app', - // Localess space ID, cna be found in the Localess Space settings - spaceId: 'I1LoVe2LocaLess4Rever', - // Localess API token, can be found in the Localess Space settings - token: 'Baz00KaT0KeN8S3CureLL' -}); - -// Fetch all Content Links -llClient.getLinks() -// Fetch content by SLUG -llClient.getContentBySlug('docs/overview') -// Fetch content by ID -llClient.getContentById('FRnIT7CUABoRCdSVVGGs') -// Fetch translations by locale -llClient.getTranslations('en') -```` - -## Sync with Visual Editor - -It will automatically inject Localess Sync Script in to the HTML page. - -````ts -import {loadLocalessSync} from "@localess/js-client"; - -// A fully qualified domain name with protocol (http/https) and port. -loadLocalessSync('https://my-localess.web.app') -```` +```bash +# Install as a project dev dependency (recommended) +npm install @localess/cli -D + +# Or install globally +npm install @localess/cli -g +``` + +--- + +## Features + +- 🔐 **Authentication** — Secure credential storage for CLI and CI/CD environments +- 🌐 **Translations** — Push and pull translation files to/from your Localess space +- 🛡️ **Type Generation** — Generate TypeScript type definitions from your Localess content schemas for end-to-end type safety + +--- + +## Authentication + +### `localess login` + +Authenticate with your Localess instance. Credentials are validated immediately and stored securely in `.localess/credentials.json` with restricted file permissions (`0600`). + +```bash +localess login --origin --space --token +``` + +If any option is omitted, the CLI will interactively prompt for the missing values. + +**Options:** + +| Flag | Description | +|------|-------------| +| `-o, --origin ` | Localess instance URL (e.g., `https://my-localess.web.app`) | +| `-s, --space ` | Space ID (found in Localess Space settings) | +| `-t, --token ` | API token (input is masked for security) | + +**Examples:** + +```bash +# Interactive login (prompts for any missing values) +localess login + +# Non-interactive login (CI/CD) +localess login --origin https://my-localess.web.app --space MY_SPACE_ID --token MY_API_TOKEN +``` + +#### Authentication via Environment Variables + +For CI/CD pipelines, you can provide credentials through environment variables instead of running `localess login`. The CLI automatically reads these variables and skips the file-based credentials: + +```bash +export LOCALESS_ORIGIN=https://my-localess.web.app +export LOCALESS_SPACE=MY_SPACE_ID +export LOCALESS_TOKEN=MY_API_TOKEN + +localess translations pull en --path ./public/locales/en.json +``` + +| Variable | Description | +|----------|-------------| +| `LOCALESS_ORIGIN` | Localess instance URL | +| `LOCALESS_SPACE` | Space ID | +| `LOCALESS_TOKEN` | API token | + +--- + +### `localess logout` + +Clear stored credentials from `.localess/credentials.json`. + +```bash +localess logout +``` + +> If you authenticated via environment variables, those must be unset manually — `logout` only affects file-based credentials. + +--- + +## Translations Management + +### `localess translations push ` + +Push a local JSON translation file to your Localess space. Only keys present in the file are affected, based on the selected update type. + +```bash +localess translations push --path [options] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | ISO 639-1 locale code (e.g., `en`, `de`, `fr`) | + +**Options:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-p, --path ` | *(required)* | Path to the JSON translations file | +| `-f, --format ` | `flat` | File format: `flat` or `nested` | +| `-t, --type ` | `add-missing` | Update strategy: `add-missing` or `update-existing` | +| `--dry-run` | `false` | Preview changes without applying them | + +**Update Strategies:** + +| Type | Description | +|------|-------------| +| `add-missing` | Adds translations for keys that do not yet exist in Localess | +| `update-existing` | Updates translations for keys that already exist in Localess | + +**File Formats:** + +- **`flat`** — A flat JSON object where keys may use dot notation: + ```json + { + "common.submit": "Submit", + "nav.home": "Home" + } + ``` + +- **`nested`** — A nested JSON object that is automatically flattened before uploading: + ```json + { + "common": { "submit": "Submit" }, + "nav": { "home": "Home" } + } + ``` + +**Examples:** + +```bash +# Push English translations (add missing keys only) +localess translations push en --path ./locales/en.json + +# Push with update-existing strategy +localess translations push en --path ./locales/en.json --type update-existing + +# Preview changes without applying (dry run) +localess translations push en --path ./locales/en.json --dry-run + +# Push nested-format translations +localess translations push de --path ./locales/de.json --format nested +``` + +--- + +### `localess translations pull ` + +Pull translations from your Localess space and save them to a local file. + +```bash +localess translations pull --path [options] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | ISO 639-1 locale code (e.g., `en`, `de`, `fr`) | + +**Options:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-p, --path ` | *(required)* | Output file path | +| `-f, --format ` | `flat` | File format: `flat` or `nested` | + +**Examples:** + +```bash +# Pull English translations as flat JSON +localess translations pull en --path ./locales/en.json + +# Pull German translations as nested JSON +localess translations pull de --path ./locales/de.json --format nested +``` + +--- + +## TypeScript Type Generation + +### `localess types generate` + +Fetch your space's OpenAPI schema from Localess and generate TypeScript type definitions. The output file provides full type safety when working with Localess content in your TypeScript projects. + +```bash +localess types generate [--path ] +``` + +**Options:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-p, --path ` | `.localess/localess.d.ts` | Path to write the generated TypeScript definitions file | + +> **Note:** Your API token must have **Development Tools** permission enabled in Localess Space settings. + +**Example:** + +```bash +# Generate types to the default location +localess types generate + +# Generate types to a custom path +localess types generate --path src/types/localess.d.ts +``` + +**Using generated types:** + +```ts +import type { Page, HeroBlock } from './.localess/localess'; +import { getLocalessClient } from "@localess/react"; + +const client = getLocalessClient(); +const content = await client.getContentBySlug('home', { locale: 'en' }); +// content.data is now fully typed as Page +``` + +--- + +## Stored Files + +| File | Description | +|------|-------------| +| `.localess/credentials.json` | Stored login credentials (created by `localess login`) | +| `.localess/localess.d.ts` | Generated TypeScript definitions (created by `localess types generate`) | + +> It is recommended to add `.localess/credentials.json` to your `.gitignore` to avoid committing sensitive credentials. + +--- + +## License + +[MIT](../../LICENSE) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1155b1c..bb90f5f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@localess/cli", - "version": "0.0.1", + "version": "3.0.0", "description": "Localess Command Line.", "keywords": [ "localess", @@ -41,9 +41,11 @@ }, "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.3.0", "commander": "^14.0.3", "chalk": "^5.6.2", - "openapi-typescript": "^7.12.0" + "openapi-typescript": "^7.12.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 1753afc..f0b693a 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -6,7 +6,7 @@ import { Links, Space, Translations, - TranslationUpdate, + TranslationUpdate, TranslationUpdateResponse, TranslationUpdateType } from "./models"; import {ICache, NoCache, TTLCache} from "./cache"; @@ -40,6 +40,14 @@ export type LocalessClientOptions = { * Set to false to disable caching. */ cacheTTL?: number | false; // in milliseconds + /** + * Number of times to retry failed fetch requests (network errors or 5xx). Default: 3 + */ + retryCount?: number; + /** + * Delay in ms between retries. Default: 500ms + */ + retryDelay?: number; } export type LinksFetchParams = { @@ -82,12 +90,21 @@ export type ContentFetchParams = { resolveLink?: boolean; } +export type TranslationFetchParams = { + /** + * Translation version to fetch, leave empty for 'published' or 'draft' for the latest draft. + * Overrides the version set in the client options. + */ + version?: 'draft' | string; +} + export interface LocalessClient { /** * Get space information * @returns {Promise} */ getSpace(): Promise + /** * Get all links * @param params{LinksFetchParams} - Fetch parameters @@ -114,17 +131,20 @@ export interface LocalessClient { /** * Get translations for the given locale * @param locale{string} - Locale identifier (ISO 639-1) + * @param params{ContentFetchParams} - Fetch parameters + * @returns {Promise} */ - getTranslations(locale: string): Promise; + getTranslations(locale: string, params?: TranslationFetchParams): Promise; /** * Update translations for the given locale * @param locale - Locale identifier (ISO 639-1) * @param type{TranslationUpdateType} - Type of update to perform (add-missing or update-existing) * @param values - Key-Value Object. Where Key is Translation ID and Value is Translated Content + * @param dryRun - If true, the API will return the changes that would be made without actually applying them * @returns {Promise} */ - updateTranslations(locale: string, type: TranslationUpdateType, values: Translations): Promise; + updateTranslations(locale: string, type: TranslationUpdateType, values: Translations, dryRun?: boolean): Promise; /** * Get OpenAPI specification @@ -139,6 +159,40 @@ export interface LocalessClient { const LOG_GROUP = `${FG_BLUE}[Localess:Client]${RESET}` +/** + * Helper: fetch with retry logic + */ +async function fetchWithRetry(url: string, options: RequestInit, retryCount: number = 3, retryDelay: number = 500, debug?: boolean): Promise { + let attempt = 0; + let lastError: any; + while (attempt <= retryCount) { + try { + const response = await fetch(url, options); + // Only retry on network error or 5xx + if (!response.ok && response.status >= 500) { + if (debug) { + console.log(LOG_GROUP, `fetchWithRetry: HTTP ${response.status} on attempt ${attempt + 1}`); + } + lastError = new Error(`HTTP ${response.status}`); + // fall through to retry + } else { + return response; + } + } catch (err) { + if (debug) { + console.log(LOG_GROUP, `fetchWithRetry: network error on attempt ${attempt + 1}`, err); + } + lastError = err; + // fall through to retry + } + attempt++; + if (attempt <= retryCount) { + await new Promise(res => setTimeout(res, retryDelay)); + } + } + throw lastError; +} + /** * Create a Localess API Client * @param {LocalessClientOptions} options connection details @@ -180,7 +234,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } try { - const response = await fetch(url, fetchOptions) + const response = await fetchWithRetry(url, fetchOptions, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'getSpace status : ', response.status); } @@ -226,7 +280,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } try { - const response = await fetch(url, fetchOptions) + const response = await fetchWithRetry(url, fetchOptions, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'getLinks status : ', response.status); } @@ -273,7 +327,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } try { - const response = await fetch(url, fetchOptions) + const response = await fetchWithRetry(url, fetchOptions, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'getContentBySlug status : ', response.status); } @@ -320,7 +374,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } try { - const response = await fetch(url, fetchOptions) + const response = await fetchWithRetry(url, fetchOptions, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'getContentById status : ', response.status); } @@ -336,11 +390,21 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } }, - async getTranslations(locale: string): Promise { + async getTranslations(locale: string, params?: TranslationFetchParams): Promise { if (options.debug) { console.log(LOG_GROUP, 'getTranslations() locale : ', locale); + console.log(LOG_GROUP, 'getTranslations() params : ', JSON.stringify(params)); + } + let version = ''; + // Options + if (options?.version && options.version == 'draft') { + version = `&version=${options.version}`; + } + // Params + if (params?.version && params.version == 'draft') { + version = `&version=${params.version}`; } - let url = `${options.origin}/api/v1/spaces/${options.spaceId}/translations/${locale}?token=${options.token}`; + let url = `${options.origin}/api/v1/spaces/${options.spaceId}/translations/${locale}?token=${options.token}${version}`; if (options.debug) { console.log(LOG_GROUP, 'getTranslations fetch url : ', url); } @@ -354,7 +418,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } try { - const response = await fetch(url, fetchOptions) + const response = await fetchWithRetry(url, fetchOptions, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'getTranslations status : ', response.status); } @@ -370,7 +434,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } }, - async updateTranslations(locale: string, type: TranslationUpdateType, values: Translations): Promise { + async updateTranslations(locale: string, type: TranslationUpdateType, values: Translations, dryRun?: boolean): Promise { if (options.debug) { console.log(LOG_GROUP, 'updateTranslations() locale : ', locale); console.log(LOG_GROUP, 'updateTranslations() type : ', type); @@ -383,19 +447,21 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { const body: TranslationUpdate = { type, values, + dryRun, } try { - const response = await fetch(url, { - ...fetchOptions, + const response = await fetchWithRetry(url, { method: 'POST', headers: { 'X-API-KEY': options.token, + ...fetchOptions.headers }, body: JSON.stringify(body), - }); + }, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'updateTranslations status : ', response.status); } + return response.json(); } catch (error: any) { console.error(LOG_GROUP, 'updateTranslations error : ', error); } @@ -419,7 +485,7 @@ export function localessClient(options: LocalessClientOptions): LocalessClient { } try { - const response = await fetch(url, fetchOptions) + const response = await fetchWithRetry(url, fetchOptions, options.retryCount, options.retryDelay, options.debug); if (options.debug) { console.log(LOG_GROUP, 'getOpenApi status : ', response.status); } diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 878de31..f5d4091 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -1,49 +1,54 @@ import {Command} from "commander"; +import {input, password} from "@inquirer/prompts"; import {localessClient} from "../../client"; import {getSession, persistSession} from "../../session"; type LoginOptions = { - token?: string; - space?: string; origin?: string; + space?: string; + token?: string; }; export const loginCommand = new Command('login') .description('Login to Localess CLI') - .option('-t, --token ', 'Token to login to Localess CLI') - .option('-s, --space ', 'Space ID to login to') .option('-o, --origin ', 'Origin of the Localess instance') + .option('-s, --space ', 'Space ID to login to') + .option('-t, --token ', 'Token to login to Localess CLI') .action(async (options: LoginOptions) => { - console.log('Logging in with options:', options); - const session = await getSession() if (session.isLoggedIn && session.method === 'file') { - console.log('Already logged in. If you want to log in with different credentials, please log out first using "localess logout" command.'); + console.log('Already logged in.'); + console.log('If you want to log in with different credentials, please log out first using "localess logout" command.'); return; } - if (options.origin && options.space && options.token) { - const client = localessClient({ - origin: options.origin, - spaceId: options.space, - token: options.token, - }); - try { - const space = await client.getSpace(); - console.log(`Successfully logged in to space: ${space.name} (${space.id})`); - await persistSession({ - origin: options.origin, - space: options.space, - token: options.token, - }) - } catch (e) { - console.error('Login failed'); - } - } else if(session.isLoggedIn && session.method === 'env') { - console.log('Already logged in with environment variables.'); - console.log('If you want to log in with different credentials, Please provide all required options: --origin, --space, and --token'); - } else { - console.log('Please provide all required options: --origin, --space, and --token'); - console.log('Or set the following environment variables: LOCALESS_ORIGIN, LOCALESS_SPACE, and LOCALESS_TOKEN'); + + const origin = options.origin ?? await input({ + message: 'Origin of the Localess instance:', + required: true, + }); + + const space = options.space ?? await input({ + message: 'Space ID:', + required: true, + }); + + const token = options.token ?? await password({ + message: 'Token:', + mask: true, + }); + + const client = localessClient({ + origin, + spaceId: space, + token, + }); + + try { + const spaceData = await client.getSpace(); + console.log(`Successfully logged in to space: ${spaceData.name} (${spaceData.id})`); + await persistSession({origin, space, token}); + } catch (e) { + console.error('Login failed'); } }); diff --git a/packages/cli/src/commands/translations/index.ts b/packages/cli/src/commands/translations/index.ts new file mode 100644 index 0000000..7658cd2 --- /dev/null +++ b/packages/cli/src/commands/translations/index.ts @@ -0,0 +1,8 @@ +import {Command} from "commander"; +import {translationsPushCommand} from "./push"; +import {translationsPullCommand} from "./pull"; + +export const translationsCommand = new Command('translations') + .description('Manage translations') + .addCommand(translationsPushCommand) + .addCommand(translationsPullCommand); diff --git a/packages/cli/src/commands/translations/pull/index.ts b/packages/cli/src/commands/translations/pull/index.ts new file mode 100644 index 0000000..e584b12 --- /dev/null +++ b/packages/cli/src/commands/translations/pull/index.ts @@ -0,0 +1,49 @@ +import {Command} from "commander"; +import {getSession} from "../../../session"; +import {localessClient} from "../../../client"; +import {TranslationFileFormat} from "../../../models"; +import {writeFile} from "../../../file"; +import {dotToNestedObject} from "../../../utils"; + +export type TranslationsPullOptions = { + path: string; + format: TranslationFileFormat; +} + +export const translationsPullCommand = new Command('pull') + .argument('', 'Locale to pull') + .description('Pull locale translations from Localess') + .requiredOption('-p, --path ', 'Path where the translations file will be saved') + .option('-f, --format ', `File format. Possible values are : ${Object.values(TranslationFileFormat)}`, TranslationFileFormat.FLAT) + .action(async (locale: string, options: TranslationsPullOptions) => { + console.log('Pulling translations with arguments:', locale); + console.log('Pulling translations with options:', options); + if (!Object.values(TranslationFileFormat).includes(options.format)) { + console.error('Invalid format provided. Possible values are :', Object.values(TranslationFileFormat)); + return; + } + + const session = await getSession() + if (!session.isLoggedIn) { + console.error('Not logged in'); + console.error('Please log in first using "localess login" command'); + return; + } + const client = localessClient({ + origin: session.origin, + spaceId: session.space, + token: session.token, + }); + + console.log('Pulling translations from Localess for locale:', locale); + const translations = await client.getTranslations(locale) + + console.log('Saving translations in file:', options.path); + if (options.format === TranslationFileFormat.FLAT) { + await writeFile(options.path, JSON.stringify(translations, null, 2)); + } else if (options.format === TranslationFileFormat.NESTED) { + const nestedTranslations = dotToNestedObject(translations); + await writeFile(options.path, JSON.stringify(nestedTranslations, null, 2)); + } + console.log('Successfully saved translations from Localess'); + }); diff --git a/packages/cli/src/commands/translations/push/index.ts b/packages/cli/src/commands/translations/push/index.ts new file mode 100644 index 0000000..5b8b66a --- /dev/null +++ b/packages/cli/src/commands/translations/push/index.ts @@ -0,0 +1,71 @@ +import {Command} from "commander"; +import {getSession} from "../../../session"; +import {localessClient} from "../../../client"; +import {TranslationFileFormat, Translations, TranslationUpdateType} from "../../../models"; +import {readFile} from "../../../file"; +import {zLocaleTranslationsSchema, zTranslationUpdateTypeSchema} from "../../../models/translation.zod"; + +export type TranslationsPushOptions = { + path: string; + format: TranslationFileFormat; + type: TranslationUpdateType; + dryRun?: boolean; +} + +export const translationsPushCommand = new Command('push') + .argument('', 'Locale to push') + .description('Push locale translations to Localess') + .requiredOption('-p, --path ', 'Path to the translations file to push') + .option('-f, --format ', `File format. Possible values are : ${Object.values(TranslationFileFormat)}`, TranslationFileFormat.FLAT) + .option('-t, --type ', `Push type. Possible values are : ${Object.values(TranslationUpdateType)}`, TranslationUpdateType.ADD_MISSING) + .option('--dry-run', 'Preview changes without applying them to Localess') + .action(async (locale: string, options: TranslationsPushOptions) => { + console.log('Pushing translations with arguments:', locale); + console.log('Pushing translations with options:', options); + if (!zTranslationUpdateTypeSchema.safeParse(options.type).success) { + console.error('Invalid type provided. Possible values are :', Object.values(TranslationUpdateType)); + return; + } + + const session = await getSession() + if (!session.isLoggedIn) { + console.error('Not logged in'); + console.error('Please log in first using "localess login" command'); + return; + } + const client = localessClient({ + origin: session.origin, + spaceId: session.space, + token: session.token, + }); + + if (options.dryRun) { + console.warn('Dry run mode enabled: No changes will be made.'); + } + + if (options.format === TranslationFileFormat.NESTED) { + console.error('Nested format is not implemented yet. Please use flat format for now.'); + } + console.log('Reading translations file from:', options.path); + const fileContent = await readFile(options.path); + const translationValues: Translations = JSON.parse(fileContent); + const pResult = zLocaleTranslationsSchema.safeParse(translationValues) + if (!pResult.success) { + console.error('Invalid translations file format:', pResult.error); + return; + } + console.log('Pushing translations to Localess with locale:', locale, 'and type:', options.type); + const response = await client.updateTranslations(locale, options.type, translationValues, options.dryRun); + if (response) { + if (response.dryRun) { + console.log('Dry run results:'); + } + console.log('Successfully pushed translations to Localess'); + console.log('Summary:', response.message); + if (response.ids) { + console.log('Updated translation IDs:', response.ids); + } + } else { + console.log('Something went wrong while pushing translations to Localess'); + } + }); diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts new file mode 100644 index 0000000..67926e7 --- /dev/null +++ b/packages/cli/src/commands/types/generate/index.ts @@ -0,0 +1,49 @@ +import {Command} from "commander"; +import {localessClient} from "../../../client"; +import {getSession} from "../../../session"; +import openapiTS, { astToString } from "openapi-typescript"; +import {join} from "node:path"; +import process from "node:process"; +import {DEFAULT_CONFIG_DIR, writeFile} from "../../../file"; + +const TYPES_PATH = join(process.cwd(), DEFAULT_CONFIG_DIR, 'localess.d.ts'); + +type TypesOptions = { + path: string; +}; + +export const typesGenerateCommand = new Command('generate') + .description('Generate types for your schemas') + .option('-p, --path ', 'Path to the file where to save the generated types. Default is .localess/localess.d.ts', TYPES_PATH) + .action(async (options: TypesOptions) => { + console.log('Types in with options:', options); + + const session = await getSession() + if (!session.isLoggedIn) { + console.error('Not logged in'); + console.error('Please log in first using "localess login" command'); + return; + } + const client = localessClient({ + origin: session.origin, + spaceId: session.space, + token: session.token, + }); + + console.log('Fetching OpenAPI specification from Localess...'); + const specification = await client.getOpenApi(); + console.log('Generating types from OpenAPI specification...'); + try { + const minimalSpec = { + openapi: '3.0.0', + info: { title: 'Schemas Only', version: '1.0.0' }, + components: { schemas: specification.components?.schemas || {} }, + }; + const ast = await openapiTS(minimalSpec, {exportType: true, rootTypes: true, rootTypesNoSchemaPrefix: true}) + const contents = astToString(ast); + await writeFile(options.path, contents); + console.log(`Types generated successfully at ${options.path}`); + } catch (e) { + console.error(e); + } + }); diff --git a/packages/cli/src/commands/types/index.ts b/packages/cli/src/commands/types/index.ts index 598890a..d3970c9 100644 --- a/packages/cli/src/commands/types/index.ts +++ b/packages/cli/src/commands/types/index.ts @@ -1,48 +1,7 @@ import {Command} from "commander"; -import {localessClient} from "../../client"; -import {getSession} from "../../session"; -import openapiTS, { astToString } from "openapi-typescript"; -import {join} from "node:path"; -import process from "node:process"; -import {DEFAULT_CONFIG_DIR, writeToFile} from "../../file"; - -const TYPES_PATH = join(process.cwd(), DEFAULT_CONFIG_DIR, 'localess.d.ts'); - -type TypesOptions = { - -}; +import {typesGenerateCommand} from "./generate"; export const typesCommand = new Command('types') .description('Generate types for your schemas') - .action(async (options: TypesOptions) => { - console.log('Types in with options:', options); - - const session = await getSession() - if (!session.isLoggedIn) { - console.error('Not logged in'); - console.error('Please log in first using "localess login" command'); - return; - } - const client = localessClient({ - origin: session.origin, - spaceId: session.space, - token: session.token, - }); + .addCommand(typesGenerateCommand); - console.log('Fetching OpenAPI specification from Localess...'); - const specification = await client.getOpenApi(); - console.log('Generating types from OpenAPI specification...'); - try { - const minimalSpec = { - openapi: '3.0.0', - info: { title: 'Schemas Only', version: '1.0.0' }, - components: { schemas: specification.components?.schemas || {} }, - }; - const ast = await openapiTS(minimalSpec, {exportType: true, rootTypes: true, rootTypesNoSchemaPrefix: true}) - const contents = astToString(ast); - await writeToFile(TYPES_PATH, contents); - console.log(`Types generated successfully at ${TYPES_PATH}`); - } catch (e) { - console.error(e); - } - }); diff --git a/packages/cli/src/file.ts b/packages/cli/src/file.ts index 5d921d8..4e3873e 100644 --- a/packages/cli/src/file.ts +++ b/packages/cli/src/file.ts @@ -1,4 +1,4 @@ -import {access, constants, mkdir, writeFile} from "node:fs/promises"; +import {access, constants, mkdir, writeFile as nodeWriteFile, readFile as nodeReadFile} from "node:fs/promises"; import {parse} from "node:path"; export const DEFAULT_CONFIG_DIR = '.localess' @@ -13,7 +13,7 @@ export async function fileExists(path: string) { } } -export async function writeToFile(filePath: string, data: string, option? : {mode? : number}) { +export async function writeFile(filePath: string, data: string, option? : {mode? : number}) { // Get the directory path const resolvedPath = parse(filePath).dir; // Ensure the directory exists @@ -24,8 +24,12 @@ export async function writeToFile(filePath: string, data: string, option? : {mod } // Write the file try { - await writeFile(filePath, data, option); + await nodeWriteFile(filePath, data, option); } catch (writeError) { } } + +export async function readFile(filePath: string): Promise { + return nodeReadFile(filePath, 'utf-8'); +} diff --git a/packages/cli/src/models/translation.zod.ts b/packages/cli/src/models/translation.zod.ts new file mode 100644 index 0000000..7e76298 --- /dev/null +++ b/packages/cli/src/models/translation.zod.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const zLocaleTranslationsSchema = z.record(z.string(), z.string()); +export const zTranslationUpdateTypeSchema = z.enum(['add-missing', 'update-existing']) + +export const zTranslationUpdateSchema = z.object({ + type: zTranslationUpdateTypeSchema, + values: zLocaleTranslationsSchema, +}); diff --git a/packages/cli/src/models/translations.ts b/packages/cli/src/models/translations.ts index 5ff209c..4d35c18 100644 --- a/packages/cli/src/models/translations.ts +++ b/packages/cli/src/models/translations.ts @@ -7,6 +7,7 @@ export interface Translations { } export type TranslationUpdate = { + dryRun?: boolean; type: TranslationUpdateType; values: Translations; } @@ -14,4 +15,16 @@ export type TranslationUpdate = { export enum TranslationUpdateType { ADD_MISSING = 'add-missing', UPDATE_EXISTING = 'update-existing', + DELETE_MISSING = 'delete-missing', +} + +export enum TranslationFileFormat { + FLAT = 'flat', + NESTED = 'nested', +} + +export type TranslationUpdateResponse = { + message: string; + ids?: string[]; + dryRun?: boolean; } diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 9c3a81d..50e1ecb 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -2,15 +2,17 @@ import {Command} from "commander"; import {loginCommand} from "./commands/login"; import {logoutCommand} from "./commands/logout"; import {typesCommand} from "./commands/types"; +import {translationsCommand} from "./commands/translations"; export const program = new Command(); program .name('Localess CLI') .description('CLI tool for Localess platform management') - .version('0.0.1'); + .version('0.0.6'); program.addCommand(loginCommand) program.addCommand(logoutCommand) +program.addCommand(translationsCommand) program.addCommand(typesCommand) diff --git a/packages/cli/src/session.ts b/packages/cli/src/session.ts index ba3fa78..a725b05 100644 --- a/packages/cli/src/session.ts +++ b/packages/cli/src/session.ts @@ -1,7 +1,7 @@ import {access, readFile} from 'node:fs/promises'; import {join} from 'node:path'; import * as process from "node:process"; -import {DEFAULT_CONFIG_DIR, writeToFile} from "./file"; +import {DEFAULT_CONFIG_DIR, writeFile} from "./file"; export type SessionData = { token: string; @@ -69,15 +69,14 @@ export async function getSession(): Promise { }; } } catch (e) { - console.error('No credentials found. Please log in using the "localess login" command.'); + // console.error('No credentials found. Please log in using the "localess login" command.'); } - console.debug('Not logged in.'); return session; } export async function persistSession(data:SessionOptions) { if (data.origin && data.token && data.space) { - await writeToFile(CREDENTIALS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 }); + await writeFile(CREDENTIALS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 }); console.log('Add session credentials to file system.'); console.log('Add .localess to .gitignore to avoid committing them to your repository.'); } else { @@ -89,7 +88,7 @@ export async function clearSession() { // Write empty JSON to the file try { await access(CREDENTIALS_PATH) - await writeToFile(CREDENTIALS_PATH, '{}', { mode: 0o600 }); + await writeFile(CREDENTIALS_PATH, '{}', { mode: 0o600 }); } catch (error) { throw new Error('Failed to clear session credentials.'); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 1c09eb3..5c86916 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,13 +1,3 @@ -export function proxyURIFromEnv(): string | undefined { - return ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - undefined - ); -} - export const RESET = "\x1b[0m" export const BRIGHT = "\x1b[1m" export const DIM = "\x1b[2m" diff --git a/packages/client/README.md b/packages/client/README.md index af4ec60..af218f5 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,55 +8,372 @@ # Localess JavaScript / TypeScript Client SDK -This client SDK is designed to work with the Localess API. It provides a simple way to interact with the Localess API from your JavaScript or TypeScript application. +The `@localess/client` package is the core JavaScript/TypeScript SDK for the [Localess](https://github.com/Lessify/localess) headless CMS platform. It provides a type-safe API client for fetching content, translations, and assets, along with Visual Editor integration utilities. -> **Important:** -> The Client is designed to be used on the server side only, as it requires your **Localess API Token** to be kept secret. -> Do not use this client in your frontend application, as it exposes your API Token to the public. +> **⚠️ Security Notice:** +> This SDK is designed for **server-side use only**. It requires a Localess API Token that must be kept secret. +> Never use this package in browser/client-side code, as it would expose your API token to the public. +> In React applications, always fetch data server-side (e.g., Next.js Server Components, API routes, or server-side rendering). + +## Requirements + +- Node.js >= 20.0.0 ## Installation -### NPM -````bash -npm install @localess/client@latest -```` +```bash +# npm +npm install @localess/client + +# yarn +yarn add @localess/client + +# pnpm +pnpm add @localess/client +``` + +--- + +## Getting Started + +### Initializing the Client + +```ts +import { localessClient } from "@localess/client"; + +const client = localessClient({ + origin: 'https://my-localess.web.app', // Fully qualified domain with protocol + spaceId: 'YOUR_SPACE_ID', // Found in Localess Space settings + token: 'YOUR_API_TOKEN', // Found in Localess Space settings (keep secret!) +}); +``` + +### Client Options + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `origin` | `string` | ✅ | — | Fully qualified domain with protocol (e.g., `https://my-localess.web.app`) | +| `spaceId` | `string` | ✅ | — | Localess Space ID, found in Space settings | +| `token` | `string` | ✅ | — | Localess API token, found in Space settings | +| `version` | `'draft' \| string` | ❌ | `'published'` | Default content version to fetch | +| `debug` | `boolean` | ❌ | `false` | Enable debug logging | +| `cacheTTL` | `number \| false` | ❌ | `300000` | Cache TTL in milliseconds (5 minutes). Set `false` to disable caching | + +--- -### Yarn -````bash -yarn add @localess/client@latest -```` +## Fetching Content -## Client +### `getContentBySlug(slug, params?)` -````ts -import {localessClient} from "@localess/client"; +Fetch a content document by its slug path. Supports generic typing for full type safety. -const llClient = localessClient({ - // A fully qualified domain name with protocol (http/https) and port. - origin: 'https://my-localess.web.app', - // Localess space ID, cna be found in the Localess Space settings - spaceId: 'I1LoVe2LocaLess4Rever', - // Localess API token, can be found in the Localess Space settings - token: 'Baz00KaT0KeN8S3CureLL' +```ts +// Basic usage +const content = await client.getContentBySlug('docs/overview'); + +// With type safety (requires generated types from @localess/cli) +import type { Page } from './.localess/localess'; + +const content = await client.getContentBySlug('home', { + locale: 'en', + resolveReference: true, + resolveLink: true, }); +``` + +### `getContentById(id, params?)` + +Fetch a content document by its unique ID. Accepts the same parameters as `getContentBySlug`. + +```ts +const content = await client.getContentById('FRnIT7CUABoRCdSVVGGs', { + locale: 'de', + version: 'draft', +}); +``` + +### Content Fetch Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `version` | `'draft' \| string` | Client default | Override the client's default content version | +| `locale` | `string` | — | ISO 639-1 locale code (e.g., `'en'`, `'de'`) | +| `resolveReference` | `boolean` | `false` | Resolve content references inline | +| `resolveLink` | `boolean` | `false` | Resolve content links inline | + +--- + +## Fetching Content Links + +### `getLinks(params?)` + +Fetch all content links from the space, optionally filtered by type or parent. + +```ts +// Fetch all links +const links = await client.getLinks(); + +// Fetch only documents under a specific parent +const legalLinks = await client.getLinks({ + kind: 'DOCUMENT', + parentSlug: 'legal', + excludeChildren: false, +}); +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `kind` | `'DOCUMENT' \| 'FOLDER'` | Filter results by content kind | +| `parentSlug` | `string` | Filter by parent slug (e.g., `'legal/policy'`) | +| `excludeChildren` | `boolean` | When `true`, excludes nested sub-slugs from results | + +--- + +## Fetching Translations + +### `getTranslations(locale)` + +Fetch all translations for a given locale. Returns a flat key-value map. + +```ts +const translations = await client.getTranslations('en'); +// { "common.submit": "Submit", "nav.home": "Home", ... } +``` + +--- + +## Assets + +### `assetLink(asset)` + +Generate a fully qualified URL for a content asset. + +```ts +import { localessClient } from "@localess/client"; + +const client = localessClient({ origin, spaceId, token }); + +// From a ContentAsset object +const url = client.assetLink(content.data.image); + +// From a URI string +const url = client.assetLink('/spaces/abc/assets/photo.jpg'); +``` + +--- + +## Visual Editor Integration + +### `loadLocalessSync(origin, force?)` + +Injects the Localess Visual Editor sync script into the document ``. This enables live-editing capabilities when your site is opened inside the Localess Visual Editor. + +```ts +import { loadLocalessSync } from "@localess/client"; + +loadLocalessSync('https://my-localess.web.app'); + +// Force injection even when not running inside an iframe +loadLocalessSync('https://my-localess.web.app', true); +``` + +### `syncScriptUrl()` + +Returns the URL of the Localess sync script, useful for manual script injection. + +```ts +const scriptUrl = client.syncScriptUrl(); +``` + +### Marking Editable Content + +Use these helpers to add Localess editable attributes to your HTML elements, enabling element selection and highlighting in the Visual Editor. + +#### `localessEditable(content)` + +Marks a content block as editable. + +```ts +import { localessEditable } from "@localess/client"; + +// Returns: { 'data-ll-id': '...', 'data-ll-schema': '...' } +
...
+``` + +#### `localessEditableField(fieldName)` + +Marks a specific field within a content block as editable, with type-safe field name inference. + +```ts +import { localessEditableField } from "@localess/client"; + +// Returns: { 'data-ll-field': 'title' } +

('title')}>...

+``` + +> **Deprecated:** `llEditable()` and `llEditableField()` are deprecated aliases. Use `localessEditable()` and `localessEditableField()` instead. + +--- + +## Listening to Visual Editor Events + +When your application is loaded inside the Localess Visual Editor, you can subscribe to editing events via `window.localess`. + +```ts +if (window.localess) { + // Subscribe to a single event + window.localess.on('change', (event) => { + if (event.type === 'change') { + setPageData(event.data); + } + }); + + // Subscribe to multiple events + window.localess.on(['input', 'change'], (event) => { + if (event.type === 'input' || event.type === 'change') { + setPageData(event.data); + } + }); +} +``` + +### Available Event Types + +| Event | Payload | Description | +|-------|---------|-------------| +| `input` | `{ type: 'input', data: any }` | Fired while a field is being edited (real-time) | +| `change` | `{ type: 'change', data: any }` | Fired after a field value is confirmed | +| `save` | `{ type: 'save' }` | Fired when content is saved | +| `publish` | `{ type: 'publish' }` | Fired when content is published | +| `pong` | `{ type: 'pong' }` | Heartbeat response from the editor | +| `enterSchema` | `{ type: 'enterSchema', id, schema, field? }` | Fired when hovering over a schema element | +| `hoverSchema` | `{ type: 'hoverSchema', id, schema, field? }` | Fired when entering a schema element | + +--- + +## Caching + +All API responses are cached by default using a TTL (time-to-live) cache. You can configure caching when initializing the client. + +```ts +// Default: 5-minute TTL cache +const client = localessClient({ origin, spaceId, token }); + +// Custom TTL (e.g., 10 minutes) +const client = localessClient({ origin, spaceId, token, cacheTTL: 600000 }); + +// Disable caching entirely +const client = localessClient({ origin, spaceId, token, cacheTTL: false }); +``` + +--- + +## Type Reference + +### `Content` + +```ts +interface Content extends ContentMetadata { + data?: T; + links?: Links; // Populated when resolveLink: true + references?: References; // Populated when resolveReference: true +} +``` + +### `ContentMetadata` + +```ts +interface ContentMetadata { + id: string; + name: string; + kind: 'FOLDER' | 'DOCUMENT'; + slug: string; + fullSlug: string; + parentSlug: string; + publishedAt?: string; + createdAt: string; + updatedAt: string; +} +``` + +### `ContentData` + +Base type for all content schema data objects. + +```ts +interface ContentDataSchema { + _id: string; + _schema?: string; + schema: string; +} + +interface ContentData extends ContentDataSchema { + [field: string]: ContentDataField | undefined; +} +``` + +### `ContentAsset` + +```ts +interface ContentAsset { + kind: 'ASSET'; + uri: string; +} +``` + +### `ContentLink` + +```ts +interface ContentLink { + kind: 'LINK'; + type: 'url' | 'content'; + target: '_blank' | '_self'; + uri: string; +} +``` + +### `ContentReference` + +```ts +interface ContentReference { + kind: 'REFERENCE'; + uri: string; +} +``` + +### `ContentRichText` + +```ts +interface ContentRichText { + type?: string; + content?: ContentRichText[]; +} +``` + +### `Links` + +A key-value map of content IDs to `ContentMetadata` objects. + +### `References` + +A key-value map of reference IDs to `Content` objects. + +### `Translations` + +A key-value map of translation keys to translated string values. + +--- -// Fetch all Content Links -llClient.getLinks() -// Fetch content by SLUG -llClient.getContentBySlug('docs/overview') -// Fetch content by ID -llClient.getContentById('FRnIT7CUABoRCdSVVGGs') -// Fetch translations by locale -llClient.getTranslations('en') -```` +## Utility Functions -## Sync with Visual Editor +| Function | Returns | Description | +|----------|---------|-------------| +| `isBrowser()` | `boolean` | Returns `true` if code is running in a browser environment | +| `isServer()` | `boolean` | Returns `true` if code is running in a server/Node.js environment | +| `isIframe()` | `boolean` | Returns `true` if the page is rendered inside an iframe | -It will automatically inject Localess Sync Script in to the HTML page. +--- -````ts -import {loadLocalessSync} from "@localess/client"; +## License -// A fully qualified domain name with protocol (http/https) and port. -loadLocalessSync('https://my-localess.web.app') -```` +[MIT](../../LICENSE) diff --git a/packages/client/package.json b/packages/client/package.json index 158b923..7db8c81 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@localess/client", - "version": "0.9.2", + "version": "3.0.0", "description": "Universal JavaScript/TypeScript SDK for Localess's API.", "keywords": [ "localess", @@ -39,7 +39,6 @@ }, "license": "MIT", "dependencies": { - "openapi3-ts": "^4.5.0" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/react/README.md b/packages/react/README.md index 3f862ef..ba61d42 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -6,89 +6,346 @@ ---- -# Localess React +# Localess React SDK -This client SDK is designed to work with the Localess API. It provides a simple way to interact with the Localess API from your React application. +The `@localess/react` package is the official React integration for the [Localess](https://github.com/Lessify/localess) headless CMS platform. It provides component mapping, rich text rendering, and Visual Editor synchronization support for React applications. + +> **⚠️ Security Notice:** +> This package uses `@localess/client` internally, which requires an API token for server-side data fetching. +> Always fetch Localess content server-side (e.g., Next.js Server Components, API routes, or `getServerSideProps`) and never expose your token in client-side code. + +## Requirements + +- Node.js >= 20.0.0 +- React 17, 18, or 19 ## Installation -### NPM -````bash +```bash +# npm npm install @localess/react -```` -### Yarn -````bash +# yarn yarn add @localess/react -```` -## Usage +# pnpm +pnpm add @localess/react +``` + +--- + +## Getting Started + +### 1. Initialize the SDK -### Initialize and Visual Editor -````tsx +Call `localessInit` once at application startup (e.g., in your root layout or `_app.tsx`) to configure the client, register your components, and optionally enable the Visual Editor. + +```tsx import { localessInit } from "@localess/react"; -import { Page, Header, Teaser, Footer } from "@/components" +import { Page, Header, Teaser, Footer } from "@/components"; localessInit({ origin: "https://my-localess.web.app", - spaceId: "I1LoVe2LocaLess4Rever", - token: "Baz00KaT0KeN8S3CureLL", - enableSync: true, //Enable Visual Editor Sync Script, + spaceId: "YOUR_SPACE_ID", + token: "YOUR_API_TOKEN", + enableSync: true, // Enable Visual Editor sync script components: { 'page': Page, 'header': Header, 'teaser': Teaser, 'footer': Footer, }, -}) -```` +}); +``` + +### Initialization Options + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `origin` | `string` | ✅ | — | Fully qualified domain with protocol (e.g., `https://my-localess.web.app`) | +| `spaceId` | `string` | ✅ | — | Localess Space ID, found in Space settings | +| `token` | `string` | ✅ | — | Localess API token (keep secret — server-side only) | +| `version` | `'draft' \| string` | ❌ | `'published'` | Default content version | +| `debug` | `boolean` | ❌ | `false` | Enable debug logging | +| `cacheTTL` | `number \| false` | ❌ | `300000` | Cache TTL in milliseconds. Set `false` to disable | +| `components` | `Record` | ❌ | `{}` | Map of schema keys to React components | +| `fallbackComponent` | `React.ElementType` | ❌ | — | Component rendered when a schema key has no registered component | +| `enableSync` | `boolean` | ❌ | `false` | Load the Visual Editor sync script for live-editing support | + +--- + +## `LocalessComponent` + +`LocalessComponent` is a dynamic renderer that maps Localess content data to your registered React components by schema key. It automatically applies Visual Editor attributes when sync is enabled. + +```tsx +import { LocalessComponent } from "@localess/react"; + +// Render a single content block + + +// Render a list of nested blocks +{data.body.map(item => ( + +))} +``` + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `data` | `ContentData` | ✅ | Content data object from Localess. The component looks up `data._schema` or `data.schema` in the component registry | +| `links` | `Links` | ❌ | Resolved content links map, forwarded to the rendered component | +| `references` | `References` | ❌ | Resolved references map, forwarded to the rendered component | +| `ref` | `React.Ref` | ❌ | Ref forwarded to the rendered component's root element | +| `...rest` | `any` | ❌ | Any additional props are forwarded to the rendered component | + +> If a schema key is not registered and no `fallbackComponent` is configured, `LocalessComponent` renders an error message in the DOM. + +--- + +## Marking Editable Content + +Use these helpers to add Visual Editor attributes to your JSX elements. They enable element highlighting and selection in the Localess Visual Editor. + +### `localessEditable(content)` -### React Component -Example of Header Component with Menu Items +Marks a content block root element as editable. -````tsx -import { llEditable, LocalessComponent } from "@localess/react"; +```tsx +import { localessEditable } from "@localess/react"; + +const Header = ({ data }) => ( + +); +``` + +### `localessEditableField(fieldName)` + +Marks a specific field within a content block as editable, with type-safe field name inference when combined with generated types. + +```tsx +import { localessEditableField } from "@localess/react"; + +const Hero = ({ data }: { data: HeroBlock }) => ( +
+

('title')}>{data.title}

+

('subtitle')}>{data.subtitle}

+
+); +``` + +> **Deprecated:** `llEditable()` and `llEditableField()` are deprecated aliases. Use `localessEditable()` and `localessEditableField()` instead. + +--- + +## Rich Text Rendering + +### `renderRichTextToReact(content)` + +Converts a Localess `ContentRichText` object to a React node tree. Supports the full range of rich text formatting produced by the Localess editor. + +```tsx +import { renderRichTextToReact } from "@localess/react"; + +const Article = ({ data }) => ( +
+

{data.title}

+
{renderRichTextToReact(data.body)}
+
+); +``` + +**Supported rich text elements:** + +- Document structure +- Headings (h1–h6) +- Paragraphs +- Text formatting: **bold**, *italic*, ~~strikethrough~~, underline +- Ordered and unordered lists +- Code blocks (with syntax highlighting support) +- Links (inline) + +--- + +## Accessing the Client + +### `getLocalessClient()` + +Returns the `LocalessClient` instance created during `localessInit`. Use this in server-side data-fetching functions. + +```ts +import { getLocalessClient } from "@localess/react"; + +async function fetchPageData(locale?: string) { + const client = getLocalessClient(); + return client.getContentBySlug('home', { locale }); +} +``` + +> Throws an error if called before `localessInit` has been executed. + +--- + +## Component Registry API + +These functions allow dynamic management of the component registry after initialization. + +```ts +import { + registerComponent, + unregisterComponent, + setComponents, + getComponent, + setFallbackComponent, + getFallbackComponent, + isSyncEnabled, +} from "@localess/react"; + +// Register a new component +registerComponent('hero-block', HeroBlock); + +// Unregister a component +unregisterComponent('hero-block'); + +// Replace the entire registry +setComponents({ 'page': Page, 'hero': Hero }); + +// Retrieve a component by schema key +const Component = getComponent('hero'); + +// Configure the fallback component +setFallbackComponent(UnknownComponent); + +// Get the current fallback component +const fallback = getFallbackComponent(); + +// Check if Visual Editor sync is enabled +const syncEnabled = isSyncEnabled(); +``` + +--- + +## Assets + +### `resolveAsset(asset)` + +Resolves a `ContentAsset` object to a fully qualified URL using the initialized client's origin. + +```tsx +import { resolveAsset } from "@localess/react"; + +const Image = ({ data }) => ( + {data.imageAlt} +); +``` + +--- + +## Visual Editor Events + +When your application is opened inside the Localess Visual Editor, subscribe to live-editing events via `window.localess`. + +```tsx +'use client'; + +import { useEffect, useState } from "react"; +import { getLocalessClient } from "@localess/react"; +import type { Content } from "@localess/react"; + +export function PageClient({ initialData }: { initialData: Content }) { + const [pageData, setPageData] = useState(initialData.data); + + useEffect(() => { + if (window.localess) { + window.localess.on(['input', 'change'], (event) => { + if (event.type === 'input' || event.type === 'change') { + setPageData(event.data); + } + }); + } + }, []); -const Header = ({data}) => { return ( - - ) +
+ {pageData.body.map(item => ( + + ))} +
+ ); } -```` +``` + +--- -### Listen for Visual Editor Events -Your application can subscribe to the Localess Visual Editor Events. -Example from NextJS 15 +## Full Example (Next.js 15 App Router) -````tsx -import { llEditable, LocalessComponent, getLocalessClient } from "@localess/react"; +```tsx +// app/layout.tsx (Server Component — safe to use API token here) +import { localessInit } from "@localess/react"; +import { Page, Header, Teaser, Footer } from "@/components"; + +localessInit({ + origin: process.env.LOCALESS_ORIGIN!, + spaceId: process.env.LOCALESS_SPACE_ID!, + token: process.env.LOCALESS_TOKEN!, + enableSync: process.env.NODE_ENV === 'development', + components: { Page, Header, Teaser, Footer }, +}); + +export default function RootLayout({ children }) { + return {children}; +} +``` -export default async function Page({searchParams}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> +```tsx +// app/page.tsx (Server Component) +import { getLocalessClient, LocalessComponent, localessEditable } from "@localess/react"; +import type { Page } from "./.localess/localess"; + +export default async function HomePage({ + searchParams, +}: { + searchParams: Promise<{ locale?: string }>; }) { - const {locale} = await searchParams - const {data} = await fetchData(locale?.toString()); - const [ pageData, setPageData ] = useState(data); - - - if (window.localess) { - window.localess.on(['input', 'change'], (event) => { - if (event.type === 'input' || event.type === 'change') { - setPageData(event.data); - } - }); - } + const { locale } = await searchParams; + const client = getLocalessClient(); + const content = await client.getContentBySlug('home', { locale }); + return ( -
- {data.body.map(item => )} +
+ {content.data?.body.map(item => ( + + ))}
- ) + ); } +``` -async function fetchData(locale?: string): Promise> { - const client = getLocalessClient(); // Get LocalessClient Initlialized before - return client.getContentBySlug('home', {locale: locale ? locale : undefined}); -} -```` +--- + +## Re-exported from `@localess/client` + +The following are re-exported for convenience so you only need to import from `@localess/react`: + +**Types:** `Content`, `ContentData`, `ContentMetadata`, `ContentDataSchema`, `ContentDataField`, `ContentAsset`, `ContentRichText`, `ContentLink`, `ContentReference`, `Links`, `References`, `Translations`, `LocalessClient`, `LocalessSync`, `EventToApp`, `EventCallback`, `EventToAppType` + +**Functions:** `localessEditable`, `localessEditableField`, `llEditable` *(deprecated)*, `llEditableField` *(deprecated)*, `isBrowser`, `isServer`, `isIframe` + +--- + +## License + +[MIT](../../LICENSE) diff --git a/packages/react/package.json b/packages/react/package.json index 124de62..08ed0bd 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@localess/react", - "version": "0.9.2", + "version": "3.0.0", "description": "ReactJS JavaScript/TypeScript SDK for Localess's API.", "keywords": [ "localess", @@ -46,31 +46,31 @@ }, "dependencies": { "@localess/client": "*", - "@tiptap/static-renderer": "^3.14.0", - "@tiptap/html": "^3.14.0", - "@tiptap/extension-bold": "^3.14.0", - "@tiptap/extension-italic": "^3.14.0", - "@tiptap/extension-strike": "^3.14.0", - "@tiptap/extension-underline": "^3.14.0", - "@tiptap/extension-document": "^3.14.0", - "@tiptap/extension-text": "^3.14.0", - "@tiptap/extension-paragraph": "^3.14.0", - "@tiptap/extension-history": "^3.14.0", - "@tiptap/extension-list-item": "^3.14.0", - "@tiptap/extension-bullet-list": "^3.14.0", - "@tiptap/extension-ordered-list": "^3.14.0", - "@tiptap/extension-code": "^3.14.0", - "@tiptap/extension-code-block-lowlight": "^3.14.0", - "@tiptap/extension-link": "^3.14.0", - "@tiptap/extension-heading": "^3.14.0" + "@tiptap/static-renderer": "^3.20.1", + "@tiptap/html": "^3.20.1", + "@tiptap/extension-bold": "^3.20.1", + "@tiptap/extension-italic": "^3.20.1", + "@tiptap/extension-strike": "^3.20.1", + "@tiptap/extension-underline": "^3.20.1", + "@tiptap/extension-document": "^3.20.1", + "@tiptap/extension-text": "^3.20.1", + "@tiptap/extension-paragraph": "^3.20.1", + "@tiptap/extension-history": "^3.20.1", + "@tiptap/extension-list-item": "^3.20.1", + "@tiptap/extension-bullet-list": "^3.20.1", + "@tiptap/extension-ordered-list": "^3.20.1", + "@tiptap/extension-code": "^3.20.1", + "@tiptap/extension-code-block-lowlight": "^3.20.1", + "@tiptap/extension-link": "^3.20.1", + "@tiptap/extension-heading": "^3.20.1" }, "devDependencies": { "@types/react": "^19.0.0", - "@types/node": "^20.12.12", + "@types/node": "^20", "react": "^19.0.0", "react-dom": "^19.0.0", "tsup": "^8.5.1", - "typescript": "^5.8.3" + "typescript": "^5.9.3" }, "engines": { "node": ">= 20.0.0" diff --git a/packages/react/src/localess-componenet.tsx b/packages/react/src/localess-componenet.tsx index f72b191..7ac1088 100644 --- a/packages/react/src/localess-componenet.tsx +++ b/packages/react/src/localess-componenet.tsx @@ -1,14 +1,15 @@ import {forwardRef} from "react"; -import {ContentData, Links, localessEditable} from "@localess/client"; +import {ContentData, Links, localessEditable, References} from "@localess/client"; import {FONT_BOLD, FONT_NORMAL} from "./console"; import {getComponent, getFallbackComponent, isSyncEnabled} from "./index"; export type LocalessComponentProps = { data: T links?: Links + references?: References; } -export const LocalessComponent = forwardRef(({data, links, ...restProps}, ref) => { +export const LocalessComponent = forwardRef(({data, links, references, ...restProps}, ref) => { if (!data) { console.error('LocalessComponent property %cdata%c is not provided.', FONT_BOLD, FONT_NORMAL) return
LocalessComponent property data is not provided.
@@ -17,12 +18,12 @@ export const LocalessComponent = forwardRef const Comp = getComponent(data._schema || data.schema); if (Comp) { const attr = isSyncEnabled() ? localessEditable(data) : {}; - return ; + return ; } // Try to use Fallback Component const FallbackComponent = getFallbackComponent() if (FallbackComponent) { - return + return } // Missing Configuration case return (