diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..b40de1a --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,27 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "subject-case": [0], + "subject-empty": [2, "never"], + "subject-full-stop": [2, "never", "."], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + "sample" + ] + ], + "type-empty": [2, "never"] + } +} diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 0000000..b4b2107 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,26 @@ +# Cursor rules — `@contentstack/utils` + +This folder holds project-specific rules for AI assistants working in **contentstack-utils-javascript**. For the full project overview, see **[`AGENTS.md`](../../AGENTS.md)** at the repository root. + +## How rules are picked up + +Each rule is a `.md`/`.mdc` file. Files with YAML frontmatter can set `description`, `globs`, and `alwaysApply`. Cursor uses these to decide when a rule is included in context. + +### Referencing rules in chat + +In Cursor, you can **`@`-mention** a rule file (e.g. type `@` and choose the rule from the list) to force its guidance into the conversation. The file name (without extension) is the usual handle, e.g. **`typescript`**, **`testing`**, **`code-review`**. + +## Rule index + +| File | `alwaysApply` | Globs | When it applies | +|------|----------------|-------|------------------| +| [`dev-workflow.md`](dev-workflow.md) | no | *(none)* | Branching, CI alignment, commits, releases, and day-to-day commands for this repo. | +| [`typescript.mdc`](typescript.mdc) | no | `src/**/*.ts`, `__test__/**/*.ts` | TypeScript version, layout, ESLint/Prettier, strictness, imports. | +| [`typescript-contentstack-utils.mdc`](typescript-contentstack-utils.mdc) | no | `src/**/*.ts` | Delivery-oriented utils only: RTE rendering, GQL helpers, endpoints JSON, Live Preview tags—**not** full CDA/CMA SDK surface. | +| [`testing.mdc`](testing.mdc) | no | `__test__/**/*.ts` | Jest, jsdom, mocks, coverage output paths, no live-test env. | +| [`code-review.mdc`](code-review.mdc) | **yes** | *(global)* | PR checklist: public API docs, compatibility, errors, dependencies, terminology (utils + delivery context). | + +## Related + +- **[`AGENTS.md`](../../AGENTS.md)** — Single entry point (package purpose, stack, commands). +- **[`skills/README.md`](../../skills/README.md)** — Longer-form skill docs for the same themes. diff --git a/.cursor/rules/code-review.mdc b/.cursor/rules/code-review.mdc new file mode 100644 index 0000000..0e5fc27 --- /dev/null +++ b/.cursor/rules/code-review.mdc @@ -0,0 +1,41 @@ +--- +description: PR review checklist for @contentstack/utils — API docs, compatibility, security, testing +alwaysApply: true +--- + +# Code review checklist — `@contentstack/utils` + +Use severity labels (**Blocker / Major / Minor**) when triaging findings. + +## Public API and documentation + +- **Blocker/Major:** New or changed **exports** from `src/index.ts` need accurate **JSDoc** (or clear type names) matching runtime behavior. +- **Major:** README / CHANGELOG updates when behavior is user-visible or migration is needed. + +## Backward compatibility + +- **Blocker:** Unplanned breaking changes to **function signatures** or **default behavior** consumed by Delivery SDK integrations or documented `renderOption` contracts. +- **Major:** Stricter throwing on inputs that previously passed (especially `getContentstackEndpoint`, RTE traversals). + +## Errors + +- This package uses **plain `Error`** (e.g. `endpoints.ts`); new code should keep messages actionable. **Major:** Silent failures where callers need to detect bad input. + +## Null safety and RTE edge cases + +- **Major:** Missing guards on **null/undefined** node or entry fragments (historically sensitive in `entry-editable` / RTE paths). +- **Minor:** Align with **`strictNullChecks: false`** legacy but avoid widening undefined leaks into public types. + +## Dependencies and SCA + +- **Major:** New runtime deps are rare—justify any addition; **`prepublishOnly`** and hooks assume **`npm test`** and Snyk-friendly trees. +- Use **`npm audit` / Snyk** expectations per org policy. + +## Tests + +- **Blocker:** Behavioral fixes or new branches without **`__test__`** coverage when risk is high (RTE nesting, GQL URL rewriting, endpoint resolution). +- **Minor:** Snapshot-only tests where a small assertion would be clearer. + +## Terminology + +- **Major:** Docs/comments must describe this as **utils** alongside **CDA / Delivery / JSON RTE / GraphQL**, not as **CMA** unless the change is explicitly management-related. diff --git a/.cursor/rules/dev-workflow.md b/.cursor/rules/dev-workflow.md new file mode 100644 index 0000000..0453f3c --- /dev/null +++ b/.cursor/rules/dev-workflow.md @@ -0,0 +1,38 @@ +--- +description: Branching, CI, local commands, commit hooks, and release flow for contentstack-utils-javascript +alwaysApply: false +--- + +# Dev workflow — `@contentstack/utils` + +## Branches and PRs + +- **CI** (`Unit-Test-CI`) runs on **push/PR** to `development`, `staging`, and `master` (see `.github/workflows/ci.yml`). +- **Branch protection:** A workflow fails PRs that target **`staging`** when the head branch is **not** `development` (see `.github/workflows/check-branch.yml`). Prefer **`development`** as the integration branch when contributing upstream. +- Target the branch your team uses for integration; align with maintainers if unsure. + +## Local development + +| Task | Command / note | +|------|----------------| +| Build | `npm run build` (runs `prebuild`: clean `dist`, `download-regions`, then `tsc` + Rollup) | +| Test | `npm test` (includes `pretest` → build; Jest + coverage; `reports/`) | +| Format | `npm run format` (Prettier: `src/**/*.ts`) | +| ESLint | No `lint` script in `package.json`; run e.g. `npx eslint src __test__` using `eslint.config.js` | + +## Git hooks (Husky) + +- **`commit-msg`:** [Conventional Commits](https://www.conventionalcommits.org/) via **`commitlint`** (`.commitlintrc.json`). +- **`pre-commit`:** Requires **Snyk** (`snyk test --all-projects`) and **Talisman** installed locally. Set `SKIP_HOOK=1` to bypass (documented in the hook). + +## PR expectations + +- **`npm test`** passes (build + unit tests). +- **CHANGELOG.md** updated for user-visible changes when maintainers expect it. +- **Dependencies:** Be mindful of Snyk/SCA workflows; security-relevant bumps should be justified. + +## Releases and versioning + +- **Package version** lives in **`package.json`** (also reflected in **`CHANGELOG.md`**). +- **`prepublishOnly`** runs **`npm test`** before publish. +- **NPM / GitHub Packages:** Publishing is driven by **GitHub release `created`** (`.github/workflows/npm-publish.yml`); requires maintainer secrets (`NPM_TOKEN`, `GIT_TOKEN`). Bump version and changelog in the same change set as the release your team uses. diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 0000000..1b7f67e --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,38 @@ +--- +description: Jest tests, mocks, reports, and environments for @contentstack/utils +globs: + - __test__/**/*.ts +alwaysApply: false +--- + +# Testing — `@contentstack/utils` + +## Framework + +- **Jest 29** with **`ts-jest`** preset (`jest.config.ts`). +- **Environment:** **`jsdom`** (suitable for HTML string assertions and DOM-related behavior). + +## Discovery and naming + +- **Pattern:** `**/__test__/**/?(*.)+(spec|test).[jt]s?(x)` — this repo uses `*.test.ts` under **`__test__/`**. +- **Mocks / fixtures:** `__test__/mock/*.ts` — follow existing mock naming (`*-mock.ts`). + +## Coverage and reports + +- **Coverage** is collected from `src/**` excluding `src/index.ts` (`collectCoverageFrom`). +- Outputs: **`reports/coverage/`**, **`reports/html/`** (jest-html-reporters), **`reports/junit/`** (jest-junit), **`reports/report.json`** (from `npm test` script). + +## Running tests + +| Command | Behavior | +|---------|----------| +| `npm test` | Runs **`pretest` → build** then Jest (CI-aligned). | +| `npm run test:debug` | Watch mode, `--runInBand`. | + +## Integration / live tests + +- **None** in this repo. No `.env` or stack credentials required for **`npm test`**. + +## Build dependency + +- **`npm test` always builds first** (`pretest`). Ensure **`src/assets/regions.json`** exists or can be downloaded so `endpoints` tests pass. diff --git a/.cursor/rules/typescript-contentstack-utils.mdc b/.cursor/rules/typescript-contentstack-utils.mdc new file mode 100644 index 0000000..70f23c6 --- /dev/null +++ b/.cursor/rules/typescript-contentstack-utils.mdc @@ -0,0 +1,34 @@ +--- +description: Contentstack utils package patterns — RTE, embedded items, GQL, endpoints (delivery-oriented companion, not CDA/CMA SDK) +globs: + - src/**/*.ts +alwaysApply: false +--- + +# `@contentstack/utils` — domain patterns + +This repository implements **companion utilities** for Contentstack **content consumption** scenarios (typically with the **Delivery SDK** and **CDA / GraphQL** responses). It does **not** implement stack configuration, delivery tokens, or HTTP clients—callers fetch entry JSON elsewhere and pass it into these helpers. + +## Mental model + +| Area | Source anchors | Notes | +|------|----------------|-------| +| Render options / callbacks | `src/options/`, `src/render-embedded-objects.ts` | `RenderOption`-style maps: node types, marks, `block` / `inline` embeds, `reference`, `display`, `default`. | +| JSON RTE → HTML | `src/json-to-html.ts`, `src/nodes/*`, `src/helper/*` | Supercharged RTE path uses `paths` arrays for nested fields (see `README.md`). | +| Embedded object types | `src/Models/embedded-object.ts`, `src/Models/json-rte-model.ts` | Align typings with shapes coming from **delivery** entry JSON, not management APIs. | +| GraphQL | `src/gql.ts`, `src/updateAssetURLForGQL.ts` | Docs/comments describe use **after** GraphQL fetches; asset URL rewriting is response-shape specific. | +| Live Preview / CSLP tags | `src/entry-editable.ts` | `addTags` / exported `addEditableTags` — preserve existing attribute and locale behavior when changing tag generation. | +| Region endpoints | `src/endpoints.ts` | **`getContentstackEndpoint(region, service?, omitHttps?)`** reads **`src/assets/regions.json`**; throws plain `Error` with human-readable messages on bad region/service. | + +## Terminology + +- Prefer **CDA / Delivery / JSON RTE / GraphQL** vocabulary when describing behavior—**not** CMA unless you are explicitly discussing something management-only (rare here). +- **“SDK”** in docs often means **this utils package** or the **Delivery SDK** together—disambiguate in user-facing text. + +## Concurrency / IO + +- Pure synchronous transforms dominate; no retry or rate-limit layer. Do not add HTTP clients without an explicit product requirement. + +## Official docs alignment + +- Behavior should stay consistent with current **Content Delivery** / **Live Preview** / **JSON RTE** documentation. When in doubt, cite the relevant Contentstack doc set in PR descriptions rather than guessing CMS semantics. diff --git a/.cursor/rules/typescript.mdc b/.cursor/rules/typescript.mdc new file mode 100644 index 0000000..ad2d6fb --- /dev/null +++ b/.cursor/rules/typescript.mdc @@ -0,0 +1,34 @@ +--- +description: TypeScript conventions for @contentstack/utils (src and tests) +globs: + - src/**/*.ts + - __test__/**/*.ts +alwaysApply: false +--- + +# TypeScript — `@contentstack/utils` + +## Tooling + +- **TypeScript ~4.9** (`package.json`, `tsconfig.json`). +- **Bundler output:** `tsc` emits to `dist/lib` with declarations in `dist/types`; **Rollup** produces `dist/index.es.js` (`rollup.config.js`). +- **Strict mode** is on with **`strictNullChecks: false`** — new code should still minimize unnecessary null/undefined gaps when touching APIs. + +## Layout + +- **Library code:** `src/**/*.ts` only (`tsconfig.json` `include`); **tests** are under `__test__/` (excluded from `tsc` program but type-checked via Jest/tsconfig paths). +- **Public exports:** Maintain **`src/index.ts`** as the single export surface unless intentionally adding secondary entry points. + +## Style and lint + +- Match existing naming (PascalCase types, camelCase functions, existing file layout under `Models/`, `nodes/`, `helper/`, `options/`). +- **ESLint** flat config: `eslint.config.js` — `@typescript-eslint` recommended for `__test__`; source files use `js.configs.recommended` baseline. +- **Prettier:** `npm run format` scopes `src/**/*.ts`; keep formatting consistent when editing tests too. + +## JSON / assets + +- **`src/assets/regions.json`:** Consumed by `src/endpoints.ts`; populated by **`npm run download-regions`** during `prebuild`. Do not assume the file is committed (see root `.gitignore`). + +## Logging + +- No shared logger dependency; avoid introducing `console` noise in library paths unless consistent with nearby code. diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml new file mode 100644 index 0000000..c63e084 --- /dev/null +++ b/.github/workflows/check-branch.yml @@ -0,0 +1,20 @@ +name: 'Check Branch' + +on: + pull_request: + +jobs: + check_branch: + runs-on: ubuntu-latest + steps: + - name: Comment PR + if: github.base_ref == 'staging' && github.head_ref != 'development' + uses: thollander/actions-comment-pull-request@v2 + with: + message: | + We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the development branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. + - name: Check branch + if: github.base_ref == 'staging' && github.head_ref != 'development' + run: | + echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the development branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." + exit 1 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ddd95c..33b9343 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,30 @@ -# This is a basic workflow to help you get started with Actions - name: Unit-Test-CI -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the master branch on: push: - branches: [ master ] + branches: [ master, staging, development ] pull_request: - branches: [ master ] + branches: [ master, staging, development ] jobs: - build: + build-test: runs-on: ubuntu-latest - # Steps represent a sequence of tasks that will be executed as part of the job steps: - - uses: actions/checkout@v2 - - name: Install modules - run: npm install - - name: Run tests - run: npm run test + - uses: actions/checkout@v3 + - uses: ArtiomTr/jest-coverage-report-action@v2 + id: coverage-utils-js + with: + output: comment, report-markdown + - uses: marocchino/sticky-pull-request-comment@v2 + with: + header: Contentstack Utils JS Coverage + recreate: true + message: ${{ steps.coverage-utils-js.outputs.report }} + - name: Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: JEST Tests + path: reports/junit/jest-*.xml + reporter: jest-junit + fail-on-error: true \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..3566f96 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,68 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + pull_request: + # The branches below must be a subset of the branches above + branches: '*' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/issues-jira.yml b/.github/workflows/issues-jira.yml new file mode 100644 index 0000000..7bf0469 --- /dev/null +++ b/.github/workflows/issues-jira.yml @@ -0,0 +1,31 @@ +name: Create Jira Ticket for Github Issue + +on: + issues: + types: [opened] + +jobs: + issue-jira: + runs-on: ubuntu-latest + steps: + + - name: Login to Jira + uses: atlassian/gajira-login@master + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + + - name: Create Jira Issue + id: create_jira + uses: atlassian/gajira-create@master + with: + project: ${{ secrets.JIRA_PROJECT }} + issuetype: ${{ secrets.JIRA_ISSUE_TYPE }} + summary: Github | Issue | ${{ github.event.repository.name }} | ${{ github.event.issue.title }} + description: | + *GitHub Issue:* ${{ github.event.issue.html_url }} + + *Description:* + ${{ github.event.issue.body }} + fields: "${{ secrets.ISSUES_JIRA_FIELDS }}" \ No newline at end of file diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..eab0907 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,34 @@ +# This workflow will publish a package to GitHub Packages when a release is created +# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages + +name: Publish package to NPM repository +on: + release: + types: [created] + +jobs: + publish-npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.x' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-git: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.x' + registry-url: 'https://npm.pkg.github.com' + scope: '@contentstack' + - run: npm ci + - run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.GIT_TOKEN }} diff --git a/.github/workflows/policy-scan.yml b/.github/workflows/policy-scan.yml new file mode 100644 index 0000000..ff25923 --- /dev/null +++ b/.github/workflows/policy-scan.yml @@ -0,0 +1,46 @@ +name: Checks the security policy and configurations +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-policy: + if: github.event.repository.visibility == 'public' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@master + - name: Checks for SECURITY.md policy file + run: | + if ! [[ -f "SECURITY.md" || -f ".github/SECURITY.md" ]]; then exit 1; fi + security-license: + if: github.event.repository.visibility == 'public' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@master + - name: Checks for License file + run: | + expected_license_files=("LICENSE" "LICENSE.txt" "LICENSE.md" "License.txt") + license_file_found=false + current_year=$(date +"%Y") + + for license_file in "${expected_license_files[@]}"; do + if [ -f "$license_file" ]; then + license_file_found=true + # check the license file for the current year, if not exists, exit with error + if ! grep -q "$current_year" "$license_file"; then + echo "License file $license_file does not contain the current year." + exit 2 + fi + break + fi + done + + if [ "$license_file_found" = false ]; then + echo "No license file found. Please add a license file to the repository." + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml new file mode 100644 index 0000000..2307d48 --- /dev/null +++ b/.github/workflows/sca-scan.yml @@ -0,0 +1,18 @@ +name: Source Composition Analysis Scan +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-sca: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --all-projects --fail-on=all + json: true + continue-on-error: true + - uses: contentstack/sca-policy@main diff --git a/.gitignore b/.gitignore index 0fb2594..582c800 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,19 @@ *.DS_Store examples/* -node_modules/* +node_modules/ .idea/* -reports/* -apidocs-templates/* -testcase-report/* +reports/ +apidocs-templates/ +testcase-report/ test/smtpconfig.js/* test/config.js/* test/sync_config.js/* test/report.json/* tap-html.html *html-report -dist/* +dist/ coverage/ -.dccache \ No newline at end of file +.dccache +snyk_output.log +talisman_output.log +regions.json \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..80416c7 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no-install commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..4f1fbbc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,69 @@ +#!/usr/bin/env sh +# Pre-commit hook to run Snyk and Talisman scans, completing both before deciding to commit + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Check if Snyk is installed +if ! command_exists snyk; then + echo "Error: Snyk is not installed. Please install it and try again." + exit 1 +fi + +# Check if Talisman is installed +if ! command_exists talisman; then + echo "Error: Talisman is not installed. Please install it and try again." + exit 1 +fi + +# Allow bypassing the hook with an environment variable +if [ "$SKIP_HOOK" = "1" ]; then + echo "Skipping Snyk and Talisman scans (SKIP_HOOK=1)." + exit 0 +fi + +# Initialize variables to track scan results +snyk_failed=false +talisman_failed=false + +# Run Snyk vulnerability scan +echo "Running Snyk vulnerability scan..." +snyk test --all-projects > snyk_output.log 2>&1 +snyk_exit_code=$? + +if [ $snyk_exit_code -eq 0 ]; then + echo "Snyk scan passed: No vulnerabilities found." +elif [ $snyk_exit_code -eq 1 ]; then + echo "Snyk found vulnerabilities. See snyk_output.log for details." + snyk_failed=true +else + echo "Snyk scan failed with error (exit code $snyk_exit_code). See snyk_output.log for details." + snyk_failed=true +fi + +# Run Talisman secret scan (continues even if Snyk failed) +echo "Running Talisman secret scan..." +talisman --githook pre-commit > talisman_output.log 2>&1 +talisman_exit_code=$? + +if [ $talisman_exit_code -eq 0 ]; then + echo "Talisman scan passed: No secrets found." +else + echo "Talisman scan failed (exit code $talisman_exit_code). See talisman_output.log for details." + talisman_failed=true +fi + +# Evaluate results after both scans +if [ "$snyk_failed" = true ] || [ "$talisman_failed" = true ]; then + echo "Commit aborted due to issues found in one or both scans." + [ "$snyk_failed" = true ] && echo "- Snyk issues: Check snyk_output.log" + [ "$talisman_failed" = true ] && echo "- Talisman issues: Check talisman_output.log" + exit 1 +fi + +# If both scans pass, allow the commit +echo "All scans passed. Proceeding with commit.cd ." +rm -f snyk_output.log talisman_output.log +exit 0 \ No newline at end of file diff --git a/.jsdoc.json b/.jsdoc.json deleted file mode 100644 index b29cc4e..0000000 --- a/.jsdoc.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "tags": { - "allowUnknownTags": true, - "dictionaries": ["jsdoc"] - }, - "source": { - "include": ["src"], - "excludePattern": "(node_modules/|jsdocs)" - }, - "templates": { - "cleverLinks": false, - "monospaceLinks": true - }, - "opts": { - "destination": "./jsdocs/", - "encoding": "utf8", - "private": true, - "recurse": true, - "template": "./node_modules/docdash", - "sort": false - }, - "docdash": { - "static": true, - "sort": false - } -} \ No newline at end of file diff --git a/.npmignore b/.npmignore index 33e1639..46aa587 100644 --- a/.npmignore +++ b/.npmignore @@ -2,5 +2,8 @@ src tsconfig.json tslint.json .prettierrc +.talismanrc jestconfig.json -CODEOWNERS \ No newline at end of file +jest.config.ts +CODEOWNERS +reports/ \ No newline at end of file diff --git a/.talismanrc b/.talismanrc index dbc73fd..78e5520 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,3 +1,34 @@ fileignoreconfig: +- filename: .github/workflows/secrets-scan.yml + ignore_detectors: + - filecontent - filename: package-lock.json - checksum: 9746d3b1ac67da5dc0f3ec6f8798166bc8b9c1e4c736de01f7c52b2f9cc194be + checksum: 85c2c0c45183180b6793501e09529c5396a66a73e876ade9a80f625b8d174102 +- filename: __test__/find-render-content.test.ts + checksum: c2508ae1fb2b20f6fe3d354558704076ecdcbe7e1ece46addaa5eb8354e60233 +- filename: __test__/json-to-html.test.ts + checksum: 8ef368136341314dc597a1e0a3a7b45ac9255897b376395db5e7e032b661682e +- filename: __test__/entry-editable.test.ts + checksum: 32e5e047c56ef0e74456eb6275c6ea8d2ffbef38901bc3eb97ae0a62e431f5a2 +- filename: src/entry-editable.ts + checksum: e130c19526679f220073ed9cc918fa28906ebf99c33c00cf8ee58f2178caddcf +- filename: .husky/pre-commit + checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193 +- filename: src/endpoints.ts + checksum: 721a1df93b02d04c1c19a76c171fe2748e4abb1fc3e43452e76fecfd8f384751 +- filename: src/options/default-node-options.ts + checksum: d455330cc4f9306889fb299171364a37ad2c3bafe1fbd334033edc94f21694a6 +- filename: package.json + checksum: 033eb21070795be5b426183f52d784347110fcb724bc9f8d63f94898ac5f0086 +- filename: src/Models/metadata-model.ts + checksum: a101e109db1ec6ee0cb16a116f9b099e547c3104881e4fa1eaef2849e2d0aaf0 +- filename: src/json-to-html.ts + checksum: a843710fc9f54bf4c7996f39561dc66491d62a9d9eeca50fa2c7c37bd6141f53 +- filename: src/render-embedded-objects.ts + checksum: 35d56d4f7b625611fef18414fccdbff014c1d90d02e17eb0efa4d6166b73e23b +- filename: .cursor/rules/code-review.mdc + checksum: 43d4fddb706e5ad6c92fe0af30bd5f44eec9b83c31dc93d3d4cc5e6208d351cc +- filename: .cursor/rules/dev-workflow.md + checksum: b9a4d68439ce9564730506ab6c4712f5034a645858bdb78ac57e43ba183cb981 +- filename: skills/code-review/SKILL.md + checksum: 4ec638a87a795e5cc0b0e66b569e04749cb27f9d3be29d4ea69f56b48225c943 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0798e0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Agent guidance — `@contentstack/utils` + +## What this package is + +**[@contentstack/utils](https://www.npmjs.com/package/@contentstack/utils)** (`contentstack-utils-javascript`) is a **JavaScript/TypeScript utilities library** for Contentstack. It is **not** the Content Delivery API (CDA) SDK or the Content Management API (CMA) SDK. It focuses on **JSON RTE / Supercharged RTE** rendering (`jsonToHTML`), **embedded entry and asset** rendering (`render`, `renderContent`), **GraphQL-oriented helpers** (`GQL`, `updateAssetURLForGQL`), **Live Preview–style editable tags** (`addEditableTags` / `addTags`), and **region endpoint lookup** (`getContentstackEndpoint`). Typical usage is **alongside** the Delivery SDK (see repository `README.md` examples with `@contentstack/delivery-sdk`). + +- **Repository:** [github.com/contentstack/contentstack-utils-javascript](https://github.com/contentstack/contentstack-utils-javascript) + +## Tech stack + +| Area | Details | +|------|---------| +| Language | TypeScript **4.9** (`tsconfig.json`, `strict: true`, `strictNullChecks: false`) | +| Build | **TypeScript** (`tsc`) → `dist/lib`; **Rollup** (`rollup -c`) → `dist/index.es.js`; types in `dist/types/` | +| Test | **Jest 29** + **ts-jest**, **jsdom** environment (`jest.config.ts`) | +| Lint / format | **ESLint 9** flat config (`eslint.config.js`); **Prettier 3** (`npm run format`) — there is **no** `lint` npm script; use `npx eslint` as needed | +| Runtime HTTP / JSON for API calls | **None** in library code; `regions.json` is a **build-time** asset (see below) | + +## Source layout and public API + +| Role | Path | +|------|------| +| Public entry (sources) | `src/index.ts` | +| Options / render types | `src/options/` | +| RTE / node model | `src/Models/`, `src/nodes/` | +| Helpers | `src/helper/` | +| GQL + asset URL rewrite | `src/gql.ts`, `src/updateAssetURLForGQL.ts` | +| Endpoints helper | `src/endpoints.ts` + `src/assets/regions.json` (generated; see build) | +| Published bundle | `dist/` (per `package.json` `main` / `types`) | + +## Common commands + +| Command | Purpose | +|---------|---------| +| `npm run build` | Cleans `dist`, ensures `src/assets/regions.json` (download or warning), runs `tsc` + Rollup | +| `npm test` | Runs `pretest` → **build**, then Jest with coverage; outputs under `reports/` | +| `npm run test:debug` | Jest watch, in-band | +| `npm run format` | Prettier on `src/**/*.ts` | +| `npm run download-regions` | Fetches `regions.json` only (used by `prebuild`) | + +**Tests:** Unit tests only, under `__test__/**/*.test.ts`, with mocks in `__test__/mock/`. There are **no** live/integration tests requiring stack credentials in this repository. + +## Credentials / environment + +- **Unit tests:** No API keys or `.env` required. +- **Build:** `download-regions` calls a public URL (`artifacts.contentstack.com`); offline builds may warn and rely on an existing `src/assets/regions.json`. Note `regions.json` is listed in `.gitignore`; clones may need a successful `npm run build` (or manual file) before tests pass. +- **Publish:** GitHub release workflow uses `NPM_TOKEN` / `GIT_TOKEN` secrets (maintainers only). + +## More detail for AI / IDE rules + +- [`.cursor/rules/README.md`](.cursor/rules/README.md) — Cursor rules index (`alwaysApply`, globs, when to use). +- [`skills/README.md`](skills/README.md) — Topic skills (testing, code review, package mental model). diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c22d9..4121676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,135 @@ # Changelog +## [1.9.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.9.0) +- Feat: Variant utilities `getVariantAliases` and `getVariantMetadataTags` to read variant alias strings from CDA entry `publish_details.variants` (requires fetches with the `x-cs-variant-uid` header set to aliases per [CDA variants](https://www.contentstack.com/docs/developers/apis/content-delivery-api#get-all-entry-variants)). + +## [1.8.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.8.0) +- Fix: JSON-to-HTML now outputs valid HTML for nested lists when JSON RTE exports the nested list as a sibling of the preceding list item (`
  • `). The SDK folds such sibling `
      `/`` }, [NodeType.LIST_ITEM]:(node: Node, next: Next) => { - return `
    1. ${next(node.children)}
    2. ` + return `${sanitizeHTML(next(node.children))}` }, - [NodeType.HR]:(node: Node, next: Next) => { + [NodeType.HR]:() => { return `
      ` }, - [NodeType.TABLE]:(node: Node, next: Next) => { - return `${next(node.children)}
      ` + [NodeType.TABLE]: (node: Node, next: Next) => { + // Generate colgroup if colWidths attribute is present + let colgroupHTML = ''; + const colWidths = getAttr(node, 'colWidths'); + if (colWidths && Array.isArray(colWidths)) { + const totalWidth = colWidths.reduce((sum: number, width: number) => sum + width, 0); + colgroupHTML = `<${NodeType.COL_GROUP} data-width="${totalWidth}">`; + colWidths.forEach((colWidth: number) => { + const widthPercentage = (colWidth / totalWidth) * 100; + colgroupHTML += `<${NodeType.COL} style="width:${widthPercentage.toFixed(2)}%"/>`; + }); + colgroupHTML += ``; + } + + // Generate table with colgroup and other attributes + return `${colgroupHTML}${sanitizeHTML(next(node.children))}`; }, [NodeType.TABLE_HEADER]:(node: Node, next: Next) => { - return `${next(node.children)}` + return `${sanitizeHTML(next(node.children))}` }, [NodeType.TABLE_BODY]:(node: Node, next: Next) => { - return `${next(node.children)}` + return `${sanitizeHTML(next(node.children))}` }, [NodeType.TABLE_FOOTER]:(node: Node, next: Next) => { - return `${next(node.children)}` + return `${sanitizeHTML(next(node.children))}` }, [NodeType.TABLE_ROW]:(node: Node, next: Next) => { - return `${next(node.children)}` + return `${sanitizeHTML(next(node.children))}` }, [NodeType.TABLE_HEAD]:(node: Node, next: Next) => { - return `${next(node.children)}` + if (getAttr(node, 'void')) return ''; + + const rowSpan = getAttr(node, 'rowSpan'); + const colSpan = getAttr(node, 'colSpan'); + const rowSpanAttr = rowSpan ? ` rowspan="${rowSpan}"` : ''; + const colSpanAttr = colSpan ? ` colspan="${colSpan}"` : ''; + + return `${sanitizeHTML(next(node.children))}` }, [NodeType.TABLE_DATA]:(node: Node, next: Next) => { - return `${next(node.children)}` + if (getAttr(node, 'void')) return ''; + + const rowSpan = getAttr(node, 'rowSpan'); + const colSpan = getAttr(node, 'colSpan'); + const rowSpanAttr = rowSpan ? ` rowspan="${rowSpan}"` : ''; + const colSpanAttr = colSpan ? ` colspan="${colSpan}"` : ''; + + return `${sanitizeHTML(next(node.children))}` }, [NodeType.BLOCK_QUOTE]:(node: Node, next: Next) => { - return `
      ${next(node.children)}
      ` + return `${sanitizeHTML(next(node.children))}` }, [NodeType.CODE]:(node: Node, next: Next) => { - return `${next(node.children)}` + return `${sanitizeHTML(next(node.children))}` }, ['reference']:(node: Node, next: Next) => { + const type = getAttr(node, 'type'); + const displayType = getAttr(node, 'display-type'); + + if ((type === 'entry' || type === 'asset') && displayType === 'link'){ + const href = getAttrString(node, 'href') || getAttrString(node, 'url') || ''; + const target = getAttrString(node, 'target'); + const assetUid = getAttrString(node, 'asset-uid'); + + let aTagAttrs = buildCommonAttrs(node); + if (href) aTagAttrs += ` href="${href}"`; + if (target) { + aTagAttrs += ` target="${target}"`; + } + if (type === 'asset') { + aTagAttrs += ` type="asset" content-type-uid="sys_assets"`; + if (assetUid) { + aTagAttrs += ` data-sys-asset-uid="${assetUid}"`; + } + aTagAttrs += ` sys-style-type="download"`; + } + return `${sanitizeHTML(next(node.children))}`; + } + + if (type === 'asset') { + const assetLink = getAttrString(node, 'asset-link'); + const src = assetLink ? encodeURI(assetLink) : ''; + const redactorAttrs = getAttr(node, 'redactor-attributes') as Record | undefined; + const alt = redactorAttrs?.['alt'] as string | undefined; + const link = getAttrString(node, 'link'); + const target = getAttrString(node, 'target') || ""; + const caption = (redactorAttrs?.['asset-caption'] as string | undefined) || getAttrString(node, 'asset-caption') || ""; + const style = getAttrString(node, 'style'); + const assetUid = getAttrString(node, 'asset-uid'); + const className = getAttrString(node, 'class-name'); + + const assetUidAttr = assetUid ? ` asset_uid="${assetUid}"` : ''; + const classAttr = className ? ` class="${sanitizeHTML(className)}"` : ''; + const srcAttr = src ? ` src="${sanitizeHTML(src)}"` : ''; + const altAttr = alt ? ` alt="${alt}"` : ''; + const targetAttr = target === "_blank" ? ` target="_blank"` : ''; + const styleAttr = style ? ` style="${style}"` : ''; + + const imageTag = ``; + const styleAttrFig = style ? ` style="${style}"` : ''; + + return `` + + (link ? `` : "") + + imageTag + + (link ? `` : "") + + (caption ? `
      ${caption}
      ` : "") + + ``; + } return `` }, ['default']:(node: Node, next: Next) => { - return next(node.children) + return sanitizeHTML(next(node.children)) }, [MarkType.BOLD]:(text: string) => { - return `${text}` + return `${sanitizeHTML(text)}` }, [MarkType.ITALIC]:(text: string) => { - return `${text}` + return `${sanitizeHTML(text)}` }, [MarkType.UNDERLINE]:(text: string) => { - return `${text}` + return `${sanitizeHTML(text)}` }, [MarkType.STRIKE_THROUGH]:(text: string) => { - return `${text}` + return `${sanitizeHTML(text)}` }, [MarkType.INLINE_CODE]:(text: string) => { - return `${text}` + return `${sanitizeHTML(text)}` + }, + [MarkType.HIGHLIGHT]:(text: string) => { + return `${sanitizeHTML(text)}` }, [MarkType.SUBSCRIPT]:(text: string) => { - return `${text}` + return `${sanitizeHTML(text)}` }, [MarkType.SUPERSCRIPT]:(text: string) => { - return `${text}` - }, + return `${sanitizeHTML(text)}` + }, + [MarkType.BREAK]:(text: string) => { + // Check if text is only newlines (which will be converted to
      by sanitizeHTML) + // If so, don't add an extra
      to avoid duplication + const onlyNewlines = /^\n+$/.test(text); + if (onlyNewlines) { + return sanitizeHTML(text); + } + return `
      ${sanitizeHTML(text)}` + }, + [MarkType.CLASSNAME_OR_ID]:(text: string, classname: string, id:string) => { + return `${sanitizeHTML(text)}` + } -} \ No newline at end of file +} diff --git a/src/options/default-options.ts b/src/options/default-options.ts index 1592cbc..a665d22 100644 --- a/src/options/default-options.ts +++ b/src/options/default-options.ts @@ -3,13 +3,35 @@ import { RenderOption } from '.'; import { Metadata } from '../Models/metadata-model'; import { EmbeddedItem } from '../Models/embedded-object'; import { EntryNode } from '../Models/json-rte-model'; +import { sanitizeHTML } from '../helper/sanitize' export const defaultOptions: RenderOption = { - [StyleType.BLOCK]: (item: EmbeddedItem | EntryNode) => - `

      ${item.title || item.uid}

      Content type: ${item._content_type_uid || (item.system ? item.system.content_type_uid : '')}

      `, - [StyleType.INLINE]: (item: EmbeddedItem| EntryNode) => `${item.title || item.uid}`, - [StyleType.LINK]: (item: EmbeddedItem| EntryNode, metadata: Metadata) => `${metadata.text || item.title || item.uid || (item.system ? item.system.uid : '')}`, - [StyleType.DISPLAY]: (item: EmbeddedItem| EntryNode, metadata: Metadata) => `${metadata.attributes.alt || item.title || item.filename || item.uid 
-    || (item.system ? item.system.uid : '')}`, - [StyleType.DOWNLOAD]: (item: EmbeddedItem| EntryNode, metadata: Metadata) => `${metadata.text || item.title || item.uid || (item.system ? item.system.content_type_uid : '')}`, + [StyleType.BLOCK]: (item: EmbeddedItem | EntryNode) => { + const title = sanitizeHTML(item.title || item.uid); + const content_type_uid = sanitizeHTML(item._content_type_uid || (item.system ? item.system.content_type_uid : '')); + return `

      ${title}

      Content type: ${content_type_uid}

      `; + }, + [StyleType.INLINE]: (item: EmbeddedItem | EntryNode) => { + const title = sanitizeHTML(item.title || item.uid); + return `${title}`; + }, + [StyleType.LINK]: (item: EmbeddedItem | EntryNode, metadata: Metadata) => { + const url = item.url ? encodeURI(sanitizeHTML(item.url)) : null; + const text = sanitizeHTML(metadata.text || item.title || item.uid || (item.system ? item.system.uid : '')); + const hrefAttr = url ? ` href="${url}"` : ''; + return `${text}`; + }, + [StyleType.DISPLAY]: (item: EmbeddedItem | EntryNode, metadata: Metadata) => { + const url = item.url ? encodeURI(sanitizeHTML(item.url)) : null; + const alt = sanitizeHTML(metadata.attributes.alt || item.title || item.filename || item.uid + || (item.system ? item.system.uid : '')); + const srcAttr = url ? ` src="${url}"` : ''; + return ``; + }, + [StyleType.DOWNLOAD]: (item: EmbeddedItem | EntryNode, metadata: Metadata) => { + const href = item.url ? encodeURI(sanitizeHTML(item.url)) : null; + const text = sanitizeHTML(metadata.text || item.title || item.uid || (item.system ? item.system.content_type_uid : '')); + const hrefAttr = href ? ` href="${href}"` : ''; + return `${text}`; + }, }; diff --git a/src/options/index.ts b/src/options/index.ts index 45a415f..dd45f8f 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -6,7 +6,7 @@ import Node from '../nodes/node'; export type Next = (nodes: AnyNode[]) => string export type RenderNode = (node: Node, next: Next) => string; -export type RenderMark = (text: string) => string; +export type RenderMark = (text: string, classname?: string, id?: string) => string; export type RenderItem = (item: EmbeddedItem | EntryNode, metadata: Metadata) => string; export interface RenderOption { [embedType: string]: RenderNode | RenderMark | RenderItem | RenderContentType; diff --git a/src/render-embedded-objects.ts b/src/render-embedded-objects.ts index 9aadfed..d091789 100644 --- a/src/render-embedded-objects.ts +++ b/src/render-embedded-objects.ts @@ -5,10 +5,14 @@ import { findEmbeddedItems, findRenderString } from './helper/find-embeded-objec import { EntryEmbedable } from './Models/embedded-object'; import { findRenderContent } from './helper/find-render-content'; /** - * - * @param {EntryEmbedable| EntryEmbedable[]} entry - Objects that contains RTE with embedded objects - * @param {string[]} paths - Key paths for RTE contents in Entry object - * @param {RenderOption?} renderOption - Optional render options to render content + * Renders RTE (Rich Text Editor) content with embedded objects in-place. + * Mutates the entry/entries by replacing embedded item tags with HTML produced + * by the provided render options. Works with a single entry or an array of entries. + * + * @param option - Configuration for rendering. + * @param option.entry - Entry or array of entries containing RTE fields with embedded objects. + * @param option.renderOption - Optional render options (node/item handlers) to produce HTML for embedded content. + * @param option.paths - Optional key paths to specific RTE fields. If omitted, all RTE paths on the entry are rendered. */ export function render(option: { entry: EntryEmbedable| EntryEmbedable[], @@ -46,10 +50,13 @@ export function render(option: { } /** - * - * @param {string | string[]} content - RTE content to render - * @param {EntryEmbedable} options.entry - Entry object containing embedded objects - * @param {RenderOption?} options.renderOption - Optional render options to render content + * Renders a single RTE content string or array of strings by replacing embedded + * item tags with HTML. Uses the entry and renderOption from the given option to + * resolve embedded references and produce output. + * + * @param content - RTE content string or array of strings containing embedded item tags. + * @param option - Must include the entry (for resolving embedded items) and optionally renderOption. + * @returns The same shape as content: a string or array of strings with embedded tags replaced by rendered HTML. */ export function renderContent(content: (string | string[]), option: Option): (string| string[]) { // return blank if content not present diff --git a/src/updateAssetURLForGQL.ts b/src/updateAssetURLForGQL.ts new file mode 100644 index 0000000..99813ed --- /dev/null +++ b/src/updateAssetURLForGQL.ts @@ -0,0 +1,72 @@ +/** + * Updates asset URLs in a GraphQL response in-place. Walks the response data, + * finds RTE fields that have `embedded_itemsConnection`, and sets each + * embedded asset's `asset-link` attribute in the JSON to the asset's `url` + * from the response. Use after fetching content via GraphQL so RTE JSON + * contains correct asset URLs for rendering. + * + * @param gqlResponse - The raw GraphQL response object (e.g. `{ data: { ... } }`). Modified in place. + */ +export function updateAssetURLForGQL(gqlResponse:any) { + try { + const response = gqlResponse?.data; + for (let contentType in response) { + if ("items" in response[contentType]) { + const entries = response[contentType].items; + + entries.forEach((entry:any) => { // iterate over all entries + processEntry(entry); + }); + } else { + processEntry(response[contentType]); + } + } + } catch (error) { + console.error('Error in updating asset URL for GQL response', error); + } +} + +function processEntry(entry:any) { + for (let field in entry) { + const fieldData = entry[field]; + if (fieldData instanceof Array) { + fieldData.forEach((data:any) => { + findRTEFieldAndUpdateURL(data); + }); + } else if (fieldData && typeof fieldData === 'object') { + findRTEFieldAndUpdateURL(fieldData); + } + } +} + +function findRTEFieldAndUpdateURL(fieldData:any) { + const rteField = findRTEField(fieldData); + + if (!rteField) return; + + const edges = rteField?.embedded_itemsConnection?.edges; + edges.forEach((edge:any) => { + const node = edge.node; + if (node?.url && node?.filename) { + + if (!node?.system?.uid) throw new Error('Asset UID not found in the response'); + + const correspondingAsset = rteField?.json?.children?.find((child:any) => child.attrs['asset-uid'] === node.system.uid); + correspondingAsset.attrs['asset-link'] = node.url; + } + }); +} + +function findRTEField(fieldData: any): any { + if (fieldData?.embedded_itemsConnection) { + return fieldData; + } + for (const key in fieldData) { + if (fieldData[key] && typeof fieldData[key] === 'object') { + const found = findRTEField(fieldData[key]); + if (found) { + return found; + } + } + } +} \ No newline at end of file diff --git a/src/variant-aliases.ts b/src/variant-aliases.ts new file mode 100644 index 0000000..78e40ac --- /dev/null +++ b/src/variant-aliases.ts @@ -0,0 +1,131 @@ +/** + * Shape returned by {@link getVariantAliases} for interoperability with other Utils SDKs (snake_case JSON keys). + */ +export interface VariantAliasesResult { + entry_uid: string; + contenttype_uid: string; + variants: string[]; +} + +/** CDA entry JSON: at minimum includes `uid`; may include `_content_type_uid` and `publish_details.variants`. */ +export type CDAEntryLike = Record; + +function assertPlainObject(value: unknown, message: string): asserts value is Record { + if (value === null || value === undefined) { + throw new TypeError(message); + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(message); + } +} + +function requireEntryUid(entry: Record): string { + const uid = entry.uid; + if (typeof uid !== 'string' || uid.length === 0) { + throw new Error('Entry uid is required. The entry must include a non-empty uid string.'); + } + return uid; +} + +function resolveContentTypeUid(entry: Record, contentTypeUid?: string): string { + const fromEntry = entry._content_type_uid; + if (typeof fromEntry === 'string' && fromEntry.length > 0) { + return fromEntry; + } + if (typeof contentTypeUid === 'string' && contentTypeUid.length > 0) { + return contentTypeUid; + } + return ''; +} + +function collectVariantAliases(entry: Record): string[] { + const publishDetails = entry.publish_details; + if (!publishDetails || typeof publishDetails !== 'object' || Array.isArray(publishDetails)) { + return []; + } + const variants = (publishDetails as Record).variants; + if (!variants || typeof variants !== 'object' || Array.isArray(variants)) { + return []; + } + const out: string[] = []; + const map = variants as Record; + for (const key of Object.keys(map)) { + const v = map[key]; + if (!v || typeof v !== 'object' || Array.isArray(v)) { + continue; + } + const alias = (v as { alias?: unknown }).alias; + if (typeof alias === 'string' && alias.length > 0) { + out.push(alias); + } + } + return out; +} + +function mapEntryToResult(entry: Record, contentTypeUid?: string): VariantAliasesResult { + return { + entry_uid: requireEntryUid(entry), + contenttype_uid: resolveContentTypeUid(entry, contentTypeUid), + variants: collectVariantAliases(entry), + }; +} + +/** + * Extracts variant **alias** strings from `publish_details.variants` on a CDA entry. + * Only present when the entry was fetched with the `x-cs-variant-uid` header set to variant **aliases** (not UIDs). + * + * @param entry - Single CDA entry object (must include `uid`). + * @param contentTypeUid - Used when `entry._content_type_uid` is missing. Otherwise omitted or empty string yields `contenttype_uid: ""`. + * @returns `{ entry_uid, contenttype_uid, variants }` with snake_case keys for cross-SDK JSON parity. + * @throws TypeError if `entry` is null/undefined or not a plain object. + * @throws Error if `entry` has no non-empty `uid`. + */ +export function getVariantAliases(entry: CDAEntryLike, contentTypeUid?: string): VariantAliasesResult; + +/** + * Extracts variant aliases for each entry in order. + * + * @param entries - Array of CDA entry objects. + * @param contentTypeUid - Applied when an entry lacks `_content_type_uid`. + * @returns One result object per input entry. + * @throws TypeError if `entries` is null/undefined or not an array, or any element is not a plain object. + * @throws Error if any entry has no non-empty `uid`. + */ +export function getVariantAliases(entries: CDAEntryLike[], contentTypeUid?: string): VariantAliasesResult[]; + +export function getVariantAliases( + entryOrEntries: CDAEntryLike | CDAEntryLike[], + contentTypeUid?: string +): VariantAliasesResult | VariantAliasesResult[] { + if (Array.isArray(entryOrEntries)) { + return entryOrEntries.map((e) => { + assertPlainObject(e, 'Each entry must be a plain object with a uid.'); + return mapEntryToResult(e, contentTypeUid); + }); + } + assertPlainObject(entryOrEntries, 'Entry is required. Provide a CDA entry object with a uid.'); + return mapEntryToResult(entryOrEntries, contentTypeUid); +} + +/** + * Serialises variant alias results for use as an HTML `data-csvariants` attribute value. + * + * @param entries - CDA entries to process (same rules as {@link getVariantAliases} for each item). + * @param contentTypeUid - Applied when an entry lacks `_content_type_uid`. + * @returns `{ "data-csvariants": "" }`. + * @throws TypeError if `entries` is null/undefined or not an array, or any element is not a plain object. + * @throws Error if any entry has no non-empty `uid`. + */ +export function getVariantMetadataTags( + entries: CDAEntryLike[], + contentTypeUid?: string +): { 'data-csvariants': string } { + if (entries === null || entries === undefined) { + throw new TypeError('Entries array is required. Provide an array of CDA entry objects.'); + } + if (!Array.isArray(entries)) { + throw new TypeError('Entries must be an array of CDA entry objects.'); + } + const payload = getVariantAliases(entries, contentTypeUid); + return { 'data-csvariants': JSON.stringify(payload) }; +} diff --git a/tsconfig.json b/tsconfig.json index 1288c39..83dfe71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "moduleResolution": "node", + "allowJs": true, "target": "es5", "module": "es2015", "declaration": true, @@ -15,8 +16,10 @@ ], "types": ["jest"], "esModuleInterop": true, + "resolveJsonModule": true, "strictNullChecks": false, "sourceMap": true, + "skipLibCheck": true, }, "include": ["src"], "exclude": ["node_modules", "__test__"] diff --git a/tslint.json b/tslint.json deleted file mode 100644 index 52e83ca..0000000 --- a/tslint.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["tslint:recommended", "tslint-config-prettier"], - "rules": { - "no-console": false - } - } \ No newline at end of file