commit 3fc22f0ed57faffc1f190d2c80abb6c6cabff4e0 Author: DrMint <29893320+DrMint@users.noreply.github.com> Date: Sun Jul 14 17:21:59 2024 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46d43ba --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# PAYLOAD CMS +PAYLOAD_API_URL=https://payload.domain.com/api +PAYLOAD_USER=myemail@domain.com +PAYLOAD_PASSWORD=somepassword123 + +PORT=8080 +MEILI_MASTER_KEY=some_api_keyqs23d1qs6d54qs897qs3 +MEILI_URL=https://meilisearch.domain.com + +WEBHOOK_TOKEN=someApiTokensd54qs6fd5ar7894q3s52x1cq + +## CACHING +DATA_CACHING=true +DATA_PRECACHING=true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9383529 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +server/data.ms +server/dumps + +.env +.cache \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2c28f1e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/shared"] + path = src/shared + url = https://github.com/Accords-Library/shared-library.git diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b4fabdc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.rulers": [100], + "editor.tabSize": 2, + "typescript.preferences.importModuleSpecifier": "non-relative", + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "package.json": ".git*, package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, .ncurc.*, .nvmrc, *.config.cjs, *.config.js, *.config.ts, *config.json, .*ignore", + ".env": ".env.*", + "README.md": "*.md" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3b8ff84 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,562 @@ +{ + "name": "meili.accords-library.com", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meili.accords-library.com", + "version": "1.0.0", + "dependencies": { + "meilisearch": "^0.41.0" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/node": { + "version": "20.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", + "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", + "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/meilisearch": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.41.0.tgz", + "integrity": "sha512-5KcGLxEXD7E+uNO7R68rCbGSHgCqeM3Q3RFFLSsN7ZrIgr8HPDXVAIlP4LHggAZfk0FkSzo8VSXifHCwa2k80g==", + "dependencies": { + "cross-fetch": "^3.1.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tsx": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz", + "integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==", + "dev": true, + "dependencies": { + "esbuild": "~0.21.5", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b513b3 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "meili.accords-library.com", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch --env-file=.env src/index.ts", + "fetch-submodules": "cd src/shared && git pull && cd ../..", + "tsc": "tsc --noEmit", + "precommit": "npm run fetch-submodules && npm run tsc" + }, + "dependencies": { + "meilisearch": "^0.41.0" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + } +} diff --git a/src/cache/dataCache.ts b/src/cache/dataCache.ts new file mode 100644 index 0000000..b4a72a8 --- /dev/null +++ b/src/cache/dataCache.ts @@ -0,0 +1,149 @@ +import { getLogger } from "src/utils/logger"; +import { writeFile, mkdir, readFile } from "fs/promises"; +import { existsSync } from "fs"; +import type { PayloadSDK } from "src/shared/payload/sdk"; + +const ON_DISK_ROOT = `.cache/dataCache`; +const ON_DISK_RESPONSE_CACHE_FILE = `${ON_DISK_ROOT}/responseCache.json`; + +export class DataCache { + private readonly logger = getLogger("[DataCache]"); + private initialized = false; + + private readonly responseCache = new Map(); + private readonly invalidationMap = new Map>(); + + private scheduleSaveTimeout: NodeJS.Timeout | undefined; + + constructor( + private readonly payload: PayloadSDK, + private readonly uncachedPayload: PayloadSDK, + private readonly onInvalidate: (urls: string[]) => Promise + ) {} + + async init() { + if (this.initialized) return; + + if (process.env.DATA_PRECACHING === "true") { + await this.precache(); + } + + this.initialized = true; + } + + private async precache() { + // Get all keys from CMS + const allSDKUrls = (await this.uncachedPayload.getAllSdkUrls()).data.urls; + + // Load cache from disk if available + if (existsSync(ON_DISK_RESPONSE_CACHE_FILE)) { + this.logger.log("Loading cache from disk..."); + const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE); + const data = JSON.parse(buffer.toString()) as [string, any][]; + for (const [key, value] of data) { + // Do not include cache where the key is no longer in the CMS + if (!allSDKUrls.includes(key)) continue; + this.set(key, value); + } + } + + const cacheSizeBeforePrecaching = this.responseCache.size; + + for (const url of allSDKUrls) { + // Do not precache response if already included in the loaded cache from disk + if (this.responseCache.has(url)) continue; + try { + await this.payload.request(url); + } catch { + this.logger.warn("Precaching failed for url", url); + } + } + + if (cacheSizeBeforePrecaching !== this.responseCache.size) { + this.scheduleSave(); + } + + this.logger.log("Precaching completed!", this.responseCache.size, "responses cached"); + } + + get(url: string) { + if (process.env.DATA_CACHING !== "true") return; + const cachedResponse = this.responseCache.get(url); + if (cachedResponse) { + this.logger.log("Retrieved cached response for", url); + return structuredClone(cachedResponse); + } + } + + set(url: string, response: any) { + if (process.env.DATA_CACHING !== "true") return; + const stringData = JSON.stringify(response); + const regex = /[a-f0-9]{24}/g; + const ids = [...stringData.matchAll(regex)].map((match) => match[0]); + const uniqueIds = [...new Set(ids)]; + + uniqueIds.forEach((id) => { + const current = this.invalidationMap.get(id); + if (current) { + current.add(url); + } else { + this.invalidationMap.set(id, new Set([url])); + } + }); + + this.responseCache.set(url, response); + this.logger.log("Cached response for", url); + if (this.initialized) { + this.scheduleSave(); + } + } + + async invalidate(ids: string[], urls: string[]) { + if (process.env.DATA_CACHING !== "true") return; + const urlsToInvalidate = new Set(urls); + + ids.forEach((id) => { + const urlsForThisId = this.invalidationMap.get(id); + if (!urlsForThisId) return; + this.invalidationMap.delete(id); + [...urlsForThisId].forEach((url) => urlsToInvalidate.add(url)); + }); + + for (const url of urlsToInvalidate) { + this.responseCache.delete(url); + this.logger.log("Invalidated cache for", url); + try { + await this.payload.request(url); + } catch (e) { + this.logger.log("Revalidation fails for", url); + } + } + + this.onInvalidate([...urlsToInvalidate]); + this.logger.log("There are currently", this.responseCache.size, "responses in cache."); + if (this.initialized) { + this.scheduleSave(); + } + } + + private scheduleSave() { + if (this.scheduleSaveTimeout) { + clearTimeout(this.scheduleSaveTimeout); + } + this.scheduleSaveTimeout = setTimeout(() => { + this.save(); + }, 10_000); + } + + private async save() { + if (!existsSync(ON_DISK_ROOT)) { + await mkdir(ON_DISK_ROOT, { recursive: true }); + } + + const serializedResponseCache = JSON.stringify([...this.responseCache]); + await writeFile(ON_DISK_RESPONSE_CACHE_FILE, serializedResponseCache, { + encoding: "utf-8", + }); + this.logger.log("Saved", ON_DISK_RESPONSE_CACHE_FILE); + } +} diff --git a/src/cache/documentCache.ts b/src/cache/documentCache.ts new file mode 100644 index 0000000..1869e08 --- /dev/null +++ b/src/cache/documentCache.ts @@ -0,0 +1,49 @@ +import type { Meilisearch } from "meilisearch"; +import { getMeiliDocumentsFromRequest } from "src/convert"; +import { MeiliIndexes } from "src/shared/meilisearch/constants"; +import type { + MeiliDocument, + MeiliDocumentRequest, +} from "src/shared/meilisearch/types"; +import { getLogger } from "src/utils/logger"; + +export class DocumentInvalidator { + private readonly logger = getLogger("[DocumentInvalidator]"); + constructor(private readonly meili: Meilisearch) {} + + async invalidate(urls: string[]) { + const index = this.meili.index(MeiliIndexes.DOCUMENT); + + const documentRequests: MeiliDocumentRequest[] = []; + + for (const url of urls) { + const result = await index.search(undefined, { + filter: `endpointCalled = "${url}"`, + limit: 1, + }); + + const doc = result.hits[0] as MeiliDocument | undefined; + if (!doc) continue; + + await index.deleteDocument(doc.docId); + documentRequests.push(doc); + } + + const documents: MeiliDocument[] = []; + for (const request of documentRequests) { + try { + documents.push(...(await getMeiliDocumentsFromRequest(request))); + } catch (e) { + this.logger.log("Failure to revalidate a document"); + } + } + + this.logger.log( + "Adding", + documents.length, + "documents to Meilisearch" + ); + + await index.addDocuments(documents); + } +} diff --git a/src/cache/tokenCache.ts b/src/cache/tokenCache.ts new file mode 100644 index 0000000..9eb589d --- /dev/null +++ b/src/cache/tokenCache.ts @@ -0,0 +1,26 @@ +export class TokenCache { + private token: string | undefined; + private expiration: number | undefined; + + get() { + if (!this.token) return undefined; + if (!this.expiration || this.expiration < Date.now()) { + console.log("[PayloadSDK] No token to be retrieved or the token expired"); + return undefined; + } + return this.token; + } + + set(newToken: string, newExpiration: number) { + this.token = newToken; + this.expiration = newExpiration * 1000; + const diffInMinutes = Math.floor( + (this.expiration - Date.now()) / 1000 / 60 + ); + console.log( + "[PayloadSDK] New token set. TTL is", + diffInMinutes, + "minutes." + ); + } + } \ No newline at end of file diff --git a/src/convert.ts b/src/convert.ts new file mode 100644 index 0000000..e08148b --- /dev/null +++ b/src/convert.ts @@ -0,0 +1,287 @@ +import { payload } from "src/services"; +import type { + MeiliDocument, + MeiliDocumentRequest, +} from "src/shared/meilisearch/types"; +import { Collections } from "src/shared/payload/constants"; +import type { + EndpointAudio, + EndpointChronologyEvent, + EndpointCollectible, + EndpointFile, + EndpointFolder, + EndpointImage, + EndpointPage, + EndpointRecorder, + EndpointVideo, +} from "src/shared/payload/endpoint-types"; +import { + formatInlineTitle, + formatRichTextContentToString, +} from "src/shared/payload/format"; +import type { PayloadSDKResponse } from "src/shared/payload/sdk"; + +const convertPageToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => + data.translations.map( + ({ language, pretitle, title, subtitle, content, summary }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: data.translations.map(({ language }) => language), + title: formatInlineTitle({ pretitle, title, subtitle }), + content: `${ + summary ? `${formatRichTextContentToString(summary)}\n\n\n` : "" + }${formatRichTextContentToString(content)}`, + updatedAt: Date.parse(data.updatedAt), + type: Collections.Pages, + slug: data.slug, + endpointCalled, + data, + }) + ); + +const convertCollectibleToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => + data.translations.map( + ({ language, pretitle, title, subtitle, description }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: data.translations.map(({ language }) => language), // Add languages from languages field + title: formatInlineTitle({ pretitle, title, subtitle }), + ...(description + ? { description: formatRichTextContentToString(description) } + : {}), + updatedAt: Date.parse(data.updatedAt), + type: Collections.Collectibles, + slug: data.slug, + endpointCalled, + data, + }) + ); + +const convertFolderToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => + data.translations.map(({ language, title, description }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: [], + title, + ...(description + ? { description: formatRichTextContentToString(description) } + : {}), + type: Collections.Folders, + slug: data.slug, + endpointCalled, + data, + })); + +const convertAudioToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => + data.translations.map(({ language, title, description }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: data.translations.map(({ language }) => language), + title, + ...(description + ? { description: formatRichTextContentToString(description) } + : {}), + updatedAt: Date.parse(data.updatedAt), + type: Collections.Audios, + id: data.id, + endpointCalled, + data, + })); + +const convertImageToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => { + if (data.translations.length > 0) { + return data.translations.map( + ({ language, title, description }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: [], + title, + ...(description + ? { description: formatRichTextContentToString(description) } + : {}), + updatedAt: Date.parse(data.updatedAt), + type: Collections.Images, + id: data.id, + endpointCalled, + data, + }) + ); + } else { + return [ + { + docId: data.id, + distinctId: data.id, + languages: [], + title: data.filename, + updatedAt: Date.parse(data.updatedAt), + type: Collections.Images, + id: data.id, + endpointCalled, + data, + }, + ]; + } +}; + +const convertVideoToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => + data.translations.map(({ language, title, description }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: data.translations.map(({ language }) => language), + title, + ...(description + ? { description: formatRichTextContentToString(description) } + : {}), + updatedAt: Date.parse(data.updatedAt), + type: Collections.Videos, + id: data.id, + endpointCalled, + data, + })); + +const convertRecorderToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => { + if (data.translations.length > 0) { + return data.translations.map(({ language, biography }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: [], + title: data.username, + ...(biography + ? { description: formatRichTextContentToString(biography) } + : {}), + type: Collections.Recorders, + id: data.id, + endpointCalled, + data, + })); + } else { + return [ + { + docId: data.id, + distinctId: data.id, + languages: [], + title: data.username, + type: Collections.Recorders, + id: data.id, + endpointCalled, + data, + }, + ]; + } +}; + +const convertFileToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => { + if (data.translations.length > 0) { + return data.translations.map( + ({ language, title, description }) => ({ + docId: `${data.id}_${language}`, + distinctId: data.id, + languages: [], + title, + ...(description + ? { description: formatRichTextContentToString(description) } + : {}), + updatedAt: Date.parse(data.updatedAt), + type: Collections.Files, + id: data.id, + endpointCalled, + data, + }) + ); + } else { + return [ + { + docId: data.id, + distinctId: data.id, + languages: [], + title: data.filename, + updatedAt: Date.parse(data.updatedAt), + type: Collections.Files, + id: data.id, + endpointCalled, + data, + }, + ]; + } +}; + +const convertChronologyEventToDocument = ({ + data, + endpointCalled, +}: PayloadSDKResponse): MeiliDocument[] => + data.events.flatMap((event, index) => + event.translations.map( + ({ language, description, title, notes }) => ({ + docId: `${data.id}_${index}_${language}`, + distinctId: `${data.id}_${index}`, + languages: event.translations.map(({ language }) => language), + ...(title ? { title } : {}), + ...(description || notes + ? { + content: `${ + description ? formatRichTextContentToString(description) : "" + }\n\n${notes ? formatRichTextContentToString(notes) : ""}`, + } + : {}), + type: Collections.ChronologyEvents, + id: data.id, + endpointCalled, + data: { date: data.date, event }, + }) + ) + ); + +export const getMeiliDocumentsFromRequest = async ( + request: MeiliDocumentRequest +): Promise => { + switch (request.type) { + case Collections.Audios: + return convertAudioToDocument(await payload.getAudioByID(request.id)); + case Collections.ChronologyEvents: + return convertChronologyEventToDocument( + await payload.getChronologyEventByID(request.id) + ); + case Collections.Collectibles: + return convertCollectibleToDocument( + await payload.getCollectible(request.slug) + ); + case Collections.Files: + return convertFileToDocument(await payload.getFileByID(request.id)); + case Collections.Folders: + return convertFolderToDocument(await payload.getFolder(request.slug)); + case Collections.Images: + return convertImageToDocument(await payload.getImageByID(request.id)); + case Collections.Pages: + return convertPageToDocument(await payload.getPage(request.slug)); + case Collections.Recorders: + return convertRecorderToDocument( + await payload.getRecorderByID(request.id) + ); + case Collections.Videos: + return convertVideoToDocument(await payload.getVideoByID(request.id)); + } +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ff20ddb --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +import http from "http"; +import { synchronizeMeiliDocs } from "./synchro"; +import type { AfterOperationWebHookMessage } from "src/shared/payload/webhooks"; +import { webhookHandler } from "src/webhook"; +import { dataCache } from "src/services"; + +await dataCache.init(); +await synchronizeMeiliDocs(); + +export const requestListener: http.RequestListener = async (req, res) => { + if (req.method !== "POST") { + res.writeHead(405).end("Method Not Allowed. Use POST."); + return; + } + + if (req.headers.authorization !== `Bearer ${process.env.WEBHOOK_TOKEN}`) { + res.writeHead(403).end("Invalid auth token."); + return; + } + + // Retrieve and parse body + const buffers: Uint8Array[] = []; + for await (const chunk of req) { + buffers.push(chunk); + } + const message = JSON.parse( + Buffer.concat(buffers).toString() + ) as AfterOperationWebHookMessage; + + // Not awaiting on purpose to respond with a 202 and not block the CMS + webhookHandler(message); + + res.writeHead(202).end("Accepted"); +}; + +http.createServer(requestListener).listen(process.env.PORT, () => { + console.log(`Server started: http://localhost:${process.env.PORT}`); +}); diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 0000000..3b948e2 --- /dev/null +++ b/src/services.ts @@ -0,0 +1,39 @@ +import { MeiliSearch } from "meilisearch"; +import { DataCache } from "src/cache/dataCache"; +import { DocumentInvalidator } from "src/cache/documentCache"; +import { TokenCache } from "src/cache/tokenCache"; +import { PayloadSDK } from "src/shared/payload/sdk"; + +if (!process.env.MEILI_URL) throw new Error("No MEILI_URL!"); +if (!process.env.MEILI_MASTER_KEY) throw new Error("No MEILI_MASTER_KEY!"); +if (!process.env.PAYLOAD_API_URL) throw new Error("No PAYLOAD_API_URL!"); +if (!process.env.PAYLOAD_USER) throw new Error("No PAYLOAD_USER!"); +if (!process.env.PAYLOAD_PASSWORD) throw new Error("No PAYLOAD_PASSWORD!"); + +export const meili = new MeiliSearch({ + host: process.env.MEILI_URL, + apiKey: process.env.MEILI_MASTER_KEY, +}); + +const tokenCache = new TokenCache(); + +export const payload = new PayloadSDK( + process.env.PAYLOAD_API_URL, + process.env.PAYLOAD_USER, + process.env.PAYLOAD_PASSWORD +); +payload.addTokenCache(tokenCache); + +export const uncachedPayload = new PayloadSDK( + process.env.PAYLOAD_API_URL, + process.env.PAYLOAD_USER, + process.env.PAYLOAD_PASSWORD +); +uncachedPayload.addTokenCache(tokenCache); + +const documentInvalidator = new DocumentInvalidator(meili); + +export const dataCache = new DataCache(payload, uncachedPayload, (urls) => + documentInvalidator.invalidate(urls) +); +payload.addDataCache(dataCache); diff --git a/src/shared b/src/shared new file mode 160000 index 0000000..806543a --- /dev/null +++ b/src/shared @@ -0,0 +1 @@ +Subproject commit 806543a487319e56cb9f678c9c3a35666f90b82a diff --git a/src/synchro.ts b/src/synchro.ts new file mode 100644 index 0000000..8ff081c --- /dev/null +++ b/src/synchro.ts @@ -0,0 +1,86 @@ +import { getMeiliDocumentsFromRequest } from "src/convert"; +import { meili, uncachedPayload } from "src/services"; +import { MeiliIndexes } from "src/shared/meilisearch/constants"; +import type { + MeiliDocument, + MeiliDocumentRequest, +} from "src/shared/meilisearch/types"; +import { Collections } from "src/shared/payload/constants"; + +export const synchronizeMeiliDocs = async () => { + const version = await meili.getVersion(); + console.log("Success connecting to Meili!"); + console.log("Meili version:", version.pkgVersion); + + const indexes = await meili.getIndexes({ limit: 1_000 }); + + await Promise.all( + indexes.results.map((index) => { + console.log("Deleting index", index.uid); + return index.delete(); + }) + ); + + await meili.createIndex(MeiliIndexes.DOCUMENT, { primaryKey: "docId" }); + const index = meili.index(MeiliIndexes.DOCUMENT); + await index.updatePagination({ maxTotalHits: 100_000 }); + await index.updateFilterableAttributes([ + "languages", + "type", + "endpointCalled", + ]); + await index.updateSortableAttributes(["title", "updatedAt"]); + await index.updateSearchableAttributes(["title", "content"]); + await index.updateDistinctAttribute("distinctId"); + // await index.updateDisplayedAttributes(["type", "page"]); + + const allIds = (await uncachedPayload.getAllIds()).data; + + const documentRequests: MeiliDocumentRequest[] = [ + ...allIds.pages.slugs.map((slug) => ({ + type: Collections.Pages as const, + slug, + })), + ...allIds.collectibles.slugs.map((slug) => ({ + type: Collections.Collectibles as const, + slug, + })), + ...allIds.folders.slugs.map((slug) => ({ + type: Collections.Folders as const, + slug, + })), + ...allIds.audios.ids.map((id) => ({ + type: Collections.Audios as const, + id, + })), + ...allIds.images.ids.map((id) => ({ + type: Collections.Images as const, + id, + })), + ...allIds.videos.ids.map((id) => ({ + type: Collections.Videos as const, + id, + })), + ...allIds.files.ids.map((id) => ({ + type: Collections.Files as const, + id, + })), + ...allIds.recorders.ids.map((id) => ({ + type: Collections.Recorders as const, + id, + })), + ...allIds.chronologyEvents.ids.map((id) => ({ + type: Collections.ChronologyEvents as const, + id, + })), + ]; + + const documents: MeiliDocument[] = []; + for (const request of documentRequests) { + documents.push(...(await getMeiliDocumentsFromRequest(request))); + } + + console.log("Adding", documents.length, "documents to Meilisearch"); + + await index.addDocuments(documents); +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..509acf2 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,6 @@ +export const getLogger = (prefix: string): Pick => ({ + debug: (...message) => console.debug(prefix, ...message), + log: (...message) => console.log(prefix, ...message), + warn: (...message) => console.warn(prefix, ...message), + error: (...message) => console.error(prefix, ...message), +}); diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 0000000..e0bccaf --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,10 @@ +import { dataCache } from "src/services"; +import type { AfterOperationWebHookMessage } from "src/shared/payload/webhooks"; + +export const webhookHandler = async ({ + id, + addedDependantIds, + urls, +}: AfterOperationWebHookMessage) => { + await dataCache.invalidate([...(id ? [id] : []), ...addedDependantIds], urls); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1a9fa34 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,58 @@ +{ + "compilerOptions": { + // Enable top-level await, and other modern ESM features. + "target": "ESNext", + "module": "ESNext", + // Enable module resolution without file extensions on relative paths, for things like npm package imports. + "moduleResolution": "Bundler", + // Allow importing TypeScript files using their native extension (.ts(x)). + "allowImportingTsExtensions": true, + // Enable JSON imports. + "resolveJsonModule": true, + // Enforce the usage of type-only imports when needed, which helps avoiding bundling issues. + "verbatimModuleSyntax": true, + // Ensure that each file can be transpiled without relying on other imports. + // This is redundant with the previous option, however it ensures that it's on even if someone disable `verbatimModuleSyntax` + "isolatedModules": true, + // Astro directly run TypeScript code, no transpilation needed. + "noEmit": true, + // Report an error when importing a file using a casing different from another import of the same file. + "forceConsistentCasingInFileNames": true, + // Properly support importing CJS modules in ESM + "esModuleInterop": true, + // Skip typechecking libraries and .d.ts files + "skipLibCheck": true, + // Allow JavaScript files to be imported + "allowJs": true, + // Allow JSX files (or files that are internally considered JSX, like Astro files) to be imported inside `.js` and `.ts` files. + "jsx": "preserve", + + // Enable strict mode. This enables a few options at a time, see https://www.typescriptlang.org/tsconfig#strict for a list. + "strict": true, + + // Report errors for fallthrough cases in switch statements + "noFallthroughCasesInSwitch": true, + // Force functions designed to override their parent class to be specified as `override`. + "noImplicitOverride": true, + // Force functions to specify that they can return `undefined` if a possible code path does not return a value. + "noImplicitReturns": true, + // Report an error when a variable is declared but never used. + "noUnusedLocals": true, + // Report an error when a parameter is declared but never used. + "noUnusedParameters": true, + // Force the usage of the indexed syntax to access fields declared using an index signature. + "noUncheckedIndexedAccess": true, + // Report an error when the value `undefined` is given to an optional property that doesn't specify `undefined` as a valid value. + "exactOptionalPropertyTypes": true, + // Report an error for unreachable code instead of just a warning. + "allowUnreachableCode": false, + // Report an error for unused labels instead of just a warning. + "allowUnusedLabels": false, + + "baseUrl": ".", + "paths": { + "pages/*": ["src/pages/*"], + "components/*": ["src/components/*"] + } + } +}