diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d10ea4..77303a5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,6 +24,12 @@ jobs: - name: Clean install Node dependencies run: pnpm i --frozen-lockfile + - name: Type check + run: pnpm run typecheck + + - name: Run unit tests + run: pnpm run test:unit + - name: Build production files run: pnpm run build diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..863984d --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,35 @@ +name: qa + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: [pull_request] + +jobs: + qa: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup pnpm 10 + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node 24 + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Clean install Node dependencies + run: pnpm i --frozen-lockfile + + - name: Type check + run: pnpm run typecheck + + - name: Run unit tests + run: pnpm run test:unit + + - name: Build production files + run: pnpm run build \ No newline at end of file diff --git a/eslint.config.ts b/eslint.config.ts index c3c17a3..19079a3 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -28,6 +28,7 @@ export default defineConfigWithVueTs( { rules: { 'vue/multi-word-component-names': 'off', + '@typescript-eslint/no-namespace': 'off', }, }, diff --git a/package.json b/package.json index 8f59947..033ba8c 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "run-p type-check \"build-only {@}\" --", + "build": "vite build", "preview": "vite preview", - "test:unit": "vitest", - "build-only": "vite build", - "type-check": "vue-tsc --build", + "test:unit": "vitest --run", + "typecheck": "vue-tsc --build", "lint": "eslint . --fix --cache", "format": "prettier --write --experimental-cli src/" }, @@ -18,9 +17,9 @@ }, "devDependencies": { "@tsconfig/node24": "^24.0.4", - "@types/jsdom": "^27.0.0", "@types/node": "^24.10.11", "@vitejs/plugin-vue": "^6.0.4", + "@vitest/coverage-v8": "^4.0.18", "@vitest/eslint-plugin": "^1.6.6", "@vue/eslint-config-typescript": "^14.6.0", "@vue/test-utils": "^2.4.6", @@ -28,11 +27,11 @@ "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "~10.7.0", + "happy-dom": "^20.5.0", "jiti": "^2.6.1", - "jsdom": "^27.4.0", - "npm-run-all2": "^8.0.4", "prettier": "3.8.1", "sass": "^1.97.3", + "type-fest": "^5.4.3", "typescript": "~5.9.3", "vite": "^7.3.1", "vite-plugin-vue-devtools": "^8.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71d3ad0..47d1967 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,18 +18,18 @@ importers: '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 - '@types/jsdom': - specifier: ^27.0.0 - version: 27.0.0 '@types/node': specifier: ^24.10.11 version: 24.10.11 '@vitejs/plugin-vue': specifier: ^6.0.4 version: 6.0.4(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(sass@1.97.3))(vue@3.5.27(typescript@5.9.3)) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3)) '@vitest/eslint-plugin': specifier: ^1.6.6 - version: 1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3)) + version: 1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3)) '@vue/eslint-config-typescript': specifier: ^14.6.0 version: 14.6.0(eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -48,21 +48,21 @@ importers: eslint-plugin-vue: specifier: ~10.7.0 version: 10.7.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))) + happy-dom: + specifier: ^20.5.0 + version: 20.5.0 jiti: specifier: ^2.6.1 version: 2.6.1 - jsdom: - specifier: ^27.4.0 - version: 27.4.0 - npm-run-all2: - specifier: ^8.0.4 - version: 8.0.4 prettier: specifier: 3.8.1 version: 3.8.1 sass: specifier: ^1.97.3 version: 1.97.3 + type-fest: + specifier: ^5.4.3 + version: 5.4.3 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -74,7 +74,7 @@ importers: version: 8.0.6(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(sass@1.97.3))(vue@3.5.27(typescript@5.9.3)) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3) + version: 4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3) vue-tsc: specifier: ^3.2.4 version: 3.2.4(typescript@5.9.3) @@ -180,6 +180,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-decorators@7.28.6': resolution: {integrity: sha512-RVdFPPyY9fCRAX68haPmOk2iyKW8PKJFthmm8NeSI3paNxKWGZIn99+VbIf0FrtCpFnPgnpF/L48tadi617ULg==} engines: {node: '>=6.9.0'} @@ -233,6 +238,14 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -751,17 +764,17 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/jsdom@27.0.0': - resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/node@24.10.11': resolution: {integrity: sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==} - '@types/tough-cookie@4.0.5': - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} '@typescript-eslint/eslint-plugin@8.53.1': resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} @@ -829,6 +842,15 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.2.25 + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/eslint-plugin@1.6.6': resolution: {integrity: sha512-bwgQxQWRtnTVzsUHK824tBmHzjV0iTx3tZaiQIYDjX3SA7TsQS8CuDVqxXrRY3FaOUMgbGavesCxI9MOfFLm7Q==} engines: {node: '>=18'} @@ -1024,6 +1046,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1174,6 +1199,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1346,6 +1375,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + happy-dom@20.5.0: + resolution: {integrity: sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1357,6 +1390,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1427,9 +1463,17 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -1447,6 +1491,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1471,10 +1518,6 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@4.0.0: - resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==} - engines: {node: ^18.17.0 || >=20.5.0} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1516,13 +1559,16 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1583,15 +1629,6 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true - npm-normalize-package-bin@4.0.0: - resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} - engines: {node: ^18.17.0 || >=20.5.0} - - npm-run-all2@8.0.4: - resolution: {integrity: sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==} - engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'} - hasBin: true - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -1624,9 +1661,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -1665,11 +1699,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - pinia@3.0.4: resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} peerDependencies: @@ -1706,10 +1735,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - read-package-json-fast@4.0.0: - resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} - engines: {node: ^18.17.0 || >=20.5.0} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1767,10 +1792,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1827,6 +1848,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1875,6 +1900,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.4.3: + resolution: {integrity: sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==} + engines: {node: '>=20'} + typescript-eslint@8.53.1: resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2045,6 +2074,10 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -2062,11 +2095,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@5.0.0: - resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2120,7 +2148,8 @@ packages: snapshots: - '@acemir/cssom@0.9.31': {} + '@acemir/cssom@0.9.31': + optional: true '@asamuzakjp/css-color@4.1.1': dependencies: @@ -2129,6 +2158,7 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 lru-cache: 11.2.4 + optional: true '@asamuzakjp/dom-selector@6.7.6': dependencies: @@ -2137,8 +2167,10 @@ snapshots: css-tree: 3.1.0 is-potential-custom-element-name: 1.0.1 lru-cache: 11.2.4 + optional: true - '@asamuzakjp/nwsapi@2.3.9': {} + '@asamuzakjp/nwsapi@2.3.9': + optional: true '@babel/code-frame@7.28.6': dependencies: @@ -2263,6 +2295,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-proposal-decorators@7.28.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -2331,12 +2367,21 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@csstools/color-helpers@5.1.0': {} + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@csstools/color-helpers@5.1.0': + optional: true '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + optional: true '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: @@ -2344,14 +2389,18 @@ snapshots: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + optional: true '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 + optional: true - '@csstools/css-syntax-patches-for-csstree@1.0.25': {} + '@csstools/css-syntax-patches-for-csstree@1.0.25': + optional: true - '@csstools/css-tokenizer@3.0.4': {} + '@csstools/css-tokenizer@3.0.4': + optional: true '@esbuild/aix-ppc64@0.27.2': optional: true @@ -2477,7 +2526,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.9.0': {} + '@exodus/bytes@1.9.0': + optional: true '@humanfs/core@0.19.1': {} @@ -2688,19 +2738,17 @@ snapshots: '@types/estree@1.0.8': {} - '@types/jsdom@27.0.0': - dependencies: - '@types/node': 24.10.11 - '@types/tough-cookie': 4.0.5 - parse5: 7.3.0 - '@types/json-schema@7.0.15': {} '@types/node@24.10.11': dependencies: undici-types: 7.16.0 - '@types/tough-cookie@4.0.5': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.11 '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: @@ -2799,14 +2847,28 @@ snapshots: vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(sass@1.97.3) vue: 3.5.27(typescript@5.9.3) - '@vitest/eslint-plugin@1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3) + + '@vitest/eslint-plugin@1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3))': dependencies: '@typescript-eslint/scope-manager': 8.53.1 '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3) + vitest: 4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3) transitivePeerDependencies: - supports-color @@ -3029,7 +3091,8 @@ snapshots: acorn@8.15.0: {} - agent-base@7.1.4: {} + agent-base@7.1.4: + optional: true ajv@6.12.6: dependencies: @@ -3056,6 +3119,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@1.0.2: {} baseline-browser-mapping@2.9.18: {} @@ -3063,6 +3132,7 @@ snapshots: bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + optional: true birpc@2.9.0: {} @@ -3139,6 +3209,7 @@ snapshots: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 + optional: true cssesc@3.0.0: {} @@ -3148,6 +3219,7 @@ snapshots: '@csstools/css-syntax-patches-for-csstree': 1.0.25 css-tree: 3.1.0 lru-cache: 11.2.4 + optional: true csstype@3.2.3: {} @@ -3155,12 +3227,14 @@ snapshots: dependencies: whatwg-mimetype: 5.0.0 whatwg-url: 15.1.0 + optional: true debug@4.4.3: dependencies: ms: 2.1.3 - decimal.js@10.6.0: {} + decimal.js@10.6.0: + optional: true deep-is@0.1.4: {} @@ -3191,7 +3265,10 @@ snapshots: emoji-regex@9.2.2: {} - entities@6.0.1: {} + entities@4.5.0: {} + + entities@6.0.1: + optional: true entities@7.0.1: {} @@ -3396,6 +3473,18 @@ snapshots: globals@14.0.0: {} + happy-dom@20.5.0: + dependencies: + '@types/node': 24.10.11 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 4.5.0 + whatwg-mimetype: 3.0.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} hookable@5.5.3: {} @@ -3405,6 +3494,9 @@ snapshots: '@exodus/bytes': 1.9.0 transitivePeerDependencies: - '@noble/hashes' + optional: true + + html-escaper@2.0.2: {} http-proxy-agent@7.0.2: dependencies: @@ -3412,6 +3504,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -3419,6 +3512,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true ignore@5.3.2: {} @@ -3451,7 +3545,8 @@ snapshots: is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-what@5.5.0: {} @@ -3461,7 +3556,18 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 jackspeak@3.4.3: dependencies: @@ -3481,6 +3587,8 @@ snapshots: js-cookie@3.0.5: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -3514,13 +3622,12 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true jsesc@3.1.0: {} json-buffer@3.0.1: {} - json-parse-even-better-errors@4.0.0: {} - json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -3546,7 +3653,8 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.4: {} + lru-cache@11.2.4: + optional: true lru-cache@5.1.1: dependencies: @@ -3556,9 +3664,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - mdn-data@2.12.2: {} + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 - memorystream@0.3.1: {} + mdn-data@2.12.2: + optional: true merge2@1.4.1: {} @@ -3604,19 +3721,6 @@ snapshots: dependencies: abbrev: 2.0.0 - npm-normalize-package-bin@4.0.0: {} - - npm-run-all2@8.0.4: - dependencies: - ansi-styles: 6.2.3 - cross-spawn: 7.0.6 - memorystream: 0.3.1 - picomatch: 4.0.3 - pidtree: 0.6.0 - read-package-json-fast: 4.0.0 - shell-quote: 1.8.3 - which: 5.0.0 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -3655,13 +3759,10 @@ snapshots: dependencies: callsites: 3.1.0 - parse5@7.3.0: - dependencies: - entities: 6.0.1 - parse5@8.0.0: dependencies: entities: 6.0.1 + optional: true path-browserify@1.0.1: {} @@ -3686,8 +3787,6 @@ snapshots: picomatch@4.0.3: {} - pidtree@0.6.0: {} - pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)): dependencies: '@vue/devtools-api': 7.7.9 @@ -3716,14 +3815,10 @@ snapshots: queue-microtask@1.2.3: {} - read-package-json-fast@4.0.0: - dependencies: - json-parse-even-better-errors: 4.0.0 - npm-normalize-package-bin: 4.0.0 - readdirp@4.1.2: {} - require-from-string@2.0.2: {} + require-from-string@2.0.2: + optional: true resolve-from@4.0.0: {} @@ -3779,6 +3874,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true semver@6.3.1: {} @@ -3790,8 +3886,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.3: {} - siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -3840,7 +3934,10 @@ snapshots: dependencies: has-flag: 4.0.0 - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true + + tagged-tag@1.0.0: {} tinybench@2.9.0: {} @@ -3853,11 +3950,13 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.19: {} + tldts-core@7.0.19: + optional: true tldts@7.0.19: dependencies: tldts-core: 7.0.19 + optional: true to-regex-range@5.0.1: dependencies: @@ -3868,10 +3967,12 @@ snapshots: tough-cookie@6.0.0: dependencies: tldts: 7.0.19 + optional: true tr46@6.0.0: dependencies: punycode: 2.3.1 + optional: true ts-api-utils@2.4.0(typescript@5.9.3): dependencies: @@ -3881,6 +3982,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.4.3: + dependencies: + tagged-tag: 1.0.0 + typescript-eslint@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -3981,7 +4086,7 @@ snapshots: jiti: 2.6.1 sass: 1.97.3 - vitest@4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3): + vitest@4.0.18(@types/node@24.10.11)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@27.4.0)(sass@1.97.3): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(sass@1.97.3)) @@ -4005,6 +4110,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.11 + happy-dom: 20.5.0 jsdom: 27.4.0 transitivePeerDependencies: - jiti @@ -4054,26 +4160,29 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true - webidl-conversions@8.0.1: {} + webidl-conversions@8.0.1: + optional: true - whatwg-mimetype@4.0.0: {} + whatwg-mimetype@3.0.0: {} - whatwg-mimetype@5.0.0: {} + whatwg-mimetype@4.0.0: + optional: true + + whatwg-mimetype@5.0.0: + optional: true whatwg-url@15.1.0: dependencies: tr46: 6.0.0 webidl-conversions: 8.0.1 + optional: true which@2.0.2: dependencies: isexe: 2.0.0 - which@5.0.0: - dependencies: - isexe: 3.1.1 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -4101,9 +4210,11 @@ snapshots: xml-name-validator@4.0.0: {} - xml-name-validator@5.0.0: {} + xml-name-validator@5.0.0: + optional: true - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true yallist@3.1.1: {} diff --git a/src/App.vue b/src/App.vue index 5a182fe..fb139bb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -59,7 +59,7 @@ import WhatsNew from './components/WhatsNew.vue' import InfoBanner from './components/InfoBanner.vue' import UtilitySection from './components/UtilitySection.vue' import TextInput from './components/TextInput.vue' -import SearchContent from './components/Utilities/SearchContent.vue' +import SearchContent from './components/Features/SearchContent.vue' import ComingSoon from './components/ComingSoon.vue' const store = useStore() diff --git a/src/components/Features/SearchContent.vue b/src/components/Features/SearchContent.vue new file mode 100644 index 0000000..293d260 --- /dev/null +++ b/src/components/Features/SearchContent.vue @@ -0,0 +1,497 @@ + + + + + diff --git a/src/components/Utilities/SearchContent.vue b/src/components/Utilities/SearchContent.vue deleted file mode 100644 index 9cca30c..0000000 --- a/src/components/Utilities/SearchContent.vue +++ /dev/null @@ -1,832 +0,0 @@ - - - - - diff --git a/src/core/collections.spec.ts b/src/core/collections.spec.ts new file mode 100644 index 0000000..37cb09a --- /dev/null +++ b/src/core/collections.spec.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Butter } from '@/types' +import { getAllCollections } from './collections' +import * as fetchModule from './fetch' + +// Mock the fetch module +vi.mock('./fetch', () => ({ + fetchWithRetry: vi.fn(), +})) + +const mockFetchWithRetry = fetchModule.fetchWithRetry as ReturnType + +describe('getAllCollections', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should fetch all collections on a single page', async () => { + const mockCollection: Butter.Collection = { + id: 1, + name: 'Collection 1', + description: 'A test collection', + } + + const mockResponse: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [mockCollection] }, + meta: { + next_page: null, + previous_page: null, + count: 1, + }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toEqual([mockCollection]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + expect(mockFetchWithRetry).toHaveBeenCalledWith( + expect.stringContaining('https://api.buttercms.com/v2/content/test_collection/'), + ) + expect(mockFetchWithRetry).toHaveBeenCalledWith( + expect.stringContaining('auth_token=test-token'), + ) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('page=1')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('page_size=100')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('levels=5')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('alt_media_text=1')) + }) + + it('should include different collection types in URL', async () => { + const mockResponse: Butter.Response<{ products: Butter.Collection[] }> = { + data: { products: [] }, + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'products', + }) + + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('content/products/')) + }) + + it('should include preview=1 in URL when preview is true', async () => { + const mockResponse: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [] }, + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllCollections({ + token: 'test-token', + preview: true, + collectionType: 'test_collection', + }) + + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('preview=1')) + }) + + it('should not include preview parameter when preview is false', async () => { + const mockResponse: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [] }, + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + const callUrl = mockFetchWithRetry.mock.calls[0]?.[0] + expect(callUrl).not.toContain('preview=1') + }) + + it('should handle pagination across multiple pages', async () => { + const mockCollection1: Butter.Collection = { + id: 1, + name: 'Item 1', + } + + const mockCollection2: Butter.Collection = { + id: 2, + name: 'Item 2', + } + + const mockPage1Response: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [mockCollection1] }, + meta: { + next_page: 2, + previous_page: null, + count: 2, + }, + } + + const mockPage2Response: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [mockCollection2] }, + meta: { + next_page: null, + previous_page: 1, + count: 2, + }, + } + + mockFetchWithRetry + .mockResolvedValueOnce(mockPage1Response) + .mockResolvedValueOnce(mockPage2Response) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toHaveLength(2) + expect(result[0]!.id).toBe(1) + expect(result[1]!.id).toBe(2) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + expect(mockFetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('page=2')) + }) + + it('should handle pagination across five pages', async () => { + const createMockCollection = (id: number): Butter.Collection => ({ + id, + name: `Item ${id}`, + }) + + mockFetchWithRetry + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(1)] }, + meta: { next_page: 2, previous_page: null, count: 5 }, + }) + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(2)] }, + meta: { next_page: 3, previous_page: 1, count: 5 }, + }) + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(3)] }, + meta: { next_page: 4, previous_page: 2, count: 5 }, + }) + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(4)] }, + meta: { next_page: 5, previous_page: 3, count: 5 }, + }) + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(5)] }, + meta: { next_page: null, previous_page: 4, count: 5 }, + }) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toHaveLength(5) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(5) + }) + + it('should return empty array when no collections exist', async () => { + const mockResponse: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [] }, + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should throw error on fetch failure', async () => { + mockFetchWithRetry.mockRejectedValueOnce(new Error('API error')) + + await expect( + getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }), + ).rejects.toThrow('Failed to fetch collection test_collection: API error') + }) + + it('should handle different error messages in fetch failure', async () => { + mockFetchWithRetry.mockRejectedValueOnce(new Error('Network timeout')) + + await expect( + getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'products', + }), + ).rejects.toThrow('Failed to fetch collection products: Network timeout') + }) + + it('should handle response with null data gracefully', async () => { + const mockResponse: Partial> = { + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should handle response with non-array data gracefully', async () => { + const mockResponse: Partial> = { + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should continue pagination until next_page is null', async () => { + const createMockCollection = (id: number): Butter.Collection => ({ + id, + name: `Item ${id}`, + }) + + mockFetchWithRetry + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(1)] }, + meta: { next_page: 2, previous_page: null, count: 100 }, + }) + .mockResolvedValueOnce({ + data: { test_collection: [createMockCollection(2)] }, + meta: { next_page: null, previous_page: 1, count: 100 }, + }) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toHaveLength(2) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + }) + + it('should handle collections with complex nested data', async () => { + const mockCollection: Butter.Collection = { + id: 1, + name: 'Complex Collection', + metadata: { + tags: ['tag1', 'tag2'], + nested: { + level2: { + value: 'test', + }, + }, + }, + items: [ + { id: 1, title: 'Item 1' }, + { id: 2, title: 'Item 2' }, + ], + } + + const mockResponse: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: [mockCollection] }, + meta: { next_page: null, previous_page: null, count: 1 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toHaveLength(1) + expect(result[0]!.metadata).toEqual(mockCollection.metadata) + expect(result[0]!.items).toEqual(mockCollection.items) + }) + + it('should handle multiple collections on one page', async () => { + const mockCollections: Butter.Collection[] = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + { id: 4, name: 'Item 4' }, + { id: 5, name: 'Item 5' }, + ] + + const mockResponse: Butter.Response<{ test_collection: Butter.Collection[] }> = { + data: { test_collection: mockCollections }, + meta: { next_page: null, previous_page: null, count: 5 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllCollections({ + token: 'test-token', + preview: false, + collectionType: 'test_collection', + }) + + expect(result).toHaveLength(5) + expect(result).toEqual(mockCollections) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) +}) diff --git a/src/core/collections.ts b/src/core/collections.ts new file mode 100644 index 0000000..bb8cac3 --- /dev/null +++ b/src/core/collections.ts @@ -0,0 +1,43 @@ +import type { Butter } from '@/types' +import { fetchWithRetry } from './fetch' + +/** + * Get all collections of a specific type from ButterCMS with automatic pagination handling + */ +export async function getAllCollections(config: { + token: string + preview: boolean + collectionType: string +}): Promise { + const allItems: Butter.Collection[] = [] + + let page = 1 + let hasMore = true + while (hasMore) { + const url = `https://api.buttercms.com/v2/content/${config.collectionType}/?auth_token=${config.token}&page=${page}&page_size=100&levels=5&alt_media_text=1${config.preview ? '&preview=1' : ''}` + + try { + const data = await fetchWithRetry<{ + [key: typeof config.collectionType]: Butter.Collection[] + }>(url) + + if ( + data.data && + Array.isArray(data.data[config.collectionType]) && + data.data[config.collectionType]!.length > 0 + ) { + allItems.push(...data.data[config.collectionType]!) + hasMore = data.meta?.next_page !== null + page++ + } else { + hasMore = false + } + } catch (error) { + throw new Error( + `Failed to fetch collection ${config.collectionType}: ${(error as Error).message}`, + ) + } + } + + return allItems +} diff --git a/src/core/fetch.spec.ts b/src/core/fetch.spec.ts new file mode 100644 index 0000000..6951a75 --- /dev/null +++ b/src/core/fetch.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Butter } from '@/types' +import { fetchWithRetry } from './fetch' + +describe('fetchWithRetry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should return data on successful fetch', async () => { + const mockData: Butter.Response = { + data: ['item1', 'item2'], + meta: { + next_page: null, + previous_page: null, + count: 2, + }, + } + + globalThis.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }) + + const result = await fetchWithRetry('https://api.example.com/test') + + expect(result).toEqual(mockData) + expect(globalThis.fetch).toHaveBeenCalledOnce() + expect(globalThis.fetch).toHaveBeenCalledWith('https://api.example.com/test') + }) + + it('should retry on failure and succeed on second attempt', async () => { + const mockData: Butter.Response = { + data: ['item1'], + meta: { next_page: null, previous_page: null, count: 1 }, + } + + globalThis.fetch = vi + .fn() + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }) + + const result = await fetchWithRetry('https://api.example.com/test', 3) + + expect(result).toEqual(mockData) + expect(globalThis.fetch).toHaveBeenCalledTimes(2) + }) + + it('should retry on HTTP error (not ok)', async () => { + const mockData: Butter.Response = { + data: ['item1'], + meta: { next_page: null, previous_page: null, count: 1 }, + } + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }) + + const result = await fetchWithRetry('https://api.example.com/test', 3) + + expect(result).toEqual(mockData) + expect(globalThis.fetch).toHaveBeenCalledTimes(2) + }) + + it('should throw error after all retries are exhausted', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + await expect(fetchWithRetry('https://api.example.com/test', 3)).rejects.toThrow('Network error') + expect(globalThis.fetch).toHaveBeenCalledTimes(3) + }) + + it('should throw HTTP error after all retries are exhausted', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + + await expect(fetchWithRetry('https://api.example.com/test', 2)).rejects.toThrow( + 'HTTP 404: Not Found', + ) + expect(globalThis.fetch).toHaveBeenCalledTimes(2) + }) + + it('should use default maxRetries of 3', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + await expect(fetchWithRetry('https://api.example.com/test')).rejects.toThrow('Network error') + expect(globalThis.fetch).toHaveBeenCalledTimes(3) + }) + + it('should exponentially backoff with retry delays', async () => { + const mockData: Butter.Response = { + data: {}, + meta: { next_page: null, previous_page: null, count: 0 }, + } + + globalThis.fetch = vi + .fn() + .mockRejectedValueOnce(new Error('Attempt 1')) + .mockRejectedValueOnce(new Error('Attempt 2')) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }) + + const startTime = Date.now() + await fetchWithRetry('https://api.example.com/test', 3) + const duration = Date.now() - startTime + + // Should have at least 1000ms + 2000ms = 3000ms of delays + expect(duration).toBeGreaterThanOrEqual(3000) + expect(globalThis.fetch).toHaveBeenCalledTimes(3) + }) + + it('should handle generic type parameter', async () => { + interface CustomData { + id: number + name: string + } + + const mockData: Butter.Response = { + data: [ + { id: 1, name: 'Test' }, + { id: 2, name: 'Item' }, + ], + meta: { next_page: null, previous_page: null, count: 2 }, + } + + globalThis.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }) + + const result = await fetchWithRetry('https://api.example.com/test') + + expect(result.data).toHaveLength(2) + expect(result.data[0]).toBeDefined() + expect(result.data[0]?.id).toBe(1) + expect(result.data[0]?.name).toBe('Test') + }) + + it('should succeed on first attempt without retries', async () => { + const mockData: Butter.Response = { + data: 'success', + meta: { next_page: null, previous_page: null, count: 1 }, + } + + globalThis.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }) + + await fetchWithRetry('https://api.example.com/test', 1) + + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/core/fetch.ts b/src/core/fetch.ts new file mode 100644 index 0000000..f1a933d --- /dev/null +++ b/src/core/fetch.ts @@ -0,0 +1,21 @@ +import type { Butter } from '@/types' + +/** Fetch generic data with retry logic */ +export async function fetchWithRetry(url: string, maxRetries = 3): Promise> { + let lastError: Error | null = null + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return await response.json() + } catch (error) { + lastError = error as Error + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) + } + } + } + throw lastError +} diff --git a/src/core/pages.spec.ts b/src/core/pages.spec.ts new file mode 100644 index 0000000..d9cb53e --- /dev/null +++ b/src/core/pages.spec.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Butter } from '@/types' +import { getAllPages } from './pages' +import * as fetchModule from './fetch' + +// Mock the fetch module +vi.mock('./fetch', () => ({ + fetchWithRetry: vi.fn(), +})) + +const mockFetchWithRetry = fetchModule.fetchWithRetry as ReturnType + +describe('getAllPages', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should fetch all pages on a single page', async () => { + const mockPage: Butter.Page = { + fields: { + title: 'Home', + description: 'Homepage', + }, + name: 'Home', + page_type: 'landing_page', + published: '2023-01-01', + scheduled: null, + slug: 'home', + status: 'published', + updated: null, + } + + const mockResponse: Butter.Response = { + data: [mockPage], + meta: { + next_page: null, + previous_page: null, + count: 1, + }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toEqual([mockPage]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + expect(mockFetchWithRetry).toHaveBeenCalledWith( + expect.stringContaining('https://api.buttercms.com/v2/pages/landing_page/'), + ) + expect(mockFetchWithRetry).toHaveBeenCalledWith( + expect.stringContaining('auth_token=test-token'), + ) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('page=1')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('page_size=100')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('levels=5')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('alt_media_text=1')) + }) + + it('should include different page types in URL', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'product_page', + }) + + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('pages/product_page/')) + }) + + it('should include preview=1 in URL when preview is true', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllPages({ + token: 'test-token', + preview: true, + pageType: 'landing_page', + }) + + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('preview=1')) + }) + + it('should not include preview parameter when preview is false', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + const callUrl = mockFetchWithRetry.mock.calls[0]?.[0] + expect(callUrl).not.toContain('preview=1') + }) + + it('should handle pagination across multiple pages', async () => { + const mockPage1: Butter.Page = { + fields: { title: 'Page 1' }, + name: 'Page 1', + page_type: 'landing_page', + published: '2023-01-01', + scheduled: null, + slug: 'page-1', + status: 'published', + updated: null, + } + + const mockPage2: Butter.Page = { + fields: { title: 'Page 2' }, + name: 'Page 2', + page_type: 'landing_page', + published: '2023-01-02', + scheduled: null, + slug: 'page-2', + status: 'published', + updated: null, + } + + const mockPage1Response: Butter.Response = { + data: [mockPage1], + meta: { + next_page: 2, + previous_page: null, + count: 2, + }, + } + + const mockPage2Response: Butter.Response = { + data: [mockPage2], + meta: { + next_page: null, + previous_page: 1, + count: 2, + }, + } + + mockFetchWithRetry + .mockResolvedValueOnce(mockPage1Response) + .mockResolvedValueOnce(mockPage2Response) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toHaveLength(2) + expect(result[0]!.slug).toBe('page-1') + expect(result[1]!.slug).toBe('page-2') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + expect(mockFetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('page=2')) + }) + + it('should handle pagination across three pages', async () => { + const createMockPage = (slug: string): Butter.Page => ({ + fields: { title: `Page ${slug}` }, + name: `Page ${slug}`, + page_type: 'landing_page', + published: '2023-01-01', + scheduled: null, + slug, + status: 'published', + updated: null, + }) + + mockFetchWithRetry + .mockResolvedValueOnce({ + data: [createMockPage('page-1')], + meta: { next_page: 2, previous_page: null, count: 3 }, + }) + .mockResolvedValueOnce({ + data: [createMockPage('page-2')], + meta: { next_page: 3, previous_page: 1, count: 3 }, + }) + .mockResolvedValueOnce({ + data: [createMockPage('page-3')], + meta: { next_page: null, previous_page: 2, count: 3 }, + }) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toHaveLength(3) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(3) + }) + + it('should return empty array when no pages exist', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should throw error on fetch failure', async () => { + mockFetchWithRetry.mockRejectedValueOnce(new Error('API error')) + + await expect( + getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }), + ).rejects.toThrow('Failed to fetch page landing_page: API error') + }) + + it('should handle different error messages in fetch failure', async () => { + mockFetchWithRetry.mockRejectedValueOnce(new Error('Network timeout')) + + await expect( + getAllPages({ + token: 'test-token', + preview: false, + pageType: 'product_page', + }), + ).rejects.toThrow('Failed to fetch page product_page: Network timeout') + }) + + it('should handle response with null data gracefully', async () => { + const mockResponse: Partial> = { + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should handle response with non-array data gracefully', async () => { + const mockResponse: Partial> = { + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should continue pagination until next_page is null', async () => { + const createMockPage = (slug: string): Butter.Page => ({ + fields: { title: `Page ${slug}` }, + name: `Page ${slug}`, + page_type: 'landing_page', + published: '2023-01-01', + scheduled: null, + slug, + status: 'published', + updated: null, + }) + + mockFetchWithRetry + .mockResolvedValueOnce({ + data: [createMockPage('page-1')], + meta: { next_page: 2, previous_page: null, count: 100 }, + }) + .mockResolvedValueOnce({ + data: [createMockPage('page-2')], + meta: { next_page: null, previous_page: 1, count: 100 }, + }) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toHaveLength(2) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + }) + + it('should handle pages with complex fields', async () => { + const mockPage: Butter.Page = { + fields: { + title: 'Complex Page', + nested: { + field: 'value', + array: [1, 2, 3], + }, + images: ['img1.jpg', 'img2.jpg'], + }, + name: 'Complex', + page_type: 'landing_page', + published: '2023-01-01', + scheduled: null, + slug: 'complex', + status: 'published', + updated: null, + } + + const mockResponse: Butter.Response = { + data: [mockPage], + meta: { next_page: null, previous_page: null, count: 1 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPages({ + token: 'test-token', + preview: false, + pageType: 'landing_page', + }) + + expect(result).toHaveLength(1) + expect(result[0]!.fields).toEqual(mockPage.fields) + }) +}) diff --git a/src/core/pages.ts b/src/core/pages.ts new file mode 100644 index 0000000..70d1778 --- /dev/null +++ b/src/core/pages.ts @@ -0,0 +1,35 @@ +import type { Butter } from '@/types' +import { fetchWithRetry } from './fetch' + +/** + * Get all pages of a specific type from ButterCMS with automatic pagination handling + */ +export async function getAllPages(config: { + token: string + preview: boolean + pageType: string +}): Promise { + const allItems: Butter.Page[] = [] + + let page = 1 + let hasMore = true + while (hasMore) { + const url = `https://api.buttercms.com/v2/pages/${config.pageType}/?auth_token=${config.token}&page=${page}&page_size=100&levels=5&alt_media_text=1${config.preview ? '&preview=1' : ''}` + + try { + const data = await fetchWithRetry(url) + + if (data.data && Array.isArray(data.data) && data.data.length > 0) { + allItems.push(...data.data) + hasMore = data.meta?.next_page !== null + page++ + } else { + hasMore = false + } + } catch (error) { + throw new Error(`Failed to fetch page ${config.pageType}: ${(error as Error).message}`) + } + } + + return allItems +} diff --git a/src/core/posts.spec.ts b/src/core/posts.spec.ts new file mode 100644 index 0000000..3fb8e98 --- /dev/null +++ b/src/core/posts.spec.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Butter } from '@/types' +import { getAllPosts } from './posts' +import * as fetchModule from './fetch' + +// Mock the fetch module +vi.mock('./fetch', () => ({ + fetchWithRetry: vi.fn(), +})) + +const mockFetchWithRetry = fetchModule.fetchWithRetry as ReturnType + +describe('getAllPosts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should fetch all posts on a single page', async () => { + const mockPost: Butter.Post = { + author: { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + slug: 'john-doe', + bio: 'A writer', + title: 'Author', + linkedin_url: '', + facebook_url: '', + pinterest_url: '', + instagram_url: '', + twitter_handle: '', + profile_image: '', + }, + body: 'Content', + categories: [], + created: '2023-01-01', + featured_image: null, + featured_image_alt: '', + meta_description: 'Description', + published: '2023-01-01', + scheduled: null, + seo_title: 'Title', + slug: 'test-post', + status: 'published', + summary: 'Summary', + tags: [], + title: 'Test Post', + updated: null, + url: 'https://example.com/test', + } + + const mockResponse: Butter.Response = { + data: [mockPost], + meta: { + next_page: null, + previous_page: null, + count: 1, + }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toEqual([mockPost]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + expect(mockFetchWithRetry).toHaveBeenCalledWith( + expect.stringContaining('https://api.buttercms.com/v2/posts/'), + ) + expect(mockFetchWithRetry).toHaveBeenCalledWith( + expect.stringContaining('auth_token=test-token'), + ) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('page=1')) + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('page_size=100')) + }) + + it('should include preview=1 in URL when preview is true', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllPosts({ + token: 'test-token', + preview: true, + }) + + expect(mockFetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('preview=1')) + }) + + it('should not include preview parameter when preview is false', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + await getAllPosts({ + token: 'test-token', + preview: false, + }) + + const callUrl = mockFetchWithRetry.mock.calls[0]?.[0] + expect(callUrl).not.toContain('preview=1') + }) + + it('should handle pagination across multiple pages', async () => { + const mockPost1: Butter.Post = { + author: { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + slug: 'john-doe', + bio: 'A writer', + title: 'Author', + linkedin_url: '', + facebook_url: '', + pinterest_url: '', + instagram_url: '', + twitter_handle: '', + profile_image: '', + }, + body: 'Content 1', + categories: [], + created: '2023-01-01', + featured_image: null, + featured_image_alt: '', + meta_description: 'Description 1', + published: '2023-01-01', + scheduled: null, + seo_title: 'Title 1', + slug: 'post-1', + status: 'published', + summary: 'Summary 1', + tags: [], + title: 'Post 1', + updated: null, + url: 'https://example.com/1', + } + + const mockPost2: Butter.Post = { + ...mockPost1, + body: 'Content 2', + meta_description: 'Description 2', + seo_title: 'Title 2', + slug: 'post-2', + summary: 'Summary 2', + title: 'Post 2', + url: 'https://example.com/2', + } + + const mockPage1Response: Butter.Response = { + data: [mockPost1], + meta: { + next_page: 2, + previous_page: null, + count: 2, + }, + } + + const mockPage2Response: Butter.Response = { + data: [mockPost2], + meta: { + next_page: null, + previous_page: 1, + count: 2, + }, + } + + mockFetchWithRetry + .mockResolvedValueOnce(mockPage1Response) + .mockResolvedValueOnce(mockPage2Response) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toHaveLength(2) + expect(result[0]!.slug).toBe('post-1') + expect(result[1]!.slug).toBe('post-2') + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + expect(mockFetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('page=2')) + }) + + it('should handle pagination across three pages', async () => { + const createMockPost = (slug: string): Butter.Post => ({ + author: { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + slug: 'john-doe', + bio: 'A writer', + title: 'Author', + linkedin_url: '', + facebook_url: '', + pinterest_url: '', + instagram_url: '', + twitter_handle: '', + profile_image: '', + }, + body: `Content ${slug}`, + categories: [], + created: '2023-01-01', + featured_image: null, + featured_image_alt: '', + meta_description: `Description ${slug}`, + published: '2023-01-01', + scheduled: null, + seo_title: `Title ${slug}`, + slug, + status: 'published', + summary: `Summary ${slug}`, + tags: [], + title: `Post ${slug}`, + updated: null, + url: `https://example.com/${slug}`, + }) + + mockFetchWithRetry + .mockResolvedValueOnce({ + data: [createMockPost('post-1')], + meta: { next_page: 2, previous_page: null, count: 3 }, + }) + .mockResolvedValueOnce({ + data: [createMockPost('post-2')], + meta: { next_page: 3, previous_page: 1, count: 3 }, + }) + .mockResolvedValueOnce({ + data: [createMockPost('post-3')], + meta: { next_page: null, previous_page: 2, count: 3 }, + }) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toHaveLength(3) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(3) + }) + + it('should return empty array when no posts exist', async () => { + const mockResponse: Butter.Response = { + data: [], + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should throw error on fetch failure', async () => { + mockFetchWithRetry.mockRejectedValueOnce(new Error('API error')) + + await expect( + getAllPosts({ + token: 'test-token', + preview: false, + }), + ).rejects.toThrow('Failed to fetch posts: API error') + }) + + it('should handle response with null data gracefully', async () => { + const mockResponse: Partial> = { + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should handle response with non-array data gracefully', async () => { + const mockResponse: Partial> = { + meta: { next_page: null, previous_page: null, count: 0 }, + } + + mockFetchWithRetry.mockResolvedValueOnce(mockResponse) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toEqual([]) + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + }) + + it('should use pagination until next_page is null', async () => { + const createMockPost = (slug: string): Butter.Post => ({ + author: { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + slug: 'john-doe', + bio: 'A writer', + title: 'Author', + linkedin_url: '', + facebook_url: '', + pinterest_url: '', + instagram_url: '', + twitter_handle: '', + profile_image: '', + }, + body: `Content ${slug}`, + categories: [], + created: '2023-01-01', + featured_image: null, + featured_image_alt: '', + meta_description: `Description ${slug}`, + published: '2023-01-01', + scheduled: null, + seo_title: `Title ${slug}`, + slug, + status: 'published', + summary: `Summary ${slug}`, + tags: [], + title: `Post ${slug}`, + updated: null, + url: `https://example.com/${slug}`, + }) + + mockFetchWithRetry + .mockResolvedValueOnce({ + data: [createMockPost('post-1')], + meta: { next_page: 2, previous_page: null, count: 100 }, + }) + .mockResolvedValueOnce({ + data: [createMockPost('post-2')], + meta: { next_page: null, previous_page: 1, count: 100 }, + }) + + const result = await getAllPosts({ + token: 'test-token', + preview: false, + }) + + expect(result).toHaveLength(2) + expect(mockFetchWithRetry).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/core/posts.ts b/src/core/posts.ts new file mode 100644 index 0000000..c4ac061 --- /dev/null +++ b/src/core/posts.ts @@ -0,0 +1,34 @@ +import type { Butter } from '@/types' +import { fetchWithRetry } from './fetch' + +/** + * Get all posts from ButterCMS with automatic pagination handling + */ +export async function getAllPosts(config: { + token: string + preview: boolean +}): Promise { + const allItems: Butter.Post[] = [] + + let page = 1 + let hasMore = true + while (hasMore) { + const url = `https://api.buttercms.com/v2/posts/?auth_token=${config.token}&page=${page}&page_size=100${config.preview ? '&preview=1' : ''}` + + try { + const data = await fetchWithRetry(url) + + if (data.data && Array.isArray(data.data) && data.data.length > 0) { + allItems.push(...data.data) + hasMore = data.meta?.next_page !== null + page++ + } else { + hasMore = false + } + } catch (error) { + throw new Error(`Failed to fetch posts: ${(error as Error).message}`) + } + } + + return allItems +} diff --git a/src/features/searchContent.spec.ts b/src/features/searchContent.spec.ts new file mode 100644 index 0000000..59e59cb --- /dev/null +++ b/src/features/searchContent.spec.ts @@ -0,0 +1,1217 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { searchContent } from './searchContent' +import * as pagesModule from '@/core/pages' +import * as postsModule from '@/core/posts' +import * as collectionsModule from '@/core/collections' + +vi.mock('@/core/pages') +vi.mock('@/core/posts') +vi.mock('@/core/collections') + +const mockGetAllPages = pagesModule.getAllPages as ReturnType +const mockGetAllPosts = postsModule.getAllPosts as ReturnType +const mockGetAllCollections = collectionsModule.getAllCollections as ReturnType + +describe('searchContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Input validation', () => { + it('should handle empty search string', async () => { + const result = await searchContent('pages', '', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(0) + expect(result.totalItems).toBe(0) + expect(mockGetAllPages).not.toHaveBeenCalled() + }) + + it('should handle whitespace-only search string', async () => { + const result = await searchContent('pages', ' ', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(0) + expect(result.totalItems).toBe(0) + expect(mockGetAllPages).not.toHaveBeenCalled() + }) + + it('should trim search string', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'test', + name: 'keyword', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent( + 'pages', + ' keyword ', + 'test-token', + false, + 'landing_page', + ) + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + }) + + describe('Pages scope', () => { + it('should search pages and return matching results', async () => { + const mockPages = [ + { + id: '1', + slug: 'about', + name: 'About Us', + page_type: 'landing_page', + published: '2023-01-01', + }, + { + id: '2', + slug: 'contact', + name: 'Contact Page', + page_type: 'landing_page', + published: '2023-01-02', + }, + ] + + mockGetAllPages.mockResolvedValueOnce(mockPages) + + const result = await searchContent('pages', 'Contact', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.totalItems).toBe(2) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.slug).toBe('contact') + expect(result.results[0]!.title).toBe('Contact Page') + }) + + it('should return error when pageType is missing', async () => { + const result = await searchContent('pages', 'test', 'test-token', false) + + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid search scope or missing required parameter') + }) + + it('should handle no matches in pages', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'First Page', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'xyz', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(0) + expect(result.totalItems).toBe(1) + }) + + it('should include preview parameter when preview is true', async () => { + mockGetAllPages.mockResolvedValueOnce([]) + + await searchContent('pages', 'test', 'test-token', true, 'landing_page') + + expect(mockGetAllPages).toHaveBeenCalledWith({ + token: 'test-token', + pageType: 'landing_page', + preview: true, + }) + }) + + it('should handle getAllPages error', async () => { + mockGetAllPages.mockRejectedValueOnce(new Error('API Error')) + + const result = await searchContent('pages', 'test', 'test-token', false, 'landing_page') + + expect(result.success).toBe(false) + expect(result.error).toContain('API Error') + }) + }) + + describe('Blog scope', () => { + it('should search blog posts and return matching results', async () => { + const mockPosts = [ + { + id: '1', + slug: 'first-post', + title: 'First Blog Post', + published: '2023-01-01', + }, + { + id: '2', + slug: 'second-post', + title: 'Second Blog Post', + published: '2023-01-02', + }, + ] + + mockGetAllPosts.mockResolvedValueOnce(mockPosts) + + const result = await searchContent('blog', 'Blog', 'test-token', false) + + expect(result.success).toBe(true) + expect(result.totalItems).toBe(2) + expect(result.results).toHaveLength(2) + expect(result.results[0]!.title).toBe('First Blog Post') + }) + + it('should handle no matches in blog posts', async () => { + mockGetAllPosts.mockResolvedValueOnce([ + { + id: '1', + slug: 'post-1', + title: 'Post Title', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('blog', 'nonexistent', 'test-token', false) + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(0) + }) + + it('should handle getAllPosts error', async () => { + mockGetAllPosts.mockRejectedValueOnce(new Error('Network Error')) + + const result = await searchContent('blog', 'test', 'test-token', false) + + expect(result.success).toBe(false) + expect(result.error).toContain('Network Error') + }) + }) + + describe('Collections scope', () => { + it('should search collections and return matching results', async () => { + const mockCollections = [ + { + id: 1, + slug: 'product-1', + name: 'Product One', + }, + { + id: 2, + slug: 'product-2', + name: 'Product Two', + }, + ] + + mockGetAllCollections.mockResolvedValueOnce(mockCollections) + + const result = await searchContent( + 'collections', + 'Product', + 'test-token', + false, + undefined, + 'products', + ) + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(2) + expect(result.results[0]!.slug).toBe('product-1') + }) + + it('should return error when collectionKey is missing', async () => { + const result = await searchContent('collections', 'test', 'test-token', false) + + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid search scope or missing required parameter') + }) + + it('should handle getAllCollections error', async () => { + mockGetAllCollections.mockRejectedValueOnce(new Error('Collection Error')) + + const result = await searchContent( + 'collections', + 'test', + 'test-token', + false, + undefined, + 'products', + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Collection Error') + }) + }) + + describe('Case-insensitive matching', () => { + it('should match lowercase search term against uppercase text', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'UPPERCASE TEXT', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'uppercase', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.matches).toHaveLength(1) + }) + + it('should match uppercase search term against lowercase text', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'lowercase text', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'LOWERCASE', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should match mixed case search term', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'MixedCase Content', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'mixed', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + }) + + describe('Whitespace normalization', () => { + it('should match text with non-breaking spaces', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Hello\u00A0World', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent( + 'pages', + 'hello world', + 'test-token', + false, + 'landing_page', + ) + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should match text with HTML nbsp entities', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Test Content', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent( + 'pages', + 'test content', + 'test-token', + false, + 'landing_page', + ) + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should normalize multiple consecutive spaces', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Text with many spaces', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'with many', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should use normalized text in snippets', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Before keyword after', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + // Snippet should contain normalized spaces, not   + expect(result.results[0]!.matches[0]!.value).toContain('Before keyword after') + expect(result.results[0]!.matches[0]!.value).not.toContain(' ') + }) + }) + + describe('Alphabetical sorting', () => { + it('should sort results alphabetically by slug', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '3', + slug: 'zebra-page', + name: 'Zebra Content', + page_type: 'landing_page', + published: '2023-01-01', + }, + { + id: '1', + slug: 'apple-page', + name: 'Apple Content', + page_type: 'landing_page', + published: '2023-01-02', + }, + { + id: '2', + slug: 'banana-page', + name: 'Banana Content', + page_type: 'landing_page', + published: '2023-01-03', + }, + ]) + + const result = await searchContent('pages', 'content', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.slug).toBe('apple-page') + expect(result.results[1]!.slug).toBe('banana-page') + expect(result.results[2]!.slug).toBe('zebra-page') + }) + }) + + describe('Multiple occurrences optimization', () => { + it('should consolidate multiple occurrences into single match with count', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'test test test test test', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'test', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.matches).toHaveLength(1) + expect(result.results[0]!.matches[0]!.count).toBe(5) + expect(result.results[0]!.matches[0]!.path).toContain('occurrences') + }) + + it('should show single occurrence without occurrence label', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'single match here', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'match', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.count).toBe(1) + expect(result.results[0]!.matches[0]!.path).not.toContain('occurrences') + expect(result.results[0]!.matches[0]!.path).toBe('name') + }) + + it('should limit snippet generation for many occurrences', async () => { + const manyOccurrences = 'keyword '.repeat(100) + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: manyOccurrences, + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.count).toBe(100) + // Should still return a valid snippet + expect(result.results[0]!.matches[0]!.value).toBeTruthy() + expect(result.results[0]!.matches[0]!.value).toContain('keyword') + }) + }) + + describe('Circular reference protection', () => { + it('should handle circular references without infinite loop', async () => { + const circularObj: Record = { + id: '1', + slug: 'circular', + name: 'Circular Reference Test', + page_type: 'landing_page', + published: '2023-01-01', + } + circularObj.self = circularObj + circularObj.nested = { parent: circularObj } + + mockGetAllPages.mockResolvedValueOnce([circularObj]) + + const result = await searchContent('pages', 'Circular', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.title).toBe('Circular Reference Test') + }) + + it('should handle self-referencing arrays', async () => { + const circularArray: Record = { + id: '1', + slug: 'array-circular', + name: 'Array Test', + page_type: 'landing_page', + published: '2023-01-01', + items: [], + } + ;(circularArray.items as Array>).push(circularArray) + + mockGetAllPages.mockResolvedValueOnce([circularArray]) + + const result = await searchContent('pages', 'Array', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + }) + + describe('Depth limit protection', () => { + it('should stop recursion at depth 10', async () => { + // Create deeply nested object (12 levels) + let deepObj: Record = { value: 'deep keyword' } + for (let i = 0; i < 12; i++) { + deepObj = { level: deepObj } + } + + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'deep', + name: 'Deep Object', + page_type: 'landing_page', + published: '2023-01-01', + data: deepObj, + }, + ]) + + const result = await searchContent('pages', 'deep', 'test-token', false, 'landing_page') + + // Should find match in name field but not in deeply nested value + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.title).toBe('Deep Object') + }) + + it('should handle depth 10 structures correctly', async () => { + // Create object at depth 9 (will be depth 10 when counting from root item) + // Root item = depth 0, data = depth 1, nested = depth 2, etc. + let obj: Record = { target: 'findme' } + for (let i = 0; i < 8; i++) { + obj = { nested: obj } + } + + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page', + name: 'Page', + page_type: 'landing_page', + published: '2023-01-01', + data: obj, + }, + ]) + + const result = await searchContent('pages', 'findme', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + // Should find it at depth 10 (root=0, data=1, nested=2...10) + expect(result.results).toHaveLength(1) + }) + }) + + describe('Nested object searching', () => { + it('should find matches in nested objects', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page Name', + page_type: 'landing_page', + published: '2023-01-01', + fields: { + heading: 'Searchable Content', + subheading: 'More nested content', + }, + }, + ]) + + const result = await searchContent('pages', 'Searchable', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.matches.some((m) => m.path.includes('fields'))).toBe(true) + }) + + it('should find matches in arrays within objects', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page', + page_type: 'landing_page', + published: '2023-01-01', + tags: ['keyword', 'searchable', 'content'], + }, + ]) + + const result = await searchContent('pages', 'searchable', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.matches.some((m) => m.path.includes('tags'))).toBe(true) + }) + + it('should handle deeply nested structures', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page', + page_type: 'landing_page', + published: '2023-01-01', + data: { + level1: { + level2: { + level3: { + value: 'target keyword found', + }, + }, + }, + }, + }, + ]) + + const result = await searchContent('pages', 'target', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.matches.some((m) => m.path.includes('level3'))).toBe(true) + }) + + it('should find matches in multiple nested fields', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'search-keyword', + name: 'Search Title', + page_type: 'landing_page', + published: '2023-01-01', + meta: { + description: 'This contains search term', + keywords: ['search', 'seo'], + }, + }, + ]) + + const result = await searchContent('pages', 'search', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches.length).toBeGreaterThan(1) + }) + }) + + describe('Title and slug fallbacks', () => { + it('should use name as title for pages', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-slug', + name: 'Page Title', + page_type: 'landing_page', + published: '2023-01-01', + content: 'searchable content', + }, + ]) + + const result = await searchContent('pages', 'searchable', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.title).toBe('Page Title') + }) + + it('should use title as fallback when name is missing', async () => { + mockGetAllPosts.mockResolvedValueOnce([ + { + id: '1', + slug: 'post-slug', + title: 'Post Title', + published: '2023-01-01', + content: 'searchable content', + }, + ]) + + const result = await searchContent('blog', 'searchable', 'test-token', false) + + expect(result.success).toBe(true) + expect(result.results[0]!.title).toBe('Post Title') + }) + + it('should use slug as fallback when name and title are missing', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'fallback-slug', + page_type: 'landing_page', + published: '2023-01-01', + content: 'searchable content here', + }, + ]) + + const result = await searchContent('pages', 'searchable', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.title).toBe('fallback-slug') + }) + + it('should use Untitled when no name, title, or slug available', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + page_type: 'landing_page', + published: '2023-01-01', + content: 'searchable content', + }, + ]) + + const result = await searchContent('pages', 'searchable', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.title).toBe('Untitled') + }) + + it('should use N/A for slug when slug is missing', async () => { + mockGetAllCollections.mockResolvedValueOnce([ + { + id: 1, + name: 'Collection Item', + }, + ]) + + const result = await searchContent( + 'collections', + 'Collection', + 'test-token', + false, + undefined, + 'items', + ) + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.slug).toBe('N/A') + }) + }) + + describe('Number and boolean matching', () => { + it('should match number values', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page', + page_type: 'landing_page', + published: '2023-01-01', + priority: 5, + }, + ]) + + const result = await searchContent('pages', '5', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches.some((m) => m.value === '5')).toBe(true) + }) + + it('should match boolean values', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page', + page_type: 'landing_page', + published: '2023-01-01', + featured: true, + }, + ]) + + const result = await searchContent('pages', 'true', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches.some((m) => m.value === 'true')).toBe(true) + }) + + it('should match partial numbers', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page', + page_type: 'landing_page', + published: '2023-01-01', + year: 2023, + }, + ]) + + const result = await searchContent('pages', '202', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + }) + + describe('Context snippets', () => { + it('should include ellipsis before match when context precedes', async () => { + const longText = 'A'.repeat(150) + ' keyword ' + 'B'.repeat(50) + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: longText, + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.value).toMatch(/^\.\.\./) + }) + + it('should include ellipsis after match when content follows', async () => { + const longText = 'keyword ' + 'B'.repeat(150) + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: longText, + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.value).toMatch(/\.\.\.$/) + }) + + it('should not include ellipsis when match is at start', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'keyword is at the beginning', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.value.startsWith('...')).toBe(false) + }) + + it('should not include ellipsis when match is at end', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'This ends with keyword', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.value.endsWith('...')).toBe(false) + }) + + it('should create readable context around matches', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Before text with some context around the keyword and after text with more content', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + const snippet = result.results[0]!.matches[0]!.value + expect(snippet).toContain('keyword') + expect(snippet).toContain('context around') + expect(snippet).toContain('after text') + }) + }) + + describe('Special characters and edge cases', () => { + it('should handle special characters in search', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Price: $99.99 (special)', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', '$99.99', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should handle unicode characters', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Café résumé naïve', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'café', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should handle null and undefined values gracefully', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Valid Name', + page_type: 'landing_page', + published: '2023-01-01', + description: null, + metadata: undefined, + }, + ]) + + const result = await searchContent('pages', 'Valid', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + + it('should handle empty strings in fields', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Test Page', + page_type: 'landing_page', + published: '2023-01-01', + description: '', + content: 'findme', + }, + ]) + + const result = await searchContent('pages', 'findme', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + }) + + describe('Empty and edge cases', () => { + it('should handle empty item array', async () => { + mockGetAllPages.mockResolvedValueOnce([]) + + const result = await searchContent('pages', 'anything', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(0) + expect(result.totalItems).toBe(0) + }) + + it('should handle items with no matching fields', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'Page One', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'xyz123', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(0) + }) + + it('should ignore whitespace-only matches in fields', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: ' ', + page_type: 'landing_page', + published: '2023-01-01', + description: 'Real content with keyword', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches.every((m) => m.value.trim().length > 0)).toBe(true) + }) + }) + + describe('Match count property', () => { + it('should include count property for single match', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'keyword found', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'keyword', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.matches[0]!.count).toBe(1) + }) + + it('should include accurate count for multiple matches', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'page-1', + name: 'match match match', + page_type: 'landing_page', + published: '2023-01-01', + }, + ]) + + const result = await searchContent('pages', 'match', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results[0]!.matches[0]!.count).toBe(3) + }) + }) + + describe('Integration tests', () => { + it('should handle complex real-world page object', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: 'page-123', + slug: 'product-launch', + name: 'Our New Product Launch', + page_type: 'landing_page', + published: '2023-06-15', + updated: '2023-06-20', + seo_title: 'Launch Your Business Success', + seo_description: 'Discover how our product can transform your business', + fields: { + hero_image: 'image.jpg', + hero_title: 'Launch Your Dreams', + hero_subtitle: 'Join thousands of successful businesses', + cta_text: 'Get Started Today', + cta_url: '/signup', + features: [ + { title: 'Fast Setup', description: 'Get launched in 5 minutes' }, + { title: 'Secure Platform', description: 'Enterprise-grade security' }, + ], + }, + tags: ['launch', 'product', 'business'], + author: { + name: 'John Doe', + email: 'john@example.com', + }, + }, + ]) + + const result = await searchContent('pages', 'launch', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.title).toBe('Our New Product Launch') + expect(result.results[0]!.matches.length).toBeGreaterThan(0) + }) + + it('should handle multiple pages with different match patterns', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'about', + name: 'About Company', + page_type: 'landing_page', + published: '2023-01-01', + content: 'We serve our customers', + }, + { + id: '2', + slug: 'blog', + name: 'Blog Page', + page_type: 'landing_page', + published: '2023-01-02', + content: 'Serve the community', + }, + { + id: '3', + slug: 'contact', + name: 'Contact Us', + page_type: 'landing_page', + published: '2023-01-03', + content: 'No match here', + }, + ]) + + const result = await searchContent('pages', 'serve', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(2) + expect(result.results[0]!.slug).toBe('about') + expect(result.results[1]!.slug).toBe('blog') + }) + + it('should handle partial word matching', async () => { + mockGetAllPages.mockResolvedValueOnce([ + { + id: '1', + slug: 'catalog', + name: 'Product Catalog', + page_type: 'landing_page', + published: '2023-01-01', + }, + { + id: '2', + slug: 'categories', + name: 'All Categories', + page_type: 'landing_page', + published: '2023-01-02', + }, + ]) + + const result = await searchContent('pages', 'cat', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + // Should match both "catalog" and "categories" + expect(result.results).toHaveLength(2) + }) + }) + + describe('Performance tests', () => { + it('should handle large datasets efficiently', async () => { + const largeDataset = Array.from({ length: 1000 }, (_, i) => ({ + id: String(i), + slug: `page-${i}`, + name: i % 10 === 0 ? `Found ${i}` : `Other ${i}`, + page_type: 'landing_page', + published: '2023-01-01', + })) + + mockGetAllPages.mockResolvedValueOnce(largeDataset) + + const startTime = Date.now() + const result = await searchContent('pages', 'Found', 'test-token', false, 'landing_page') + const duration = Date.now() - startTime + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(100) + expect(duration).toBeLessThan(2000) // Should complete in under 2 seconds + }) + + it('should handle objects with many fields', async () => { + const objectWithManyFields: Record = { + id: '1', + slug: 'complex', + name: 'Complex Object', + page_type: 'landing_page', + published: '2023-01-01', + } + + // Add 50 fields + for (let i = 0; i < 50; i++) { + objectWithManyFields[`field_${i}`] = i === 25 ? 'target keyword' : `value ${i}` + } + + mockGetAllPages.mockResolvedValueOnce([objectWithManyFields]) + + const result = await searchContent('pages', 'target', 'test-token', false, 'landing_page') + + expect(result.success).toBe(true) + expect(result.results).toHaveLength(1) + }) + }) +}) diff --git a/src/features/searchContent.ts b/src/features/searchContent.ts new file mode 100644 index 0000000..c911c8a --- /dev/null +++ b/src/features/searchContent.ts @@ -0,0 +1,229 @@ +import { getAllPages } from '@/core/pages' +import { getAllPosts } from '@/core/posts' +import { getAllCollections } from '@/core/collections' + +interface SearchResponse { + success: boolean + results: Array<{ + title: string + slug: string + matches: Array<{ path: string; value: string; count: number }> + }> + totalItems: number | null + error?: string +} + +interface MatchAccumulator { + path: string + snippets: string[] + count: number +} + +function normalizeWhitespace(str: string): string { + return str + .replace(/ /gi, ' ') + .replace(/\u00A0/g, ' ') + .replace(/\s+/g, ' ') +} + +function createContextSnippet( + normalizedText: string, + matchIndex: number, + matchLength: number, + contextSize = 100, +): string { + const contextStart = Math.max(0, matchIndex - contextSize) + const contextEnd = Math.min(normalizedText.length, matchIndex + matchLength + contextSize) + + let snippet = normalizedText.substring(contextStart, contextEnd) + + if (contextStart > 0) snippet = '...' + snippet + if (contextEnd < normalizedText.length) snippet = snippet + '...' + + return snippet +} + +function searchObject( + obj: unknown, + searchLower: string, + path = '', + depth = 0, + visited = new WeakSet(), +): Map { + const matchMap = new Map() + + // Guard against excessive depth and circular references + if (depth > 10) return matchMap + if (obj === null || obj === undefined) return matchMap + + // Prevent circular reference issues + if (typeof obj === 'object' && !Array.isArray(obj)) { + if (visited.has(obj as object)) return matchMap + visited.add(obj as object) + } + + if (typeof obj === 'string') { + const normalizedObj = normalizeWhitespace(obj) + const normalizedSearchLower = normalizeWhitespace(searchLower) + const lowerNormalizedObj = normalizedObj.toLowerCase() + + // Find all matches in this string + let searchIndex = 0 + let occurrenceCount = 0 + const snippets: string[] = [] + + while ((searchIndex = lowerNormalizedObj.indexOf(normalizedSearchLower, searchIndex)) !== -1) { + occurrenceCount++ + + // Limit snippets to first 3 occurrences to prevent bloat + if (snippets.length < 3) { + snippets.push( + createContextSnippet(normalizedObj, searchIndex, normalizedSearchLower.length), + ) + } + + searchIndex += normalizedSearchLower.length + } + + if (occurrenceCount > 0) { + matchMap.set(path || 'root', { + path: path || 'root', + snippets, + count: occurrenceCount, + }) + } + } else if (typeof obj === 'number' || typeof obj === 'boolean') { + const stringValue = String(obj) + if (stringValue.toLowerCase().includes(searchLower)) { + matchMap.set(path || 'root', { + path: path || 'root', + snippets: [stringValue], + count: 1, + }) + } + } else if (Array.isArray(obj)) { + obj.forEach((item, index) => { + const nestedMatches = searchObject(item, searchLower, `${path}[${index}]`, depth + 1, visited) + nestedMatches.forEach((value, key) => { + matchMap.set(key, value) + }) + }) + } else if (typeof obj === 'object') { + for (const [key, value] of Object.entries(obj)) { + const nestedMatches = searchObject( + value, + searchLower, + path ? `${path}.${key}` : key, + depth + 1, + visited, + ) + nestedMatches.forEach((value, key) => { + matchMap.set(key, value) + }) + } + } + + return matchMap +} + +export async function searchContent( + scope: 'pages' | 'blog' | 'collections', + searchString: string, + token: string, + preview: boolean, + pageType?: string, + collectionKey?: string, +): Promise { + // Validate and normalize search input + const trimmedSearch = searchString.trim() + if (!trimmedSearch) { + return { + success: true, + results: [], + totalItems: 0, + } + } + + const searchLower = trimmedSearch.toLowerCase() + + try { + let allItems: unknown[] = [] + + if (scope === 'pages' && pageType) { + allItems = await getAllPages({ + token, + pageType, + preview, + }) + } else if (scope === 'blog') { + allItems = await getAllPosts({ + token, + preview, + }) + } else if (scope === 'collections' && collectionKey) { + allItems = await getAllCollections({ + token, + collectionType: collectionKey, + preview, + }) + } else { + throw new Error('Invalid search scope or missing required parameter') + } + + const searchResults: Array<{ + title: string + slug: string + matches: Array<{ path: string; value: string; count: number }> + }> = [] + + for (const itemData of allItems) { + const matchMap = searchObject(itemData, searchLower) + + if (matchMap.size > 0) { + const item = itemData as Record + + // Convert map to array of matches + const matches = Array.from(matchMap.values()).map((acc) => { + // For multiple occurrences, show count in path and use first snippet + if (acc.count > 1) { + return { + path: `${acc.path} (${acc.count} occurrences)`, + value: acc.snippets[0] || '', + count: acc.count, + } + } + // For single occurrence, return as-is + return { + path: acc.path, + value: acc.snippets[0] || '', + count: acc.count, + } + }) + + const validMatches = matches.filter((m) => m.value && m.value.trim().length > 0) + + if (validMatches.length > 0) { + searchResults.push({ + title: + (item.name as string) || + (item.title as string) || + (item.slug as string) || + 'Untitled', + slug: (item.slug as string) || 'N/A', + matches: validMatches, + }) + } + } + } + + searchResults.sort((a, b) => a.slug.localeCompare(b.slug)) + return { success: true, results: searchResults, totalItems: allItems.length } + } catch (error) { + return { + success: false, + results: [], + totalItems: null, + error: (error as Error).message, + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..553f75e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,78 @@ +export namespace Butter { + export interface Response { + data: Data + meta: { + next_page: number | null + previous_page: number | null + count: number + } + } + + export interface Category { + name: string + slug: CategorySlug + recent_posts?: Post[] + } + + export interface Tag { + name: string + slug: TagSlug + recent_posts?: Post[] + } + + export interface Author { + first_name: string + last_name: string + email: string + slug: AuthorSlug + bio: string + title: string + linkedin_url: string + facebook_url: string + pinterest_url: string + instagram_url: string + twitter_handle: string + profile_image: string + recent_posts?: Post[] + } + + export interface Post { + author: Omit, 'recent_posts'> + body?: string + categories: Butter.Category[] + created: string + featured_image: string | null + featured_image_alt: string + meta_description: string + published: string | null + scheduled: string | null + seo_title: string + slug: PostSlug + status: 'published' | 'draft' | 'scheduled' + summary: string + tags: Butter.Tag[] + title: string + updated: string | null + url: string + } + + export interface Page< + PageModel extends object = object, + PageType extends string = string, + PageSlug extends string = string, + > { + fields: PageModel + name: string + page_type: PageType + published: string | null + scheduled: string | null + slug: PageSlug + status: 'published' | 'draft' | 'scheduled' + updated: string | null + } + + export interface Collection { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 913b8f2..aba91ac 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,6 +4,7 @@ "exclude": ["src/**/__tests__/*"], "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "module": "ESNEXT", "paths": { "@/*": ["./src/*"] diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index 7d1d8ce..43d0725 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -6,6 +6,6 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", "lib": [], - "types": ["node", "jsdom"] + "types": ["node"] } } diff --git a/vite.config.ts b/vite.config.ts index 2dbb669..a9dab1b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ import { fileURLToPath, URL } from 'node:url' -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' @@ -19,5 +19,13 @@ export default defineConfig({ __VUE_OPTIONS_API__: false, __VUE_PROD_DEVTOOLS__: false, __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false + }, + test: { + coverage: { + enabled: true, + provider: 'v8', + include: ['src/**/*.ts', 'src/**/*.vue'], + exclude: ['**/node_modules/**', 'src/main.ts', 'src/types.ts'] + } } })