diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts new file mode 100644 index 00000000..b5904a15 --- /dev/null +++ b/__tests__/github.test.ts @@ -0,0 +1,47 @@ +import { getGitHubMetaWithFallback, getGitHubPublicFork } from '@/admin/github'; +import { TEMPLATE_BASE_OWNER, TEMPLATE_BASE_REPO } from '@/site/config'; + +describe('GitHub', () => { + it('fetches base repo meta', async () => { + const meta = await getGitHubMetaWithFallback({ + owner: TEMPLATE_BASE_OWNER, + repo: TEMPLATE_BASE_REPO, + }); + expect(meta).toBeDefined(); + expect(meta.url).toBeDefined(); + expect(meta.isForkedFromBase).toEqual(false); + expect(meta.label).toBeDefined(); + expect(meta.title).toBeDefined(); + expect(meta.isBehind).toEqual(false); + expect(meta.isBaseRepo).toBe(true); + }); + it('fetches fork meta', async () => { + const fork = await getGitHubPublicFork(); + const metaFork = await getGitHubMetaWithFallback(fork); + expect(metaFork.isForkedFromBase).toEqual(true); + }); + it('handles nonexistent repos', async () => { + const meta = await getGitHubMetaWithFallback({ + owner: 'nonexistent', + repo: 'nonexistent', + }); + expect(meta).toBeDefined(); + expect(meta.url).toBeDefined(); + expect(meta.isForkedFromBase).toEqual(false); + expect(meta.label).toEqual('Unknown'); + expect(meta.title).toEqual('Unknown'); + expect(meta.isBehind).toBeUndefined(); + }); + it('handles fetch errors', async () => { + const meta = await getGitHubMetaWithFallback({ + owner: 'gibberish / / *', + repo: 'bad text for a url.com', + }); + expect(meta).toBeDefined(); + expect(meta.url).toBeDefined(); + expect(meta.isForkedFromBase).toEqual(false); + expect(meta.label).toEqual('Unknown'); + expect(meta.title).toEqual('Unknown'); + expect(meta.isBehind).toBeUndefined(); + }); +}); diff --git a/__tests__/html.test.ts b/__tests__/html.test.ts index 9468d976..d9eda9b1 100644 --- a/__tests__/html.test.ts +++ b/__tests__/html.test.ts @@ -1,5 +1,7 @@ -import { htmlHasBrParagraphBreaks, safelyParseFormattedHtml } from '@/utility/html'; -import { parameterize } from '@/utility/string'; +import { + htmlHasBrParagraphBreaks, + safelyParseFormattedHtml, +} from '@/utility/html'; describe('HTML', () => { it('safely parses', () => { diff --git a/jest.config.mjs b/jest.config.ts similarity index 67% rename from jest.config.mjs rename to jest.config.ts index b063a90b..eb690b56 100644 --- a/jest.config.mjs +++ b/jest.config.ts @@ -1,4 +1,5 @@ /* eslint-disable max-len */ +import type { Config } from 'jest'; import nextJest from 'next/jest.js'; const createJestConfig = nextJest({ @@ -7,12 +8,10 @@ const createJestConfig = nextJest({ }); // Add any custom config to be passed to Jest -/** @type {import('jest').Config} */ -const config = { - // Add more setup options before each test is run - // setupFilesAfterEnv: ['/jest.setup.js'], - - testEnvironment: 'jest-environment-jsdom', +const config: Config = { + coverageProvider: 'v8', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest.setup.ts'], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 00000000..3673ac04 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1 @@ +import 'cross-fetch/polyfill'; \ No newline at end of file diff --git a/package.json b/package.json index 4f3cf89a..a905be0a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@next/bundle-analyzer": "15.1.6", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", @@ -57,12 +58,14 @@ "@types/sanitize-html": "^2.13.0", "autoprefixer": "10.4.20", "clsx": "^2.1.1", + "cross-fetch": "^4.1.0", "eslint": "9.18.0", "eslint-config-next": "15.1.6", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "postcss": "8.5.1", "tailwindcss": "3.4.17", + "ts-node": "^10.9.2", "typescript": "5.7.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 890b8eb4..5b8add6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,16 +113,19 @@ importers: version: 15.1.6 '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@3.4.17) + version: 0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3))) '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.17) + version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3))) + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 '@testing-library/react': specifier: ^16.2.0 - version: 16.2.0(@testing-library/dom@10.1.0)(@types/react-dom@19.0.3(@types/react@19.0.7))(@types/react@19.0.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.7))(@types/react@19.0.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -147,6 +150,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cross-fetch: + specifier: ^4.1.0 + version: 4.1.0 eslint: specifier: 9.18.0 version: 9.18.0(jiti@1.21.7) @@ -155,7 +161,7 @@ importers: version: 15.1.6(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.10.7) + version: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -164,7 +170,10 @@ importers: version: 8.5.1 tailwindcss: specifier: 3.4.17 - version: 3.4.17 + version: 3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) typescript: specifier: 5.7.3 version: 5.7.3 @@ -566,6 +575,10 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -847,6 +860,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@next/bundle-analyzer@15.1.6': resolution: {integrity: sha512-hGzQyDqJzFHcHNCyTqM3o05BpVq5tGnRODccZBVJDBf5Miv/26UJPMB0wh9L9j3ylgHC+0/v8BaBnBBek1rC6Q==} @@ -1487,8 +1503,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' - '@testing-library/dom@10.1.0': - resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} '@testing-library/jest-dom@6.6.3': @@ -1514,6 +1530,18 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1819,6 +1847,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -2112,6 +2143,12 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2250,6 +2287,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -3217,6 +3258,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -3355,6 +3399,15 @@ packages: sass: optional: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -4098,6 +4151,9 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@3.0.0: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} @@ -4114,6 +4170,20 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -4233,6 +4303,9 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.2.0: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} @@ -4255,6 +4328,9 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4276,6 +4352,9 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4371,6 +4450,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5121,6 +5204,10 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@discoveryjs/json-ext@0.5.7': {} '@emnapi/runtime@1.2.0': @@ -5305,7 +5392,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -5319,7 +5406,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.10.7) + jest-config: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -5475,6 +5562,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@next/bundle-analyzer@15.1.6': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -6169,16 +6261,16 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.17)': + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)))': dependencies: - tailwindcss: 3.4.17 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) - '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17)': + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.17 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) - '@testing-library/dom@10.1.0': + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.24.2 '@babel/runtime': 7.24.5 @@ -6199,10 +6291,10 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@16.2.0(@testing-library/dom@10.1.0)(@types/react-dom@19.0.3(@types/react@19.0.7))(@types/react@19.0.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.7))(@types/react@19.0.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.24.5 - '@testing-library/dom': 10.1.0 + '@testing-library/dom': 10.4.0 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) optionalDependencies: @@ -6211,6 +6303,14 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -6552,6 +6652,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arg@4.1.3: {} + arg@5.0.2: {} argparse@1.0.10: @@ -6902,13 +7004,13 @@ snapshots: cookie@0.7.1: {} - create-jest@29.7.0(@types/node@22.10.7): + create-jest@29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.10.7) + jest-config: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -6917,6 +7019,14 @@ snapshots: - supports-color - ts-node + create-require@1.1.1: {} + + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -7029,6 +7139,8 @@ snapshots: diff-sequences@29.6.3: {} + diff@4.0.2: {} + dlv@1.1.3: {} doctrine@2.1.0: @@ -7926,16 +8038,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@22.10.7): + jest-cli@29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.10.7) + create-jest: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@22.10.7) + jest-config: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -7945,7 +8057,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.10.7): + jest-config@29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: '@babel/core': 7.24.5 '@jest/test-sequencer': 29.7.0 @@ -7971,6 +8083,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.10.7 + ts-node: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -8205,12 +8318,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.10.7): + jest@29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@22.10.7) + jest-cli: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -8355,6 +8468,8 @@ snapshots: dependencies: semver: 7.6.3 + make-error@1.3.6: {} + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -8465,6 +8580,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-int64@0.4.0: {} node-releases@2.0.14: {} @@ -8678,12 +8797,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.1 - postcss-load-config@4.0.2(postcss@8.5.1): + postcss-load-config@4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: lilconfig: 3.1.3 yaml: 2.4.2 optionalDependencies: postcss: 8.5.1 + ts-node: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) postcss-nested@6.2.0(postcss@8.5.1): dependencies: @@ -9194,7 +9314,7 @@ snapshots: symbol-tree@3.2.4: {} - tailwindcss@3.4.17: + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -9213,7 +9333,7 @@ snapshots: postcss: 8.5.1 postcss-import: 15.1.0(postcss@8.5.1) postcss-js: 4.0.1(postcss@8.5.1) - postcss-load-config: 4.0.2(postcss@8.5.1) + postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) postcss-nested: 6.2.0(postcss@8.5.1) postcss-selector-parser: 6.1.2 resolve: 1.22.8 @@ -9256,6 +9376,8 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@3.0.0: dependencies: punycode: 2.3.1 @@ -9270,6 +9392,24 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.10.7 + acorn: 8.14.0 + acorn-walk: 8.3.2 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.7.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -9391,6 +9531,8 @@ snapshots: uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.2.0: dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -9418,6 +9560,8 @@ snapshots: dependencies: makeerror: 1.0.12 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} webpack-bundle-analyzer@4.10.1: @@ -9450,6 +9594,11 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -9543,6 +9692,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} + yocto-queue@0.1.0: {} zod-to-json-schema@3.24.1(zod@3.23.8): diff --git a/src/admin/github/GitHubForkStatusBadge.tsx b/src/admin/github/GitHubForkStatusBadge.tsx new file mode 100644 index 00000000..e9cd6263 --- /dev/null +++ b/src/admin/github/GitHubForkStatusBadge.tsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react'; +import GitHubForkStatusBadgeClient from './GitHubForkStatusBadgeClient'; +import GitHubForkStatusBadgeServer from './GitHubForkStatusBadgeServer'; +import { IS_DEVELOPMENT } from '@/site/config'; + +export default function GitHubForkStatusBadge() { + return IS_DEVELOPMENT + ? + : + + ; +} diff --git a/src/admin/github/GitHubForkStatusBadgeClient.tsx b/src/admin/github/GitHubForkStatusBadgeClient.tsx new file mode 100644 index 00000000..eaf5323a --- /dev/null +++ b/src/admin/github/GitHubForkStatusBadgeClient.tsx @@ -0,0 +1,75 @@ +import Spinner from '@/components/Spinner'; +import clsx from 'clsx/lite'; +import Link from 'next/link'; +import { BiLogoGithub } from 'react-icons/bi'; + +export default function GitHubForkStatusBadgeClient({ + url, + label, + style = 'mono', + title, +}: { + url?: string + label?: string + style?: 'success' | 'warning' | 'mono' + title?: string +}) { + const classNameForStyle = () => { + switch (style) { + case 'success': return clsx( + 'text-green-700 hover:text-green-700', + 'dark:text-green-400 dark:hover:text-green-400', + 'bg-green-100/75 dark:bg-green-900/50', + 'border-green-300/25', + ); + case 'warning': return clsx( + 'text-amber-700 hover:text-amber-700', + 'dark:text-amber-400 dark:hover:text-amber-400', + 'bg-amber-100/75 dark:bg-amber-900/50', + 'border-amber-300/25 dark:border-amber-900', + ); + default: return clsx( + 'text-gray-700 hover:text-gray-700', + 'dark:text-gray-300 dark:hover:text-gray-300', + 'bg-white dark:bg-transparent', + 'border-main', + ); + } + }; + + const className = clsx( + 'opacity-0 transition-opacity animate-fade-in', + 'inline-flex items-center gap-2', + 'border transition-colors', + url ? 'hover:underline' : 'select-none', + 'pl-[4.5px] pr-2.5 py-[3px]', + 'rounded-full shadow-sm', + classNameForStyle(), + ); + + const content = <> + {!label + ? + : } + {label ?? 'Checking'} + ; + + return url + ? + {content} + + : + {content} + ; +} diff --git a/src/admin/github/GitHubForkStatusBadgeServer.tsx b/src/admin/github/GitHubForkStatusBadgeServer.tsx new file mode 100644 index 00000000..34677866 --- /dev/null +++ b/src/admin/github/GitHubForkStatusBadgeServer.tsx @@ -0,0 +1,31 @@ +import GitHubForkStatusBadgeClient from './GitHubForkStatusBadgeClient'; +import { + VERCEL_GIT_BRANCH, + VERCEL_GIT_REPO_OWNER, + VERCEL_GIT_REPO_SLUG, +} from '@/site/config'; +import { getGitHubMetaWithFallback } from '.'; + +export default async function GitHubForkStatusBadgeServer() { + const owner = VERCEL_GIT_REPO_OWNER; + const repo = VERCEL_GIT_REPO_SLUG; + const branch = VERCEL_GIT_BRANCH; + + const { + url, + isForkedFromBase, + isBaseRepo, + label, + title, + isBehind, + } = await getGitHubMetaWithFallback({ owner, repo, branch }); + + return isForkedFromBase || isBaseRepo + ? + : null; +} diff --git a/src/admin/github/index.ts b/src/admin/github/index.ts new file mode 100644 index 00000000..129f352c --- /dev/null +++ b/src/admin/github/index.ts @@ -0,0 +1,153 @@ +import { + TEMPLATE_BASE_OWNER, + TEMPLATE_BASE_REPO, + TEMPLATE_BASE_BRANCH, +} from '@/site/config'; + +const DEFAULT_BRANCH = 'main'; + +const FALLBACK_TEXT = 'Unknown'; + +// Cache all results for 2 minutes to avoid rate limiting +// GitHub API requests limited to 60 requests per hour +const FETCH_CONFIG: RequestInit = { + next: { revalidate: 120 }, +}; + +interface RepoParams { + owner?: string + repo?: string + branch?: string +}; + +// Website urls + +const getGitHubRepoUrl = ({ + owner = TEMPLATE_BASE_OWNER, + repo = TEMPLATE_BASE_REPO, +}: RepoParams = {}) => + `https://github.com/${owner}/${repo}`; + +export const getGitHubCompareUrl = ({ + owner, + repo, + branch = DEFAULT_BRANCH, +}: RepoParams = {}) => + // eslint-disable-next-line max-len + `${getGitHubRepoUrl({ owner, repo })}/compare/${branch}...${TEMPLATE_BASE_OWNER}:${TEMPLATE_BASE_REPO}:${TEMPLATE_BASE_BRANCH}`; + +// API urls + +const getGitHubApiRepoUrl = ({ + owner = TEMPLATE_BASE_OWNER, + repo = TEMPLATE_BASE_REPO, +}: RepoParams = {}) => + `https://api.github.com/repos/${owner}/${repo}`; + +const getGitHubApiCommitsUrl = (params?: RepoParams) => + `${getGitHubApiRepoUrl(params)}/commits/main`; + +const getGitHubApiForksUrl = (params?: RepoParams) => + `${getGitHubApiRepoUrl(params)}/forks`; + +const getGitHubApiCompareUrl = ({ + owner, + repo, + branch = 'main', +}: RepoParams = {}) => + // eslint-disable-next-line max-len + `${getGitHubApiRepoUrl()}/compare/${TEMPLATE_BASE_BRANCH}...${owner}:${repo}:${branch}`; + +// Requests + +export const getLatestBaseRepoCommitSha = async () => { + const response = await fetch(getGitHubApiCommitsUrl(), FETCH_CONFIG); + const data = await response.json(); + return data.sha ? data.sha.slice(0, 7) as string : undefined; +}; + +const getIsRepoForkedFromBase = async (params: RepoParams) => { + const response = await fetch(getGitHubApiRepoUrl(params), FETCH_CONFIG); + const data = await response.json(); + return ( + Boolean(data.fork) && + data.source?.full_name === `${TEMPLATE_BASE_OWNER}/${TEMPLATE_BASE_REPO}` + ); +}; + +const getGitHubCommitsBehind = async (params?: RepoParams) => { + const response = await fetch(getGitHubApiCompareUrl(params), FETCH_CONFIG); + const data = await response.json(); + return data.behind_by as number; +}; + +const isRepoBaseRepo = async ({ owner, repo }: RepoParams) => + owner?.toLowerCase() === TEMPLATE_BASE_OWNER && + repo?.toLowerCase() === TEMPLATE_BASE_REPO; + +export const getGitHubPublicFork = async ( + params?: RepoParams, +): Promise => { + const response = await fetch(getGitHubApiForksUrl(params), FETCH_CONFIG); + const fork = (await response.json())[0]; + return { + owner: fork.owner.login, + repo: fork.name, + }; +}; + +const getGitHubMeta = async (params: RepoParams) => { + const [ + url, + isForkedFromBase, + isBaseRepo, + behindBy, + ] = await Promise.all([ + getGitHubRepoUrl(params), + getIsRepoForkedFromBase(params), + isRepoBaseRepo(params), + getGitHubCommitsBehind(params), + ]); + + const isBehind = behindBy === undefined + ? undefined + : behindBy > 0; + + const label = isBehind === undefined + ? FALLBACK_TEXT + : isBehind + ? `${behindBy} Behind` + : 'Synced'; + + const title = isBehind === undefined + ? FALLBACK_TEXT + : isBehind + // eslint-disable-next-line max-len + ? `This fork is ${behindBy} commit${behindBy === 1 ? '' : 's'} behind. Consider syncing on GitHub for the latest updates.` + : 'This fork is up to date.'; + + return { + url, + isForkedFromBase, + isBaseRepo, + behindBy, + isBehind, + label, + title, + }; +}; + +export const getGitHubMetaWithFallback = (params: RepoParams) => + getGitHubMeta(params) + .catch(e => { + console.error('Error retrieving GitHub meta', { params, error: e }); + return { + url: undefined, + isForkedFromBase: false, + isBaseRepo: undefined, + behindBy: undefined, + isBehind: undefined, + label: FALLBACK_TEXT, + title: FALLBACK_TEXT, + }; + }); diff --git a/src/app/admin/configuration/page.tsx b/src/app/admin/configuration/page.tsx index ce4719f3..ecb28453 100644 --- a/src/app/admin/configuration/page.tsx +++ b/src/app/admin/configuration/page.tsx @@ -1,6 +1,8 @@ import ClearCacheButton from '@/admin/ClearCacheButton'; +import GitHubForkStatusBadge from '@/admin/github/GitHubForkStatusBadge'; import Container from '@/components/Container'; import SiteGrid from '@/components/SiteGrid'; +import { IS_DEVELOPMENT, IS_VERCEL_GIT_PROVIDER_GITHUB } from '@/site/config'; import SiteChecklist from '@/site/SiteChecklist'; export default async function AdminConfigurationPage() { @@ -8,10 +10,12 @@ export default async function AdminConfigurationPage() { -
-
+
+
App Configuration
+ {(IS_VERCEL_GIT_PROVIDER_GITHUB || IS_DEVELOPMENT) && + }
diff --git a/src/site/SiteChecklist.tsx b/src/site/SiteChecklist.tsx index 1b7dab75..935d0f9c 100644 --- a/src/site/SiteChecklist.tsx +++ b/src/site/SiteChecklist.tsx @@ -11,7 +11,7 @@ export default function SiteChecklist({ return ( }> diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 9ed5a71d..d0a27b78 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -98,11 +98,11 @@ export default function SiteChecklistClient({ aiError, // Component props simplifiedView, - isTestingConnections, + isAnalyzingConfiguration, }: ConfigChecklistStatus & Partial>> & { simplifiedView?: boolean - isTestingConnections?: boolean + isAnalyzingConfiguration?: boolean }) { const renderLink = (href: string, text: string, external = true) => <> @@ -214,11 +214,11 @@ export default function SiteChecklistClient({ icon={} > {databaseError && renderError({ connection: { provider: 'Database', error: databaseError}, @@ -245,7 +245,7 @@ export default function SiteChecklistClient({ {storageError && renderError({ connection: { provider: 'Storage', error: storageError}, @@ -300,11 +300,11 @@ export default function SiteChecklistClient({ icon={} > Store auth secret in environment variable: {!hasAuthSecret && @@ -376,11 +376,11 @@ export default function SiteChecklistClient({ optional > {aiError && renderError({ @@ -392,11 +392,11 @@ export default function SiteChecklistClient({ {renderEnvVars(['OPENAI_SECRET_KEY'])} {kvError && renderError({ diff --git a/src/site/SiteChecklistServer.tsx b/src/site/SiteChecklistServer.tsx index af73d43b..e50255ab 100644 --- a/src/site/SiteChecklistServer.tsx +++ b/src/site/SiteChecklistServer.tsx @@ -8,6 +8,7 @@ export default async function SiteChecklistServer({ simplifiedView?: boolean }) { const connectionErrors = await testConnectionsAction().catch(() => ({})); + return ( { - const response = await fetch(GITHUB_API_URL); - const data = await response.json(); - return data.sha.slice(0, 7); -}; diff --git a/tailwind.config.js b/tailwind.config.js index a728d01d..889d2572 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -29,7 +29,7 @@ module.exports = { 'rotate-pulse': 'rotate-pulse 0.75s linear infinite normal both running', 'fade-in': - 'fade-in 0.5s linear', + 'fade-in 0.5s linear both running', 'hover-drift': 'hover-drift 8s linear infinite', 'hover-wobble':