From 13f78fc688dbc1988638fa311ef39ce26888010d Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:10 +0200 Subject: [PATCH 01/12] build: run the hub as a React Router framework app on Express --- .gitignore | 7 +- site/.dockerignore | 5 + site/.prettierignore | 8 +- site/Dockerfile | 21 + site/compose.yaml | 26 + site/eslint.config.js | 9 +- site/package-lock.json | 1570 +++++++++++++++++++++++++++++++++++++-- site/package.json | 25 +- site/server/config.ts | 43 ++ site/server/log.ts | 7 + site/server/test.env | 4 + site/tsconfig.app.json | 18 - site/tsconfig.json | 22 +- site/tsconfig.node.json | 13 - site/vite.config.ts | 6 +- 15 files changed, 1676 insertions(+), 108 deletions(-) create mode 100644 site/.dockerignore create mode 100644 site/Dockerfile create mode 100644 site/compose.yaml create mode 100644 site/server/config.ts create mode 100644 site/server/log.ts create mode 100644 site/server/test.env delete mode 100644 site/tsconfig.app.json delete mode 100644 site/tsconfig.node.json diff --git a/.gitignore b/.gitignore index 22bd289..fe82579 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ -dist/ -dist-ssr/ +build/ +.react-router/ +site/data/ +.env *.tsbuildinfo .DS_Store -site/src/data/changelog.json diff --git a/site/.dockerignore b/site/.dockerignore new file mode 100644 index 0000000..b019948 --- /dev/null +++ b/site/.dockerignore @@ -0,0 +1,5 @@ +node_modules +build +.react-router +data +.env diff --git a/site/.prettierignore b/site/.prettierignore index d30cce6..73c5d0e 100644 --- a/site/.prettierignore +++ b/site/.prettierignore @@ -1,6 +1,4 @@ -dist -dist-ssr +build +.react-router +data package-lock.json -src/data/registry.json -src/data/github.json -src/data/changelog.json diff --git a/site/Dockerfile b/site/Dockerfile new file mode 100644 index 0000000..0f71cb6 --- /dev/null +++ b/site/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . + +FROM deps AS build +RUN npm run build && npm prune --omit=dev + +FROM node:24-slim +ENV NODE_ENV=production DATA_DIR=/data +WORKDIR /app +RUN mkdir /data && chown node:node /data +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/build ./build +COPY --from=build /app/server ./server +COPY --from=build /app/scripts/rules.mjs ./scripts/ +USER node +EXPOSE 3000 +CMD ["node", "--disable-warning=ExperimentalWarning", "server/main.ts"] diff --git a/site/compose.yaml b/site/compose.yaml new file mode 100644 index 0000000..384a3c7 --- /dev/null +++ b/site/compose.yaml @@ -0,0 +1,26 @@ +services: + app: + build: { context: ., target: deps } + command: [npm, run, dev] + environment: + APP_URL: http://localhost:3000 + REDIS_URL: redis://redis:6379 + SESSION_SECRET: local-only-session-secret + DEV_LOGIN: ${DEV_LOGIN:?set DEV_LOGIN to your GitHub login} + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + ports: ['3000:3000', '24678:24678'] + volumes: [./app:/app/app, ./server:/app/server, data:/app/data] + depends_on: + redis: { condition: service_healthy } + + redis: + image: redis:8-alpine + command: [redis-server, --appendonly, 'yes', --maxmemory, 96mb, --maxmemory-policy, noeviction] + healthcheck: + test: [CMD, redis-cli, ping] + interval: 5s + volumes: [redis:/data] + +volumes: + data: {} + redis: {} diff --git a/site/eslint.config.js b/site/eslint.config.js index 7710719..8b73d06 100644 --- a/site/eslint.config.js +++ b/site/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; export default defineConfig( - { ignores: ['dist', 'dist-ssr'] }, + { ignores: ['build', '.react-router', 'data'] }, { files: ['**/*.{ts,tsx}'], extends: [ @@ -16,10 +16,15 @@ export default defineConfig( prettier, ], languageOptions: { - ecmaVersion: 2022, + ecmaVersion: 2024, globals: globals.browser, }, }, + { + files: ['server/**/*.ts', 'app/entry.server.tsx'], + languageOptions: { globals: globals.node }, + rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }] }, + }, { files: ['**/*.{js,mjs}'], extends: [js.configs.recommended, prettier], diff --git a/site/package-lock.json b/site/package-lock.json index e8f9ebe..6c43328 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -6,18 +6,28 @@ "": { "name": "graphite-hub", "dependencies": { + "@react-router/express": "^8.5.1", + "@react-router/node": "^8.5.1", "@tailwindcss/vite": "^4.3.3", + "ajv": "^8.20.0", + "express": "^5.3.0", + "ioredis": "^6.0.0", + "nodemailer": "^10.3.1", + "pino": "^10.4.0", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router": "^8.4.0", + "react-router": "^8.5.1", "tailwindcss": "^4.3.3", "yaml": "^2.9.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@react-router/dev": "^8.5.1", + "@types/express": "^5.0.6", + "@types/node": "^24.19.2", + "@types/nodemailer": "^8.0.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", @@ -486,6 +496,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz", + "integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -564,6 +580,138 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@react-router/dev": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.5.1.tgz", + "integrity": "sha512-1NQ/lP8m7Kd9G4cGKPoEMZe0qcWOnRkf8QxpAcnX/3V5wJp+/mDvM9QUqwUxI+RywytNNGtc4uuDFhwt0xMLbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@react-router/node": "8.5.1", + "@remix-run/node-fetch-server": "^0.14.1", + "babel-dead-code-elimination": "^1.0.12", + "chokidar": "^5.0.0", + "dedent": "^1.7.2", + "es-module-lexer": "^2.1.0", + "exit-hook": "5.1.0", + "isbot": "^5.1.40", + "jsesc": "3.1.0", + "lodash": "^4.18.1", + "p-map": "^7.0.4", + "pathe": "^2.0.3", + "picocolors": "^1.1.1", + "pkg-types": "^2.3.1", + "react-refresh": "^0.18.0", + "semver": "^7.8.1", + "tinyglobby": "^0.2.16", + "valibot": "^1.4.1" + }, + "bin": { + "react-router": "bin.cjs" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "@react-router/serve": "^8.5.1", + "@vitejs/plugin-rsc": "~0.5.26", + "react-router": "^8.5.1", + "react-server-dom-webpack": "^19.2.7", + "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0", + "vite": "^7.0.0 || ^8.0.0", + "wrangler": "^4.0.0" + }, + "peerDependenciesMeta": { + "@react-router/serve": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@react-router/dev/node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-router/express": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-8.5.1.tgz", + "integrity": "sha512-pWx/cvrOztywTanlaUI/i0T3EbK+OGxdOL0HASHlc1QSLsadx39bNqKuMJFacZscBtZh8NgR1glAcrkIpUL06Q==", + "license": "MIT", + "dependencies": { + "@react-router/node": "8.5.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "express": "^4.22.2 || ^5", + "react-router": "8.5.1", + "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@react-router/node": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-8.5.1.tgz", + "integrity": "sha512-hEr3YFQaSoQL8T85+DqNOpdDLfuD2mKEeGdQAEvfix2bQdCtcpatE0uzB3zYidIUex0iEJG/eOZdAjLrzlJrjA==", + "license": "MIT", + "dependencies": { + "@remix-run/node-fetch-server": "^0.14.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react-router": "8.5.1", + "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/node-fetch-server": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.14.2.tgz", + "integrity": "sha512-1cpM/f5i1ka/9CUMBwZXZp7Bn92x8VQjzg+F4eEe88DlVdNHkAKrf/vvbZoYgdkfy2bWHtGC6LQGLmojEqOF4Q==", + "license": "MIT" + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -1382,6 +1530,27 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1396,6 +1565,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1403,11 +1604,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1417,12 +1652,33 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.70.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", @@ -1666,34 +1922,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", - "dev": true, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } + "node": ">= 0.6" } }, "node_modules/acorn": { @@ -1720,22 +1959,43 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1759,6 +2019,30 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -1806,6 +2090,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacheable": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", @@ -1820,6 +2113,35 @@ "qified": "^0.10.1" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -1841,6 +2163,64 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/confbox": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.2.tgz", + "integrity": "sha512-ZUAKhKlkCOrIGKc8cj7zek9s9z1mveHif/25nUshI2BzCRzF7KH7KHa+Lnh2ubq+e1tIlaFuP763mzW4qVoehg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1848,12 +2228,30 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1873,14 +2271,13 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1894,6 +2291,21 @@ } } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1901,6 +2313,24 @@ "dev": true, "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1910,13 +2340,46 @@ "node": ">=8" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.438", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", - "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", "dev": true, "license": "ISC" }, + "node_modules/encodeurl": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/enhanced-resolve": { "version": "5.25.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.0.tgz", @@ -1930,6 +2393,43 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1940,6 +2440,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2080,6 +2586,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -2144,11 +2674,82 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exit-hook": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-5.1.0.tgz", + "integrity": "sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.2.0.tgz", + "integrity": "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2165,6 +2766,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2192,6 +2809,27 @@ "flat-cache": "^6.1.23" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2228,6 +2866,24 @@ "dev": true, "license": "ISC" }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2242,6 +2898,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2252,6 +2917,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2278,12 +2980,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2297,6 +3023,18 @@ "node": ">=20" } }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2321,6 +3059,42 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2341,6 +3115,42 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz", + "integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "2.0.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2364,6 +3174,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2401,10 +3227,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -2716,6 +3541,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2735,6 +3567,65 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2755,7 +3646,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2783,6 +3673,22 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-releases": { "version": "2.0.57", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", @@ -2793,9 +3699,64 @@ "node": ">=18" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "node_modules/nodemailer": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.3.1.tgz", + "integrity": "sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", @@ -2843,6 +3804,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2863,6 +3846,23 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2881,6 +3881,55 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", + "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.3", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -2935,6 +3984,39 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2965,6 +4047,56 @@ "dev": true, "license": "MIT" }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -2986,10 +4118,20 @@ "react": "^19.3.0" } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-router": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", - "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz", + "integrity": "sha512-5u1eQ46QjuXjNfEnLwzrnou1Bm5dquO3xjVYkkMYCudtkicD2AQHtx+fvXzPATtPJx8TW6DR1x1TDRqsfHyZBw==", "license": "MIT", "dependencies": { "@remix-run/route-pattern": "^0.22.1", @@ -2999,15 +4141,61 @@ "node": ">=22.22.0" }, "peerDependencies": { + "@types/react": ">=19.2.0", + "@types/react-dom": ">=19.2.0", "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, "react-dom": { "optional": true } } }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", @@ -3041,6 +4229,37 @@ "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -3057,6 +4276,57 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3080,6 +4350,87 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3089,6 +4440,30 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3108,6 +4483,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thread-stream": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.1.tgz", + "integrity": "sha512-Stg6edJXC/6/xqGwSXwFokrUhChJ18amEjpKH89WrQQ0oVFDF+pUUcTpiwWhFUrE89Vf16FBOlwhaw+lescxMQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3124,6 +4511,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3150,11 +4546,29 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3188,6 +4602,22 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -3229,6 +4659,30 @@ "punycode": "^2.1.0" } }, + "node_modules/valibot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz", + "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", @@ -3332,6 +4786,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/site/package.json b/site/package.json index 6f73f80..bf87641 100644 --- a/site/package.json +++ b/site/package.json @@ -3,28 +3,39 @@ "private": true, "type": "module", "scripts": { - "dev": "node scripts/compile.mjs && vite", - "build": "node scripts/fetch-github.mjs && node scripts/compile.mjs && tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs", - "preview": "vite preview", + "dev": "node --env-file-if-exists=.env --disable-warning=ExperimentalWarning server/main.ts", + "build": "react-router build", + "start": "node --disable-warning=ExperimentalWarning server/main.ts", + "typecheck": "react-router typegen && tsc", "lint": "eslint .", "format:check": "prettier --check .", - "sync:github": "node scripts/fetch-github.mjs", - "test": "node --test", + "test": "node --env-file=server/test.env --disable-warning=ExperimentalWarning --test \"server/**/*.test.ts\" \"scripts/*.test.mjs\"", + "size": "node scripts/check-size.mjs", "sync:releases": "node scripts/sync.mjs" }, "dependencies": { + "@react-router/express": "^8.5.1", + "@react-router/node": "^8.5.1", "@tailwindcss/vite": "^4.3.3", + "ajv": "^8.20.0", + "express": "^5.3.0", + "ioredis": "^6.0.0", + "nodemailer": "^10.3.1", + "pino": "^10.4.0", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router": "^8.4.0", + "react-router": "^8.5.1", "tailwindcss": "^4.3.3", "yaml": "^2.9.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@react-router/dev": "^8.5.1", + "@types/express": "^5.0.6", + "@types/node": "^24.19.2", + "@types/nodemailer": "^8.0.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/site/server/config.ts b/site/server/config.ts new file mode 100644 index 0000000..f82ec3c --- /dev/null +++ b/site/server/config.ts @@ -0,0 +1,43 @@ +const production = process.env.NODE_ENV === 'production'; +const devLogin = production ? undefined : process.env.DEV_LOGIN; + +function required(name: string) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function optional(name: string) { + return process.env[name] || undefined; +} + +export const config = { + production, + port: Number(process.env.PORT ?? 3000), + appUrl: required('APP_URL'), + dataDir: process.env.DATA_DIR ?? 'data', + redisUrl: required('REDIS_URL'), + sessionSecret: required('SESSION_SECRET'), + organisation: process.env.GITHUB_ORG ?? 'Project-Graphite', + platformRepo: process.env.PLATFORM_REPO ?? 'Project-Graphite/platform', + dev: devLogin ? { login: devLogin, owner: process.env.DEV_ROLE !== 'member' } : undefined, + oauth: devLogin + ? undefined + : { clientId: required('GITHUB_CLIENT_ID'), clientSecret: required('GITHUB_CLIENT_SECRET') }, + app: production + ? { + clientId: required('PLATFORM_APP_CLIENT_ID'), + privateKey: required('PLATFORM_APP_PRIVATE_KEY'), + } + : undefined, + githubToken: production ? undefined : optional('GITHUB_TOKEN'), + webhookSecret: production ? required('GITHUB_WEBHOOK_SECRET') : optional('GITHUB_WEBHOOK_SECRET'), + deployReportSecret: production + ? required('DEPLOY_REPORT_SECRET') + : optional('DEPLOY_REPORT_SECRET'), + smtpUrl: optional('SMTP_URL'), + alertsEmail: optional('ALERTS_EMAIL'), + alertsFrom: optional('ALERTS_FROM'), +}; diff --git a/site/server/log.ts b/site/server/log.ts new file mode 100644 index 0000000..3065577 --- /dev/null +++ b/site/server/log.ts @@ -0,0 +1,7 @@ +import { pino } from 'pino'; + +export const log = pino({ + base: undefined, + timestamp: pino.stdTimeFunctions.isoTime, + formatters: { level: (label) => ({ level: label }) }, +}); diff --git a/site/server/test.env b/site/server/test.env new file mode 100644 index 0000000..998536d --- /dev/null +++ b/site/server/test.env @@ -0,0 +1,4 @@ +APP_URL=http://localhost:3000 +REDIS_URL=redis://localhost:6379 +SESSION_SECRET=test-only-session-secret +DEV_LOGIN=tester diff --git a/site/tsconfig.app.json b/site/tsconfig.app.json deleted file mode 100644 index e2ae63c..0000000 --- a/site/tsconfig.app.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "resolveJsonModule": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/site/tsconfig.json b/site/tsconfig.json index d32ff68..5295dbe 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -1,4 +1,22 @@ { - "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] + "include": ["app", "server", "scripts", ".react-router/types/**/*", "vite.config.ts"], + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "allowJs": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + } } diff --git a/site/tsconfig.node.json b/site/tsconfig.node.json deleted file mode 100644 index e0db684..0000000 --- a/site/tsconfig.node.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "moduleResolution": "bundler", - "verbatimModuleSyntax": true, - "noEmit": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["vite.config.ts"] -} diff --git a/site/vite.config.ts b/site/vite.config.ts index da17daf..5149bff 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import { reactRouter } from '@react-router/dev/vite'; import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [tailwindcss(), reactRouter()], }); From 6f049711fccc02dfce1f079cbfd087e6fb1eb43e Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:10 +0200 Subject: [PATCH 02/12] feat: SQLite with migrations, Redis, the event bus and the snapshot read model --- site/app/types.ts | 94 ++++++++++++++++++ site/server/bus.ts | 84 ++++++++++++++++ site/server/db.ts | 54 +++++++++++ site/server/migrations/001-initial.sql | 121 ++++++++++++++++++++++++ site/server/migrations/002-profiles.sql | 6 ++ site/server/redis.ts | 21 ++++ site/server/snapshot.ts | 113 ++++++++++++++++++++++ site/server/testing.ts | 24 +++++ 8 files changed, 517 insertions(+) create mode 100644 site/app/types.ts create mode 100644 site/server/bus.ts create mode 100644 site/server/db.ts create mode 100644 site/server/migrations/001-initial.sql create mode 100644 site/server/migrations/002-profiles.sql create mode 100644 site/server/redis.ts create mode 100644 site/server/snapshot.ts create mode 100644 site/server/testing.ts diff --git a/site/app/types.ts b/site/app/types.ts new file mode 100644 index 0000000..3bfb9b3 --- /dev/null +++ b/site/app/types.ts @@ -0,0 +1,94 @@ +export type ProjectStatus = 'live' | 'wip' | 'archived' | 'source-only'; + +export interface ProjectContribution { + github: string; + summary: string; +} + +export interface ProjectShowcase { + problem: string; + approach: string; + outcome: string; + contributions: ProjectContribution[]; +} + +export interface Project { + slug: string; + parent: string | null; + kind: 'project' | 'epic'; + name: string; + summary: string; + tags: string[]; + repo: string; + repoUrl: string; + owner: string; + authors: string[]; + status: ProjectStatus; + pinned: boolean; + showcase: ProjectShowcase | null; + url: string | null; + path: string; +} + +export interface MemberLink { + label: string; + url: string; +} + +export interface StackGroup { + group: string; + items: string[]; +} + +export interface Member { + handle: string; + github: string; + name: string; + avatarUrl: string; + headline: string | null; + bio: string | null; + stack: StackGroup[]; + links: MemberLink[]; + publicRepos: number | null; + followers: number | null; + joined: string | null; + path: string; +} + +export type ChangeType = 'breaking' | 'security' | 'added' | 'changed' | 'fixed' | 'removed'; + +export interface ReleaseChange { + text: string; + commit: string | null; + pr: number | null; + author: string | null; +} + +export interface ReleaseGroup { + type: ChangeType; + changes: ReleaseChange[]; +} + +export interface Release { + id: string; + project: string; + version: string; + tag: string | null; + previous: string | null; + date: string; + title: string | null; + summary: string | null; + groups: ReleaseGroup[]; +} + +export interface Directory { + members: Member[]; + projects: Project[]; + releases: Release[]; +} + +export interface Viewer { + login: string; + avatarUrl: string; + owner: boolean; +} diff --git a/site/server/bus.ts b/site/server/bus.ts new file mode 100644 index 0000000..a8f3d35 --- /dev/null +++ b/site/server/bus.ts @@ -0,0 +1,84 @@ +import type { Database } from './db.ts'; +import type { Redis } from './redis.ts'; + +export interface HubEvent { + topic: string; + key?: string; + kind: string; + public: boolean; + summary: string; + actor?: string; + detail: Record; + at: string; +} + +export interface Delivery { + id?: string; + event: HubEvent; +} + +const STREAM = 'events'; +const STREAM_DAYS = 7; + +export function createBus(db: Database, redis: Redis, subscriber: Redis) { + const listeners = new Set<(delivery: Delivery) => void>(); + const insert = db.prepare( + 'INSERT INTO activity (kind, public, ref, actor, summary, detail, at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + const assign = db.prepare('UPDATE activity SET seq = ? WHERE id = ?'); + + subscriber.on('pmessage', (_pattern: string, _channel: string, message: string) => { + const delivery = JSON.parse(message) as Delivery; + for (const listener of listeners) { + listener(delivery); + } + }); + const ready = subscriber.psubscribe('topic:*'); + + async function send(delivery: Delivery) { + await redis.publish(`topic:${delivery.event.topic}`, JSON.stringify(delivery)); + } + + return { + ready, + async record(event: HubEvent) { + const { lastInsertRowid } = insert.run( + event.kind, + event.public ? 1 : 0, + event.key ?? null, + event.actor ?? null, + event.summary, + JSON.stringify(event.detail), + event.at, + ); + const id = (await redis.xadd( + STREAM, + 'MINID', + '~', + String(Date.now() - STREAM_DAYS * 86_400_000), + '*', + 'event', + JSON.stringify(event), + )) as string; + assign.run(id, lastInsertRowid); + await send({ id, event }); + return id; + }, + publish(event: HubEvent) { + return send({ event }); + }, + async since(id: string) { + const entries = await redis.xrange(STREAM, `(${id}`, '+'); + return entries.map(([entryId, fields]) => ({ + id: entryId, + event: JSON.parse(fields[1]) as HubEvent, + })); + }, + listen(listener: (delivery: Delivery) => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +export type Bus = ReturnType; diff --git a/site/server/db.ts b/site/server/db.ts new file mode 100644 index 0000000..ac4a692 --- /dev/null +++ b/site/server/db.ts @@ -0,0 +1,54 @@ +import { mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +const migrations = new URL('./migrations/', import.meta.url); + +export type Database = DatabaseSync; + +export function openDatabase(file: string) { + if (file !== ':memory:') { + mkdirSync(path.dirname(file), { recursive: true }); + } + const db = new DatabaseSync(file); + db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + db.exec( + 'CREATE TABLE IF NOT EXISTS migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)', + ); + const applied = new Set( + db + .prepare('SELECT name FROM migrations') + .all() + .map((row) => row.name as string), + ); + for (const name of readdirSync(migrations) + .filter((file) => file.endsWith('.sql')) + .sort()) { + if (!applied.has(name)) { + transaction(db, () => { + db.exec(readFileSync(new URL(name, migrations), 'utf8')); + db.prepare('INSERT INTO migrations (name, applied_at) VALUES (?, ?)').run( + name, + new Date().toISOString(), + ); + }); + } + } + return db; +} + +export function transaction(db: Database, work: () => T) { + db.exec('BEGIN IMMEDIATE'); + try { + const result = work(); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +export function json(value: unknown) { + return JSON.parse(value as string) as T; +} diff --git a/site/server/migrations/001-initial.sql b/site/server/migrations/001-initial.sql new file mode 100644 index 0000000..d572eb8 --- /dev/null +++ b/site/server/migrations/001-initial.sql @@ -0,0 +1,121 @@ +CREATE TABLE state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE members ( + handle TEXT PRIMARY KEY, + login TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + avatar_url TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + public_repos INTEGER, + followers INTEGER, + github_since TEXT, + joined_at TEXT NOT NULL, + synced_at TEXT NOT NULL +); + +CREATE TABLE profiles ( + handle TEXT PRIMARY KEY, + headline TEXT, + bio TEXT, + blocks TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL +); + +CREATE TABLE projects ( + slug TEXT PRIMARY KEY, + repo TEXT NOT NULL, + entry TEXT NOT NULL, + synced_at TEXT NOT NULL +); + +CREATE INDEX projects_repo ON projects (repo COLLATE NOCASE); + +CREATE TABLE repo_stats ( + repo TEXT PRIMARY KEY COLLATE NOCASE, + stars INTEGER NOT NULL, + forks INTEGER NOT NULL, + open_issues INTEGER NOT NULL, + pushed_at TEXT, + languages TEXT NOT NULL, + synced_at TEXT NOT NULL +); + +CREATE TABLE releases ( + id TEXT PRIMARY KEY, + project TEXT NOT NULL, + version TEXT NOT NULL, + tag TEXT, + previous TEXT, + date TEXT NOT NULL, + title TEXT, + summary TEXT, + groups TEXT NOT NULL, + UNIQUE (project, version) +); + +CREATE TABLE deploys ( + id INTEGER PRIMARY KEY, + project TEXT NOT NULL, + tag TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT NOT NULL, + ok INTEGER NOT NULL, + health_status INTEGER, + health_ms INTEGER, + by TEXT, + UNIQUE (project, started_at) +); + +CREATE TABLE uptime ( + project TEXT NOT NULL, + minute TEXT NOT NULL, + ok INTEGER NOT NULL, + ms INTEGER, + PRIMARY KEY (project, minute) +); + +CREATE TABLE uptime_days ( + project TEXT NOT NULL, + day TEXT NOT NULL, + minutes INTEGER NOT NULL, + up INTEGER NOT NULL, + p95_ms INTEGER, + PRIMARY KEY (project, day) +); + +CREATE TABLE activity ( + id INTEGER PRIMARY KEY, + seq TEXT UNIQUE, + kind TEXT NOT NULL, + public INTEGER NOT NULL, + ref TEXT, + actor TEXT, + summary TEXT NOT NULL, + detail TEXT NOT NULL, + at TEXT NOT NULL +); + +CREATE INDEX activity_at ON activity (at); + +CREATE TABLE webhook_deliveries ( + delivery_id TEXT PRIMARY KEY, + event TEXT NOT NULL, + received_at TEXT NOT NULL, + processed_at TEXT +); + +CREATE TABLE errors ( + fingerprint TEXT PRIMARY KEY, + first_at TEXT NOT NULL, + last_at TEXT NOT NULL, + count INTEGER NOT NULL, + message TEXT NOT NULL, + stack TEXT, + route TEXT, + request_id TEXT, + resolved_at TEXT, + alerted_at TEXT +); diff --git a/site/server/migrations/002-profiles.sql b/site/server/migrations/002-profiles.sql new file mode 100644 index 0000000..f3444d1 --- /dev/null +++ b/site/server/migrations/002-profiles.sql @@ -0,0 +1,6 @@ +INSERT INTO profiles (handle, headline, bio, blocks, updated_at) VALUES + ('nier0o', NULL, NULL, '[{"type":"links","links":[{"label":"github","url":"https://github.com/Nier0o"}]}]', '2026-11-05T00:00:00.000Z'), + ('amrmscll', 'Full-stack Software Engineer', 'Computer Science and AI graduate of FCAI, Cairo University, building document-intelligence software at Syntheia. A normal week touches a TypeScript API, a React front end, a Python ML service and a Helm chart. Most useful where those meet, and most interested in whichever part I have not done before.', '[{"type":"stack","groups":[{"group":"languages","items":["TypeScript","JavaScript","Python","C++","SQL"]},{"group":"backend","items":["NestJS","Express","FastAPI","Django","Prisma","GraphQL"]},{"group":"frontend","items":["React","Redux Toolkit","Tailwind","MUI","Vite","Office.js"]},{"group":"data","items":["PostgreSQL","MongoDB","Redis","Qdrant","Milvus"]},{"group":"ml","items":["PyTorch","Transformers","spaCy","scikit-learn","Celery"]},{"group":"platform","items":["Docker","Kubernetes","Helm","Terraform","AWS","Azure"]},{"group":"testing","items":["Jest","Pytest","Playwright","SonarQube"]}]},{"type":"links","links":[{"label":"github","url":"https://github.com/AmrMsCLL"},{"label":"linkedin","url":"https://www.linkedin.com/in/amrsherieycll"},{"label":"email","url":"mailto:amrsherieycll@gmail.com"},{"label":"discord","url":"https://discord.com/users/412317378337046529"}]}]', '2026-11-05T00:00:00.000Z'), + ('bluocaroot', 'Software Engineer at Syntheia', 'Software engineer from Egypt, building document-intelligence software at Syntheia: a NestJS API, a React portal and an Outlook add-in on Office.js. Came up through competitive programming, which still shapes how I approach problems, and outside work I build tools like Digital to Physical to learn the parts of the stack I have not touched yet.', '[{"type":"stack","groups":[{"group":"languages","items":["TypeScript","JavaScript","C++","Python","C","SQL"]},{"group":"backend","items":["NestJS","Node.js","Express","GraphQL"]},{"group":"frontend","items":["React","MUI","Tailwind","Vite","Office.js"]},{"group":"data","items":["PostgreSQL","MongoDB"]},{"group":"platform","items":["Docker","Azure","Linux","Git"]},{"group":"testing","items":["Jest","SonarQube"]}]},{"type":"links","links":[{"label":"github","url":"https://github.com/BluoCaroot"},{"label":"linkedin","url":"https://www.linkedin.com/in/assem-amr-abdelfattah/"},{"label":"codeforces","url":"https://codeforces.com/profile/BluoCaroot"}]}]', '2026-11-05T00:00:00.000Z'), + ('mahmoudhanyfathalla', NULL, NULL, '[{"type":"links","links":[{"label":"github","url":"https://github.com/MahmoudHanyFathalla"}]}]', '2026-11-05T00:00:00.000Z'), + ('omar-mohamed24', 'Backend & Full-stack Software Engineer', 'Software engineer who goes looking for the hard parts. Wrote a Game Boy emulator from scratch in C++20, compiled it to WebAssembly and shipped it as PixelPocket, where it passes all of Blarggs CPU tests and plays in any browser. Built GameForge, a ten-service platform where an LLM planner, an MCP asset pipeline and a Godot build step turn one sentence into a playable game. Codeforces Expert with 1000+ problems solved, CS graduate of FCAI, Cairo University, and formerly a prompt engineer at Scale AI.', '[{"type":"stack","groups":[{"group":"languages","items":["C++","JavaScript","Python","Java","C#"]},{"group":"backend","items":["Node.js","Express","Django","REST APIs","Microservices"]},{"group":"frontend","items":["React","WebAssembly","HTML","CSS"]},{"group":"data","items":["MongoDB","MySQL","MinIO"]},{"group":"ai","items":["LLM Integration","MCP","Stability AI","Prompt Engineering"]},{"group":"platform","items":["Docker","Docker Compose","GitHub Actions","Linux","Git"]}]},{"type":"links","links":[{"label":"github","url":"https://github.com/Omar-Mohamed24"},{"label":"linkedin","url":"https://www.linkedin.com/in/omar-mohamed-8034032a3"},{"label":"email","url":"mailto:omarmohamed67371@gmail.com"},{"label":"codeforces","url":"https://codeforces.com/profile/Maliiketh"}]}]', '2026-11-05T00:00:00.000Z'); diff --git a/site/server/redis.ts b/site/server/redis.ts new file mode 100644 index 0000000..dd79f82 --- /dev/null +++ b/site/server/redis.ts @@ -0,0 +1,21 @@ +import { Redis } from 'ioredis'; + +export type { Redis }; + +export function connectRedis(url: string) { + const command = new Redis(url, { maxRetriesPerRequest: 2, lazyConnect: false }); + return { command, subscriber: command.duplicate() }; +} + +export async function slidingLimit(redis: Redis, key: string, max: number, windowMs: number) { + const now = Date.now(); + const bucket = `limit:${key}`; + const [, , [, count]] = (await redis + .multi() + .zremrangebyscore(bucket, 0, now - windowMs) + .zadd(bucket, now, `${now}:${Math.random()}`) + .zcard(bucket) + .pexpire(bucket, windowMs) + .exec()) as [unknown, unknown, [unknown, number]]; + return count <= max; +} diff --git a/site/server/snapshot.ts b/site/server/snapshot.ts new file mode 100644 index 0000000..b55d2b7 --- /dev/null +++ b/site/server/snapshot.ts @@ -0,0 +1,113 @@ +import type { Directory, Member, MemberLink, Project, Release, StackGroup } from '../app/types.ts'; +import { compareVersions } from '../scripts/rules.mjs'; +import type { Bus } from './bus.ts'; +import { json, type Database } from './db.ts'; +import { storedEntries, type RegistryEntry } from './registry.ts'; + +const TOPICS = new Set(['members', 'profiles', 'projects', 'releases']); + +type Block = { type: 'stack'; groups: StackGroup[] } | { type: 'links'; links: MemberLink[] }; + +export function projectFromEntry(entry: RegistryEntry): Project { + return { + slug: entry.slug, + parent: entry.parent ?? null, + kind: entry.kind, + name: entry.name, + summary: entry.summary, + tags: entry.tags ?? [], + repo: entry.repo, + repoUrl: `https://github.com/${entry.repo}`, + owner: entry.owner, + authors: entry.authors, + status: entry.status, + pinned: entry.pinned ?? false, + showcase: entry.showcase ?? null, + url: entry.deploy.kind === 'none' ? null : `https://${entry.deploy.host}`, + path: entry.parent + ? `/epics/${entry.parent}/${entry.slug}` + : `/${entry.kind === 'epic' ? 'epics' : 'projects'}/${entry.slug}`, + }; +} + +function build(db: Database): Directory { + const members = db + .prepare( + `SELECT m.handle, m.login, m.name, m.avatar_url, m.public_repos, m.followers, m.github_since, + p.headline, p.bio, p.blocks + FROM members m LEFT JOIN profiles p USING (handle) + ORDER BY m.name COLLATE NOCASE`, + ) + .all() + .map((row): Member => { + const blocks = json(row.blocks ?? '[]'); + return { + handle: row.handle as string, + github: row.login as string, + name: row.name as string, + avatarUrl: row.avatar_url as string, + headline: row.headline as string | null, + bio: row.bio as string | null, + stack: blocks.flatMap((block) => (block.type === 'stack' ? block.groups : [])), + links: blocks.flatMap((block) => (block.type === 'links' ? block.links : [])), + publicRepos: row.public_repos as number | null, + followers: row.followers as number | null, + joined: row.github_since as string | null, + path: `/members/${row.handle as string}`, + }; + }); + + const projects = storedEntries(db) + .filter((entry) => entry.listed !== false) + .map(projectFromEntry); + const listed = new Set(projects.map((project) => project.slug)); + + const releases = db + .prepare('SELECT * FROM releases') + .all() + .filter((row) => listed.has(row.project as string)) + .map((row): Release => ({ + id: row.id as string, + project: row.project as string, + version: row.version as string, + tag: row.tag as string | null, + previous: row.previous as string | null, + date: row.date as string, + title: row.title as string | null, + summary: row.summary as string | null, + groups: json(row.groups), + })) + .sort( + (a, b) => + b.date.localeCompare(a.date) || + compareVersions(b.version, a.version) || + a.project.localeCompare(b.project), + ); + + return { members, projects, releases }; +} + +export function createSnapshot(db: Database, bus: Bus) { + let current: Directory | undefined; + let healthy = false; + bus.listen(({ event }) => { + if (TOPICS.has(event.topic)) { + current = undefined; + } + }); + return { + get() { + if (!current) { + healthy = false; + current = build(db); + healthy = true; + } + return current; + }, + get healthy() { + return healthy; + }, + }; +} + +export type Snapshot = ReturnType; diff --git a/site/server/testing.ts b/site/server/testing.ts new file mode 100644 index 0000000..8519c1e --- /dev/null +++ b/site/server/testing.ts @@ -0,0 +1,24 @@ +import type { Bus, HubEvent } from './bus.ts'; +import { openDatabase } from './db.ts'; + +export function memoryDatabase() { + return openDatabase(':memory:'); +} + +export function recordingBus() { + const recorded: HubEvent[] = []; + const published: HubEvent[] = []; + const bus = { + ready: Promise.resolve(), + record: async (event: HubEvent) => { + recorded.push(event); + return `${Date.now()}-${recorded.length}`; + }, + publish: async (event: HubEvent) => { + published.push(event); + }, + since: async () => [], + listen: () => () => true, + } as unknown as Bus; + return { bus, recorded, published }; +} From 16c15d223f270ce4699529b8a2d49a068f3e6de4 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:11 +0200 Subject: [PATCH 03/12] feat: GitHub sign-in behind ?graphite with Redis sessions, trusted client addresses and security headers --- site/server/address.ts | 48 +++++++++++ site/server/auth.test.ts | 111 ++++++++++++++++++++++++ site/server/auth.ts | 176 +++++++++++++++++++++++++++++++++++++++ site/server/headers.ts | 31 +++++++ site/server/internal.ts | 40 +++++++++ site/server/sessions.ts | 105 +++++++++++++++++++++++ 6 files changed, 511 insertions(+) create mode 100644 site/server/address.ts create mode 100644 site/server/auth.test.ts create mode 100644 site/server/auth.ts create mode 100644 site/server/headers.ts create mode 100644 site/server/internal.ts create mode 100644 site/server/sessions.ts diff --git a/site/server/address.ts b/site/server/address.ts new file mode 100644 index 0000000..ac357b8 --- /dev/null +++ b/site/server/address.ts @@ -0,0 +1,48 @@ +import type { IncomingMessage } from 'node:http'; +import { BlockList, isIPv6 } from 'node:net'; + +let cloudflare = new BlockList(); + +export function cloudflareRanges(text: string) { + const ranges = new BlockList(); + for (const line of text.split(/\s+/).filter(Boolean)) { + const [address, prefix] = line.split('/'); + ranges.addSubnet(address, Number(prefix), isIPv6(address) ? 'ipv6' : 'ipv4'); + } + return ranges; +} + +export async function refreshCloudflareRanges() { + const lists = await Promise.all( + ['https://www.cloudflare.com/ips-v4', 'https://www.cloudflare.com/ips-v6'].map(async (url) => { + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!response.ok) { + throw new Error(`Cloudflare answered ${response.status} for ${url}`); + } + return response.text(); + }), + ); + cloudflare = cloudflareRanges(lists.join('\n')); +} + +function plain(address: string) { + return address.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/, ''); +} + +export function inRanges(ranges: BlockList, address: string) { + const bare = plain(address); + return ranges.check(bare, isIPv6(bare) ? 'ipv6' : 'ipv4'); +} + +export function viaEdge(request: IncomingMessage) { + return request.headers['x-forwarded-for'] !== undefined; +} + +export function clientAddress(request: IncomingMessage, ranges = cloudflare) { + const forwarded = (request.headers['x-forwarded-for'] as string | undefined) + ?.split(',') + .map((part) => part.trim()); + const peer = plain(forwarded?.at(-1) ?? request.socket.remoteAddress ?? ''); + const original = request.headers['cf-connecting-ip'] as string | undefined; + return forwarded && original && inRanges(ranges, peer) ? plain(original) : peer; +} diff --git a/site/server/auth.test.ts b/site/server/auth.test.ts new file mode 100644 index 0000000..605d093 --- /dev/null +++ b/site/server/auth.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import type { IncomingMessage } from 'node:http'; +import { describe, it } from 'node:test'; +import { clientAddress, cloudflareRanges } from './address.ts'; +import { cookies, safeReturn, sameOrigin } from './auth.ts'; +import { createVerifier, sign } from './internal.ts'; +import { createSealer } from './sessions.ts'; + +function request(headers: Record, remoteAddress = '172.18.0.5') { + return { headers, socket: { remoteAddress } } as unknown as IncomingMessage; +} + +describe('session sealing', () => { + const sealer = createSealer('secret-one'); + + it('round-trips the GitHub token', () => { + assert.equal(sealer.open(sealer.seal('gho_token')), 'gho_token'); + }); + + it('refuses a tampered token and one sealed with another secret', () => { + const sealed = sealer.seal('gho_token'); + const [iv, body] = sealed.split('.'); + const flipped = Buffer.from(body, 'base64url'); + flipped[0] ^= 1; + assert.throws(() => sealer.open(`${iv}.${flipped.toString('base64url')}`)); + assert.throws(() => createSealer('secret-two').open(sealed)); + }); +}); + +describe('sign-in helpers', () => { + it('returns only to a path on this site', () => { + assert.equal(safeReturn('/members/amrmscll'), '/members/amrmscll'); + assert.equal(safeReturn('//evil.example'), '/'); + assert.equal(safeReturn('/\\evil.example'), '/'); + assert.equal(safeReturn('https://evil.example'), '/'); + assert.equal(safeReturn(undefined), '/'); + }); + + it('reads cookies and checks the origin of state-changing requests', () => { + assert.deepEqual(cookies(request({ cookie: 'hub_session=abc%3D; other=1' })), { + hub_session: 'abc=', + other: '1', + }); + assert.ok(sameOrigin(request({ origin: 'http://localhost:3000' }))); + assert.ok(!sameOrigin(request({ origin: 'https://evil.example' }))); + assert.ok(!sameOrigin(request({}))); + }); +}); + +describe('clientAddress', () => { + const ranges = cloudflareRanges('173.245.48.0/20\n2400:cb00::/32'); + + it('trusts CF-Connecting-IP only when the peer is Cloudflare', () => { + assert.equal( + clientAddress( + request({ 'x-forwarded-for': '173.245.48.10', 'cf-connecting-ip': '198.51.100.4' }), + ranges, + ), + '198.51.100.4', + ); + assert.equal( + clientAddress( + request({ 'x-forwarded-for': '2400:cb00::1', 'cf-connecting-ip': '198.51.100.4' }), + ranges, + ), + '198.51.100.4', + ); + assert.equal( + clientAddress( + request({ 'x-forwarded-for': '203.0.113.9', 'cf-connecting-ip': '198.51.100.4' }), + ranges, + ), + '203.0.113.9', + ); + }); + + it('uses the socket address for direct internal calls', () => { + assert.equal( + clientAddress(request({ 'cf-connecting-ip': '198.51.100.4' }, '::ffff:172.18.0.9'), ranges), + '172.18.0.9', + ); + }); +}); + +describe('internal request verification', () => { + const now = 1_762_300_000_000; + const stamp = String(now / 1000); + const body = Buffer.from('{"project":"strata"}'); + const signed = (headers: Record = {}) => + request({ + 'x-graphite-timestamp': stamp, + 'x-graphite-signature': sign('shared', stamp, body), + ...headers, + }); + + it('accepts a fresh signed request once', () => { + const verified = createVerifier('shared', () => now); + assert.ok(verified(signed(), body)); + assert.ok(!verified(signed(), body)); + }); + + it('refuses a wrong secret, an old stamp, an altered body and anything that came through the edge', () => { + assert.ok(!createVerifier('other', () => now)(signed(), body)); + assert.ok(!createVerifier('shared', () => now + 301_000)(signed(), body)); + assert.ok(!createVerifier('shared', () => now)(signed(), Buffer.from('{}'))); + assert.ok( + !createVerifier('shared', () => now)(signed({ 'x-forwarded-for': '173.245.48.10' }), body), + ); + assert.ok(!createVerifier(undefined, () => now)(signed(), body)); + }); +}); diff --git a/site/server/auth.ts b/site/server/auth.ts new file mode 100644 index 0000000..f8bf365 --- /dev/null +++ b/site/server/auth.ts @@ -0,0 +1,176 @@ +import { randomBytes } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import express, { type Request, type Response } from 'express'; +import { clientAddress } from './address.ts'; +import { config } from './config.ts'; +import { GitHubError, request as asUser } from './github.ts'; +import { slidingLimit, type Redis } from './redis.ts'; +import { sameSecret, type Session, type Sessions } from './sessions.ts'; + +const SESSION_COOKIE = 'hub_session'; +const STATE_COOKIE = 'hub_state'; +const RECHECK_MS = 60_000; + +export function cookies(request: IncomingMessage) { + return Object.fromEntries( + (request.headers.cookie ?? '').split(';').flatMap((part) => { + const index = part.indexOf('='); + return index > 0 + ? [[part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())]] + : []; + }), + ); +} + +function setCookie(response: ServerResponse, name: string, value: string, maxAge: number) { + response.appendHeader( + 'Set-Cookie', + `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.production ? '; Secure' : ''}`, + ); +} + +export function safeReturn(path: unknown) { + return typeof path === 'string' && /^\/(?![/\\])/.test(path) ? path : '/'; +} + +export function sameOrigin(request: IncomingMessage) { + return request.headers.origin === new URL(config.appUrl).origin; +} + +async function membership(token: string) { + const found = await asUser<{ state: string; role: string }>( + `/user/memberships/orgs/${config.organisation}`, + token, + ).catch((error: unknown) => { + if (error instanceof GitHubError && [401, 403, 404].includes(error.status)) { + return undefined; + } + throw error; + }); + return found?.state === 'active' ? { owner: found.role === 'admin' } : undefined; +} + +export function createAuth(sessions: Sessions, redis: Redis) { + async function current(request: IncomingMessage, response: ServerResponse) { + const id = cookies(request)[SESSION_COOKIE]; + const session = await sessions.read(id); + if (!id || !session) { + return undefined; + } + if (config.dev || Date.now() - session.checkedAt < RECHECK_MS) { + return { id, session }; + } + const member = await membership(sessions.githubToken(session)).catch(() => session); + if (!member) { + await sessions.destroy(id, session.login); + setCookie(response, SESSION_COOKIE, '', 0); + return undefined; + } + const checked: Session = { ...session, owner: member.owner, checkedAt: Date.now() }; + await sessions.update(id, checked); + return { id, session: checked }; + } + + async function begin( + response: Response, + login: string, + owner: boolean, + avatarUrl: string, + token: string, + to: string, + ) { + const id = await sessions.create(login, owner, avatarUrl, token); + setCookie(response, SESSION_COOKIE, id, 7 * 86_400); + response.redirect(303, to); + } + + const router = express.Router(); + + router.get('/auth/sign-in', async (request: Request, response: Response) => { + if (!(await slidingLimit(redis, `sign-in:${clientAddress(request)}`, 10, 600_000))) { + response + .status(429) + .type('text') + .send('Too many sign-in attempts; try again in a few minutes.'); + return; + } + const to = safeReturn(request.query.return); + if (config.dev) { + await begin( + response, + config.dev.login, + config.dev.owner, + `https://avatars.githubusercontent.com/${config.dev.login}`, + 'dev', + to, + ); + return; + } + const state = randomBytes(16).toString('base64url'); + setCookie(response, STATE_COOKIE, `${state} ${to}`, 600); + const query = new URLSearchParams({ + client_id: config.oauth!.clientId, + redirect_uri: `${config.appUrl}/auth/callback`, + scope: 'read:org', + state, + }); + response.redirect(302, `https://github.com/login/oauth/authorize?${query}`); + }); + + router.get('/auth/callback', async (request: Request, response: Response) => { + const [state, to] = (cookies(request)[STATE_COOKIE] ?? '').split(' '); + setCookie(response, STATE_COOKIE, '', 0); + if (!state || !sameSecret(state, String(request.query.state ?? ''))) { + response.status(400).type('text').send('The sign-in expired; start again.'); + return; + } + const exchange = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: config.oauth!.clientId, + client_secret: config.oauth!.clientSecret, + code: request.query.code, + }), + signal: AbortSignal.timeout(10_000), + }); + const { access_token: token } = (await exchange.json()) as { access_token?: string }; + if (!token) { + response.status(400).type('text').send('GitHub refused the sign-in; start again.'); + return; + } + const user = await asUser<{ login: string; avatar_url: string }>('/user', token); + const member = await membership(token); + if (!member) { + response + .status(403) + .type('text') + .send(`Signed out: ${user.login} is not a member of ${config.organisation}.`); + return; + } + await begin(response, user.login, member.owner, user.avatar_url, token, safeReturn(to)); + }); + + router.post( + '/auth/sign-out', + express.urlencoded({ extended: false }), + async (request: Request, response: Response) => { + const signedIn = await current(request, response); + if ( + !signedIn || + !sameOrigin(request) || + !sameSecret(String(request.body?.csrf ?? ''), signedIn.session.csrf) + ) { + response.status(403).type('text').send('Sign-out refused.'); + return; + } + await sessions.destroy(signedIn.id, signedIn.session.login); + setCookie(response, SESSION_COOKIE, '', 0); + response.redirect(303, '/'); + }, + ); + + return { router, current }; +} + +export type Auth = ReturnType; diff --git a/site/server/headers.ts b/site/server/headers.ts new file mode 100644 index 0000000..61deb14 --- /dev/null +++ b/site/server/headers.ts @@ -0,0 +1,31 @@ +import type { NextFunction, Request, Response } from 'express'; + +export function contentSecurityPolicy(nonce: string) { + return Object.entries({ + 'default-src': ["'self'"], + 'script-src': ["'self'", `'nonce-${nonce}'`], + 'style-src': ["'self'", `'nonce-${nonce}'`], + 'img-src': ["'self'", 'data:', 'https://avatars.githubusercontent.com'], + 'font-src': ["'self'"], + 'connect-src': ["'self'"], + 'form-action': ["'self'"], + 'frame-ancestors': ["'none'"], + 'object-src': ["'none'"], + 'base-uri': ["'self'"], + 'upgrade-insecure-requests': [], + }) + .map(([directive, sources]) => [directive, ...sources].join(' ')) + .join('; '); +} + +export function securityHeaders(_request: Request, response: Response, next: NextFunction) { + response.setHeader('Strict-Transport-Security', 'max-age=31536000'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.setHeader( + 'Permissions-Policy', + 'camera=(), geolocation=(), microphone=(), payment=(), usb=()', + ); + response.setHeader('X-Frame-Options', 'DENY'); + next(); +} diff --git a/site/server/internal.ts b/site/server/internal.ts new file mode 100644 index 0000000..cee4cc2 --- /dev/null +++ b/site/server/internal.ts @@ -0,0 +1,40 @@ +import { createHmac } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; +import { viaEdge } from './address.ts'; +import { sameSecret } from './sessions.ts'; + +const SKEW_SECONDS = 300; + +export function sign(secret: string, stamp: string, body: Buffer | string) { + return createHmac('sha256', secret).update(`${stamp}.`).update(body).digest('hex'); +} + +export function createVerifier(secret: string | undefined, now = () => Date.now()) { + const seen = new Map(); + return function verified(request: IncomingMessage, body: Buffer) { + const stamp = String(request.headers['x-graphite-timestamp'] ?? ''); + const signature = String(request.headers['x-graphite-signature'] ?? ''); + const at = now(); + if ( + !secret || + viaEdge(request) || + !/^\d+$/.test(stamp) || + Math.abs(at / 1000 - Number(stamp)) > SKEW_SECONDS + ) { + return false; + } + if (!sameSecret(sign(secret, stamp, body), signature)) { + return false; + } + for (const [key, until] of seen) { + if (until < at) { + seen.delete(key); + } + } + if (seen.has(signature)) { + return false; + } + seen.set(signature, at + 2 * SKEW_SECONDS * 1000); + return true; + }; +} diff --git a/site/server/sessions.ts b/site/server/sessions.ts new file mode 100644 index 0000000..388aa12 --- /dev/null +++ b/site/server/sessions.ts @@ -0,0 +1,105 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + timingSafeEqual, +} from 'node:crypto'; +import type { Redis } from './redis.ts'; + +export interface Session { + login: string; + owner: boolean; + avatarUrl: string; + token: string; + csrf: string; + createdAt: string; + checkedAt: number; +} + +const TTL_SECONDS = 7 * 86_400; + +export function createSealer(secret: string) { + const key = createHash('sha256').update(secret).digest(); + return { + seal(value: string) { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const body = Buffer.concat([cipher.update(value), cipher.final(), cipher.getAuthTag()]); + return `${iv.toString('base64url')}.${body.toString('base64url')}`; + }, + open(sealed: string) { + const [iv, body] = sealed.split('.'); + const data = Buffer.from(body, 'base64url'); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url')); + decipher.setAuthTag(data.subarray(-16)); + return Buffer.concat([decipher.update(data.subarray(0, -16)), decipher.final()]).toString(); + }, + }; +} + +export function sameSecret(a: string, b: string) { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +export function createSessions(redis: Redis, secret: string) { + const sealer = createSealer(secret); + const sessionKey = (id: string) => `session:${id}`; + const loginKey = (login: string) => `sessions:${login.toLowerCase()}`; + + return { + async create(login: string, owner: boolean, avatarUrl: string, githubToken: string) { + const id = randomBytes(32).toString('base64url'); + const session: Session = { + login, + owner, + avatarUrl, + token: sealer.seal(githubToken), + csrf: randomBytes(24).toString('base64url'), + createdAt: new Date().toISOString(), + checkedAt: Date.now(), + }; + await redis + .multi() + .set(sessionKey(id), JSON.stringify(session), 'EX', TTL_SECONDS) + .sadd(loginKey(login), id) + .expire(loginKey(login), TTL_SECONDS) + .exec(); + return id; + }, + async read(id: string | undefined) { + if (!id) { + return undefined; + } + const stored = await redis.get(sessionKey(id)); + if (!stored) { + return undefined; + } + const session = JSON.parse(stored) as Session; + await redis + .multi() + .expire(sessionKey(id), TTL_SECONDS) + .expire(loginKey(session.login), TTL_SECONDS) + .exec(); + return session; + }, + githubToken(session: Session) { + return sealer.open(session.token); + }, + async update(id: string, session: Session) { + await redis.set(sessionKey(id), JSON.stringify(session), 'KEEPTTL'); + }, + async destroy(id: string, login: string) { + await redis.multi().del(sessionKey(id)).srem(loginKey(login), id).exec(); + }, + async revoke(login: string) { + const ids = await redis.smembers(loginKey(login)); + await redis.del(loginKey(login), ...ids.map(sessionKey)); + return ids.length; + }, + }; +} + +export type Sessions = ReturnType; From 877c5068f694a0bd3ef55ea188b68e5815e54778 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:11 +0200 Subject: [PATCH 04/12] feat: capture errors by fingerprint and alert the owner once a day at most --- site/server/errors.test.ts | 74 ++++++++++++++++++++++++++ site/server/errors.ts | 103 +++++++++++++++++++++++++++++++++++++ site/server/mail.ts | 17 ++++++ 3 files changed, 194 insertions(+) create mode 100644 site/server/errors.test.ts create mode 100644 site/server/errors.ts create mode 100644 site/server/mail.ts diff --git a/site/server/errors.test.ts b/site/server/errors.test.ts new file mode 100644 index 0000000..7f22e35 --- /dev/null +++ b/site/server/errors.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createErrors, fingerprint } from './errors.ts'; +import { memoryDatabase, recordingBus } from './testing.ts'; + +const stack = (line: number) => + `Error: boom\n at load (/app/server/registry.ts:${line}:7)\n at run (/app/server/main.ts:3:1)`; + +describe('fingerprint', () => { + it('ignores numbers, ids and line positions', () => { + assert.equal( + fingerprint('Project 12 failed after 300 ms', stack(10)), + fingerprint('Project 7 failed after 41 ms', stack(99)), + ); + assert.equal( + fingerprint('Delivery 0f8f6e4a-1111-2222-3333-444455556666 failed', null), + fingerprint('Delivery 9a9a9a9a-aaaa-bbbb-cccc-ddddeeeeffff failed', null), + ); + }); + + it('separates different messages and different top frames', () => { + assert.notEqual(fingerprint('boom', stack(1)), fingerprint('bang', stack(1))); + assert.notEqual( + fingerprint('boom', stack(1)), + fingerprint('boom', 'Error: boom\n at other (/app/server/sync.ts:1:1)'), + ); + }); +}); + +describe('capture', () => { + async function setup() { + const db = memoryDatabase(); + const alerts: string[] = []; + const capture = createErrors(db, recordingBus().bus, async (subject) => { + alerts.push(subject); + return true; + }); + return { db, alerts, capture }; + } + + const settle = () => new Promise((resolve) => setImmediate(resolve)); + + it('groups repeats under one fingerprint and alerts the owner once', async () => { + const { db, alerts, capture } = await setup(); + capture(new Error('Registry fetch failed for 3 files'), { route: '/', requestId: 'r1' }); + await settle(); + capture(new Error('Registry fetch failed for 5 files'), { route: '/', requestId: 'r2' }); + await settle(); + + const rows = db.prepare('SELECT count, request_id, alerted_at FROM errors').all(); + assert.equal(rows.length, 1); + assert.equal(rows[0].count, 2); + assert.equal(rows[0].request_id, 'r2'); + assert.ok(rows[0].alerted_at); + assert.equal(alerts.length, 1); + }); + + it('alerts again for a resolved error only after a quiet day', async () => { + const { db, alerts, capture } = await setup(); + capture(new Error('boom')); + await settle(); + db.prepare("UPDATE errors SET resolved_at = '2026-11-01T00:00:00.000Z'").run(); + capture(new Error('boom')); + await settle(); + assert.equal(alerts.length, 1); + + db.prepare( + "UPDATE errors SET resolved_at = '2026-11-01T00:00:00.000Z', alerted_at = '2026-01-01T00:00:00.000Z'", + ).run(); + capture(new Error('boom')); + await settle(); + assert.equal(alerts.length, 2); + }); +}); diff --git a/site/server/errors.ts b/site/server/errors.ts new file mode 100644 index 0000000..921c831 --- /dev/null +++ b/site/server/errors.ts @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto'; +import type { Bus } from './bus.ts'; +import type { Database } from './db.ts'; +import { log } from './log.ts'; + +export interface Where { + route?: string; + requestId?: string; +} + +const DAY_MS = 86_400_000; + +export function describe(error: unknown) { + return error instanceof Error + ? { message: error.message, stack: error.stack ?? null } + : { message: String(error), stack: null }; +} + +export function fingerprint(message: string, stack: string | null) { + const normalised = message + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') + .replace(/\b[0-9a-f]{7,}\b/gi, '') + .replace(/\d+/g, ''); + const frame = + stack + ?.split('\n') + .find((line) => line.trim().startsWith('at ')) + ?.trim() + .replace(/:\d+:\d+\)?$/, '') ?? ''; + return createHash('sha256').update(`${normalised}\n${frame}`).digest('hex').slice(0, 16); +} + +export function createErrors( + db: Database, + bus: Bus, + alert: (subject: string, text: string) => Promise, +) { + const find = db.prepare('SELECT resolved_at, alerted_at FROM errors WHERE fingerprint = ?'); + const upsert = db.prepare( + `INSERT INTO errors (fingerprint, first_at, last_at, count, message, stack, route, request_id) + VALUES (?, ?, ?, 1, ?, ?, ?, ?) + ON CONFLICT (fingerprint) DO UPDATE SET last_at = excluded.last_at, count = count + 1, + message = excluded.message, stack = excluded.stack, route = excluded.route, + request_id = excluded.request_id, resolved_at = NULL`, + ); + const alerted = db.prepare('UPDATE errors SET alerted_at = ? WHERE fingerprint = ?'); + + async function notify( + key: string, + message: string, + stack: string | null, + where: Where, + now: string, + ) { + const sent = await alert( + `Graphite Hub error: ${message.slice(0, 120)}`, + [ + message, + '', + `Route: ${where.route ?? 'none'}`, + `Request: ${where.requestId ?? 'none'}`, + `Fingerprint: ${key}`, + '', + stack ?? '', + ].join('\n'), + ); + if (sent) { + alerted.run(now, key); + } + } + + return function capture(error: unknown, where: Where = {}) { + const { message, stack } = describe(error); + const key = fingerprint(message, stack); + const now = new Date().toISOString(); + log.error( + { err: error, route: where.route, requestId: where.requestId, fingerprint: key }, + message, + ); + const before = find.get(key); + upsert.run(key, now, now, message, stack, where.route ?? null, where.requestId ?? null); + const fresh = !before || before.resolved_at !== null; + const quiet = + !before?.alerted_at || Date.parse(before.alerted_at as string) < Date.now() - DAY_MS; + if (fresh && quiet) { + notify(key, message, stack, where, now).catch((failure: unknown) => + log.error({ err: failure, fingerprint: key }, 'owner alert failed'), + ); + } + bus + .publish({ + topic: 'errors', + kind: 'error', + public: false, + summary: message.slice(0, 200), + detail: { fingerprint: key, route: where.route }, + at: now, + }) + .catch((failure: unknown) => log.error({ err: failure }, 'error event not published')); + }; +} + +export type Capture = ReturnType; diff --git a/site/server/mail.ts b/site/server/mail.ts new file mode 100644 index 0000000..ecbeaa5 --- /dev/null +++ b/site/server/mail.ts @@ -0,0 +1,17 @@ +import { createTransport } from 'nodemailer'; +import { config } from './config.ts'; + +const transport = config.smtpUrl ? createTransport(config.smtpUrl) : undefined; + +export async function alertOwner(subject: string, text: string) { + if (!transport || !config.alertsEmail) { + return false; + } + await transport.sendMail({ + from: config.alertsFrom ?? config.alertsEmail, + to: config.alertsEmail, + subject, + text, + }); + return true; +} From a6f26e370e1cb383a71c6a4ab70b40661c59f7ef Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:12 +0200 Subject: [PATCH 05/12] feat: read the registry and GitHub, and handle the organisation webhook --- site/server/github.ts | 88 ++++++++++++++++++++++ site/server/readers.ts | 142 +++++++++++++++++++++++++++++++++++ site/server/registry.test.ts | 84 +++++++++++++++++++++ site/server/registry.ts | 120 +++++++++++++++++++++++++++++ site/server/sync.ts | 89 ++++++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 site/server/github.ts create mode 100644 site/server/readers.ts create mode 100644 site/server/registry.test.ts create mode 100644 site/server/registry.ts create mode 100644 site/server/sync.ts diff --git a/site/server/github.ts b/site/server/github.ts new file mode 100644 index 0000000..282e589 --- /dev/null +++ b/site/server/github.ts @@ -0,0 +1,88 @@ +import { createHmac, createSign, timingSafeEqual } from 'node:crypto'; +import { config } from './config.ts'; + +let installation: { token: string; expires: number } | undefined; + +export class GitHubError extends Error { + readonly status: number; + + constructor(status: number, path: string) { + super(`GitHub answered ${status} for ${path}`); + this.status = status; + } +} + +async function call(path: string, token: string | undefined, init: RequestInit, accept: string) { + const response = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + Accept: accept, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'project-graphite-hub', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new GitHubError(response.status, path); + } + return response; +} + +export async function request(path: string, token: string | undefined, init: RequestInit = {}) { + const response = await call(path, token, init, 'application/vnd.github+json'); + return (response.status === 204 ? undefined : await response.json()) as T; +} + +function appJwt(clientId: string, privateKey: string) { + const now = Math.floor(Date.now() / 1000); + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const unsigned = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ iat: now - 60, exp: now + 540, iss: clientId })}`; + return `${unsigned}.${createSign('RSA-SHA256').update(unsigned).sign(privateKey, 'base64url')}`; +} + +async function token() { + if (!config.app) { + return config.githubToken; + } + if (installation && installation.expires > Date.now() + 300_000) { + return installation.token; + } + const jwt = appJwt(config.app.clientId, config.app.privateKey); + const { id } = await request<{ id: number }>(`/orgs/${config.organisation}/installation`, jwt); + const created = await request<{ token: string; expires_at: string }>( + `/app/installations/${id}/access_tokens`, + jwt, + { method: 'POST' }, + ); + installation = { token: created.token, expires: Date.parse(created.expires_at) }; + return installation.token; +} + +export async function github(path: string, init?: RequestInit) { + return request(path, await token(), init); +} + +export async function githubText(path: string) { + return (await call(path, await token(), {}, 'application/vnd.github.raw+json')).text(); +} + +export async function paginate(path: string) { + const items: T[] = []; + for (let page = 1; ; page += 1) { + const batch = await github( + `${path}${path.includes('?') ? '&' : '?'}per_page=100&page=${page}`, + ); + items.push(...batch); + if (batch.length < 100) { + return items; + } + } +} + +export function validSignature(secret: string, body: Buffer, header: string | undefined) { + const expected = Buffer.from(`sha256=${createHmac('sha256', secret).update(body).digest('hex')}`); + const given = Buffer.from(header ?? ''); + return given.length === expected.length && timingSafeEqual(given, expected); +} diff --git a/site/server/readers.ts b/site/server/readers.ts new file mode 100644 index 0000000..56909a8 --- /dev/null +++ b/site/server/readers.ts @@ -0,0 +1,142 @@ +import type { Bus } from './bus.ts'; +import { config } from './config.ts'; +import { transaction, type Database } from './db.ts'; +import { github, paginate } from './github.ts'; + +interface GitHubUser { + login: string; + name: string | null; + avatar_url: string; + public_repos: number; + followers: number; + created_at: string; +} + +interface GitHubRepository { + full_name: string; + stargazers_count: number; + forks_count: number; + open_issues_count: number; + pushed_at: string | null; +} + +export async function syncMembers(db: Database, bus: Bus) { + const [listed, admins] = await Promise.all([ + paginate<{ login: string }>(`/orgs/${config.organisation}/members`), + paginate<{ login: string }>(`/orgs/${config.organisation}/members?role=admin`), + ]); + const owners = new Set(admins.map((admin) => admin.login)); + const users = await Promise.all( + listed.map((member) => github(`/users/${member.login}`)), + ); + const now = new Date().toISOString(); + const fields = 'login, name, avatar_url, role, public_repos, followers, github_since'; + const before = JSON.stringify(db.prepare(`SELECT ${fields} FROM members ORDER BY handle`).all()); + const current = new Set(users.map((user) => user.login.toLowerCase())); + const removed = db + .prepare('SELECT handle, login FROM members') + .all() + .filter((row) => !current.has(row.handle as string)) + .map((row) => row.login as string); + + transaction(db, () => { + const upsert = db.prepare( + `INSERT INTO members (handle, ${fields}, joined_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (handle) DO UPDATE SET login = excluded.login, name = excluded.name, + avatar_url = excluded.avatar_url, role = excluded.role, public_repos = excluded.public_repos, + followers = excluded.followers, github_since = excluded.github_since, synced_at = excluded.synced_at`, + ); + for (const user of users) { + upsert.run( + user.login.toLowerCase(), + user.login, + user.name?.trim() || user.login, + user.avatar_url, + owners.has(user.login) ? 'owner' : 'member', + user.public_repos, + user.followers, + user.created_at.slice(0, 4), + now, + now, + ); + } + const remove = db.prepare('DELETE FROM members WHERE login = ?'); + for (const login of removed) { + remove.run(login); + } + }); + + if ( + JSON.stringify(db.prepare(`SELECT ${fields} FROM members ORDER BY handle`).all()) !== before + ) { + await bus.publish({ + topic: 'members', + kind: 'members', + public: true, + summary: `Members synced: ${users.length} in the organisation`, + detail: { removed: removed.length }, + at: now, + }); + } + return removed; +} + +export async function syncStats(db: Database, bus: Bus, repos: string[]) { + const now = new Date().toISOString(); + const previous = new Map( + db + .prepare('SELECT repo, stars, forks, open_issues, pushed_at, languages FROM repo_stats') + .all() + .map((row) => [row.repo as string, row]), + ); + const upsert = db.prepare( + `INSERT INTO repo_stats (repo, stars, forks, open_issues, pushed_at, languages, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (repo) DO UPDATE SET stars = excluded.stars, forks = excluded.forks, + open_issues = excluded.open_issues, pushed_at = excluded.pushed_at, + languages = excluded.languages, synced_at = excluded.synced_at`, + ); + const stars: Record = {}; + for (const repo of repos) { + const [details, languages] = await Promise.all([ + github(`/repos/${repo}`), + github>(`/repos/${repo}/languages`), + ]); + const key = repo.toLowerCase(); + const row = { + repo: key, + stars: details.stargazers_count, + forks: details.forks_count, + open_issues: details.open_issues_count, + pushed_at: details.pushed_at, + languages: JSON.stringify(languages), + }; + const before = previous.get(key); + if (JSON.stringify(before) === JSON.stringify(row)) { + continue; + } + upsert.run(key, row.stars, row.forks, row.open_issues, row.pushed_at, row.languages, now); + if (before && before.stars !== row.stars) { + stars[key] = row.stars - (before.stars as number); + } + await bus.publish({ + topic: 'stats', + key, + kind: 'stats', + public: true, + summary: `${repo} stats changed`, + detail: { stars: row.stars, forks: row.forks }, + at: now, + }); + } + if (Object.keys(stars).length > 0) { + await bus.record({ + topic: 'stats', + kind: 'sync', + public: true, + summary: `Stars changed on ${Object.keys(stars).length} repositories`, + detail: { stars }, + at: now, + }); + } +} diff --git a/site/server/registry.test.ts b/site/server/registry.test.ts new file mode 100644 index 0000000..5b4248c --- /dev/null +++ b/site/server/registry.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { applyEntries, storedEntries, validateEntries, type RegistryEntry } from './registry.ts'; +import { memoryDatabase } from './testing.ts'; + +const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required: ['slug', 'name'], + properties: { + slug: { type: 'string', pattern: '^[a-z0-9-]+$' }, + name: { type: 'string', minLength: 2 }, + }, +}; + +const entry = (slug: string, name = 'A project'): RegistryEntry => ({ + slug, + kind: 'project', + name, + summary: 'A project summary long enough to pass.', + repo: `project-graphite/${slug}`, + owner: 'amrmscll', + authors: ['AmrMsCLL'], + status: 'live', + deploy: { kind: 'none' }, +}); + +describe('validateEntries', () => { + it('accepts an entry the schema accepts', () => { + const { entries, problems } = validateEntries(schema, [ + { name: 'strata.yml', text: 'slug: strata\nname: Strata\n' }, + ]); + assert.deepEqual(problems, []); + assert.equal(entries[0].slug, 'strata'); + }); + + it('skips an entry the schema refuses and names the file', () => { + const { entries, problems } = validateEntries(schema, [ + { name: 'bad.yml', text: 'slug: Bad Slug\nname: B\n' }, + ]); + assert.equal(entries.length, 0); + assert.match(problems[0], /^projects\/bad\.yml: /); + }); + + it('skips an entry whose slug is not its file name', () => { + const { entries, problems } = validateEntries(schema, [ + { name: 'other.yml', text: 'slug: strata\nname: Strata\n' }, + ]); + assert.equal(entries.length, 0); + assert.match(problems[0], /does not match the file name/); + }); +}); + +describe('applyEntries', () => { + it('reports added, changed and removed slugs and stores only the incoming set', () => { + const db = memoryDatabase(); + applyEntries(db, [entry('strata'), entry('tracker')], '2026-11-05T00:00:00.000Z'); + + const diff = applyEntries( + db, + [entry('strata', 'Strata renamed'), entry('pixelpocket')], + '2026-11-05T01:00:00.000Z', + ); + + assert.deepEqual(diff, { added: ['pixelpocket'], removed: ['tracker'], changed: ['strata'] }); + assert.deepEqual( + storedEntries(db).map((stored) => [stored.slug, stored.name]), + [ + ['pixelpocket', 'A project'], + ['strata', 'Strata renamed'], + ], + ); + }); + + it('reports nothing when the registry did not change', () => { + const db = memoryDatabase(); + applyEntries(db, [entry('strata')], '2026-11-05T00:00:00.000Z'); + assert.deepEqual(applyEntries(db, [entry('strata')], '2026-11-05T01:00:00.000Z'), { + added: [], + removed: [], + changed: [], + }); + }); +}); diff --git a/site/server/registry.ts b/site/server/registry.ts new file mode 100644 index 0000000..b5bfcd8 --- /dev/null +++ b/site/server/registry.ts @@ -0,0 +1,120 @@ +import Ajv2020 from 'ajv/dist/2020.js'; +import { parse } from 'yaml'; +import type { ProjectShowcase, ProjectStatus } from '../app/types.ts'; +import type { Bus } from './bus.ts'; +import { config } from './config.ts'; +import { json, transaction, type Database } from './db.ts'; +import { github, githubText } from './github.ts'; +import { log } from './log.ts'; + +export interface RegistryEntry { + slug: string; + parent?: string; + kind: 'project' | 'epic'; + name: string; + summary: string; + tags?: string[]; + repo: string; + owner: string; + authors: string[]; + status: ProjectStatus; + listed?: boolean; + pinned?: boolean; + paths?: string[]; + releases?: 'auto' | 'manual'; + showcase?: ProjectShowcase; + deploy: { kind: 'compose' | 'pages'; host: string } | { kind: 'none' }; +} + +export function validateEntries(schema: object, files: { name: string; text: string }[]) { + const ajv = new Ajv2020({ allErrors: true }); + const valid = ajv.compile(schema); + const entries: RegistryEntry[] = []; + const problems: string[] = []; + for (const { name, text } of files) { + const entry: unknown = parse(text); + if (!valid(entry)) { + problems.push(`projects/${name}: ${ajv.errorsText(valid.errors)}`); + } else if (`${entry.slug}.yml` !== name) { + problems.push(`projects/${name}: slug ${entry.slug} does not match the file name`); + } else { + entries.push(entry); + } + } + return { entries, problems }; +} + +export function storedEntries(db: Database) { + return db + .prepare('SELECT entry FROM projects ORDER BY slug') + .all() + .map((row) => json(row.entry)); +} + +export function applyEntries(db: Database, entries: RegistryEntry[], now: string) { + const existing = new Map( + db + .prepare('SELECT slug, entry FROM projects') + .all() + .map((row) => [row.slug as string, row.entry as string]), + ); + const incoming = new Map(entries.map((entry) => [entry.slug, JSON.stringify(entry)])); + const added = [...incoming.keys()].filter((slug) => !existing.has(slug)); + const removed = [...existing.keys()].filter((slug) => !incoming.has(slug)); + const changed = [...incoming].filter( + ([slug, entry]) => existing.has(slug) && existing.get(slug) !== entry, + ); + transaction(db, () => { + const upsert = db.prepare( + `INSERT INTO projects (slug, repo, entry, synced_at) VALUES (?, ?, ?, ?) + ON CONFLICT (slug) DO UPDATE SET repo = excluded.repo, entry = excluded.entry, synced_at = excluded.synced_at`, + ); + for (const entry of entries) { + upsert.run(entry.slug, entry.repo, incoming.get(entry.slug)!, now); + } + const remove = db.prepare('DELETE FROM projects WHERE slug = ?'); + for (const slug of removed) { + remove.run(slug); + } + }); + return { added, removed, changed: changed.map(([slug]) => slug) }; +} + +export async function syncRegistry(db: Database, bus: Bus) { + const [schema, listing] = await Promise.all([ + githubText(`/repos/${config.platformRepo}/contents/schema/project.schema.json`), + github<{ name: string; type: string }[]>(`/repos/${config.platformRepo}/contents/projects`), + ]); + const files = await Promise.all( + listing + .filter((file) => file.type === 'file' && file.name.endsWith('.yml')) + .map(async (file) => ({ + name: file.name, + text: await githubText(`/repos/${config.platformRepo}/contents/projects/${file.name}`), + })), + ); + const { entries, problems } = validateEntries(JSON.parse(schema), files); + for (const problem of problems) { + log.warn({ problem }, 'registry entry skipped'); + } + const now = new Date().toISOString(); + const { added, removed, changed } = applyEntries(db, entries, now); + if (added.length + removed.length + changed.length > 0) { + await bus.record({ + topic: 'projects', + kind: 'sync', + public: true, + summary: `Registry synced: ${added.length} added, ${changed.length} changed, ${removed.length} removed`, + detail: { added, changed, removed, skipped: problems.length }, + at: now, + }); + } + return entries; +} + +export function dispatchPlatformSync() { + return github(`/repos/${config.platformRepo}/dispatches`, { + method: 'POST', + body: JSON.stringify({ event_type: 'sync-registry' }), + }); +} diff --git a/site/server/sync.ts b/site/server/sync.ts new file mode 100644 index 0000000..1030781 --- /dev/null +++ b/site/server/sync.ts @@ -0,0 +1,89 @@ +import type { Bus } from './bus.ts'; +import { importChangelog, recordRelease, syncReleases, type GitHubRelease } from './changelog.ts'; +import { config } from './config.ts'; +import type { Database } from './db.ts'; +import { dispatchPlatformSync, storedEntries, syncRegistry } from './registry.ts'; +import { syncMembers, syncStats } from './readers.ts'; +import type { Sessions } from './sessions.ts'; +import { recordUptimeSummary } from './uptime.ts'; + +interface Push { + ref: string; + repository: { full_name: string; default_branch: string }; + commits: { added: string[]; modified: string[]; removed: string[] }[]; +} + +interface RepositoryEvent { + repository?: { full_name: string }; +} + +export function changedFiles(push: Push) { + return new Set( + push.commits.flatMap((commit) => [...commit.added, ...commit.modified, ...commit.removed]), + ); +} + +export function createSync(db: Database, bus: Bus, sessions: Sessions) { + async function members() { + for (const login of await syncMembers(db, bus)) { + await sessions.revoke(login); + } + } + + async function registry() { + const entries = await syncRegistry(db, bus); + await importChangelog(db, bus, entries); + return entries; + } + + async function stats(repo: string) { + if (storedEntries(db).some((entry) => entry.repo.toLowerCase() === repo.toLowerCase())) { + await syncStats(db, bus, [repo]); + } + } + + return { + async full() { + const entries = await registry(); + await members(); + await syncStats(db, bus, [...new Set(entries.map((entry) => entry.repo))]); + await syncReleases(db, bus, entries); + await recordUptimeSummary(db, bus); + }, + + async webhook(event: string, payload: unknown) { + const repo = (payload as RepositoryEvent).repository?.full_name; + if (event === 'push') { + const push = payload as Push; + if (push.ref !== `refs/heads/${push.repository.default_branch}`) { + return; + } + const files = changedFiles(push); + if (push.repository.full_name.toLowerCase() === config.platformRepo.toLowerCase()) { + if ( + [...files].some((file) => file.startsWith('projects/') || file.startsWith('schema/')) + ) { + await registry(); + } + } else if (files.has('.graphite.yml')) { + await dispatchPlatformSync(); + } + await stats(push.repository.full_name); + } else if (event === 'release' && repo) { + await recordRelease( + db, + bus, + storedEntries(db), + repo, + (payload as { release: GitHubRelease }).release, + ); + } else if (['star', 'fork', 'repository'].includes(event) && repo) { + await stats(repo); + } else if (['member', 'membership', 'organization'].includes(event)) { + await members(); + } + }, + }; +} + +export type Sync = ReturnType; From dabb423c0656e9a4e1068d1972abb7f3418e125b Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:12 +0200 Subject: [PATCH 06/12] feat: the changelog starts at 1.0.0 and follows GitHub releases --- releases/.gitkeep | 0 releases/digital-to-physical/1.0.0.yml | 55 --------- releases/digital-to-physical/1.0.1.yml | 13 -- releases/graphite-message-lab/0.1.0.yml | 31 ----- releases/graphite-message-lab/1.0.0.yml | 43 ------- releases/graphite-message-lab/1.1.0.yml | 30 ----- releases/graphite-message-lab/1.2.0.yml | 27 ----- releases/graphite-tracker/0.1.0.yml | 39 ------ releases/graphite-tracker/0.2.0.yml | 71 ----------- releases/graphite-tracker/0.3.0.yml | 67 ----------- releases/graphite-tracker/1.0.0.yml | 51 -------- releases/graphite-tracker/1.1.0.yml | 43 ------- releases/graphite-tracker/1.2.0.yml | 63 ---------- releases/graphite-tracker/1.3.0.yml | 64 ---------- releases/graphite-tracker/1.4.0.yml | 42 ------- releases/graphite-tracker/1.4.1.yml | 12 -- releases/graphite-tracker/1.5.0.yml | 13 -- releases/graphite-tracker/1.5.1.yml | 13 -- releases/graphite-tracker/1.5.2.yml | 13 -- releases/graphite-ui/0.1.0.yml | 34 ------ releases/graphite-ui/0.2.0.yml | 13 -- releases/graphite-ui/0.2.1.yml | 13 -- releases/graphite-ui/0.3.0.yml | 13 -- releases/graphite-ui/0.4.0.yml | 13 -- releases/graphite-ui/0.4.1.yml | 13 -- releases/graphite-ui/0.4.2.yml | 13 -- releases/graphite-ui/0.4.3.yml | 13 -- releases/graphite-ui/0.5.0.yml | 13 -- releases/graphite-ui/0.5.1.yml | 12 -- releases/graphite-ui/0.6.0-rc.1.yml | 24 ---- releases/graphite-ui/0.6.0-rc.2.yml | 12 -- releases/graphite-ui/0.6.0-rc.3.yml | 12 -- releases/graphite-ui/1.0.0.yml | 13 -- releases/graphite-ui/1.0.1.yml | 13 -- releases/graphite-ui/1.0.2.yml | 13 -- releases/graphite-ui/1.0.3.yml | 8 -- releases/graphite-ui/1.1.0.yml | 13 -- releases/graphite-ui/1.1.1.yml | 13 -- releases/graphite-ui/1.2.0.yml | 13 -- releases/graphite-ui/1.3.0.yml | 13 -- releases/graphite-ui/1.3.1.yml | 13 -- releases/graphite-ui/1.3.2.yml | 13 -- releases/graphite-ui/1.3.3.yml | 13 -- releases/graphite-ui/1.3.4.yml | 13 -- releases/pixelpocket/0.1.0.yml | 48 -------- releases/pixelpocket/0.1.1.yml | 12 -- releases/pixelpocket/0.2.0.yml | 20 ---- releases/stack-switch/2.0.1.yml | 39 ------ releases/stack-switch/2.1.0.yml | 21 ---- releases/stack-switch/2.2.0.yml | 22 ---- releases/stack-switch/2.3.0.yml | 47 -------- releases/stack-switch/2.3.1.yml | 34 ------ releases/stack-switch/2.4.0.yml | 12 -- releases/stack-switch/2.4.1.yml | 12 -- releases/strata/0.1.0.yml | 70 ----------- releases/strata/0.10.0.yml | 13 -- releases/strata/0.100.0.yml | 13 -- releases/strata/0.100.1.yml | 12 -- releases/strata/0.101.0.yml | 12 -- releases/strata/0.102.0.yml | 12 -- releases/strata/0.103.0.yml | 12 -- releases/strata/0.103.1.yml | 12 -- releases/strata/0.104.0.yml | 13 -- releases/strata/0.11.0.yml | 13 -- releases/strata/0.12.0.yml | 13 -- releases/strata/0.13.0.yml | 13 -- releases/strata/0.13.1.yml | 13 -- releases/strata/0.13.2.yml | 13 -- releases/strata/0.14.0.yml | 13 -- releases/strata/0.15.0.yml | 13 -- releases/strata/0.15.1.yml | 13 -- releases/strata/0.16.0.yml | 13 -- releases/strata/0.17.0.yml | 13 -- releases/strata/0.18.0.yml | 13 -- releases/strata/0.19.0.yml | 13 -- releases/strata/0.2.0.yml | 136 --------------------- releases/strata/0.20.0.yml | 13 -- releases/strata/0.21.0.yml | 13 -- releases/strata/0.22.0.yml | 13 -- releases/strata/0.23.0.yml | 13 -- releases/strata/0.24.0.yml | 13 -- releases/strata/0.25.0.yml | 13 -- releases/strata/0.26.0.yml | 13 -- releases/strata/0.27.0.yml | 13 -- releases/strata/0.28.0.yml | 13 -- releases/strata/0.29.0.yml | 13 -- releases/strata/0.3.0.yml | 36 ------ releases/strata/0.30.0.yml | 13 -- releases/strata/0.31.0.yml | 13 -- releases/strata/0.32.0.yml | 13 -- releases/strata/0.33.0.yml | 13 -- releases/strata/0.34.0.yml | 13 -- releases/strata/0.35.0.yml | 13 -- releases/strata/0.36.0.yml | 13 -- releases/strata/0.37.0.yml | 13 -- releases/strata/0.38.0.yml | 13 -- releases/strata/0.39.0.yml | 13 -- releases/strata/0.4.0.yml | 87 -------------- releases/strata/0.40.0.yml | 13 -- releases/strata/0.41.0.yml | 13 -- releases/strata/0.42.0.yml | 13 -- releases/strata/0.43.0.yml | 13 -- releases/strata/0.44.0.yml | 13 -- releases/strata/0.45.0.yml | 13 -- releases/strata/0.45.1.yml | 13 -- releases/strata/0.45.2.yml | 12 -- releases/strata/0.45.3.yml | 12 -- releases/strata/0.45.4.yml | 12 -- releases/strata/0.46.0.yml | 12 -- releases/strata/0.46.1.yml | 18 --- releases/strata/0.46.2.yml | 18 --- releases/strata/0.46.3.yml | 23 ---- releases/strata/0.46.4.yml | 12 -- releases/strata/0.46.5.yml | 12 -- releases/strata/0.46.6.yml | 13 -- releases/strata/0.47.0.yml | 13 -- releases/strata/0.48.0.yml | 13 -- releases/strata/0.49.0.yml | 12 -- releases/strata/0.49.1.yml | 13 -- releases/strata/0.49.2.yml | 20 ---- releases/strata/0.5.0.yml | 78 ------------ releases/strata/0.5.1.yml | 23 ---- releases/strata/0.50.0.yml | 13 -- releases/strata/0.51.0.yml | 16 --- releases/strata/0.52.0.yml | 12 -- releases/strata/0.52.1.yml | 13 -- releases/strata/0.52.2.yml | 13 -- releases/strata/0.52.3.yml | 13 -- releases/strata/0.52.4.yml | 13 -- releases/strata/0.52.5.yml | 13 -- releases/strata/0.53.0.yml | 13 -- releases/strata/0.53.1.yml | 13 -- releases/strata/0.54.0.yml | 13 -- releases/strata/0.55.0.yml | 13 -- releases/strata/0.56.0.yml | 13 -- releases/strata/0.57.0.yml | 44 ------- releases/strata/0.58.0.yml | 17 --- releases/strata/0.58.1.yml | 20 ---- releases/strata/0.58.2.yml | 12 -- releases/strata/0.59.0.yml | 24 ---- releases/strata/0.6.0.yml | 58 --------- releases/strata/0.60.0.yml | 12 -- releases/strata/0.60.1.yml | 12 -- releases/strata/0.61.0.yml | 24 ---- releases/strata/0.61.1.yml | 12 -- releases/strata/0.61.2.yml | 12 -- releases/strata/0.62.0.yml | 16 --- releases/strata/0.62.1.yml | 12 -- releases/strata/0.62.2.yml | 12 -- releases/strata/0.63.0.yml | 13 -- releases/strata/0.64.0.yml | 14 --- releases/strata/0.64.1.yml | 13 -- releases/strata/0.64.2.yml | 13 -- releases/strata/0.65.0.yml | 19 --- releases/strata/0.66.0.yml | 13 -- releases/strata/0.67.0.yml | 13 -- releases/strata/0.68.0.yml | 18 --- releases/strata/0.69.0.yml | 14 --- releases/strata/0.69.1.yml | 13 -- releases/strata/0.7.0.yml | 13 -- releases/strata/0.70.0.yml | 13 -- releases/strata/0.71.0.yml | 13 -- releases/strata/0.72.0.yml | 13 -- releases/strata/0.73.0.yml | 13 -- releases/strata/0.73.1.yml | 13 -- releases/strata/0.74.0.yml | 13 -- releases/strata/0.75.0.yml | 13 -- releases/strata/0.76.0.yml | 13 -- releases/strata/0.77.0.yml | 14 --- releases/strata/0.78.0.yml | 13 -- releases/strata/0.79.0.yml | 13 -- releases/strata/0.79.1.yml | 13 -- releases/strata/0.79.2.yml | 13 -- releases/strata/0.8.0.yml | 13 -- releases/strata/0.80.0.yml | 14 --- releases/strata/0.81.0.yml | 13 -- releases/strata/0.82.0.yml | 14 --- releases/strata/0.83.0.yml | 14 --- releases/strata/0.84.0.yml | 13 -- releases/strata/0.84.1.yml | 13 -- releases/strata/0.85.0.yml | 13 -- releases/strata/0.86.0.yml | 14 --- releases/strata/0.86.1.yml | 13 -- releases/strata/0.86.2.yml | 13 -- releases/strata/0.86.3.yml | 13 -- releases/strata/0.87.0.yml | 13 -- releases/strata/0.87.1.yml | 13 -- releases/strata/0.87.10.yml | 13 -- releases/strata/0.87.11.yml | 13 -- releases/strata/0.87.12.yml | 13 -- releases/strata/0.87.2.yml | 14 --- releases/strata/0.87.3.yml | 13 -- releases/strata/0.87.4.yml | 13 -- releases/strata/0.87.5.yml | 14 --- releases/strata/0.87.6.yml | 13 -- releases/strata/0.87.7.yml | 13 -- releases/strata/0.87.8.yml | 13 -- releases/strata/0.87.9.yml | 13 -- releases/strata/0.88.0.yml | 23 ---- releases/strata/0.89.0.yml | 13 -- releases/strata/0.9.0.yml | 18 --- releases/strata/0.90.0.yml | 13 -- releases/strata/0.90.1.yml | 13 -- releases/strata/0.91.0.yml | 13 -- releases/strata/0.91.1.yml | 13 -- releases/strata/0.91.2.yml | 13 -- releases/strata/0.92.0.yml | 13 -- releases/strata/0.93.0.yml | 13 -- releases/strata/0.94.0.yml | 13 -- releases/strata/0.94.1.yml | 13 -- releases/strata/0.94.2.yml | 13 -- releases/strata/0.94.3.yml | 13 -- releases/strata/0.95.0.yml | 13 -- releases/strata/0.96.0.yml | 13 -- releases/strata/0.96.1.yml | 14 --- releases/strata/0.97.0.yml | 13 -- releases/strata/0.98.0.yml | 13 -- releases/strata/0.99.0.yml | 13 -- releases/strata/1.0.0.yml | 8 -- releases/strata/1.0.1.yml | 13 -- releases/strata/1.0.3.yml | 12 -- releases/strata/1.0.4.yml | 16 --- releases/strata/1.0.5.yml | 12 -- releases/strata/1.0.6.yml | 12 -- releases/strata/1.1.0.yml | 12 -- releases/strata/1.1.1.yml | 13 -- releases/strata/1.1.2.yml | 13 -- releases/strata/1.1.3.yml | 13 -- releases/strata/1.2.0.yml | 13 -- releases/strata/1.2.1.yml | 12 -- releases/tasktune/2.2.0.yml | 42 ------- releases/tasktune/2.2.3.yml | 26 ---- releases/tasktune/2.3.0.yml | 46 ------- releases/tasktune/2.3.1.yml | 13 -- releases/tasktune/2.4.0.yml | 31 ----- releases/windhawk-mods/1.0.0.yml | 31 ----- site/scripts/compile.mjs | 17 --- site/scripts/releases.mjs | 152 ------------------------ site/scripts/sync.mjs | 135 ++++++++------------- site/server/changelog.test.ts | 108 +++++++++++++++++ site/server/changelog.ts | 141 ++++++++++++++++++++++ 241 files changed, 298 insertions(+), 4541 deletions(-) delete mode 100644 releases/.gitkeep delete mode 100644 releases/digital-to-physical/1.0.0.yml delete mode 100644 releases/digital-to-physical/1.0.1.yml delete mode 100644 releases/graphite-message-lab/0.1.0.yml delete mode 100644 releases/graphite-message-lab/1.0.0.yml delete mode 100644 releases/graphite-message-lab/1.1.0.yml delete mode 100644 releases/graphite-message-lab/1.2.0.yml delete mode 100644 releases/graphite-tracker/0.1.0.yml delete mode 100644 releases/graphite-tracker/0.2.0.yml delete mode 100644 releases/graphite-tracker/0.3.0.yml delete mode 100644 releases/graphite-tracker/1.0.0.yml delete mode 100644 releases/graphite-tracker/1.1.0.yml delete mode 100644 releases/graphite-tracker/1.2.0.yml delete mode 100644 releases/graphite-tracker/1.3.0.yml delete mode 100644 releases/graphite-tracker/1.4.0.yml delete mode 100644 releases/graphite-tracker/1.4.1.yml delete mode 100644 releases/graphite-tracker/1.5.0.yml delete mode 100644 releases/graphite-tracker/1.5.1.yml delete mode 100644 releases/graphite-tracker/1.5.2.yml delete mode 100644 releases/graphite-ui/0.1.0.yml delete mode 100644 releases/graphite-ui/0.2.0.yml delete mode 100644 releases/graphite-ui/0.2.1.yml delete mode 100644 releases/graphite-ui/0.3.0.yml delete mode 100644 releases/graphite-ui/0.4.0.yml delete mode 100644 releases/graphite-ui/0.4.1.yml delete mode 100644 releases/graphite-ui/0.4.2.yml delete mode 100644 releases/graphite-ui/0.4.3.yml delete mode 100644 releases/graphite-ui/0.5.0.yml delete mode 100644 releases/graphite-ui/0.5.1.yml delete mode 100644 releases/graphite-ui/0.6.0-rc.1.yml delete mode 100644 releases/graphite-ui/0.6.0-rc.2.yml delete mode 100644 releases/graphite-ui/0.6.0-rc.3.yml delete mode 100644 releases/graphite-ui/1.0.0.yml delete mode 100644 releases/graphite-ui/1.0.1.yml delete mode 100644 releases/graphite-ui/1.0.2.yml delete mode 100644 releases/graphite-ui/1.0.3.yml delete mode 100644 releases/graphite-ui/1.1.0.yml delete mode 100644 releases/graphite-ui/1.1.1.yml delete mode 100644 releases/graphite-ui/1.2.0.yml delete mode 100644 releases/graphite-ui/1.3.0.yml delete mode 100644 releases/graphite-ui/1.3.1.yml delete mode 100644 releases/graphite-ui/1.3.2.yml delete mode 100644 releases/graphite-ui/1.3.3.yml delete mode 100644 releases/graphite-ui/1.3.4.yml delete mode 100644 releases/pixelpocket/0.1.0.yml delete mode 100644 releases/pixelpocket/0.1.1.yml delete mode 100644 releases/pixelpocket/0.2.0.yml delete mode 100644 releases/stack-switch/2.0.1.yml delete mode 100644 releases/stack-switch/2.1.0.yml delete mode 100644 releases/stack-switch/2.2.0.yml delete mode 100644 releases/stack-switch/2.3.0.yml delete mode 100644 releases/stack-switch/2.3.1.yml delete mode 100644 releases/stack-switch/2.4.0.yml delete mode 100644 releases/stack-switch/2.4.1.yml delete mode 100644 releases/strata/0.1.0.yml delete mode 100644 releases/strata/0.10.0.yml delete mode 100644 releases/strata/0.100.0.yml delete mode 100644 releases/strata/0.100.1.yml delete mode 100644 releases/strata/0.101.0.yml delete mode 100644 releases/strata/0.102.0.yml delete mode 100644 releases/strata/0.103.0.yml delete mode 100644 releases/strata/0.103.1.yml delete mode 100644 releases/strata/0.104.0.yml delete mode 100644 releases/strata/0.11.0.yml delete mode 100644 releases/strata/0.12.0.yml delete mode 100644 releases/strata/0.13.0.yml delete mode 100644 releases/strata/0.13.1.yml delete mode 100644 releases/strata/0.13.2.yml delete mode 100644 releases/strata/0.14.0.yml delete mode 100644 releases/strata/0.15.0.yml delete mode 100644 releases/strata/0.15.1.yml delete mode 100644 releases/strata/0.16.0.yml delete mode 100644 releases/strata/0.17.0.yml delete mode 100644 releases/strata/0.18.0.yml delete mode 100644 releases/strata/0.19.0.yml delete mode 100644 releases/strata/0.2.0.yml delete mode 100644 releases/strata/0.20.0.yml delete mode 100644 releases/strata/0.21.0.yml delete mode 100644 releases/strata/0.22.0.yml delete mode 100644 releases/strata/0.23.0.yml delete mode 100644 releases/strata/0.24.0.yml delete mode 100644 releases/strata/0.25.0.yml delete mode 100644 releases/strata/0.26.0.yml delete mode 100644 releases/strata/0.27.0.yml delete mode 100644 releases/strata/0.28.0.yml delete mode 100644 releases/strata/0.29.0.yml delete mode 100644 releases/strata/0.3.0.yml delete mode 100644 releases/strata/0.30.0.yml delete mode 100644 releases/strata/0.31.0.yml delete mode 100644 releases/strata/0.32.0.yml delete mode 100644 releases/strata/0.33.0.yml delete mode 100644 releases/strata/0.34.0.yml delete mode 100644 releases/strata/0.35.0.yml delete mode 100644 releases/strata/0.36.0.yml delete mode 100644 releases/strata/0.37.0.yml delete mode 100644 releases/strata/0.38.0.yml delete mode 100644 releases/strata/0.39.0.yml delete mode 100644 releases/strata/0.4.0.yml delete mode 100644 releases/strata/0.40.0.yml delete mode 100644 releases/strata/0.41.0.yml delete mode 100644 releases/strata/0.42.0.yml delete mode 100644 releases/strata/0.43.0.yml delete mode 100644 releases/strata/0.44.0.yml delete mode 100644 releases/strata/0.45.0.yml delete mode 100644 releases/strata/0.45.1.yml delete mode 100644 releases/strata/0.45.2.yml delete mode 100644 releases/strata/0.45.3.yml delete mode 100644 releases/strata/0.45.4.yml delete mode 100644 releases/strata/0.46.0.yml delete mode 100644 releases/strata/0.46.1.yml delete mode 100644 releases/strata/0.46.2.yml delete mode 100644 releases/strata/0.46.3.yml delete mode 100644 releases/strata/0.46.4.yml delete mode 100644 releases/strata/0.46.5.yml delete mode 100644 releases/strata/0.46.6.yml delete mode 100644 releases/strata/0.47.0.yml delete mode 100644 releases/strata/0.48.0.yml delete mode 100644 releases/strata/0.49.0.yml delete mode 100644 releases/strata/0.49.1.yml delete mode 100644 releases/strata/0.49.2.yml delete mode 100644 releases/strata/0.5.0.yml delete mode 100644 releases/strata/0.5.1.yml delete mode 100644 releases/strata/0.50.0.yml delete mode 100644 releases/strata/0.51.0.yml delete mode 100644 releases/strata/0.52.0.yml delete mode 100644 releases/strata/0.52.1.yml delete mode 100644 releases/strata/0.52.2.yml delete mode 100644 releases/strata/0.52.3.yml delete mode 100644 releases/strata/0.52.4.yml delete mode 100644 releases/strata/0.52.5.yml delete mode 100644 releases/strata/0.53.0.yml delete mode 100644 releases/strata/0.53.1.yml delete mode 100644 releases/strata/0.54.0.yml delete mode 100644 releases/strata/0.55.0.yml delete mode 100644 releases/strata/0.56.0.yml delete mode 100644 releases/strata/0.57.0.yml delete mode 100644 releases/strata/0.58.0.yml delete mode 100644 releases/strata/0.58.1.yml delete mode 100644 releases/strata/0.58.2.yml delete mode 100644 releases/strata/0.59.0.yml delete mode 100644 releases/strata/0.6.0.yml delete mode 100644 releases/strata/0.60.0.yml delete mode 100644 releases/strata/0.60.1.yml delete mode 100644 releases/strata/0.61.0.yml delete mode 100644 releases/strata/0.61.1.yml delete mode 100644 releases/strata/0.61.2.yml delete mode 100644 releases/strata/0.62.0.yml delete mode 100644 releases/strata/0.62.1.yml delete mode 100644 releases/strata/0.62.2.yml delete mode 100644 releases/strata/0.63.0.yml delete mode 100644 releases/strata/0.64.0.yml delete mode 100644 releases/strata/0.64.1.yml delete mode 100644 releases/strata/0.64.2.yml delete mode 100644 releases/strata/0.65.0.yml delete mode 100644 releases/strata/0.66.0.yml delete mode 100644 releases/strata/0.67.0.yml delete mode 100644 releases/strata/0.68.0.yml delete mode 100644 releases/strata/0.69.0.yml delete mode 100644 releases/strata/0.69.1.yml delete mode 100644 releases/strata/0.7.0.yml delete mode 100644 releases/strata/0.70.0.yml delete mode 100644 releases/strata/0.71.0.yml delete mode 100644 releases/strata/0.72.0.yml delete mode 100644 releases/strata/0.73.0.yml delete mode 100644 releases/strata/0.73.1.yml delete mode 100644 releases/strata/0.74.0.yml delete mode 100644 releases/strata/0.75.0.yml delete mode 100644 releases/strata/0.76.0.yml delete mode 100644 releases/strata/0.77.0.yml delete mode 100644 releases/strata/0.78.0.yml delete mode 100644 releases/strata/0.79.0.yml delete mode 100644 releases/strata/0.79.1.yml delete mode 100644 releases/strata/0.79.2.yml delete mode 100644 releases/strata/0.8.0.yml delete mode 100644 releases/strata/0.80.0.yml delete mode 100644 releases/strata/0.81.0.yml delete mode 100644 releases/strata/0.82.0.yml delete mode 100644 releases/strata/0.83.0.yml delete mode 100644 releases/strata/0.84.0.yml delete mode 100644 releases/strata/0.84.1.yml delete mode 100644 releases/strata/0.85.0.yml delete mode 100644 releases/strata/0.86.0.yml delete mode 100644 releases/strata/0.86.1.yml delete mode 100644 releases/strata/0.86.2.yml delete mode 100644 releases/strata/0.86.3.yml delete mode 100644 releases/strata/0.87.0.yml delete mode 100644 releases/strata/0.87.1.yml delete mode 100644 releases/strata/0.87.10.yml delete mode 100644 releases/strata/0.87.11.yml delete mode 100644 releases/strata/0.87.12.yml delete mode 100644 releases/strata/0.87.2.yml delete mode 100644 releases/strata/0.87.3.yml delete mode 100644 releases/strata/0.87.4.yml delete mode 100644 releases/strata/0.87.5.yml delete mode 100644 releases/strata/0.87.6.yml delete mode 100644 releases/strata/0.87.7.yml delete mode 100644 releases/strata/0.87.8.yml delete mode 100644 releases/strata/0.87.9.yml delete mode 100644 releases/strata/0.88.0.yml delete mode 100644 releases/strata/0.89.0.yml delete mode 100644 releases/strata/0.9.0.yml delete mode 100644 releases/strata/0.90.0.yml delete mode 100644 releases/strata/0.90.1.yml delete mode 100644 releases/strata/0.91.0.yml delete mode 100644 releases/strata/0.91.1.yml delete mode 100644 releases/strata/0.91.2.yml delete mode 100644 releases/strata/0.92.0.yml delete mode 100644 releases/strata/0.93.0.yml delete mode 100644 releases/strata/0.94.0.yml delete mode 100644 releases/strata/0.94.1.yml delete mode 100644 releases/strata/0.94.2.yml delete mode 100644 releases/strata/0.94.3.yml delete mode 100644 releases/strata/0.95.0.yml delete mode 100644 releases/strata/0.96.0.yml delete mode 100644 releases/strata/0.96.1.yml delete mode 100644 releases/strata/0.97.0.yml delete mode 100644 releases/strata/0.98.0.yml delete mode 100644 releases/strata/0.99.0.yml delete mode 100644 releases/strata/1.0.0.yml delete mode 100644 releases/strata/1.0.1.yml delete mode 100644 releases/strata/1.0.3.yml delete mode 100644 releases/strata/1.0.4.yml delete mode 100644 releases/strata/1.0.5.yml delete mode 100644 releases/strata/1.0.6.yml delete mode 100644 releases/strata/1.1.0.yml delete mode 100644 releases/strata/1.1.1.yml delete mode 100644 releases/strata/1.1.2.yml delete mode 100644 releases/strata/1.1.3.yml delete mode 100644 releases/strata/1.2.0.yml delete mode 100644 releases/strata/1.2.1.yml delete mode 100644 releases/tasktune/2.2.0.yml delete mode 100644 releases/tasktune/2.2.3.yml delete mode 100644 releases/tasktune/2.3.0.yml delete mode 100644 releases/tasktune/2.3.1.yml delete mode 100644 releases/tasktune/2.4.0.yml delete mode 100644 releases/windhawk-mods/1.0.0.yml delete mode 100644 site/scripts/compile.mjs delete mode 100644 site/scripts/releases.mjs create mode 100644 site/server/changelog.test.ts create mode 100644 site/server/changelog.ts diff --git a/releases/.gitkeep b/releases/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/releases/digital-to-physical/1.0.0.yml b/releases/digital-to-physical/1.0.0.yml deleted file mode 100644 index afb6c11..0000000 --- a/releases/digital-to-physical/1.0.0.yml +++ /dev/null @@ -1,55 +0,0 @@ -project: digital-to-physical -version: 1.0.0 -tag: v1.0.0 -previous: null -date: 2026-09-18 -title: First public release -summary: > - Digital to Physical launches as a private, browser-only tool that turns comic archives into - print-ready PDFs that bind cleanly into booklets. Spreads stay aligned across the fold, page - counts are padded for folding, and a live audit checks the layout before you build. -changes: - - type: added - text: Convert .cbz, .cbr, .zip and .rar comic archives into PDFs entirely in your browser, with RAR archives unpacked through WebAssembly and no files ever uploaded - commit: 7db67fd - author: BluoCaroot - - type: added - text: Live booklet preview and page grid showing trim boxes, spreads, padding pages and cover placement - commit: 7db67fd - author: BluoCaroot - - type: added - text: Automatic splitting of landscape double-page spreads, with left-to-right and right-to-left manga reading orders - commit: 7db67fd - author: BluoCaroot - - type: added - text: Spread alignment that keeps both halves of a split spread facing each other across the fold, for binding or two-up screen reading - commit: 7db67fd - author: BluoCaroot - - type: added - text: Booklet padding to a multiple of four or a custom multiple so the printed volume folds cleanly - commit: 7db67fd - author: BluoCaroot - - type: added - text: Advert and filler pool that fills padding pages with your own images, spread evenly across the volume with deterministic per-book ordering - commit: 7db67fd - author: BluoCaroot - - type: added - text: Back cover choices including auto-composed art, a solid colour, variant covers, a page from the archive, the front cover, an advert or your own uploaded image - commit: 7db67fd - author: BluoCaroot - - type: added - text: Live pre-print audit that checks the multiple-of-four page count, facing spread pairs and advert balance before you build - commit: 7db67fd - author: BluoCaroot - - type: added - text: Print and bind, Faithful copy and Two-up on screen presets - commit: 7db67fd - author: BluoCaroot - - type: added - text: Paper sizes from US comic to JIS B5 manga and custom sizes, with margins, DPI and optional carrier sheets with crop marks - commit: 7db67fd - author: BluoCaroot - - type: added - text: Whole JPEG pages are embedded byte for byte with no re-compression - commit: 7db67fd - author: BluoCaroot diff --git a/releases/digital-to-physical/1.0.1.yml b/releases/digital-to-physical/1.0.1.yml deleted file mode 100644 index 44bcf85..0000000 --- a/releases/digital-to-physical/1.0.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: digital-to-physical -version: 1.0.1 -tag: v1.0.1 -previous: v1.0.0 -date: 2026-09-27 -title: Project Graphite branding -summary: > - The app now carries the shared Project Graphite pencil-A mark in its browser tab and header. -changes: - - type: changed - text: Replaced the favicon and the header brand glyph with the Project Graphite pencil-A mark - commit: 66b9d7f - author: AmrMsCLL diff --git a/releases/graphite-message-lab/0.1.0.yml b/releases/graphite-message-lab/0.1.0.yml deleted file mode 100644 index 17265a9..0000000 --- a/releases/graphite-message-lab/0.1.0.yml +++ /dev/null @@ -1,31 +0,0 @@ -project: graphite-message-lab -version: 0.1.0 -tag: v0.1.0 -previous: null -date: 2026-09-16 -title: First message feed -summary: > - The first version of the lab: visitors post a short message, Django stores it in PostgreSQL, and a - Celery worker processes it in the background while the page polls for the result. It ships with a - local Docker Compose setup and container images published to GitHub Container Registry. -changes: - - type: added - text: Visitors can post a message with their name from the browser and watch it appear on a feed that refreshes from the JSON API at /api/messages/ - commit: 2c96953 - author: AmrMsCLL - - type: added - text: A Celery worker picks up each new message through Redis, counts its words and marks it processed - commit: 2c96953 - author: AmrMsCLL - - type: added - text: Health endpoints at /up/ and /up/databases report whether the app, PostgreSQL and Redis are reachable - commit: 2c96953 - author: AmrMsCLL - - type: added - text: Docker Compose setup for local development, with CI that publishes immutable web and worker images to GitHub Container Registry on every push to main - commit: 2c96953 - author: AmrMsCLL - - type: added - text: Production resources are generated for Kubernetes from the platform registry entry, with Docker Compose kept for local development only - commit: "2973625" - author: AmrMsCLL diff --git a/releases/graphite-message-lab/1.0.0.yml b/releases/graphite-message-lab/1.0.0.yml deleted file mode 100644 index 68e487f..0000000 --- a/releases/graphite-message-lab/1.0.0.yml +++ /dev/null @@ -1,43 +0,0 @@ -project: graphite-message-lab -version: 1.0.0 -tag: v1.0.0 -previous: v0.1.0 -date: 2026-09-17 -title: Live on Coolify with private review -summary: > - Message Lab went live at message-lab.project-graphite.com, deployed by Coolify from a production - Compose stack that redeploys automatically after each successful push to main. Messages are now - private by default and reach the public wall only after an administrator publishes them. -changes: - - type: changed - text: Production now runs as a Coolify-managed Docker Compose application instead of on Kubernetes - commit: "95754e0" - author: AmrMsCLL - - type: added - text: Production Compose stack that pulls the prebuilt GHCR images, limits CPU and memory, persists PostgreSQL and Redis in named volumes and runs migrations before Gunicorn starts - commit: "95754e0" - author: AmrMsCLL - - type: added - text: Trusted CSRF origins, HTTPS detection behind the proxy and secure cookies outside debug mode - commit: "95754e0" - author: AmrMsCLL - - type: added - text: Every successful push to main redeploys production through the Coolify deployment webhook - commit: d99ffc5 - author: AmrMsCLL - - type: added - text: Visitors choose between Private and Public after review, every message lands in a private inbox, and only messages an administrator publishes appear on the public wall - commit: 5b53693 - author: AmrMsCLL - - type: added - text: The admin inbox shows each message's requested visibility and moderation flag, with bulk actions to publish messages or make them private - commit: 5b53693 - author: AmrMsCLL - - type: added - text: Messages containing flagged language are marked for moderation and always stay private - commit: 5b53693 - author: AmrMsCLL - - type: added - text: The project is registered in Project Graphite as a live, listed application - commit: 4f4b04e - author: AmrMsCLL diff --git a/releases/graphite-message-lab/1.1.0.yml b/releases/graphite-message-lab/1.1.0.yml deleted file mode 100644 index 62b509c..0000000 --- a/releases/graphite-message-lab/1.1.0.yml +++ /dev/null @@ -1,30 +0,0 @@ -project: graphite-message-lab -version: 1.1.0 -tag: v1.1.0 -previous: v1.0.0 -date: 2026-09-23 -title: Wall privacy fixes and Graphite theme -summary: > - An audit pass closed gaps that could put private or flagged messages on the public wall, made the - wall resilient to failed requests, and moved the site and admin onto the shared Graphite theme. -changes: - - type: security - text: Messages that never requested publication can no longer be published to the wall, and a flagged display name now keeps a message private - commit: 402cad8 - author: AmrMsCLL - - type: fixed - text: A failed refresh keeps the messages already on the wall and shows a styled notice, form errors fall back to a readable message, screen readers no longer re-announce the wall on every refresh, and the header wraps at phone width - commit: 35c51dc - author: AmrMsCLL - - type: fixed - text: Docker Compose honours the documented APP_UID, APP_GID and CELERY_LOG_LEVEL settings - commit: bd75c15 - author: AmrMsCLL - - type: changed - text: The site and the admin use the Graphite dark theme with a single SVG favicon in place of the upstream favicon kit - commit: cfaa1a1 - author: AmrMsCLL - - type: removed - text: DATABASE_URL is no longer read; PostgreSQL is configured only through the POSTGRES_* variables - commit: 4017ed6 - author: AmrMsCLL diff --git a/releases/graphite-message-lab/1.2.0.yml b/releases/graphite-message-lab/1.2.0.yml deleted file mode 100644 index 3f0ea60..0000000 --- a/releases/graphite-message-lab/1.2.0.yml +++ /dev/null @@ -1,27 +0,0 @@ -project: graphite-message-lab -version: 1.2.0 -tag: v1.2.0 -previous: v1.1.0 -date: 2026-09-27 -title: Form privacy, contrast and worker health -summary: > - Follow-up fixes keep unsent message text out of URLs, raise faint text to WCAG AA contrast and add a - health check for the production Celery worker. The favicon and header now carry the Project - Graphite mark. -changes: - - type: security - text: The message form posts its data, so unsent message text never reaches the URL or access logs when the page script fails to load - commit: d24b42d - author: AmrMsCLL - - type: fixed - text: Faint text meets WCAG AA contrast - commit: f21247e - author: AmrMsCLL - - type: added - text: The production Celery worker has a health check that pings it every 60 seconds - commit: 4395fe1 - author: AmrMsCLL - - type: changed - text: The favicon and header tile use the Project Graphite pencil-A mark - commit: b3a63a5 - author: AmrMsCLL diff --git a/releases/graphite-tracker/0.1.0.yml b/releases/graphite-tracker/0.1.0.yml deleted file mode 100644 index b77ac96..0000000 --- a/releases/graphite-tracker/0.1.0.yml +++ /dev/null @@ -1,39 +0,0 @@ -project: graphite-tracker -version: 0.1.0 -tag: v0.1.0 -previous: null -date: 2026-09-20 -title: Catalogue and library -summary: > - The first working tracker. Readers can create an account, discover movies, TV, anime, manga, - manhwa and games from TMDB, MangaDex and IGDB, and keep them in a personal library with their - progress. -changes: - - type: added - text: Readers can register, verify their account and sign in - commit: 1eb038b - author: AmrMsCLL - - type: added - text: Discover shows recent and popular movies from TMDB, with search - commit: 1eb038b - author: AmrMsCLL - - type: added - text: A personal library keeps each title in a list such as planned, in progress or completed - commit: 1eb038b - author: AmrMsCLL - - type: added - text: TV and anime from TMDB, manga and manhwa from MangaDex and games from IGDB join the catalogue - commit: 1f6244f - author: AmrMsCLL - - type: added - text: Title pages show the details of any catalogue entry - commit: 1f6244f - author: AmrMsCLL - - type: added - text: The library tracks progress such as episodes and chapters for each title - commit: 1f6244f - author: AmrMsCLL - - type: added - text: Readers choose which source each category uses from a sources settings page - commit: 1f6244f - author: AmrMsCLL diff --git a/releases/graphite-tracker/0.2.0.yml b/releases/graphite-tracker/0.2.0.yml deleted file mode 100644 index a9a8e7c..0000000 --- a/releases/graphite-tracker/0.2.0.yml +++ /dev/null @@ -1,71 +0,0 @@ -project: graphite-tracker -version: 0.2.0 -tag: v0.2.0 -previous: v0.1.0 -date: 2026-09-23 -title: Games, home rows and catalogue polish -summary: > - Games get their own tracking page with RAWG as an alternative source, the home page fills with - recent titles, and a large round of fixes makes catalogue data, sessions and the library - behave reliably. -changes: - - type: added - text: RAWG can be used as the game catalogue instead of IGDB - commit: 22ccf1e - author: AmrMsCLL - - type: added - text: A games page lets signed-in readers search games and manage the games they track - commit: db8c902 - author: AmrMsCLL - - type: added - text: The home page shows rows of recent titles for each category - commit: 05829ba - author: AmrMsCLL - - type: added - text: Adding a title asks which list it goes into - commit: 90c32f9 - author: AmrMsCLL - - type: added - text: Title pages list alternate titles, and pasting a TMDB or MangaDex link opens the recognised title - commit: 623fc8f - author: AmrMsCLL - - type: changed - text: Discover filters offer status and sort options that match the category - commit: 03ca963 - author: AmrMsCLL - - type: changed - text: Library progress fields follow what each title can track, and game notifications need a platform - commit: cbb575a - author: AmrMsCLL - - type: changed - text: Source credits link to the source, and sources the server has not configured cannot be enabled - commit: d115836 - author: AmrMsCLL - - type: changed - text: The app matches the graphite-hub theme and tab icon - commit: 01c8d37 - author: AmrMsCLL - - type: changed - text: Concurrent requests for the same source data share one load and requests to each source are queued - commit: 0a027b0 - author: AmrMsCLL - - type: fixed - text: Sessions renew before the access token expires, sign-out works after expiry and pages stay loaded during renewal - commit: 2c0c167 - author: AmrMsCLL - - type: fixed - text: IGDB year filters, paging, status and release dates are correct - commit: fef3d57 - author: AmrMsCLL - - type: fixed - text: TMDB TV genres and anime paging are correct, and Korean titles no longer appear in MangaDex manga lists - commit: 67c1fdc - author: AmrMsCLL - - type: fixed - text: The server fails fast when Redis is unreachable and closes its connections on shutdown - commit: 7db2b49 - author: AmrMsCLL - - type: fixed - text: Production Compose refuses to start without its required secrets and passes the game source settings - commit: 34c1b58 - author: AmrMsCLL diff --git a/releases/graphite-tracker/0.3.0.yml b/releases/graphite-tracker/0.3.0.yml deleted file mode 100644 index eb327c1..0000000 --- a/releases/graphite-tracker/0.3.0.yml +++ /dev/null @@ -1,67 +0,0 @@ -project: graphite-tracker -version: 0.3.0 -tag: v0.3.0 -previous: v0.2.0 -date: 2026-09-25 -title: Imports, reviews and profiles -summary: > - Readers can bring their Mihon and AniYomi libraries across, rate and review what they finish, - and share a public profile, while connectors and the redesigned poster-card interface are - hardened for real traffic. -changes: - - type: added - text: Readers can import Mihon and AniYomi backups and review every match before it is applied - commit: 3d4c465 - author: AmrMsCLL - - type: added - text: Readers can rate and review completed or dropped titles, with spoiler warnings and private reviews - commit: a5dbd3f - author: AmrMsCLL - - type: added - text: Public profiles show statistics, activity, library, ratings and reviews, each with its own privacy setting - commit: 13ee7ad - author: AmrMsCLL - - type: added - text: The administrator can resolve reports, hide or restore reviews and deactivate readers - commit: a5dbd3f - author: AmrMsCLL - - type: added - text: Privacy, terms and credits pages - commit: 13ee7ad - author: AmrMsCLL - - type: security - text: Sign-in, registration and catalogue reads are rate limited, and a replayed refresh token signs its user out everywhere - commit: ca787ca - author: AmrMsCLL - - type: changed - text: Discover, home rows, games and the library share one poster card, with filters on every Discover section and a paginated library - commit: 80ea487 - author: AmrMsCLL - - type: changed - text: One library editor handles lists, progress, platforms, preferred source and notifications everywhere - commit: 80ea487 - author: AmrMsCLL - - type: changed - text: Source connectors share timeouts, retries, a circuit breaker, a response size cap and a host allowlist - commit: ca787ca - author: AmrMsCLL - - type: changed - text: The server image applies database migrations on start - commit: 3655fe6 - author: AmrMsCLL - - type: fixed - text: An import can no longer be applied twice, and oversized backup text is dropped instead of stalling the server - commit: fc6928e - author: AmrMsCLL - - type: fixed - text: Rejected upstream requests no longer make a source unavailable for everyone - commit: e9a7be5 - author: AmrMsCLL - - type: fixed - text: A failed scheduled session renewal no longer signs the reader out - commit: c784c54 - author: AmrMsCLL - - type: fixed - text: Faint text meets WCAG AA contrast and phone layouts are tighter - commit: a5967cb - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.0.0.yml b/releases/graphite-tracker/1.0.0.yml deleted file mode 100644 index d8e7d5a..0000000 --- a/releases/graphite-tracker/1.0.0.yml +++ /dev/null @@ -1,51 +0,0 @@ -project: graphite-tracker -version: 1.0.0 -tag: v1.0.0 -previous: v0.3.0 -date: 2026-09-26 -title: Live with email and release digests -summary: > - Graphite Tracker goes live in production, deployed automatically to Coolify from main. Account - email arrives for real, and readers get daily or weekly digests of new episodes, chapters and - releases for the titles they follow. -changes: - - type: added - text: Every push to main publishes the image and deploys the tracker to production on Coolify - commit: f7344ea - author: AmrMsCLL - - type: added - text: Verification, password reset and email change links are delivered by email - commit: e3f0206 - author: AmrMsCLL - - type: added - text: Account settings cover email, password, time zone, a data export and account deletion - commit: e3f0206 - author: AmrMsCLL - - type: added - text: New episodes, chapters and releases of followed titles are detected and sent as daily or weekly email digests - commit: 42dd341 - author: AmrMsCLL - - type: added - text: Notification settings, a one-click unsubscribe page and an admin list of rejected digests - commit: 14b98d7 - author: AmrMsCLL - - type: added - text: Operators can switch any source off with DISABLED_SOURCES - commit: aab3abe - author: AmrMsCLL - - type: changed - text: Title pages link every attached source, and library entries follow title, category and global source preferences - commit: aab3abe - author: AmrMsCLL - - type: changed - text: Import previews list titles already in the library as conflicts - commit: aab3abe - author: AmrMsCLL - - type: fixed - text: Adult titles stay out of IGDB and RAWG lists and every source's title details - commit: aab3abe - author: AmrMsCLL - - type: fixed - text: One refused address no longer stops digests for other readers - commit: b4f1f39 - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.1.0.yml b/releases/graphite-tracker/1.1.0.yml deleted file mode 100644 index 6d25e8c..0000000 --- a/releases/graphite-tracker/1.1.0.yml +++ /dev/null @@ -1,43 +0,0 @@ -project: graphite-tracker -version: 1.1.0 -tag: v1.1.0 -previous: v1.0.0 -date: 2026-09-26 -title: Adult content controls and loading states -summary: > - Readers decide whether adult titles appear and whether their artwork is blurred, search spans - every category at once, and the app shows skeletons, outage and not-found pages instead of - bare loading text. -changes: - - type: added - text: Readers can opt into adult content behind a confirmation, with adult artwork blurred by default - commit: 81a0691 - author: AmrMsCLL - - type: added - text: Home search shows results for every category on one page - commit: 81a0691 - author: AmrMsCLL - - type: added - text: Loading skeletons, a not-found page and an outage page that reloads once the server is back - commit: d34af2f - author: AmrMsCLL - - type: added - text: The administrator can see private profiles and reviews, marked as private - commit: 6a6e549 - author: AmrMsCLL - - type: changed - text: Adult filtering is enforced by each source on the server, and result counts appear only when exact - commit: 0c84771 - author: AmrMsCLL - - type: changed - text: Game search ranks results by title match and popularity - commit: 0c84771 - author: AmrMsCLL - - type: fixed - text: MangaDex covers load in production instead of a placeholder - commit: f5dba31 - author: AmrMsCLL - - type: fixed - text: The Discover genre filter stays in place while it loads, and empty filtered pages explain why - commit: e50e6c7 - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.2.0.yml b/releases/graphite-tracker/1.2.0.yml deleted file mode 100644 index ab2450f..0000000 --- a/releases/graphite-tracker/1.2.0.yml +++ /dev/null @@ -1,63 +0,0 @@ -project: graphite-tracker -version: 1.2.0 -tag: v1.2.0 -previous: v1.1.0 -date: 2026-09-26 -title: Roles, inbox and phone layout -summary: > - A system manager now runs the site and appoints administrators, readers get an in-app - notification inbox, title pages gain cast, crew and trailers, and the whole app is designed - for phones. -changes: - - type: breaking - text: The admin:grant command is replaced by system-manager:grant, and the system manager appoints administrators from the admin page - commit: e2cd9a2 - author: AmrMsCLL - - type: added - text: The system manager can turn adult content off for everyone - commit: c019b99 - author: AmrMsCLL - - type: added - text: An in-app notification center with a bell and pop-ups for new releases - commit: f461865 - author: AmrMsCLL - - type: added - text: Readers can hide single library entries from their profile - commit: 1e6f50d - author: AmrMsCLL - - type: added - text: Title pages show cast and crew for every category and link trailers - commit: 94d3279 - author: AmrMsCLL - - type: added - text: Pasting a source link into any search opens that title - commit: 173c8c5 - author: AmrMsCLL - - type: changed - text: The app is designed for phones, with a bottom tab bar, account menu, bottom-sheet dialogs and collapsible filters - commit: 23e1fce - author: AmrMsCLL - - type: changed - text: Settings live under the reader's own profile, and old settings links redirect there - commit: 68f8512 - author: AmrMsCLL - - type: changed - text: Release notifications are offered only for titles still coming out - commit: 6a5b842 - author: AmrMsCLL - - type: changed - text: Discover search sits beside the heading and the year is picked from a list - commit: 16ec734 - author: AmrMsCLL - - type: changed - text: Forms and confirmations use in-app messages and dialogs instead of browser prompts - commit: '1e56937' - author: AmrMsCLL - - type: changed - text: Source credits and source links move into the page footer - commit: b19b977 - author: AmrMsCLL - - type: changed - text: Skeletons appear wherever content is still loading, with custom scrollbars and smoother motion - commit: 09a14ff - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.3.0.yml b/releases/graphite-tracker/1.3.0.yml deleted file mode 100644 index b9c4764..0000000 --- a/releases/graphite-tracker/1.3.0.yml +++ /dev/null @@ -1,64 +0,0 @@ -project: graphite-tracker -version: 1.3.0 -tag: v1.3.0 -previous: v1.2.0 -date: 2026-09-26 -title: Better sources and audit fixes -summary: > - AniList and MangaUpdates become the default anime, manga and manhwa sources and TV browse shows - real scripted series, while an audit of the first four phases closes account, digest, privacy - and import defects and raises capacity on the single server container. -changes: - - type: added - text: AniList is the default anime source, with season browsing and the latest aired episode - commit: fa47616 - pr: 9 - author: AmrMsCLL - - type: added - text: MangaUpdates is the default manga and manhwa source, separating manga from manhwa and manhua - commit: 86d6239 - pr: 8 - author: AmrMsCLL - - type: changed - text: TV browse shows scripted series and miniseries with real audiences instead of talk shows and zero-vote uploads - commit: fbcb891 - pr: 7 - author: AmrMsCLL - - type: changed - text: Searches are cached longer and a busy source answers at once with a try-again message - commit: 3b97f55 - author: AmrMsCLL - - type: security - text: Password reset and change cancel pending email changes, and the old address is warned about a requested change - commit: a6fd79a - pr: 10 - author: AmrMsCLL - - type: security - text: Security headers including HSTS are sent and X-Powered-By is off - commit: a6fd79a - pr: 10 - author: AmrMsCLL - - type: fixed - text: Backup uploads no longer run the server out of memory - commit: 3b97f55 - author: AmrMsCLL - - type: fixed - text: Private ratings stay off public profiles - commit: a6fd79a - pr: 10 - author: AmrMsCLL - - type: fixed - text: Digests survive sender outages and crashes without losing or repeating releases - commit: a6fd79a - pr: 10 - author: AmrMsCLL - - type: fixed - text: Imports no longer overwrite shared catalogue titles, and saved titles are refreshed from their source weekly - commit: a6fd79a - pr: 10 - author: AmrMsCLL - - type: fixed - text: Progress can go past a title's stored totals - commit: a6fd79a - pr: 10 - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.4.0.yml b/releases/graphite-tracker/1.4.0.yml deleted file mode 100644 index b474f45..0000000 --- a/releases/graphite-tracker/1.4.0.yml +++ /dev/null @@ -1,42 +0,0 @@ -project: graphite-tracker -version: 1.4.0 -tag: v1.4.0 -previous: v1.3.0 -date: 2026-09-27 -title: Content filters and default sources -summary: > - Content filtering gets stricter and more precise across manga, manhwa, games and TV, and the - system manager can choose which source each category uses by default for the whole site. -changes: - - type: added - text: The system manager chooses the default source for each category across the site - commit: 487f854 - author: AmrMsCLL - - type: changed - text: Adult filters for manga, manhwa and games cover more tags, while widely read titles stay visible - commit: e610fd7 - author: AmrMsCLL - - type: changed - text: Ecchi and harem manga are hidden unless widely read on MangaUpdates - commit: f3071e1 - author: AmrMsCLL - - type: changed - text: LGBTQ-tagged titles are hidden from search, browse and title pages for every reader - commit: c614948 - author: AmrMsCLL - - type: changed - text: Genre lists follow the reader's adult content setting - commit: e610fd7 - author: AmrMsCLL - - type: changed - text: Anime no longer appears in TV browse and search - commit: e610fd7 - author: AmrMsCLL - - type: changed - text: A new favicon and header mark - commit: e531411 - author: AmrMsCLL - - type: fixed - text: MangaUpdates searches no longer fail on series without a year or genres - commit: e610fd7 - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.4.1.yml b/releases/graphite-tracker/1.4.1.yml deleted file mode 100644 index 60f7a48..0000000 --- a/releases/graphite-tracker/1.4.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: graphite-tracker -version: 1.4.1 -tag: v1.4.1 -previous: v1.4.0 -date: 2026-10-07 -title: null -summary: null -changes: - - type: changed - text: Reject backups with no library entries - commit: 6a1f08c - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.5.0.yml b/releases/graphite-tracker/1.5.0.yml deleted file mode 100644 index f569818..0000000 --- a/releases/graphite-tracker/1.5.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-tracker -version: 1.5.0 -tag: v1.5.0 -previous: v1.4.1 -date: 2026-10-19 -title: null -summary: null -changes: - - type: added - text: UI on Material UI with graphite-ui 1.0.0 - commit: ded3923 - pr: 25 - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.5.1.yml b/releases/graphite-tracker/1.5.1.yml deleted file mode 100644 index c845ec6..0000000 --- a/releases/graphite-tracker/1.5.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-tracker -version: 1.5.1 -tag: v1.5.1 -previous: v1.5.0 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Placeholder-only fields where a heading already names them, graphite-ui 1.0.2 - commit: 8a82b44 - pr: 26 - author: AmrMsCLL diff --git a/releases/graphite-tracker/1.5.2.yml b/releases/graphite-tracker/1.5.2.yml deleted file mode 100644 index b076755..0000000 --- a/releases/graphite-tracker/1.5.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-tracker -version: 1.5.2 -tag: v1.5.2 -previous: v1.5.1 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Restore the floating field labels, graphite-ui 1.0.3 - commit: 7533f37 - pr: 27 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.1.0.yml b/releases/graphite-ui/0.1.0.yml deleted file mode 100644 index 9425e1f..0000000 --- a/releases/graphite-ui/0.1.0.yml +++ /dev/null @@ -1,34 +0,0 @@ -project: graphite-ui -version: 0.1.0 -tag: v0.1.0 -previous: null -date: 2026-10-01 -title: Shared theme and components -summary: > - The first release of @project-graphite/ui moves Graphite Tracker's generic design tokens, base - styles and components into one package, so every Graphite web app can share the same dark look. -changes: - - type: added - text: Shared dark theme with the Graphite colour, radius, shadow, easing and layout tokens plus the live, in-progress and dormant status colours - commit: ff1a146 - author: AmrMsCLL - - type: added - text: Sixteen React components including AppShell, Dialog, ConfirmDialog, Menu, Popover, GridListbox, Field, Toggle, Pagination, Skeleton, Snackbar and OutageGate - commit: ff1a146 - author: AmrMsCLL - - type: added - text: UiProvider keeps routing with the host app, so the package does not depend on react-router - commit: ff1a146 - author: AmrMsCLL - - type: added - text: Dialogs, menus and popovers restore focus and support keyboard navigation - commit: ff1a146 - author: AmrMsCLL - - type: added - text: Local preview page showing every component in every state - commit: ff1a146 - author: AmrMsCLL - - type: added - text: Version tags publish the package as a GitHub release - commit: ff1a146 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.2.0.yml b/releases/graphite-ui/0.2.0.yml deleted file mode 100644 index e7bb99e..0000000 --- a/releases/graphite-ui/0.2.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.2.0 -tag: v0.2.0 -previous: v0.1.0 -date: 2026-10-01 -title: null -summary: null -changes: - - type: added - text: Add the components Strata's foundation needs - commit: c5f14e9 - pr: 6 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.2.1.yml b/releases/graphite-ui/0.2.1.yml deleted file mode 100644 index e60382a..0000000 --- a/releases/graphite-ui/0.2.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.2.1 -tag: v0.2.1 -previous: v0.2.0 -date: 2026-10-01 -title: null -summary: null -changes: - - type: fixed - text: Keep the page gutter beside the sidebar - commit: 904f145 - pr: 8 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.3.0.yml b/releases/graphite-ui/0.3.0.yml deleted file mode 100644 index bada388..0000000 --- a/releases/graphite-ui/0.3.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.3.0 -tag: v0.3.0 -previous: v0.2.1 -date: 2026-10-05 -title: null -summary: null -changes: - - type: added - text: A sidebar that collapses from the header, a page header, an error state with retry, and loading skeletons that wait briefly before showing - commit: 2c8ebae - pr: 11 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.4.0.yml b/releases/graphite-ui/0.4.0.yml deleted file mode 100644 index 22e45d6..0000000 --- a/releases/graphite-ui/0.4.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.4.0 -tag: v0.4.0 -previous: v0.3.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Themed select, date and time pickers, colour swatches and calm skeletons (0.4.0) - commit: 1b1bcfa - pr: 13 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.4.1.yml b/releases/graphite-ui/0.4.1.yml deleted file mode 100644 index b6b07b1..0000000 --- a/releases/graphite-ui/0.4.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.4.1 -tag: v0.4.1 -previous: v0.4.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: fixed - text: Stop focus rings on open lists and the 1px page scroll from the header border (0.4.1) - commit: b9c8a7c - pr: 14 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.4.2.yml b/releases/graphite-ui/0.4.2.yml deleted file mode 100644 index fc5404a..0000000 --- a/releases/graphite-ui/0.4.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.4.2 -tag: v0.4.2 -previous: v0.4.1 -date: 2026-10-11 -title: null -summary: null -changes: - - type: fixed - text: Menus open like the other popovers, themed disclosures and a quiet sidebar scrollbar (0.4.2) - commit: a935530 - pr: 15 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.4.3.yml b/releases/graphite-ui/0.4.3.yml deleted file mode 100644 index b6742b7..0000000 --- a/releases/graphite-ui/0.4.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.4.3 -tag: v0.4.3 -previous: v0.4.2 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Progress meter and icons for goals, gifts, meals, spending and travel (0.4.3) - commit: 29c3f62 - pr: 16 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.5.0.yml b/releases/graphite-ui/0.5.0.yml deleted file mode 100644 index a9a0048..0000000 --- a/releases/graphite-ui/0.5.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 0.5.0 -tag: v0.5.0 -previous: v0.4.3 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Undo snacks, danger confirms, safer dialogs and phone-friendly tabs, popovers and touch - targets (0.5.0) - commit: e4423e1 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.5.1.yml b/releases/graphite-ui/0.5.1.yml deleted file mode 100644 index 24250b5..0000000 --- a/releases/graphite-ui/0.5.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: graphite-ui -version: 0.5.1 -tag: v0.5.1 -previous: v0.5.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Editor icons and start-aligned popovers (0.5.1) - commit: 38eaea3 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.6.0-rc.1.yml b/releases/graphite-ui/0.6.0-rc.1.yml deleted file mode 100644 index af852e5..0000000 --- a/releases/graphite-ui/0.6.0-rc.1.yml +++ /dev/null @@ -1,24 +0,0 @@ -project: graphite-ui -version: 0.6.0-rc.1 -tag: v0.6.0-rc.1 -previous: v0.5.1 -date: 2026-10-19 -title: null -summary: null -changes: - - type: added - text: Restyle DatePicker with Material UI parts - commit: e8bed4b - author: AmrMsCLL - - type: added - text: Build tabs, pagination, the shell's tab bar and the display pieces on Material UI - commit: 3bac782 - author: AmrMsCLL - - type: added - text: Build dialogs, menus, popovers, the command palette and snackbars on Material UI - commit: 676c272 - author: AmrMsCLL - - type: added - text: Build the theme, provider, buttons and form fields on Material UI - commit: f5f913f - author: AmrMsCLL diff --git a/releases/graphite-ui/0.6.0-rc.2.yml b/releases/graphite-ui/0.6.0-rc.2.yml deleted file mode 100644 index ef11fdc..0000000 --- a/releases/graphite-ui/0.6.0-rc.2.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: graphite-ui -version: 0.6.0-rc.2 -tag: v0.6.0-rc.2 -previous: v0.6.0-rc.1 -date: 2026-10-19 -title: null -summary: null -changes: - - type: added - text: Checkbox, Radio and Material UI menu and popover triggers - commit: 79b6234 - author: AmrMsCLL diff --git a/releases/graphite-ui/0.6.0-rc.3.yml b/releases/graphite-ui/0.6.0-rc.3.yml deleted file mode 100644 index 247b130..0000000 --- a/releases/graphite-ui/0.6.0-rc.3.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: graphite-ui -version: 0.6.0-rc.3 -tag: v0.6.0-rc.3 -previous: v0.6.0-rc.2 -date: 2026-10-19 -title: null -summary: null -changes: - - type: added - text: GridListbox and ColorSwatches on Material UI parts - commit: c6a4e9d - author: AmrMsCLL diff --git a/releases/graphite-ui/1.0.0.yml b/releases/graphite-ui/1.0.0.yml deleted file mode 100644 index ca8b50d..0000000 --- a/releases/graphite-ui/1.0.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.0.0 -tag: v1.0.0 -previous: v0.6.0-rc.3 -date: 2026-10-19 -title: null -summary: null -changes: - - type: breaking - text: Components on Material UI (1.0.0) - commit: 5cc78be - pr: 18 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.0.1.yml b/releases/graphite-ui/1.0.1.yml deleted file mode 100644 index c7476f8..0000000 --- a/releases/graphite-ui/1.0.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.0.1 -tag: v1.0.1 -previous: v1.0.0 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Pass the page's CSP nonce to the MUI style cache (1.0.1) - commit: 1c37b08 - pr: 19 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.0.2.yml b/releases/graphite-ui/1.0.2.yml deleted file mode 100644 index 1d5a1c7..0000000 --- a/releases/graphite-ui/1.0.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.0.2 -tag: v1.0.2 -previous: v1.0.1 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Graphite surfaces for dialogs and popovers, optional field labels (1.0.2) - commit: df8ee60 - pr: 20 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.0.3.yml b/releases/graphite-ui/1.0.3.yml deleted file mode 100644 index 7c46826..0000000 --- a/releases/graphite-ui/1.0.3.yml +++ /dev/null @@ -1,8 +0,0 @@ -project: graphite-ui -version: 1.0.3 -tag: v1.0.3 -previous: v1.0.2 -date: 2026-10-19 -title: null -summary: No user-facing changes. -changes: [] diff --git a/releases/graphite-ui/1.1.0.yml b/releases/graphite-ui/1.1.0.yml deleted file mode 100644 index 463bfc7..0000000 --- a/releases/graphite-ui/1.1.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.1.0 -tag: v1.1.0 -previous: v1.0.3 -date: 2026-10-19 -title: null -summary: null -changes: - - type: added - text: Graphite colours across the MUI components and a smooth sidebar collapse (1.1.0) - commit: cc1e7e0 - pr: 22 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.1.1.yml b/releases/graphite-ui/1.1.1.yml deleted file mode 100644 index 46cf3d4..0000000 --- a/releases/graphite-ui/1.1.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.1.1 -tag: v1.1.1 -previous: v1.1.0 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Stop MUI's scroll lock from shifting the page (1.1.1) - commit: bf8eb0e - pr: 23 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.2.0.yml b/releases/graphite-ui/1.2.0.yml deleted file mode 100644 index b237724..0000000 --- a/releases/graphite-ui/1.2.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.2.0 -tag: v1.2.0 -previous: v1.1.1 -date: 2026-10-20 -title: null -summary: null -changes: - - type: added - text: Motion across the shell, overlays and controls (1.2.0) - commit: f332716 - pr: 24 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.3.0.yml b/releases/graphite-ui/1.3.0.yml deleted file mode 100644 index 711a788..0000000 --- a/releases/graphite-ui/1.3.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.3.0 -tag: v1.3.0 -previous: v1.2.0 -date: 2026-10-27 -title: null -summary: null -changes: - - type: added - text: Design system primitives and type scale (1.3.0) - commit: b5ff258 - pr: 29 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.3.1.yml b/releases/graphite-ui/1.3.1.yml deleted file mode 100644 index d7674d4..0000000 --- a/releases/graphite-ui/1.3.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.3.1 -tag: v1.3.1 -previous: v1.3.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: fixed - text: One continuous border under tabs in PageActionsRow, typed IconButton href (1.3.1) - commit: c575c24 - pr: 30 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.3.2.yml b/releases/graphite-ui/1.3.2.yml deleted file mode 100644 index 07bada3..0000000 --- a/releases/graphite-ui/1.3.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.3.2 -tag: v1.3.2 -previous: v1.3.1 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Outage copy per cause and IconButton target and rel (1.3.2) - commit: 7d1a4d9 - pr: 31 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.3.3.yml b/releases/graphite-ui/1.3.3.yml deleted file mode 100644 index 16158df..0000000 --- a/releases/graphite-ui/1.3.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.3.3 -tag: v1.3.3 -previous: v1.3.2 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: No keyboard on touch when a dialog or sheet opens, native scrollbars on touch (1.3.3) - commit: e239648 - pr: 32 - author: AmrMsCLL diff --git a/releases/graphite-ui/1.3.4.yml b/releases/graphite-ui/1.3.4.yml deleted file mode 100644 index 103fa75..0000000 --- a/releases/graphite-ui/1.3.4.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: graphite-ui -version: 1.3.4 -tag: v1.3.4 -previous: v1.3.3 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: 15 px body, 36 px controls, token chip border, one-line row meta and design fixes (1.3.4) - commit: 43506c3 - pr: 33 - author: AmrMsCLL diff --git a/releases/pixelpocket/0.1.0.yml b/releases/pixelpocket/0.1.0.yml deleted file mode 100644 index 43f4032..0000000 --- a/releases/pixelpocket/0.1.0.yml +++ /dev/null @@ -1,48 +0,0 @@ -project: pixelpocket -version: 0.1.0 -tag: v0.1.0 -previous: null -date: 2026-10-11 -title: null -summary: null -changes: - - type: fixed - text: Build the emulator WebAssembly through the frontend Dockerfile; allow manual changelog runs - commit: d91e33e - author: AmrMsCLL - - type: changed - text: Redesign game shelves and admin docs - commit: a848b2d - author: Omar-Mohamed24 - - type: added - text: Add admin dashboard and email/UI updates - commit: 2e42f6a - author: Omar-Mohamed24 - - type: changed - text: Refactor emulator core and align project docs - commit: a261c14 - author: Omar-Mohamed24 - - type: changed - text: Update main.yml - commit: 481efb3 - author: AmrMsCLL - - type: changed - text: Lowercase the backend and frontend folders - commit: acd26a0 - author: AmrMsCLL - - type: changed - text: Normalize app folders and deployment config - commit: e882e2e - author: AmrMsCLL - - type: changed - text: Update .graphite.yml - commit: 02fd96c - author: Omar-Mohamed24 - - type: changed - text: Bootstrap PixelPocket monorepo stack - commit: 98bf417 - author: Omar-Mohamed24 - - type: changed - text: Initial commit - commit: 0eba4b6 - author: Omar-Mohamed24 diff --git a/releases/pixelpocket/0.1.1.yml b/releases/pixelpocket/0.1.1.yml deleted file mode 100644 index 81a8e36..0000000 --- a/releases/pixelpocket/0.1.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: pixelpocket -version: 0.1.1 -tag: v0.1.1 -previous: v0.1.0 -date: 2026-10-13 -title: null -summary: null -changes: - - type: changed - text: Pad rack rows and preserve real client IP - commit: 26d9971 - author: Omar-Mohamed24 diff --git a/releases/pixelpocket/0.2.0.yml b/releases/pixelpocket/0.2.0.yml deleted file mode 100644 index 038f5c3..0000000 --- a/releases/pixelpocket/0.2.0.yml +++ /dev/null @@ -1,20 +0,0 @@ -project: pixelpocket -version: 0.2.0 -tag: v0.2.0 -previous: v0.1.1 -date: 2026-10-13 -title: null -summary: null -changes: - - type: added - text: Add route-based SEO metadata and crawl files - commit: e38c8e1 - author: Omar-Mohamed24 - - type: changed - text: Harden auth and account security flows - commit: 48fe78f - author: Omar-Mohamed24 - - type: removed - text: Remove redundant inline comments - commit: 4f37569 - author: Omar-Mohamed24 diff --git a/releases/stack-switch/2.0.1.yml b/releases/stack-switch/2.0.1.yml deleted file mode 100644 index e6d816b..0000000 --- a/releases/stack-switch/2.0.1.yml +++ /dev/null @@ -1,39 +0,0 @@ -project: stack-switch -version: 2.0.1 -tag: stack-switch/v2.0.1 -previous: null -date: 2026-09-15 -title: Tech Stack Launcher -summary: > - The first version, released as Tech Stack Launcher, adds a taskbar button whose panel starts a - configurable working set of apps, folders, editors, URLs and terminal commands in order and closes - the same selection again in reverse order. -changes: - - type: added - text: Taskbar launcher panel that starts apps, folders, VS Code workspaces, URLs, terminal commands and windowless commands in order, and closes the selected items again in reverse order - commit: bf76283 - author: AmrMsCLL - - type: added - text: Per-item toggles that persist across restarts, collapsible groups and profile chips that switch the whole selection in one click - commit: bf76283 - author: AmrMsCLL - - type: added - text: Eight taskbar anchors read from the taskbar's live layout, using a layered child window instead of XAML injection so no symbol resolution is needed - commit: bf76283 - author: AmrMsCLL - - type: added - text: Search box, per-item right-click menu with Launch now, Close now, Show in File Explorer and Copy target, and a dot on every running item - commit: bf76283 - author: AmrMsCLL - - type: added - text: Ctrl+Alt+S hotkey, an optional tray icon and an optional profile launch after signing in - commit: bf76283 - author: AmrMsCLL - - type: added - text: The {root} token and environment variables expand in every path - commit: bf76283 - author: AmrMsCLL - - type: added - text: Dark and light panel themes styled after VS Code's Dark Modern and Light Modern palettes - commit: bf76283 - author: AmrMsCLL diff --git a/releases/stack-switch/2.1.0.yml b/releases/stack-switch/2.1.0.yml deleted file mode 100644 index 65c33f3..0000000 --- a/releases/stack-switch/2.1.0.yml +++ /dev/null @@ -1,21 +0,0 @@ -project: stack-switch -version: 2.1.0 -tag: stack-switch/v2.1.0 -previous: stack-switch/v2.0.1 -date: 2026-09-15 -title: Reliable terminal items -summary: > - Terminal items now keep their full command, honour their window state and can be waited on. -changes: - - type: fixed - text: Terminal commands containing semicolons or double quotes reach Windows Terminal intact, with PowerShell commands passed as an encoded command - commit: 2479ea9 - author: AmrMsCLL - - type: fixed - text: Minimized and Maximized now apply to terminal items, which open in their own Windows Terminal window - commit: 2479ea9 - author: AmrMsCLL - - type: fixed - text: Wait for it to exit waits for the terminal command instead of returning as soon as Windows Terminal starts - commit: 2479ea9 - author: AmrMsCLL diff --git a/releases/stack-switch/2.2.0.yml b/releases/stack-switch/2.2.0.yml deleted file mode 100644 index d5604cf..0000000 --- a/releases/stack-switch/2.2.0.yml +++ /dev/null @@ -1,22 +0,0 @@ -project: stack-switch -version: 2.2.0 -tag: stack-switch/v2.2.0 -previous: stack-switch/v2.1.0 -date: 2026-09-15 -title: Grouping and reordering -summary: > - Items sharing a group name now gather under one heading, and the panel order, which decides - what starts first, can be rearranged directly. -changes: - - type: changed - text: Items with the same group name appear under one heading wherever they sit in the settings - commit: 95d8ca3 - author: AmrMsCLL - - type: added - text: Drag an item or a whole group heading, or use Alt+Up and Alt+Down, to reorder the panel and the launch order - commit: 95d8ca3 - author: AmrMsCLL - - type: added - text: Reset order to settings discards the saved panel order - commit: 95d8ca3 - author: AmrMsCLL diff --git a/releases/stack-switch/2.3.0.yml b/releases/stack-switch/2.3.0.yml deleted file mode 100644 index 14fd04b..0000000 --- a/releases/stack-switch/2.3.0.yml +++ /dev/null @@ -1,47 +0,0 @@ -project: stack-switch -version: 2.3.0 -tag: stack-switch/v2.3.0 -previous: stack-switch/v2.2.0 -date: 2026-09-23 -title: StackSwitch -summary: > - The launcher is renamed StackSwitch and gets a full correctness pass: sequences follow the panel - order, the panel and button behave on every taskbar, terminal items target their own window, and - the dark panel moves to the Project Graphite colours. -changes: - - type: changed - text: Tech Stack Launcher is renamed StackSwitch, with the mod id stack-switch - commit: bed7234 - author: AmrMsCLL - - type: fixed - text: Launch, Close, tray profiles and the sign-in auto-run follow the panel's grouped and dragged order, and Cancel during the close grace wait no longer force-kills the item - commit: c4f1b5a - author: AmrMsCLL - - type: fixed - text: Panel hover, focus, filtering and group dragging behave correctly, Alt+F4 no longer destroys the panel and the panel slides from its anchor - commit: 47f6f3d - author: AmrMsCLL - - type: fixed - text: Mixed-DPI taskbars no longer overwrite each other, the panel opens over the clicked button, and the tray icon and hotkey follow settings and theme changes - commit: 0897c1e - author: AmrMsCLL - - type: fixed - text: A font name of 32 characters or more no longer crashes Explorer, and a root folder containing {root} no longer expands forever - commit: "9568046" - author: AmrMsCLL - - type: fixed - text: Minimized and maximized terminal items act only on the Windows Terminal window they opened, and quoted paths ending in a backslash keep their closing quote - commit: b9fda5a - author: AmrMsCLL - - type: fixed - text: Profile chips that do not fit on one row wrap onto further rows instead of disappearing - commit: 3f79b15 - author: AmrMsCLL - - type: removed - text: The Always Windows Terminal host option, which behaved like Auto; a stored value now loads as Auto - commit: 571ec74 - author: AmrMsCLL - - type: changed - text: The dark panel uses the Project Graphite colours, and the light panel's dim text meets 4.5:1 contrast - commit: 7f51db0 - author: AmrMsCLL diff --git a/releases/stack-switch/2.3.1.yml b/releases/stack-switch/2.3.1.yml deleted file mode 100644 index 95ba5ee..0000000 --- a/releases/stack-switch/2.3.1.yml +++ /dev/null @@ -1,34 +0,0 @@ -project: stack-switch -version: 2.3.1 -tag: stack-switch/v2.3.1 -previous: stack-switch/v2.3.0 -date: 2026-09-27 -title: Options line and panel items -summary: > - Each item's settings card shrinks to nine fields with a single Options line, items can be added - and edited from the panel itself, and rarely used global settings are gone. -changes: - - type: breaking - text: The per-item launch, timing and appearance fields are replaced by one comma-separated Options line, and values saved in the old fields are not migrated - commit: 5f9abca - author: AmrMsCLL - - type: added - text: The panel's + button adds items from running apps, a file, a folder or a blank item, and right-clicking a panel item offers Edit and Remove - commit: 5f9abca - author: AmrMsCLL - - type: removed - text: Rarely used global settings such as theme, accent colour, animation, font, panel size, launch stagger and terminal host; their defaults now apply - commit: 5f9abca - author: AmrMsCLL - - type: changed - text: Terminal items keep their window open unless Wait for it to exit is on, and new items start from neutral defaults with the root folder set to %USERPROFILE% - commit: 5f9abca - author: AmrMsCLL - - type: fixed - text: Quoted targets and shell targets such as Store apps show their real icon - commit: 5f9abca - author: AmrMsCLL - - type: changed - text: The mod's author name is AmrMsCLL, matching its GitHub handle - commit: 2003d1d - author: AmrMsCLL diff --git a/releases/stack-switch/2.4.0.yml b/releases/stack-switch/2.4.0.yml deleted file mode 100644 index c58cd08..0000000 --- a/releases/stack-switch/2.4.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: stack-switch -version: 2.4.0 -tag: stack-switch/v2.4.0 -previous: stack-switch/v2.3.1 -date: 2026-10-20 -title: null -summary: null -changes: - - type: added - text: Move all stack-switch UI into the panel - commit: b298f86 - author: AmrMsCLL diff --git a/releases/stack-switch/2.4.1.yml b/releases/stack-switch/2.4.1.yml deleted file mode 100644 index 6825b84..0000000 --- a/releases/stack-switch/2.4.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: stack-switch -version: 2.4.1 -tag: stack-switch/v2.4.1 -previous: stack-switch/v2.4.0 -date: 2026-10-20 -title: null -summary: null -changes: - - type: changed - text: Update stack-switch.wh.cpp - commit: 7ff0413 - author: AmrMsCLL diff --git a/releases/strata/0.1.0.yml b/releases/strata/0.1.0.yml deleted file mode 100644 index 2bdf8d1..0000000 --- a/releases/strata/0.1.0.yml +++ /dev/null @@ -1,70 +0,0 @@ -project: strata -version: 0.1.0 -tag: v0.1.0 -previous: null -date: 2026-10-02 -title: Accounts, two-step sign-in and the app shell -summary: > - The first release of Strata sets up its foundation: secure accounts with two-step sign-in, the - settings pages, and the shared Graphite look on desktop and phone, live at its own subdomain. -changes: - - type: added - text: Sign up, email verification, sign-in, password reset and email change - commit: 7b0091a - pr: 2 - author: AmrMsCLL - - type: added - text: The app shell with a desktop sidebar and a phone tab bar, built on the shared Graphite components - commit: 7b0091a - pr: 2 - author: AmrMsCLL - - type: added - text: Export your account data or delete your account at any time - commit: 7b0091a - pr: 2 - author: AmrMsCLL - - type: added - text: Privacy and terms pages written for Strata - commit: 7b0091a - pr: 2 - author: AmrMsCLL - - type: security - text: Signing up or changing email never reveals whether an address already has an account - commit: 7b0091a - pr: 2 - author: AmrMsCLL - - type: security - text: A strict content security policy, rate limits and request logs that hold no personal data - commit: 7b0091a - pr: 2 - author: AmrMsCLL - - type: added - text: Two-step sign-in with an authenticator app and single-use recovery codes - commit: b56ebfb - pr: 8 - author: AmrMsCLL - - type: added - text: Settings for your profile, security, signed-in devices and data - commit: b56ebfb - pr: 8 - author: AmrMsCLL - - type: added - text: See every signed-in device and sign out one or all of the others - commit: b56ebfb - pr: 8 - author: AmrMsCLL - - type: added - text: Emails when a new device signs in, the password changes or two-step sign-in is turned on or off - commit: b56ebfb - pr: 8 - author: AmrMsCLL - - type: security - text: Passwords found in known data breaches are refused, without the password leaving Strata - commit: b56ebfb - pr: 8 - author: AmrMsCLL - - type: security - text: Password, email and two-step changes and account deletion ask for your password and code again - commit: b56ebfb - pr: 8 - author: AmrMsCLL diff --git a/releases/strata/0.10.0.yml b/releases/strata/0.10.0.yml deleted file mode 100644 index 0825cf5..0000000 --- a/releases/strata/0.10.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.10.0 -tag: v0.10.0 -previous: v0.9.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Import pages from Markdown files and Notion exports - commit: 88f8986 - pr: 90 - author: AmrMsCLL diff --git a/releases/strata/0.100.0.yml b/releases/strata/0.100.0.yml deleted file mode 100644 index f94b97c..0000000 --- a/releases/strata/0.100.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.100.0 -tag: v0.100.0 -previous: v0.99.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Custom board columns, hidden empty invites and aligned member roles - commit: "230e115" - pr: 260 - author: AmrMsCLL diff --git a/releases/strata/0.100.1.yml b/releases/strata/0.100.1.yml deleted file mode 100644 index 38af770..0000000 --- a/releases/strata/0.100.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.100.1 -tag: v0.100.1 -previous: v0.100.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Needs you as a stat tile, the time beside the greeting date and contained stat text on phones - commit: 7bd8ae5 - author: AmrMsCLL diff --git a/releases/strata/0.101.0.yml b/releases/strata/0.101.0.yml deleted file mode 100644 index 1a67104..0000000 --- a/releases/strata/0.101.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.101.0 -tag: v0.101.0 -previous: v0.100.1 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: 12-hour or 24-hour time, and heading buttons beside described titles - commit: 3bdb2eb - author: AmrMsCLL diff --git a/releases/strata/0.102.0.yml b/releases/strata/0.102.0.yml deleted file mode 100644 index 0099beb..0000000 --- a/releases/strata/0.102.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.102.0 -tag: v0.102.0 -previous: v0.101.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Read mode with saved drafts, tabbed activity, rich comments and a centered view - commit: ad68368 - author: AmrMsCLL diff --git a/releases/strata/0.103.0.yml b/releases/strata/0.103.0.yml deleted file mode 100644 index aff7e97..0000000 --- a/releases/strata/0.103.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.103.0 -tag: v0.103.0 -previous: v0.102.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Watchers on shared tasks, pages and boards, and an editable task title in the header - commit: 3f62d61 - author: AmrMsCLL diff --git a/releases/strata/0.103.1.yml b/releases/strata/0.103.1.yml deleted file mode 100644 index 6ee5460..0000000 --- a/releases/strata/0.103.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.103.1 -tag: v0.103.1 -previous: v0.103.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Let the header title fill the row so long titles are not cut off - commit: 53bcb46 - author: AmrMsCLL diff --git a/releases/strata/0.104.0.yml b/releases/strata/0.104.0.yml deleted file mode 100644 index 959626a..0000000 --- a/releases/strata/0.104.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.104.0 -tag: v0.104.0 -previous: v0.103.1 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: An eye button with a Jira-style watch menu, a clearer view toggle and even spacing in the task - panel - commit: 3a79289 - author: AmrMsCLL diff --git a/releases/strata/0.11.0.yml b/releases/strata/0.11.0.yml deleted file mode 100644 index 859ccfc..0000000 --- a/releases/strata/0.11.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.11.0 -tag: v0.11.0 -previous: v0.10.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Add a quote of the day Home widget - commit: 3c362cb - pr: 91 - author: AmrMsCLL diff --git a/releases/strata/0.12.0.yml b/releases/strata/0.12.0.yml deleted file mode 100644 index 3d5a911..0000000 --- a/releases/strata/0.12.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.12.0 -tag: v0.12.0 -previous: v0.11.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Add a brainstorm timer and voting rounds to boards - commit: 2b7bd14 - pr: 92 - author: AmrMsCLL diff --git a/releases/strata/0.13.0.yml b/releases/strata/0.13.0.yml deleted file mode 100644 index 016169a..0000000 --- a/releases/strata/0.13.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.13.0 -tag: v0.13.0 -previous: v0.12.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Send inbox notices as push notifications to chosen devices - commit: 88c5132 - pr: 93 - author: AmrMsCLL diff --git a/releases/strata/0.13.1.yml b/releases/strata/0.13.1.yml deleted file mode 100644 index 57f71c9..0000000 --- a/releases/strata/0.13.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.13.1 -tag: v0.13.1 -previous: v0.13.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: fixed - text: Gaps found in a full UI pass - commit: 9fd5393 - pr: 96 - author: AmrMsCLL diff --git a/releases/strata/0.13.2.yml b/releases/strata/0.13.2.yml deleted file mode 100644 index aefd211..0000000 --- a/releases/strata/0.13.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.13.2 -tag: v0.13.2 -previous: v0.13.1 -date: 2026-10-11 -title: null -summary: null -changes: - - type: fixed - text: Treat blank optional settings as unset so production starts without VAPID keys - commit: 745f750 - pr: 97 - author: AmrMsCLL diff --git a/releases/strata/0.14.0.yml b/releases/strata/0.14.0.yml deleted file mode 100644 index 887b04c..0000000 --- a/releases/strata/0.14.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.14.0 -tag: v0.14.0 -previous: v0.13.2 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: One page frame, themed controls and create buttons in one place across Strata - commit: 9c974a5 - pr: 98 - author: AmrMsCLL diff --git a/releases/strata/0.15.0.yml b/releases/strata/0.15.0.yml deleted file mode 100644 index 52fa81e..0000000 --- a/releases/strata/0.15.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.15.0 -tag: v0.15.0 -previous: v0.14.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Show the title first in agenda list entries - commit: 48bd7e2 - pr: 99 - author: AmrMsCLL diff --git a/releases/strata/0.15.1.yml b/releases/strata/0.15.1.yml deleted file mode 100644 index 529d660..0000000 --- a/releases/strata/0.15.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.15.1 -tag: v0.15.1 -previous: v0.15.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: fixed - text: Layouts that broke on phones, even form spacing and graphite-ui 0.4.2 - commit: 0a43ee6 - pr: 100 - author: AmrMsCLL diff --git a/releases/strata/0.16.0.yml b/releases/strata/0.16.0.yml deleted file mode 100644 index fc65e9a..0000000 --- a/releases/strata/0.16.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.16.0 -tag: v0.16.0 -previous: v0.15.1 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Turn features on in Settings, starting with Goals - commit: c569dae - pr: 101 - author: AmrMsCLL diff --git a/releases/strata/0.17.0.yml b/releases/strata/0.17.0.yml deleted file mode 100644 index 14a462b..0000000 --- a/releases/strata/0.17.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.17.0 -tag: v0.17.0 -previous: v0.16.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: People, with birthdays on the agenda, gift ideas and events together - commit: f9b4eb9 - pr: 102 - author: AmrMsCLL diff --git a/releases/strata/0.18.0.yml b/releases/strata/0.18.0.yml deleted file mode 100644 index ec3227a..0000000 --- a/releases/strata/0.18.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.18.0 -tag: v0.18.0 -previous: v0.17.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Recipes and a weekly meal plan with a shopping list - commit: dee5ec5 - pr: 103 - author: AmrMsCLL diff --git a/releases/strata/0.19.0.yml b/releases/strata/0.19.0.yml deleted file mode 100644 index 0a779ee..0000000 --- a/releases/strata/0.19.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.19.0 -tag: v0.19.0 -previous: v0.18.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Spending, with monthly budgets, statement import and shared splits - commit: 7d53b76 - pr: 104 - author: AmrMsCLL diff --git a/releases/strata/0.2.0.yml b/releases/strata/0.2.0.yml deleted file mode 100644 index 57c4ece..0000000 --- a/releases/strata/0.2.0.yml +++ /dev/null @@ -1,136 +0,0 @@ -project: strata -version: 0.2.0 -tag: v0.2.0 -previous: v0.1.0 -date: 2026-10-04 -title: Spaces, tasks, subscriptions, the dashboard, the agenda and Tidy -summary: > - Strata grows from an account into a workspace: shared spaces with invitations, files, search, - tasks, recurring subscriptions, a customisable Home dashboard, an agenda with events and RSVPs, - and Tidy for clearing out clutter. It can also be installed as an app. -changes: - - type: added - text: Spaces you can share with others as owners, editors or viewers, with tags, links between items and a trash - commit: 435eba6 - pr: 10 - author: AmrMsCLL - - type: added - text: Screens for spaces, their members, tags and the trash - commit: 9d7485d - pr: 11 - author: AmrMsCLL - - type: added - text: Invitations to a space or to Strata, an invite-only switch for the site, and share links that expire - commit: de88b08 - pr: 12 - author: AmrMsCLL - - type: added - text: Screens for sending and accepting invitations, and site settings for the system manager - commit: 6a7e9d2 - pr: 13 - author: AmrMsCLL - - type: added - text: An activity stream for each space, a security log for your account and an inbox for notifications - commit: 71854f5 - pr: 14 - author: AmrMsCLL - - type: added - text: The inbox bell, each space's activity tab and the security log in settings - commit: 9d31686 - pr: 15 - author: AmrMsCLL - - type: added - text: File uploads of up to 25 MB each, with a storage limit per person - commit: 6b33d66 - pr: 16 - author: AmrMsCLL - - type: security - text: Location and camera details are removed from uploaded photos, and downloads can never run as a page - commit: 6b33d66 - pr: 16 - author: AmrMsCLL - - type: added - text: Upload, preview and download files in a space - commit: a7de3fa - pr: 17 - author: AmrMsCLL - - type: added - text: Personal access tokens with limited permissions, which can never change your account or security settings - commit: d3d0a52 - pr: 18 - author: AmrMsCLL - - type: added - text: Search across your spaces and a command palette on Ctrl+K - commit: a34e210 - pr: 19 - author: AmrMsCLL - - type: added - text: Install Strata as an app, with an offline page and a prompt when a new version is ready - commit: b01fbb3 - pr: 20 - author: AmrMsCLL - - type: added - text: Repeat rules that follow your time zone, shared by tasks, events and subscriptions - commit: a52d7fa - pr: 21 - author: AmrMsCLL - - type: added - text: Task lists with sub-tasks, repeats, assignment, reminders and a Today view - commit: 253732f - pr: 22 - author: AmrMsCLL - - type: added - text: A Tasks tab in each space with quick add and a task editor, and the Today page - commit: 2dd6ff2 - pr: 23 - author: AmrMsCLL - - type: added - text: Recurring, for subscriptions and bills, with renewals, price history, trial warnings, reminders and totals per currency - commit: 2e65f77 - pr: 24 - author: AmrMsCLL - - type: security - text: Payment labels refuse anything that looks like a card number, so card details are never stored - commit: 2e65f77 - pr: 24 - author: AmrMsCLL - - type: added - text: The Recurring overview with monthly and yearly totals, next renewals and a "still worth it?" list - commit: 57a6805 - pr: 25 - author: AmrMsCLL - - type: added - text: A Home dashboard of widgets you arrange, resize and configure, with templates and undo - commit: f74ae8c - pr: 26 - author: AmrMsCLL - - type: added - text: Events with repeats, places and meeting links, and an agenda of events, due tasks and renewals - commit: 0b04584 - pr: 27 - author: AmrMsCLL - - type: added - text: List, week and month views of the agenda, and RSVP pages guests can use without an account - commit: 4f867e5 - pr: 28 - author: AmrMsCLL - - type: added - text: Tidy finds duplicate, large and old uploads and subscriptions that look unused or overlap - commit: 0f507c2 - pr: 29 - author: AmrMsCLL - - type: added - text: Archive, trash, tag or cancel many items at once after checking exactly what will change, then undo it from the history - commit: fb7667b - pr: 30 - author: AmrMsCLL - - type: added - text: Tagging rules that tag new items in a space by words in their title - commit: 0f507c2 - pr: 29 - author: AmrMsCLL - - type: added - text: Scan a folder on your computer for duplicate, large and old files, without uploading anything - commit: 25d16f4 - pr: 31 - author: AmrMsCLL diff --git a/releases/strata/0.20.0.yml b/releases/strata/0.20.0.yml deleted file mode 100644 index 09ffa3a..0000000 --- a/releases/strata/0.20.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.20.0 -tag: v0.20.0 -previous: v0.19.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Trips, with itinerary, packing list, documents, notes and spending - commit: 23b91d5 - pr: 105 - author: AmrMsCLL diff --git a/releases/strata/0.21.0.yml b/releases/strata/0.21.0.yml deleted file mode 100644 index 50d1595..0000000 --- a/releases/strata/0.21.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.21.0 -tag: v0.21.0 -previous: v0.20.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Live shared boards that guests can edit, and a clearer share dialog - commit: f54065f - pr: 106 - author: AmrMsCLL diff --git a/releases/strata/0.22.0.yml b/releases/strata/0.22.0.yml deleted file mode 100644 index 941115c..0000000 --- a/releases/strata/0.22.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.22.0 -tag: v0.22.0 -previous: v0.21.0 -date: 2026-10-12 -title: null -summary: null -changes: - - type: added - text: To-do page across spaces and an addable To-do list widget - commit: ecada36 - pr: 107 - author: AmrMsCLL diff --git a/releases/strata/0.23.0.yml b/releases/strata/0.23.0.yml deleted file mode 100644 index 2f720e3..0000000 --- a/releases/strata/0.23.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.23.0 -tag: v0.23.0 -previous: v0.22.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Recheck followed calendars when the agenda opens and every 15 minutes - commit: c7f988b - pr: 109 - author: AmrMsCLL diff --git a/releases/strata/0.24.0.yml b/releases/strata/0.24.0.yml deleted file mode 100644 index 1497208..0000000 --- a/releases/strata/0.24.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.24.0 -tag: v0.24.0 -previous: v0.23.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Day and week time grid, coloured entries, and details for followed calendar events - commit: 2c8c7e6 - pr: 110 - author: AmrMsCLL diff --git a/releases/strata/0.25.0.yml b/releases/strata/0.25.0.yml deleted file mode 100644 index a3e06e0..0000000 --- a/releases/strata/0.25.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.25.0 -tag: v0.25.0 -previous: v0.24.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Read Egyptian card exports, Arabic statements and two-month subscriptions - commit: 1c047ac - pr: 111 - author: AmrMsCLL diff --git a/releases/strata/0.26.0.yml b/releases/strata/0.26.0.yml deleted file mode 100644 index 1763178..0000000 --- a/releases/strata/0.26.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.26.0 -tag: v0.26.0 -previous: v0.25.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Free grid with drag and resize, card look, optional fixed height and dashboard backgrounds - commit: 3767b85 - pr: 112 - author: AmrMsCLL diff --git a/releases/strata/0.27.0.yml b/releases/strata/0.27.0.yml deleted file mode 100644 index 1827c83..0000000 --- a/releases/strata/0.27.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.27.0 -tag: v0.27.0 -previous: v0.26.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Show today's events, followed calendars, renewals and birthdays alongside due tasks - commit: 877c3e8 - pr: 114 - author: AmrMsCLL diff --git a/releases/strata/0.28.0.yml b/releases/strata/0.28.0.yml deleted file mode 100644 index 1ab1268..0000000 --- a/releases/strata/0.28.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.28.0 -tag: v0.28.0 -previous: v0.27.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Add the Central Bank of Egypt EGP rate next to the ECB rates - commit: "0439606" - pr: 115 - author: AmrMsCLL diff --git a/releases/strata/0.29.0.yml b/releases/strata/0.29.0.yml deleted file mode 100644 index 92eb2ff..0000000 --- a/releases/strata/0.29.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.29.0 -tag: v0.29.0 -previous: v0.28.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Accounts and cards, with income, refunds and transfers - commit: 751cd7d - pr: 116 - author: AmrMsCLL diff --git a/releases/strata/0.3.0.yml b/releases/strata/0.3.0.yml deleted file mode 100644 index 40d41f1..0000000 --- a/releases/strata/0.3.0.yml +++ /dev/null @@ -1,36 +0,0 @@ -project: strata -version: 0.3.0 -tag: v0.3.0 -previous: v0.2.0 -date: 2026-10-04 -title: Sharing from other apps, the wall display and quicker tasks -summary: > - The installed app can receive links and files from your phone's share menu, any dashboard can - run as a wall display, three new widgets join Home, tasks can be typed in plain language, and Tidy - can send a weekly summary. -changes: - - type: added - text: Save links, text and files to Strata from your phone's share menu - commit: 0b62331 - pr: 32 - author: AmrMsCLL - - type: added - text: Show a dashboard as a wall display that keeps the screen on - commit: 3d05461 - pr: 33 - author: AmrMsCLL - - type: added - text: Countdown, focus timer and Tidy status widgets for Home - commit: a7eed82 - pr: 34 - author: AmrMsCLL - - type: added - text: Type tasks in plain language, such as "pay rent friday 9am #home !2", and see what was understood before adding - commit: a135ecf - pr: 35 - author: AmrMsCLL - - type: added - text: An optional weekly Tidy summary in your inbox on Monday mornings - commit: 432ba39 - pr: 36 - author: AmrMsCLL diff --git a/releases/strata/0.30.0.yml b/releases/strata/0.30.0.yml deleted file mode 100644 index 93c569b..0000000 --- a/releases/strata/0.30.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.30.0 -tag: v0.30.0 -previous: v0.29.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Record bank text messages forwarded from an Android phone - commit: c86cab7 - pr: 117 - author: AmrMsCLL diff --git a/releases/strata/0.31.0.yml b/releases/strata/0.31.0.yml deleted file mode 100644 index b168be9..0000000 --- a/releases/strata/0.31.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.31.0 -tag: v0.31.0 -previous: v0.30.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Editable categories per space and merchant rules - commit: 67cd007 - pr: 118 - author: AmrMsCLL diff --git a/releases/strata/0.32.0.yml b/releases/strata/0.32.0.yml deleted file mode 100644 index f47a2dd..0000000 --- a/releases/strata/0.32.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.32.0 -tag: v0.32.0 -previous: v0.31.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Payday months, overall budget, rollover, daily allowance and budget alerts - commit: 70bf8ad - pr: 119 - author: AmrMsCLL diff --git a/releases/strata/0.33.0.yml b/releases/strata/0.33.0.yml deleted file mode 100644 index 69e75df..0000000 --- a/releases/strata/0.33.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.33.0 -tag: v0.33.0 -previous: v0.32.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Installment plans and credit card due dates with reminders - commit: 79b5a8e - pr: 120 - author: AmrMsCLL diff --git a/releases/strata/0.34.0.yml b/releases/strata/0.34.0.yml deleted file mode 100644 index 8742b2d..0000000 --- a/releases/strata/0.34.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.34.0 -tag: v0.34.0 -previous: v0.33.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Insights page, CSV export and Home widgets for spending and payments - commit: "4698069" - pr: 121 - author: AmrMsCLL diff --git a/releases/strata/0.35.0.yml b/releases/strata/0.35.0.yml deleted file mode 100644 index 45eeddc..0000000 --- a/releases/strata/0.35.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.35.0 -tag: v0.35.0 -previous: v0.34.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Savings goals, assets and net worth - commit: "9536022" - pr: 122 - author: AmrMsCLL diff --git a/releases/strata/0.36.0.yml b/releases/strata/0.36.0.yml deleted file mode 100644 index 182d2c6..0000000 --- a/releases/strata/0.36.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.36.0 -tag: v0.36.0 -previous: v0.35.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: One-line quick add such as 120 talabat - commit: df7a28b - pr: 123 - author: AmrMsCLL diff --git a/releases/strata/0.37.0.yml b/releases/strata/0.37.0.yml deleted file mode 100644 index 660d7a1..0000000 --- a/releases/strata/0.37.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.37.0 -tag: v0.37.0 -previous: v0.36.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Dashboard background on every page, lighter header, tidier dashboard settings - commit: 9a4a636 - pr: 124 - author: AmrMsCLL diff --git a/releases/strata/0.38.0.yml b/releases/strata/0.38.0.yml deleted file mode 100644 index d218283..0000000 --- a/releases/strata/0.38.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.38.0 -tag: v0.38.0 -previous: v0.37.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Spending in the main areas, Tidy as an extra feature, Today in Agenda, full-width pages - commit: 0a32926 - pr: 125 - author: AmrMsCLL diff --git a/releases/strata/0.39.0.yml b/releases/strata/0.39.0.yml deleted file mode 100644 index 4c14142..0000000 --- a/releases/strata/0.39.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.39.0 -tag: v0.39.0 -previous: v0.38.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Tabs and a lighter overview - commit: 32967c0 - pr: 127 - author: AmrMsCLL diff --git a/releases/strata/0.4.0.yml b/releases/strata/0.4.0.yml deleted file mode 100644 index f36f488..0000000 --- a/releases/strata/0.4.0.yml +++ /dev/null @@ -1,87 +0,0 @@ -project: strata -version: 0.4.0 -tag: v0.4.0 -previous: v0.3.0 -date: 2026-10-05 -title: Notes, real-time editing and simple databases -summary: > - Spaces gain notes that people write together in real time, with offline drafts, version history, - mentions, templates and simple databases. Home gains weather, news, quick note and habit widgets - and can pick a dashboard by time and device. Recurring totals everything in one currency, reads - bank statements and splits shared costs. -changes: - - type: changed - text: A desktop layout with a collapsible sidebar, settings changes behind their own dialogs, and steadier loading and error states - commit: bfa0914 - pr: 37 - author: AmrMsCLL - - type: added - text: Reach every area on a phone from the More tab - commit: aa21d5b - pr: 38 - author: AmrMsCLL - - type: added - text: Weather and news widgets for Home, with feeds fetched safely by the server - commit: b321aaa - pr: 39 - author: AmrMsCLL - - type: added - text: See all your subscriptions in one home currency at European Central Bank rates, and pick common services from a built-in list - commit: 085538b - pr: 40 - author: AmrMsCLL - - type: added - text: Notes in every space, with nested pages that are included in search - commit: b1ca9b4 - pr: 41 - author: AmrMsCLL - - type: added - text: Write notes together in real time and see who else is on the page - commit: f157524 - pr: 42 - author: AmrMsCLL - - type: added - text: Keep writing notes offline, and restore earlier versions from the page history - commit: a23cd20 - pr: 43 - author: AmrMsCLL - - type: added - text: Mention other pages with @, see which pages link back, and add images to notes - commit: 786a9af - pr: 44 - author: AmrMsCLL - - type: added - text: Start pages from templates, and jot quick notes from Home or the search palette - commit: 6115a25 - pr: 45 - author: AmrMsCLL - - type: added - text: Turn a page into a database with typed properties, shown as a table, board, list or calendar - commit: b850e9f - pr: 46 - author: AmrMsCLL - - type: added - text: Tidy rules can tag files by type, such as every PDF or image - commit: 974477c - pr: 47 - author: AmrMsCLL - - type: added - text: Home can open the right dashboard for the time of day and your device - commit: fc8e964 - pr: 48 - author: AmrMsCLL - - type: added - text: Track habits with daily check-ins and streaks, in a space or from Home - commit: a57d5a2 - pr: 49 - author: AmrMsCLL - - type: added - text: Import a bank statement to find recurring charges and add them as subscriptions, without the file leaving your browser - commit: "7e59916" - pr: 50 - author: AmrMsCLL - - type: added - text: Split shared subscriptions between members and settle up who owes what each month - commit: c0063d4 - pr: 51 - author: AmrMsCLL diff --git a/releases/strata/0.40.0.yml b/releases/strata/0.40.0.yml deleted file mode 100644 index 4b056a6..0000000 --- a/releases/strata/0.40.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.40.0 -tag: v0.40.0 -previous: v0.39.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Admin:grant script; admins can open Site settings - commit: "89e1712" - pr: 128 - author: AmrMsCLL diff --git a/releases/strata/0.41.0.yml b/releases/strata/0.41.0.yml deleted file mode 100644 index e26f8cd..0000000 --- a/releases/strata/0.41.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.41.0 -tag: v0.41.0 -previous: v0.40.0 -date: 2026-10-14 -title: null -summary: null -changes: - - type: added - text: Storage page with usage against the per-person quota - commit: 5f80f55 - pr: 129 - author: AmrMsCLL diff --git a/releases/strata/0.42.0.yml b/releases/strata/0.42.0.yml deleted file mode 100644 index 7300f14..0000000 --- a/releases/strata/0.42.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.42.0 -tag: v0.42.0 -previous: v0.41.0 -date: 2026-10-15 -title: null -summary: null -changes: - - type: added - text: 12-hour or 24-hour time; 150 MB default storage limit - commit: 8b75e2e - pr: 130 - author: AmrMsCLL diff --git a/releases/strata/0.43.0.yml b/releases/strata/0.43.0.yml deleted file mode 100644 index fa52fd2..0000000 --- a/releases/strata/0.43.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.43.0 -tag: v0.43.0 -previous: v0.42.0 -date: 2026-10-15 -title: null -summary: null -changes: - - type: added - text: Currency search, digital clock, day grid, category chips and widget gallery - commit: ddabb2e - pr: 131 - author: AmrMsCLL diff --git a/releases/strata/0.44.0.yml b/releases/strata/0.44.0.yml deleted file mode 100644 index 83bdc57..0000000 --- a/releases/strata/0.44.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.44.0 -tag: v0.44.0 -previous: v0.43.0 -date: 2026-10-15 -title: null -summary: null -changes: - - type: added - text: Steady sidebar, Spending tab, icon-only header actions, Journal behind Pages, nested pages - commit: 62ac44d - pr: 133 - author: AmrMsCLL diff --git a/releases/strata/0.45.0.yml b/releases/strata/0.45.0.yml deleted file mode 100644 index 95a2697..0000000 --- a/releases/strata/0.45.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.45.0 -tag: v0.45.0 -previous: v0.44.0 -date: 2026-10-15 -title: null -summary: null -changes: - - type: added - text: Admin page with site settings and every account - commit: 00a2c5f - pr: 134 - author: AmrMsCLL diff --git a/releases/strata/0.45.1.yml b/releases/strata/0.45.1.yml deleted file mode 100644 index c0b33c8..0000000 --- a/releases/strata/0.45.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.45.1 -tag: v0.45.1 -previous: v0.45.0 -date: 2026-10-15 -title: null -summary: null -changes: - - type: fixed - text: Animate the sidebar again; journal entries get their own Pages tab - commit: 9aaab0d - pr: 141 - author: AmrMsCLL diff --git a/releases/strata/0.45.2.yml b/releases/strata/0.45.2.yml deleted file mode 100644 index 27f2d9a..0000000 --- a/releases/strata/0.45.2.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.45.2 -tag: v0.45.2 -previous: v0.45.1 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Accept numeric timestamps from the SMS forwarder - commit: 1d0a644 - author: AmrMsCLL diff --git a/releases/strata/0.45.3.yml b/releases/strata/0.45.3.yml deleted file mode 100644 index 74bbe30..0000000 --- a/releases/strata/0.45.3.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.45.3 -tag: v0.45.3 -previous: v0.45.2 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Stop raw values leaking into the UI and fix nav, layout and state bugs - commit: c404e28 - author: AmrMsCLL diff --git a/releases/strata/0.45.4.yml b/releases/strata/0.45.4.yml deleted file mode 100644 index 32d8622..0000000 --- a/releases/strata/0.45.4.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.45.4 -tag: v0.45.4 -previous: v0.45.3 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Stop session refresh locking out everyone behind one address - commit: 84aaee1 - author: AmrMsCLL diff --git a/releases/strata/0.46.0.yml b/releases/strata/0.46.0.yml deleted file mode 100644 index bb86168..0000000 --- a/releases/strata/0.46.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.46.0 -tag: v0.46.0 -previous: v0.45.4 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Move pages and boards to the trash and name browser tabs - commit: 4b52840 - author: AmrMsCLL diff --git a/releases/strata/0.46.1.yml b/releases/strata/0.46.1.yml deleted file mode 100644 index 94eb3ee..0000000 --- a/releases/strata/0.46.1.yml +++ /dev/null @@ -1,18 +0,0 @@ -project: strata -version: 0.46.1 -tag: v0.46.1 -previous: v0.46.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Queue uploads so parallel requests cannot exhaust memory - commit: bfb9a98 - pr: 144 - author: AmrMsCLL - - type: security - text: Parse feeds, calendars, recipes and uploads in linear time - commit: ae6f330 - pr: 142 - author: AmrMsCLL diff --git a/releases/strata/0.46.2.yml b/releases/strata/0.46.2.yml deleted file mode 100644 index 997af26..0000000 --- a/releases/strata/0.46.2.yml +++ /dev/null @@ -1,18 +0,0 @@ -project: strata -version: 0.46.2 -tag: v0.46.2 -previous: v0.46.1 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Revoke what removed members left behind and scope shared files - commit: 5a8bed2 - pr: 146 - author: AmrMsCLL - - type: fixed - text: Tighten sign-in origin checks and password confirmation limits - commit: 5d04c9b - pr: 145 - author: AmrMsCLL diff --git a/releases/strata/0.46.3.yml b/releases/strata/0.46.3.yml deleted file mode 100644 index 07a74a5..0000000 --- a/releases/strata/0.46.3.yml +++ /dev/null @@ -1,23 +0,0 @@ -project: strata -version: 0.46.3 -tag: v0.46.3 -previous: v0.46.2 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Strip metadata from GIFs and from every JPEG segment and trailer - commit: 35c44c5 - pr: 149 - author: AmrMsCLL - - type: fixed - text: Revoke the calendar link and turn off webhooks when securing an account - commit: f5534ef - pr: 148 - author: AmrMsCLL - - type: fixed - text: Bound outbound fetches and keep webhook deliveries behind other jobs - commit: 364ff69 - pr: 147 - author: AmrMsCLL diff --git a/releases/strata/0.46.4.yml b/releases/strata/0.46.4.yml deleted file mode 100644 index 274f0df..0000000 --- a/releases/strata/0.46.4.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.46.4 -tag: v0.46.4 -previous: v0.46.3 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Make agenda, dashboard, notes and list rows usable on phones and touch tablets - commit: cc53a0b - author: AmrMsCLL diff --git a/releases/strata/0.46.5.yml b/releases/strata/0.46.5.yml deleted file mode 100644 index 0bcab93..0000000 --- a/releases/strata/0.46.5.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.46.5 -tag: v0.46.5 -previous: v0.46.4 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Readable reminder dates, plain calendar sync errors and visible failures - commit: f730f50 - author: AmrMsCLL diff --git a/releases/strata/0.46.6.yml b/releases/strata/0.46.6.yml deleted file mode 100644 index 4e41f78..0000000 --- a/releases/strata/0.46.6.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.46.6 -tag: v0.46.6 -previous: v0.46.5 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Date beside the heading, no quick add, full-width insights, edit recurring in place - commit: 361cdc1 - pr: 150 - author: AmrMsCLL diff --git a/releases/strata/0.47.0.yml b/releases/strata/0.47.0.yml deleted file mode 100644 index 3d4af6c..0000000 --- a/releases/strata/0.47.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.47.0 -tag: v0.47.0 -previous: v0.46.6 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Budgets only when set, category chart, categories in the menu - commit: d861761 - pr: 151 - author: AmrMsCLL diff --git a/releases/strata/0.48.0.yml b/releases/strata/0.48.0.yml deleted file mode 100644 index 2e53d1d..0000000 --- a/releases/strata/0.48.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.48.0 -tag: v0.48.0 -previous: v0.47.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Keep the card ending on each expense and show spending per card - commit: 53d7c61 - pr: 152 - author: AmrMsCLL diff --git a/releases/strata/0.49.0.yml b/releases/strata/0.49.0.yml deleted file mode 100644 index 4072dd3..0000000 --- a/releases/strata/0.49.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.49.0 -tag: v0.49.0 -previous: v0.48.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Confirm destructive actions and offer undo after trashing, completing and marking read - commit: b1ef04b - author: AmrMsCLL diff --git a/releases/strata/0.49.1.yml b/releases/strata/0.49.1.yml deleted file mode 100644 index c4e46f5..0000000 --- a/releases/strata/0.49.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.49.1 -tag: v0.49.1 -previous: v0.49.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Open subscriptions from the agenda and search on the Recurring tab - commit: 823f820 - pr: 153 - author: AmrMsCLL diff --git a/releases/strata/0.49.2.yml b/releases/strata/0.49.2.yml deleted file mode 100644 index 9054df4..0000000 --- a/releases/strata/0.49.2.yml +++ /dev/null @@ -1,20 +0,0 @@ -project: strata -version: 0.49.2 -tag: v0.49.2 -previous: v0.49.1 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Show amounts one way in money lists - commit: d7afb96 - author: AmrMsCLL - - type: fixed - text: One icon per area, one unread cap, one admin check and every area in search - commit: c5ea5c4 - author: AmrMsCLL - - type: fixed - text: Call to-do items tasks, format dates one way and show time zones only when they differ - commit: acfa4ab - author: AmrMsCLL diff --git a/releases/strata/0.5.0.yml b/releases/strata/0.5.0.yml deleted file mode 100644 index d35f098..0000000 --- a/releases/strata/0.5.0.yml +++ /dev/null @@ -1,78 +0,0 @@ -project: strata -version: 0.5.0 -tag: v0.5.0 -previous: v0.4.0 -date: 2026-10-07 -title: null -summary: null -changes: - - type: added - text: Add webhooks and an API reference to settings - commit: 516ce6f - pr: 64 - author: AmrMsCLL - - type: added - text: Add a dependency-free load test script - commit: 3a806e0 - pr: 66 - author: AmrMsCLL - - type: added - text: Back up the database and files every night - commit: 006986a - pr: 65 - author: AmrMsCLL - - type: added - text: Write a weekly review page with one click - commit: 7879f2d - pr: 63 - author: AmrMsCLL - - type: added - text: Follow outside calendars in the Agenda - commit: d94cdae - pr: 62 - author: AmrMsCLL - - type: added - text: Share the agenda as a private calendar link - commit: 185c51a - pr: 61 - author: AmrMsCLL - - type: added - text: Let people vote on dates for an event - commit: 7dfdf27 - pr: 60 - author: AmrMsCLL - - type: added - text: Comment on pages - commit: ce5f0f6 - pr: 58 - author: AmrMsCLL - - type: added - text: Keep a daily journal with a one-tap mood - commit: 2f919b8 - pr: 57 - author: AmrMsCLL - - type: added - text: Add a pinned and recent pages widget to Home - commit: 29c0d0d - pr: 56 - author: AmrMsCLL - - type: fixed - text: Tighten invite emails and refuse dev secrets in production - commit: 7cd076c - pr: 55 - author: AmrMsCLL - - type: fixed - text: Limit failed sign-ins per account and hide which emails exist - commit: 2b58c90 - pr: 54 - author: AmrMsCLL - - type: fixed - text: Only follow mentions inside Strata and load note images by file id - commit: f87b03d - pr: 53 - author: AmrMsCLL - - type: fixed - text: End sessions and tokens properly - commit: 425daae - pr: 52 - author: AmrMsCLL diff --git a/releases/strata/0.5.1.yml b/releases/strata/0.5.1.yml deleted file mode 100644 index 54d8e37..0000000 --- a/releases/strata/0.5.1.yml +++ /dev/null @@ -1,23 +0,0 @@ -project: strata -version: 0.5.1 -tag: v0.5.1 -previous: v0.5.0 -date: 2026-10-07 -title: null -summary: null -changes: - - type: fixed - text: Lock while creating the Weekly reviews page - commit: 294d37d - pr: 70 - author: AmrMsCLL - - type: fixed - text: Session refresh across tabs and follow-ups from the browser checks - commit: da372c8 - pr: 68 - author: AmrMsCLL - - type: fixed - text: Duplicate review pages and the editor's offline label - commit: "2043858" - pr: 67 - author: AmrMsCLL diff --git a/releases/strata/0.50.0.yml b/releases/strata/0.50.0.yml deleted file mode 100644 index 3a5b40b..0000000 --- a/releases/strata/0.50.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.50.0 -tag: v0.50.0 -previous: v0.49.2 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Pick what an expense was paid with from saved cards and online methods - commit: 9a37678 - pr: 154 - author: AmrMsCLL diff --git a/releases/strata/0.51.0.yml b/releases/strata/0.51.0.yml deleted file mode 100644 index 7e8de3e..0000000 --- a/releases/strata/0.51.0.yml +++ /dev/null @@ -1,16 +0,0 @@ -project: strata -version: 0.51.0 -tag: v0.51.0 -previous: v0.50.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: fixed - text: Keep page content inside narrow screens - commit: 790e68f - author: AmrMsCLL - - type: added - text: Keep quick notes alongside pages and save them from Home - commit: ec913f8 - author: AmrMsCLL diff --git a/releases/strata/0.52.0.yml b/releases/strata/0.52.0.yml deleted file mode 100644 index bc8806d..0000000 --- a/releases/strata/0.52.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.52.0 -tag: v0.52.0 -previous: v0.51.0 -date: 2026-10-16 -title: null -summary: null -changes: - - type: added - text: Update Home widgets live - commit: 6e5135f - author: AmrMsCLL diff --git a/releases/strata/0.52.1.yml b/releases/strata/0.52.1.yml deleted file mode 100644 index 217ccaf..0000000 --- a/releases/strata/0.52.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.52.1 -tag: v0.52.1 -previous: v0.52.0 -date: 2026-10-19 -title: null -summary: null -changes: - - type: changed - text: Feat/mui migration - commit: ced4070 - pr: 157 - author: AmrMsCLL diff --git a/releases/strata/0.52.2.yml b/releases/strata/0.52.2.yml deleted file mode 100644 index 0179d35..0000000 --- a/releases/strata/0.52.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.52.2 -tag: v0.52.2 -previous: v0.52.1 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Give MUI styles a per-request CSP nonce - commit: c7fb923 - pr: 158 - author: AmrMsCLL diff --git a/releases/strata/0.52.3.yml b/releases/strata/0.52.3.yml deleted file mode 100644 index dbc9589..0000000 --- a/releases/strata/0.52.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.52.3 -tag: v0.52.3 -previous: v0.52.2 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Placeholder-only fields where a heading already names them, graphite-ui 1.0.2 - commit: 2f53721 - pr: 159 - author: AmrMsCLL diff --git a/releases/strata/0.52.4.yml b/releases/strata/0.52.4.yml deleted file mode 100644 index 206bfa3..0000000 --- a/releases/strata/0.52.4.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.52.4 -tag: v0.52.4 -previous: v0.52.3 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Restore the floating field labels, graphite-ui 1.0.3 - commit: 7d4f8a6 - pr: 160 - author: AmrMsCLL diff --git a/releases/strata/0.52.5.yml b/releases/strata/0.52.5.yml deleted file mode 100644 index f84cd00..0000000 --- a/releases/strata/0.52.5.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.52.5 -tag: v0.52.5 -previous: v0.52.4 -date: 2026-10-19 -title: null -summary: null -changes: - - type: fixed - text: Working widget menus, steady edit mode and loading, backdrop on every page - commit: 0169bbd - pr: 161 - author: AmrMsCLL diff --git a/releases/strata/0.53.0.yml b/releases/strata/0.53.0.yml deleted file mode 100644 index 5bbad93..0000000 --- a/releases/strata/0.53.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.53.0 -tag: v0.53.0 -previous: v0.52.5 -date: 2026-10-19 -title: null -summary: null -changes: - - type: added - text: Every app is a feature you can turn on, off and reorder; Trash moves to settings - commit: 2e0ddb8 - pr: 162 - author: AmrMsCLL diff --git a/releases/strata/0.53.1.yml b/releases/strata/0.53.1.yml deleted file mode 100644 index aa2714a..0000000 --- a/releases/strata/0.53.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.53.1 -tag: v0.53.1 -previous: v0.53.0 -date: 2026-10-20 -title: null -summary: null -changes: - - type: fixed - text: Mobile layout and one drag-and-drop area for apps in settings - commit: 09e7ba4 - pr: 163 - author: AmrMsCLL diff --git a/releases/strata/0.54.0.yml b/releases/strata/0.54.0.yml deleted file mode 100644 index c439038..0000000 --- a/releases/strata/0.54.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.54.0 -tag: v0.54.0 -previous: v0.53.1 -date: 2026-10-20 -title: null -summary: null -changes: - - type: added - text: Widget minimum sizes, Organise, new widgets and empty states - commit: cf00773 - pr: 164 - author: AmrMsCLL diff --git a/releases/strata/0.55.0.yml b/releases/strata/0.55.0.yml deleted file mode 100644 index 427abd6..0000000 --- a/releases/strata/0.55.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.55.0 -tag: v0.55.0 -previous: v0.54.0 -date: 2026-10-20 -title: null -summary: null -changes: - - type: added - text: Page transitions and motion across Strata, graphite-ui 1.2.0 - commit: b061cde - pr: 165 - author: AmrMsCLL diff --git a/releases/strata/0.56.0.yml b/releases/strata/0.56.0.yml deleted file mode 100644 index 68bb7a6..0000000 --- a/releases/strata/0.56.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.56.0 -tag: v0.56.0 -previous: v0.55.0 -date: 2026-10-20 -title: null -summary: null -changes: - - type: added - text: Skeletons that mirror the loaded layout, with seamless empty states - commit: c8e0a92 - pr: 166 - author: AmrMsCLL diff --git a/releases/strata/0.57.0.yml b/releases/strata/0.57.0.yml deleted file mode 100644 index cc499ac..0000000 --- a/releases/strata/0.57.0.yml +++ /dev/null @@ -1,44 +0,0 @@ -project: strata -version: 0.57.0 -tag: v0.57.0 -previous: v0.56.0 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Remove decorative dashboard gradients and effects - commit: 3cbe32b - author: AmrMsCLL - - type: fixed - text: Simplify sharing controls and labels - commit: 745a549 - author: AmrMsCLL - - type: fixed - text: Remove the automatic heading outline - commit: "8252775" - author: AmrMsCLL - - type: fixed - text: Disable dragging ordinary text - commit: 1d97332 - author: AmrMsCLL - - type: fixed - text: Keep slash-menu keyboard selection visible - commit: 07702b1 - author: AmrMsCLL - - type: fixed - text: Stabilize save feedback and responsive editor layout - commit: 14ee104 - author: AmrMsCLL - - type: fixed - text: Use sentence case for website actions - commit: caa389f - author: AmrMsCLL - - type: added - text: Preview and resume linked Markdown imports - commit: af439a0 - author: AmrMsCLL - - type: added - text: Add durable collaboration and page-scoped access - commit: 88475b6 - author: AmrMsCLL diff --git a/releases/strata/0.58.0.yml b/releases/strata/0.58.0.yml deleted file mode 100644 index 777ec05..0000000 --- a/releases/strata/0.58.0.yml +++ /dev/null @@ -1,17 +0,0 @@ -project: strata -version: 0.58.0 -tag: v0.58.0 -previous: v0.57.0 -date: 2026-10-24 -title: null -summary: null -changes: - - type: added - text: Android Capacitor app with SMS chat and digital app capture - commit: fa3984d - pr: 167 - author: AmrMsCLL - - type: added - text: Android Capacitor app with SMS chat and digital app capture - commit: bd6ad88 - author: AmrMsCLL diff --git a/releases/strata/0.58.1.yml b/releases/strata/0.58.1.yml deleted file mode 100644 index 41b8204..0000000 --- a/releases/strata/0.58.1.yml +++ /dev/null @@ -1,20 +0,0 @@ -project: strata -version: 0.58.1 -tag: v0.58.1 -previous: v0.58.0 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Wrap list item content on mobile - commit: "8876e69" - author: AmrMsCLL - - type: fixed - text: Skip page transitions on touch screens - commit: 5f8ddc6 - author: AmrMsCLL - - type: fixed - text: Constrain mobile layouts and improve touch controls - commit: bbe4b10 - author: AmrMsCLL diff --git a/releases/strata/0.58.2.yml b/releases/strata/0.58.2.yml deleted file mode 100644 index 6e93ac1..0000000 --- a/releases/strata/0.58.2.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.58.2 -tag: v0.58.2 -previous: v0.58.1 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Read SMS chats via notification listener to pass Play Protect - commit: ec561ed - author: AmrMsCLL diff --git a/releases/strata/0.59.0.yml b/releases/strata/0.59.0.yml deleted file mode 100644 index 3e30d2e..0000000 --- a/releases/strata/0.59.0.yml +++ /dev/null @@ -1,24 +0,0 @@ -project: strata -version: 0.59.0 -tag: v0.59.0 -previous: v0.58.2 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Use Strata logo for Android adaptive and mipmap launcher icons - commit: b60e50e - author: AmrMsCLL - - type: added - text: Add a visual pattern comparison page - commit: 6e0dff0 - author: AmrMsCLL - - type: fixed - text: Restore color backdrops and constrain page navigation - commit: 7ab75b0 - author: AmrMsCLL - - type: fixed - text: Serialize session refresh across browser tabs - commit: 041f4f5 - author: AmrMsCLL diff --git a/releases/strata/0.6.0.yml b/releases/strata/0.6.0.yml deleted file mode 100644 index 3fdb614..0000000 --- a/releases/strata/0.6.0.yml +++ /dev/null @@ -1,58 +0,0 @@ -project: strata -version: 0.6.0 -tag: v0.6.0 -previous: v0.5.1 -date: 2026-10-09 -title: null -summary: null -changes: - - type: added - text: Show shared pages and boards to guests - commit: 9181f6a - pr: 86 - author: AmrMsCLL - - type: added - text: Pick page icons and widen pages per device - commit: "17e9637" - pr: 84 - author: AmrMsCLL - - type: added - text: Show a page tree in the sidebar with drag to move and nest - commit: 23c69be - pr: 83 - author: AmrMsCLL - - type: added - text: Start boards from templates, vote with dots and print boards - commit: "974e624" - pr: 82 - author: AmrMsCLL - - type: added - text: Put boards on events and Home, and turn board notes into tasks - commit: bf5ddda - pr: 81 - author: AmrMsCLL - - type: added - text: Embed boards in pages with a live preview - commit: bdf15a6 - pr: 80 - author: AmrMsCLL - - type: added - text: Give boards and pages their own places in the sidebar - commit: 2087ab8 - pr: 79 - author: AmrMsCLL - - type: added - text: Add callouts, toggles, tables and a block handle to pages - commit: 93fbb10 - pr: 78 - author: AmrMsCLL - - type: added - text: Add a / block menu to the page editor - commit: 00e71fd - pr: 77 - author: AmrMsCLL - - type: added - text: Add boards drawn with Excalidraw - commit: a718cba - pr: 76 - author: AmrMsCLL diff --git a/releases/strata/0.60.0.yml b/releases/strata/0.60.0.yml deleted file mode 100644 index be9b357..0000000 --- a/releases/strata/0.60.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.60.0 -tag: v0.60.0 -previous: v0.59.0 -date: 2026-10-24 -title: null -summary: null -changes: - - type: added - text: Split Play Protect-clean standard APK and capture APK flavors - commit: 7fca7a5 - author: AmrMsCLL diff --git a/releases/strata/0.60.1.yml b/releases/strata/0.60.1.yml deleted file mode 100644 index aba0077..0000000 --- a/releases/strata/0.60.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.60.1 -tag: v0.60.1 -previous: v0.60.0 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Isolate capture classes from standard APK and use clean applicationId - commit: e1fcc54 - author: AmrMsCLL diff --git a/releases/strata/0.61.0.yml b/releases/strata/0.61.0.yml deleted file mode 100644 index 714b3c2..0000000 --- a/releases/strata/0.61.0.yml +++ /dev/null @@ -1,24 +0,0 @@ -project: strata -version: 0.61.0 -tag: v0.61.0 -previous: v0.60.1 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Apply the selected dashboard visual patterns - commit: 9cdb803 - author: AmrMsCLL - - type: fixed - text: Remove the current-month shortcut from overview - commit: "4134239" - author: AmrMsCLL - - type: added - text: Use one space selection across workspace apps - commit: 76f432d - author: AmrMsCLL - - type: added - text: Support scoped aggregate workspace reads - commit: b3fe509 - author: AmrMsCLL diff --git a/releases/strata/0.61.1.yml b/releases/strata/0.61.1.yml deleted file mode 100644 index ce0fc4f..0000000 --- a/releases/strata/0.61.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.61.1 -tag: v0.61.1 -previous: v0.61.0 -date: 2026-10-24 -title: null -summary: null -changes: - - type: fixed - text: Restore android.intent.category.LAUNCHER in AndroidManifest - commit: 2ce58dc - author: AmrMsCLL diff --git a/releases/strata/0.61.2.yml b/releases/strata/0.61.2.yml deleted file mode 100644 index 112d27e..0000000 --- a/releases/strata/0.61.2.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.61.2 -tag: v0.61.2 -previous: v0.61.1 -date: 2026-10-25 -title: null -summary: null -changes: - - type: fixed - text: Load production strata origin in capacitor android webview - commit: 8ea4f90 - author: AmrMsCLL diff --git a/releases/strata/0.62.0.yml b/releases/strata/0.62.0.yml deleted file mode 100644 index 5ad6e05..0000000 --- a/releases/strata/0.62.0.yml +++ /dev/null @@ -1,16 +0,0 @@ -project: strata -version: 0.62.0 -tag: v0.62.0 -previous: v0.61.2 -date: 2026-10-25 -title: null -summary: null -changes: - - type: fixed - text: Stack public landing actions on mobile - commit: 28094c6 - author: AmrMsCLL - - type: added - text: Move workspace selection and breadcrumbs into navbar - commit: 94c5695 - author: AmrMsCLL diff --git a/releases/strata/0.62.1.yml b/releases/strata/0.62.1.yml deleted file mode 100644 index ccecd3b..0000000 --- a/releases/strata/0.62.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.62.1 -tag: v0.62.1 -previous: v0.62.0 -date: 2026-10-25 -title: null -summary: null -changes: - - type: fixed - text: Align mobile tab icons with selection indicators - commit: "1212751" - author: AmrMsCLL diff --git a/releases/strata/0.62.2.yml b/releases/strata/0.62.2.yml deleted file mode 100644 index 7565a22..0000000 --- a/releases/strata/0.62.2.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 0.62.2 -tag: v0.62.2 -previous: v0.62.1 -date: 2026-10-25 -title: null -summary: null -changes: - - type: fixed - text: Unify android capture into main apk and streamline native phone setup - commit: 7cd1b50 - author: AmrMsCLL diff --git a/releases/strata/0.63.0.yml b/releases/strata/0.63.0.yml deleted file mode 100644 index f63e5be..0000000 --- a/releases/strata/0.63.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.63.0 -tag: v0.63.0 -previous: v0.62.2 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Page actions share the tabs row, auth pages on one panel frame - commit: 2e160c2 - pr: 169 - author: AmrMsCLL diff --git a/releases/strata/0.64.0.yml b/releases/strata/0.64.0.yml deleted file mode 100644 index baa3cd1..0000000 --- a/releases/strata/0.64.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.64.0 -tag: v0.64.0 -previous: v0.63.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Compress responses, validate TRUST_PROXY, no-store API and real files for robots, sitemap and - llms - commit: febea6d - pr: 170 - author: AmrMsCLL diff --git a/releases/strata/0.64.1.yml b/releases/strata/0.64.1.yml deleted file mode 100644 index c2eefb6..0000000 --- a/releases/strata/0.64.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.64.1 -tag: v0.64.1 -previous: v0.64.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: changed - text: Lazy routes, vendor chunks and a bundle budget - commit: 0149e3b - pr: 171 - author: AmrMsCLL diff --git a/releases/strata/0.64.2.yml b/releases/strata/0.64.2.yml deleted file mode 100644 index 97d41ec..0000000 --- a/releases/strata/0.64.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.64.2 -tag: v0.64.2 -previous: v0.64.1 -date: 2026-10-28 -title: null -summary: null -changes: - - type: changed - text: Share resource requests and refresh calendars once per 15 minutes - commit: 491d33b - pr: 172 - author: AmrMsCLL diff --git a/releases/strata/0.65.0.yml b/releases/strata/0.65.0.yml deleted file mode 100644 index 4c1dc27..0000000 --- a/releases/strata/0.65.0.yml +++ /dev/null @@ -1,19 +0,0 @@ -project: strata -version: 0.65.0 -tag: v0.65.0 -previous: v0.64.2 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Meta description, Open Graph, canonical, JSON-LD and a social image - commit: 265fe29 - pr: 174 - author: AmrMsCLL - - type: changed - text: One blurred backdrop layer on Home, scroll only on area change, steady time grid and a - shell-only precache - commit: 8a9b2af - pr: 173 - author: AmrMsCLL diff --git a/releases/strata/0.66.0.yml b/releases/strata/0.66.0.yml deleted file mode 100644 index ea3bf20..0000000 --- a/releases/strata/0.66.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.66.0 -tag: v0.66.0 -previous: v0.65.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Designed 404, themed offline and maintenance pages, crash report link - commit: 37d8191 - pr: 175 - author: AmrMsCLL diff --git a/releases/strata/0.67.0.yml b/releases/strata/0.67.0.yml deleted file mode 100644 index 479498b..0000000 --- a/releases/strata/0.67.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.67.0 -tag: v0.67.0 -previous: v0.66.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Authentication on by default with a global guard and explicit public routes - commit: 818e10b - pr: 176 - author: AmrMsCLL diff --git a/releases/strata/0.68.0.yml b/releases/strata/0.68.0.yml deleted file mode 100644 index 5c674f0..0000000 --- a/releases/strata/0.68.0.yml +++ /dev/null @@ -1,18 +0,0 @@ -project: strata -version: 0.68.0 -tag: v0.68.0 -previous: v0.67.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Per-sender SMS templates for six institutions with a corpus harness - commit: 9e4696c - pr: 178 - author: AmrMsCLL - - type: added - text: Correlate 500s with a request id and log slow or failed queries - commit: ea2bec5 - pr: 177 - author: AmrMsCLL diff --git a/releases/strata/0.69.0.yml b/releases/strata/0.69.0.yml deleted file mode 100644 index c6ecae3..0000000 --- a/releases/strata/0.69.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.69.0 -tag: v0.69.0 -previous: v0.68.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Default write limit and stream cap, Redis state logging, safer cookies, HSTS preload and - per-address sign-in lockout - commit: 96a86aa - pr: 179 - author: AmrMsCLL diff --git a/releases/strata/0.69.1.yml b/releases/strata/0.69.1.yml deleted file mode 100644 index 1a78eb7..0000000 --- a/releases/strata/0.69.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.69.1 -tag: v0.69.1 -previous: v0.69.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: fixed - text: One date format, quiet metadata lines, an Overdue label and a focus ring on page titles - commit: db1739d - pr: 181 - author: AmrMsCLL diff --git a/releases/strata/0.7.0.yml b/releases/strata/0.7.0.yml deleted file mode 100644 index aeb376f..0000000 --- a/releases/strata/0.7.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.7.0 -tag: v0.7.0 -previous: v0.6.0 -date: 2026-10-09 -title: null -summary: null -changes: - - type: added - text: Comment on a block of a page - commit: 98b9f79 - pr: 85 - author: AmrMsCLL diff --git a/releases/strata/0.70.0.yml b/releases/strata/0.70.0.yml deleted file mode 100644 index 10d8609..0000000 --- a/releases/strata/0.70.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.70.0 -tag: v0.70.0 -previous: v0.69.1 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Boards on their own full-screen route with an overlay bar - commit: af68a91 - pr: 182 - author: AmrMsCLL diff --git a/releases/strata/0.71.0.yml b/releases/strata/0.71.0.yml deleted file mode 100644 index d4dfa7f..0000000 --- a/releases/strata/0.71.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.71.0 -tag: v0.71.0 -previous: v0.70.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Report 500s to Sentry and expose Prometheus metrics behind a token - commit: a4e2a1d - pr: 183 - author: AmrMsCLL diff --git a/releases/strata/0.72.0.yml b/releases/strata/0.72.0.yml deleted file mode 100644 index 661c5cf..0000000 --- a/releases/strata/0.72.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.72.0 -tag: v0.72.0 -previous: v0.71.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Task status, description, comments, attachments, reorder and week settings - commit: baa88d8 - pr: 184 - author: AmrMsCLL diff --git a/releases/strata/0.73.0.yml b/releases/strata/0.73.0.yml deleted file mode 100644 index b7d0a13..0000000 --- a/releases/strata/0.73.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.73.0 -tag: v0.73.0 -previous: v0.72.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: SMS templates for the remaining eighteen institutions - commit: 85d7395 - pr: 186 - author: AmrMsCLL diff --git a/releases/strata/0.73.1.yml b/releases/strata/0.73.1.yml deleted file mode 100644 index 59ca881..0000000 --- a/releases/strata/0.73.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.73.1 -tag: v0.73.1 -previous: v0.73.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: changed - text: Cheaper editor and realtime hot paths, no drag and drop in the editor - commit: 5bc5428 - pr: 187 - author: AmrMsCLL diff --git a/releases/strata/0.74.0.yml b/releases/strata/0.74.0.yml deleted file mode 100644 index ad7f387..0000000 --- a/releases/strata/0.74.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.74.0 -tag: v0.74.0 -previous: v0.73.1 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Money model with account kinds, cards, merchants, balance history and 64-bit amounts - commit: a0e30ca - pr: 185 - author: AmrMsCLL diff --git a/releases/strata/0.75.0.yml b/releases/strata/0.75.0.yml deleted file mode 100644 index 6223496..0000000 --- a/releases/strata/0.75.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.75.0 -tag: v0.75.0 -previous: v0.74.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Grouped list, task sheet, quick-add suggestions, board view and phone swipe - commit: af9a7ad - pr: 189 - author: AmrMsCLL diff --git a/releases/strata/0.76.0.yml b/releases/strata/0.76.0.yml deleted file mode 100644 index ab4cd53..0000000 --- a/releases/strata/0.76.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.76.0 -tag: v0.76.0 -previous: v0.75.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Page-height calendar grid with drag to create, move and resize, a Day view and an event sheet - commit: c38363d - pr: 190 - author: AmrMsCLL diff --git a/releases/strata/0.77.0.yml b/releases/strata/0.77.0.yml deleted file mode 100644 index b5428d3..0000000 --- a/releases/strata/0.77.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.77.0 -tag: v0.77.0 -previous: v0.76.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Capture v2 with confidence gate, dedup, confirmed transfer pairing, learned rules and parsing - reports - commit: 4ff8150 - pr: 191 - author: AmrMsCLL diff --git a/releases/strata/0.78.0.yml b/releases/strata/0.78.0.yml deleted file mode 100644 index 2c2e65f..0000000 --- a/releases/strata/0.78.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.78.0 -tag: v0.78.0 -previous: v0.77.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Selection bubble menu, grouped slash menu, columns, callout tints and code highlighting - commit: 6ab45a9 - pr: 192 - author: AmrMsCLL diff --git a/releases/strata/0.79.0.yml b/releases/strata/0.79.0.yml deleted file mode 100644 index 55c473c..0000000 --- a/releases/strata/0.79.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.79.0 -tag: v0.79.0 -previous: v0.78.0 -date: 2026-10-28 -title: null -summary: null -changes: - - type: added - text: Now strip with next event, due today, safe to spend and Needs you chips from one GET /me/home - commit: 9f1fdda - pr: 193 - author: AmrMsCLL diff --git a/releases/strata/0.79.1.yml b/releases/strata/0.79.1.yml deleted file mode 100644 index e6edac1..0000000 --- a/releases/strata/0.79.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.79.1 -tag: v0.79.1 -previous: v0.79.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: The dashboard background shows on every page, with cards, stats and notices tinted alike - commit: 76b1d66 - pr: 196 - author: AmrMsCLL diff --git a/releases/strata/0.79.2.yml b/releases/strata/0.79.2.yml deleted file mode 100644 index 8d3a360..0000000 --- a/releases/strata/0.79.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.79.2 -tag: v0.79.2 -previous: v0.79.1 -date: 2026-10-29 -title: null -summary: null -changes: - - type: changed - text: Send finished strokes once, reconcile changed keys and store thumbnails at save - commit: 201fea8 - pr: 197 - author: AmrMsCLL diff --git a/releases/strata/0.8.0.yml b/releases/strata/0.8.0.yml deleted file mode 100644 index 3978a87..0000000 --- a/releases/strata/0.8.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.8.0 -tag: v0.8.0 -previous: v0.7.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Save links as bookmarks with a read-later list and a reader view - commit: e245191 - pr: 87 - author: AmrMsCLL diff --git a/releases/strata/0.80.0.yml b/releases/strata/0.80.0.yml deleted file mode 100644 index eca099b..0000000 --- a/releases/strata/0.80.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.80.0 -tag: v0.80.0 -previous: v0.79.2 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Private, reliable Android capture with sender allow-list, retrying outbox and re-authenticated - 90-day phone tokens - commit: 57759a2 - pr: 199 - author: AmrMsCLL diff --git a/releases/strata/0.81.0.yml b/releases/strata/0.81.0.yml deleted file mode 100644 index d289d17..0000000 --- a/releases/strata/0.81.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.81.0 -tag: v0.81.0 -previous: v0.80.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Cut time format, app tiles and push groups; space Overview tab and week start in calendars - commit: 88ad010 - pr: 200 - author: AmrMsCLL diff --git a/releases/strata/0.82.0.yml b/releases/strata/0.82.0.yml deleted file mode 100644 index da22bb0..0000000 --- a/releases/strata/0.82.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.82.0 -tag: v0.82.0 -previous: v0.81.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Audit share links, page grants, webhooks, roles, admin and deletions; alert on refresh token - reuse; hashed network context on auth events; sealed capture suggestions - commit: f0ad65e - pr: 205 - author: AmrMsCLL diff --git a/releases/strata/0.83.0.yml b/releases/strata/0.83.0.yml deleted file mode 100644 index 8f17b68..0000000 --- a/releases/strata/0.83.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.83.0 -tag: v0.83.0 -previous: v0.82.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Image resize, captions and alt text, file attachments, PDF preview, embeds, contents block, - page links and people mentions - commit: 76cb840 - pr: 204 - author: AmrMsCLL diff --git a/releases/strata/0.84.0.yml b/releases/strata/0.84.0.yml deleted file mode 100644 index 5e3ab14..0000000 --- a/releases/strata/0.84.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.84.0 -tag: v0.84.0 -previous: v0.83.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Overview with a Now block, review cards, balances by account and live updates - commit: c2d6717 - pr: 201 - author: AmrMsCLL diff --git a/releases/strata/0.84.1.yml b/releases/strata/0.84.1.yml deleted file mode 100644 index b579ff8..0000000 --- a/releases/strata/0.84.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.84.1 -tag: v0.84.1 -previous: v0.84.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: Empty, loading and error states for every data region, one h1 per page - commit: 4e311e2 - pr: 207 - author: AmrMsCLL diff --git a/releases/strata/0.85.0.yml b/releases/strata/0.85.0.yml deleted file mode 100644 index bc400a6..0000000 --- a/releases/strata/0.85.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.85.0 -tag: v0.85.0 -previous: v0.84.1 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Legal tab with terms, privacy, open-source licenses and support - commit: a613d5b - pr: 208 - author: AmrMsCLL diff --git a/releases/strata/0.86.0.yml b/releases/strata/0.86.0.yml deleted file mode 100644 index ad2c60b..0000000 --- a/releases/strata/0.86.0.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.86.0 -tag: v0.86.0 -previous: v0.85.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Page covers, full width by default with a narrow option, presence in the title row, collapsed - sections and a Pages tree - commit: 697ee41 - pr: 210 - author: AmrMsCLL diff --git a/releases/strata/0.86.1.yml b/releases/strata/0.86.1.yml deleted file mode 100644 index 825d694..0000000 --- a/releases/strata/0.86.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.86.1 -tag: v0.86.1 -previous: v0.86.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: Reachable tabs with swipe, phone-sized dialogs, 44px targets and a bordered space switcher - commit: a738d62 - pr: 212 - author: AmrMsCLL diff --git a/releases/strata/0.86.2.yml b/releases/strata/0.86.2.yml deleted file mode 100644 index d9e27da..0000000 --- a/releases/strata/0.86.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.86.2 -tag: v0.86.2 -previous: v0.86.1 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: Free a live update stream's place when the server ends it - commit: 802d742 - pr: 215 - author: AmrMsCLL diff --git a/releases/strata/0.86.3.yml b/releases/strata/0.86.3.yml deleted file mode 100644 index d573c21..0000000 --- a/releases/strata/0.86.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.86.3 -tag: v0.86.3 -previous: v0.86.2 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: Give the editor's injected base styles the page's CSP nonce - commit: "5473382" - pr: 213 - author: AmrMsCLL diff --git a/releases/strata/0.87.0.yml b/releases/strata/0.87.0.yml deleted file mode 100644 index 79eb8e0..0000000 --- a/releases/strata/0.87.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.0 -tag: v0.87.0 -previous: v0.86.3 -date: 2026-10-29 -title: null -summary: null -changes: - - type: added - text: Accounts grouped by kind with their cards, handles, funding, balance history and reconcile - commit: 39f614f - pr: 209 - author: AmrMsCLL diff --git a/releases/strata/0.87.1.yml b/releases/strata/0.87.1.yml deleted file mode 100644 index af3bf40..0000000 --- a/releases/strata/0.87.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.1 -tag: v0.87.1 -previous: v0.87.0 -date: 2026-10-29 -title: null -summary: null -changes: - - type: changed - text: Lighter capture listener, R8 and a lean APK, lint in CI - commit: 20bb747 - pr: 220 - author: AmrMsCLL diff --git a/releases/strata/0.87.10.yml b/releases/strata/0.87.10.yml deleted file mode 100644 index d8d2ae6..0000000 --- a/releases/strata/0.87.10.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.10 -tag: v0.87.10 -previous: v0.87.9 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Final consistency sweep - commit: bd26fcd - pr: 232 - author: AmrMsCLL diff --git a/releases/strata/0.87.11.yml b/releases/strata/0.87.11.yml deleted file mode 100644 index 56ce72f..0000000 --- a/releases/strata/0.87.11.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.11 -tag: v0.87.11 -previous: v0.87.10 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Period navigation Today button matches the arrow height - commit: 5951ff3 - pr: 233 - author: AmrMsCLL diff --git a/releases/strata/0.87.12.yml b/releases/strata/0.87.12.yml deleted file mode 100644 index b4e7632..0000000 --- a/releases/strata/0.87.12.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.12 -tag: v0.87.12 -previous: v0.87.11 -date: 2026-10-31 -title: null -summary: null -changes: - - type: fixed - text: Bare plus add buttons, recurring stat wrap and phone dialog layout - commit: 1013d96 - pr: 235 - author: AmrMsCLL diff --git a/releases/strata/0.87.2.yml b/releases/strata/0.87.2.yml deleted file mode 100644 index 7f7489f..0000000 --- a/releases/strata/0.87.2.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.87.2 -tag: v0.87.2 -previous: v0.87.1 -date: 2026-10-29 -title: null -summary: null -changes: - - type: fixed - text: Page scroll ends above the tab bar, exclusive row and tab swipes, compact phone header, Legal - only in Settings - commit: ba561a8 - pr: 221 - author: AmrMsCLL diff --git a/releases/strata/0.87.3.yml b/releases/strata/0.87.3.yml deleted file mode 100644 index 8dada8a..0000000 --- a/releases/strata/0.87.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.3 -tag: v0.87.3 -previous: v0.87.2 -date: 2026-10-29 -title: null -summary: null -changes: - - type: changed - text: Parallel first data, route preloads, smaller first load and transition-rendered updates - commit: 57181c7 - pr: 222 - author: AmrMsCLL diff --git a/releases/strata/0.87.4.yml b/releases/strata/0.87.4.yml deleted file mode 100644 index 1ad8815..0000000 --- a/releases/strata/0.87.4.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.4 -tag: v0.87.4 -previous: v0.87.3 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Identical phone header, 7-day phone week, row-safe tab swipe, update check on resume - commit: faaa7cc - pr: 226 - author: AmrMsCLL diff --git a/releases/strata/0.87.5.yml b/releases/strata/0.87.5.yml deleted file mode 100644 index 6b5e0c6..0000000 --- a/releases/strata/0.87.5.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.87.5 -tag: v0.87.5 -previous: v0.87.4 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Shared design-consistency fixes for errors, empty states, chips, dialogs, stats, task and - habit rows - commit: c634d74 - pr: 227 - author: AmrMsCLL diff --git a/releases/strata/0.87.6.yml b/releases/strata/0.87.6.yml deleted file mode 100644 index f72e3e2..0000000 --- a/releases/strata/0.87.6.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.6 -tag: v0.87.6 -previous: v0.87.5 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Pages, boards, files and bookmarks consistency pass - commit: b556cce - pr: 228 - author: AmrMsCLL diff --git a/releases/strata/0.87.7.yml b/releases/strata/0.87.7.yml deleted file mode 100644 index c52d615..0000000 --- a/releases/strata/0.87.7.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.7 -tag: v0.87.7 -previous: v0.87.6 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Spending consistency pass - commit: 4df6af1 - pr: 230 - author: AmrMsCLL diff --git a/releases/strata/0.87.8.yml b/releases/strata/0.87.8.yml deleted file mode 100644 index 95ca66f..0000000 --- a/releases/strata/0.87.8.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.8 -tag: v0.87.8 -previous: v0.87.7 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Settings, spaces, people, goals, recipes, trips, tidy, wall and public pages consistency pass - commit: ffc5ab6 - pr: 229 - author: AmrMsCLL diff --git a/releases/strata/0.87.9.yml b/releases/strata/0.87.9.yml deleted file mode 100644 index f09b8da..0000000 --- a/releases/strata/0.87.9.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.87.9 -tag: v0.87.9 -previous: v0.87.8 -date: 2026-10-30 -title: null -summary: null -changes: - - type: fixed - text: Home, tasks, agenda, inbox, more and journal consistency pass - commit: 778c714 - pr: 231 - author: AmrMsCLL diff --git a/releases/strata/0.88.0.yml b/releases/strata/0.88.0.yml deleted file mode 100644 index 3c92048..0000000 --- a/releases/strata/0.88.0.yml +++ /dev/null @@ -1,23 +0,0 @@ -project: strata -version: 0.88.0 -tag: v0.88.0 -previous: v0.87.12 -date: 2026-10-31 -title: null -summary: null -changes: - - type: fixed - text: Read real CIB texts, pair credit card payoffs and guess categories - commit: c61d5a9 - pr: 238 - author: AmrMsCLL - - type: fixed - text: Drop transaction swipe actions and always show every category - commit: df4387e - pr: 237 - author: AmrMsCLL - - type: added - text: Cards section on accounts with endings seen on transactions - commit: b206a26 - pr: 236 - author: AmrMsCLL diff --git a/releases/strata/0.89.0.yml b/releases/strata/0.89.0.yml deleted file mode 100644 index 753bc00..0000000 --- a/releases/strata/0.89.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.89.0 -tag: v0.89.0 -previous: v0.88.0 -date: 2026-11-01 -title: null -summary: null -changes: - - type: added - text: Record InstaPay transfers, ignore a card ending and date text balances - commit: 283e15c - pr: 239 - author: AmrMsCLL diff --git a/releases/strata/0.9.0.yml b/releases/strata/0.9.0.yml deleted file mode 100644 index 0284f07..0000000 --- a/releases/strata/0.9.0.yml +++ /dev/null @@ -1,18 +0,0 @@ -project: strata -version: 0.9.0 -tag: v0.9.0 -previous: v0.8.0 -date: 2026-10-11 -title: null -summary: null -changes: - - type: added - text: Comment on a shape on a board, with pins that follow it - commit: bc09e17 - pr: 89 - author: AmrMsCLL - - type: added - text: Add an are-my-apps-up Home widget - commit: 74024ad - pr: 88 - author: AmrMsCLL diff --git a/releases/strata/0.90.0.yml b/releases/strata/0.90.0.yml deleted file mode 100644 index 1ae7ef0..0000000 --- a/releases/strata/0.90.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.90.0 -tag: v0.90.0 -previous: v0.89.0 -date: 2026-11-01 -title: null -summary: null -changes: - - type: added - text: /page nested pages, single-line titles, shared comments panel and a site-wide UI pass - commit: e14783b - pr: 240 - author: AmrMsCLL diff --git a/releases/strata/0.90.1.yml b/releases/strata/0.90.1.yml deleted file mode 100644 index b0f54b9..0000000 --- a/releases/strata/0.90.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.90.1 -tag: v0.90.1 -previous: v0.90.0 -date: 2026-11-01 -title: null -summary: null -changes: - - type: fixed - text: Load the sidebar page tree only while the sidebar shows - commit: d57577e - pr: 241 - author: AmrMsCLL diff --git a/releases/strata/0.91.0.yml b/releases/strata/0.91.0.yml deleted file mode 100644 index baff6bb..0000000 --- a/releases/strata/0.91.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.91.0 -tag: v0.91.0 -previous: v0.90.1 -date: 2026-11-01 -title: null -summary: null -changes: - - type: added - text: Confluence-style toolbar, nested menus, side panel comments and /pages URLs - commit: f6ece99 - pr: 244 - author: AmrMsCLL diff --git a/releases/strata/0.91.1.yml b/releases/strata/0.91.1.yml deleted file mode 100644 index 45d5787..0000000 --- a/releases/strata/0.91.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.91.1 -tag: v0.91.1 -previous: v0.91.0 -date: 2026-11-01 -title: null -summary: null -changes: - - type: fixed - text: Working board drops, lane add buttons, card checkboxes and phone layout fixes - commit: f40fe01 - pr: 245 - author: AmrMsCLL diff --git a/releases/strata/0.91.2.yml b/releases/strata/0.91.2.yml deleted file mode 100644 index 7ab00fe..0000000 --- a/releases/strata/0.91.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.91.2 -tag: v0.91.2 -previous: v0.91.1 -date: 2026-11-01 -title: null -summary: null -changes: - - type: fixed - text: Pages no longer scroll sideways, Strata in the share sheet and a working back button - commit: 40be64e - pr: 246 - author: AmrMsCLL diff --git a/releases/strata/0.92.0.yml b/releases/strata/0.92.0.yml deleted file mode 100644 index 259017a..0000000 --- a/releases/strata/0.92.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.92.0 -tag: v0.92.0 -previous: v0.91.2 -date: 2026-11-01 -title: null -summary: null -changes: - - type: added - text: Save any shared link as a bookmark straight away - commit: 242c48a - pr: 248 - author: AmrMsCLL diff --git a/releases/strata/0.93.0.yml b/releases/strata/0.93.0.yml deleted file mode 100644 index 3a66098..0000000 --- a/releases/strata/0.93.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.93.0 -tag: v0.93.0 -previous: v0.92.0 -date: 2026-11-02 -title: null -summary: null -changes: - - type: added - text: Money lives on the account, not in a space - commit: 93ac1b7 - pr: 249 - author: AmrMsCLL diff --git a/releases/strata/0.94.0.yml b/releases/strata/0.94.0.yml deleted file mode 100644 index 436ac7d..0000000 --- a/releases/strata/0.94.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.94.0 -tag: v0.94.0 -previous: v0.93.0 -date: 2026-11-02 -title: null -summary: null -changes: - - type: added - text: Events, bookmarks, goals, people, recipes, habits and files belong to the account - commit: 80f0198 - pr: 250 - author: AmrMsCLL diff --git a/releases/strata/0.94.1.yml b/releases/strata/0.94.1.yml deleted file mode 100644 index fe6c222..0000000 --- a/releases/strata/0.94.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.94.1 -tag: v0.94.1 -previous: v0.94.0 -date: 2026-11-03 -title: null -summary: null -changes: - - type: fixed - text: Account and space polish pass - commit: b6e816e - pr: 251 - author: AmrMsCLL diff --git a/releases/strata/0.94.2.yml b/releases/strata/0.94.2.yml deleted file mode 100644 index 36f2ded..0000000 --- a/releases/strata/0.94.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.94.2 -tag: v0.94.2 -previous: v0.94.1 -date: 2026-11-03 -title: null -summary: null -changes: - - type: fixed - text: Steady scrollbar, widget scrolling, aligned block handle, board tasks - commit: 00e840f - pr: 252 - author: AmrMsCLL diff --git a/releases/strata/0.94.3.yml b/releases/strata/0.94.3.yml deleted file mode 100644 index efb6f10..0000000 --- a/releases/strata/0.94.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.94.3 -tag: v0.94.3 -previous: v0.94.2 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Move installed phones to the account capture endpoint - commit: c576ae3 - pr: 253 - author: AmrMsCLL diff --git a/releases/strata/0.95.0.yml b/releases/strata/0.95.0.yml deleted file mode 100644 index d5cce82..0000000 --- a/releases/strata/0.95.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.95.0 -tag: v0.95.0 -previous: v0.94.3 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Removing viewers, signed-in link visitors, away checks, cursor toggle and named links - commit: 7f8d8b6 - pr: 254 - author: AmrMsCLL diff --git a/releases/strata/0.96.0.yml b/releases/strata/0.96.0.yml deleted file mode 100644 index da9ed9b..0000000 --- a/releases/strata/0.96.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.96.0 -tag: v0.96.0 -previous: v0.95.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: A public folder whose files can be shared by link - commit: bc3c112 - pr: 255 - author: AmrMsCLL diff --git a/releases/strata/0.96.1.yml b/releases/strata/0.96.1.yml deleted file mode 100644 index 7289353..0000000 --- a/releases/strata/0.96.1.yml +++ /dev/null @@ -1,14 +0,0 @@ -project: strata -version: 0.96.1 -tag: v0.96.1 -previous: v0.96.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Draggable block handle, steady block detection, no handle on empty lines and Word-style image - controls - commit: "0e54039" - pr: 256 - author: AmrMsCLL diff --git a/releases/strata/0.97.0.yml b/releases/strata/0.97.0.yml deleted file mode 100644 index 92d2034..0000000 --- a/releases/strata/0.97.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.97.0 -tag: v0.97.0 -previous: v0.96.1 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: File tabs, drafts tabs, filter menus, full-height task board columns and a steady sidebar - commit: b8c216d - pr: 257 - author: AmrMsCLL diff --git a/releases/strata/0.98.0.yml b/releases/strata/0.98.0.yml deleted file mode 100644 index 580cd96..0000000 --- a/releases/strata/0.98.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.98.0 -tag: v0.98.0 -previous: v0.97.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Task types, assignee and type filters, task relations, and closing the sheet after adding - commit: c83725d - pr: 258 - author: AmrMsCLL diff --git a/releases/strata/0.99.0.yml b/releases/strata/0.99.0.yml deleted file mode 100644 index 0f3e518..0000000 --- a/releases/strata/0.99.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 0.99.0 -tag: v0.99.0 -previous: v0.98.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Assignee button on task cards and rows, and a roomier description - commit: 6a363b8 - pr: 259 - author: AmrMsCLL diff --git a/releases/strata/1.0.0.yml b/releases/strata/1.0.0.yml deleted file mode 100644 index 59401d1..0000000 --- a/releases/strata/1.0.0.yml +++ /dev/null @@ -1,8 +0,0 @@ -project: strata -version: 1.0.0 -tag: v1.0.0 -previous: v0.104.0 -date: 2026-11-04 -title: null -summary: No user-facing changes. -changes: [] diff --git a/releases/strata/1.0.1.yml b/releases/strata/1.0.1.yml deleted file mode 100644 index 19adf6d..0000000 --- a/releases/strata/1.0.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 1.0.1 -tag: v1.0.1 -previous: v1.0.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Folders and subfolders, sharing any file or folder by link, and Drive-style bulk actions - commit: c69c826 - pr: 261 - author: AmrMsCLL diff --git a/releases/strata/1.0.3.yml b/releases/strata/1.0.3.yml deleted file mode 100644 index 03cdf74..0000000 --- a/releases/strata/1.0.3.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 1.0.3 -tag: v1.0.3 -previous: v1.0.1 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Click to select, a selection rectangle, right-click menus and sortable columns - commit: 40c8cfd - author: AmrMsCLL diff --git a/releases/strata/1.0.4.yml b/releases/strata/1.0.4.yml deleted file mode 100644 index 0995621..0000000 --- a/releases/strata/1.0.4.yml +++ /dev/null @@ -1,16 +0,0 @@ -project: strata -version: 1.0.4 -tag: v1.0.4 -previous: v1.0.3 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Show mentions as chips and ease in the mention list, comment box and shared task page - commit: e35bf9c - author: AmrMsCLL - - type: added - text: Share tasks with teammates or by link, @mentions in comments and even panel spacing - commit: 246a332 - author: AmrMsCLL diff --git a/releases/strata/1.0.5.yml b/releases/strata/1.0.5.yml deleted file mode 100644 index 9591fdf..0000000 --- a/releases/strata/1.0.5.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 1.0.5 -tag: v1.0.5 -previous: v1.0.4 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Open the mention list at the caret instead of below the comment box - commit: e298581 - author: AmrMsCLL diff --git a/releases/strata/1.0.6.yml b/releases/strata/1.0.6.yml deleted file mode 100644 index 7da76b2..0000000 --- a/releases/strata/1.0.6.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 1.0.6 -tag: v1.0.6 -previous: v1.0.5 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Let the file bar blend into the page and float the selection bar - commit: 2ba0bef - author: AmrMsCLL diff --git a/releases/strata/1.1.0.yml b/releases/strata/1.1.0.yml deleted file mode 100644 index 3883c27..0000000 --- a/releases/strata/1.1.0.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 1.1.0 -tag: v1.1.0 -previous: v1.0.6 -date: 2026-11-04 -title: null -summary: null -changes: - - type: added - text: Reactions and replies on task comments, profile pictures, submenus and calmer links - commit: d06021a - author: AmrMsCLL diff --git a/releases/strata/1.1.1.yml b/releases/strata/1.1.1.yml deleted file mode 100644 index 37723bf..0000000 --- a/releases/strata/1.1.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 1.1.1 -tag: v1.1.1 -previous: v1.1.0 -date: 2026-11-04 -title: null -summary: null -changes: - - type: changed - text: UI polish, faster navigation and spaces without a personal space - commit: 562a190 - pr: 265 - author: AmrMsCLL diff --git a/releases/strata/1.1.2.yml b/releases/strata/1.1.2.yml deleted file mode 100644 index d8e9a06..0000000 --- a/releases/strata/1.1.2.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 1.1.2 -tag: v1.1.2 -previous: v1.1.1 -date: 2026-11-04 -title: null -summary: null -changes: - - type: fixed - text: Keep journal and weekly review pages private to their owner, expect account shopping lists - commit: 174fc69 - pr: 266 - author: AmrMsCLL diff --git a/releases/strata/1.1.3.yml b/releases/strata/1.1.3.yml deleted file mode 100644 index 9cdaab5..0000000 --- a/releases/strata/1.1.3.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 1.1.3 -tag: v1.1.3 -previous: v1.1.2 -date: 2026-11-04 -title: null -summary: null -changes: - - type: changed - text: Strata-themed emails, email notifications, sign-in with a handle and changing link access - commit: ebcde55 - pr: 267 - author: AmrMsCLL diff --git a/releases/strata/1.2.0.yml b/releases/strata/1.2.0.yml deleted file mode 100644 index a1ae6cc..0000000 --- a/releases/strata/1.2.0.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: strata -version: 1.2.0 -tag: v1.2.0 -previous: v1.1.3 -date: 2026-11-05 -title: null -summary: null -changes: - - type: added - text: Crash isolation, space tree, short URLs, task dialog, space settings and notification matrix - commit: 31678b2 - pr: 268 - author: AmrMsCLL diff --git a/releases/strata/1.2.1.yml b/releases/strata/1.2.1.yml deleted file mode 100644 index a54e712..0000000 --- a/releases/strata/1.2.1.yml +++ /dev/null @@ -1,12 +0,0 @@ -project: strata -version: 1.2.1 -tag: v1.2.1 -previous: v1.2.0 -date: 2026-11-05 -title: null -summary: null -changes: - - type: changed - text: Gitignore. - commit: 1fef04d - author: AmrMsCLL diff --git a/releases/tasktune/2.2.0.yml b/releases/tasktune/2.2.0.yml deleted file mode 100644 index 540105b..0000000 --- a/releases/tasktune/2.2.0.yml +++ /dev/null @@ -1,42 +0,0 @@ -project: tasktune -version: 2.2.0 -tag: tasktune/v2.2.0 -previous: null -date: 2026-09-18 -title: Media player in the taskbar -summary: > - The first version of TaskTune in this repository embeds media details and playback controls in the - Windows 11 taskbar, with per-app volume, audio-only app support and an optional spectrum visualizer. -changes: - - type: added - text: Taskbar player with album art, title and artist, scrolling text, and play, pause, stop, previous, next, shuffle, repeat and 1-60 second rewind and forward controls - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: More than 20 taskbar and system-tray placements, with independent players on multiple monitors - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: Per-application volume and mute controls, falling back to active audio sessions for apps that publish no media details - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: Session switching, configurable mouse actions and a compact mini player - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: Double-click anywhere on the player to open Spotify or another configured app - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: Solid, gradient, Acrylic, Mica and blurred album-art backgrounds - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: Optional WASAPI spectrum visualizer with several shapes, colour modes and EQ presets - commit: 0d71bfe - author: AmrMsCLL - - type: added - text: Automatic hiding when no media is playing, the taskbar is idle or an app is full-screen - commit: 0d71bfe - author: AmrMsCLL diff --git a/releases/tasktune/2.2.3.yml b/releases/tasktune/2.2.3.yml deleted file mode 100644 index e679180..0000000 --- a/releases/tasktune/2.2.3.yml +++ /dev/null @@ -1,26 +0,0 @@ -project: tasktune -version: 2.2.3 -tag: tasktune/v2.2.3 -previous: tasktune/v2.2.0 -date: 2026-09-18 -title: Click handling -summary: > - Single and double clicks on the player and album art are told apart reliably, and double-clicking - the player opens the configured app by default. -changes: - - type: fixed - text: Double clicks are recognised, because a single-click action waits for the Windows double-click time and the second click must land near the first - commit: eb07ecb - author: AmrMsCLL - - type: changed - text: Double-clicking the player opens the configured app by default, including installations that had no double-click setting saved - commit: eb07ecb - author: AmrMsCLL - - type: fixed - text: A pending click action is cancelled when its control unloads - commit: eb07ecb - author: AmrMsCLL - - type: fixed - text: Setting descriptions containing a colon are quoted, so the settings page parses - commit: eb07ecb - author: AmrMsCLL diff --git a/releases/tasktune/2.3.0.yml b/releases/tasktune/2.3.0.yml deleted file mode 100644 index fe06bf9..0000000 --- a/releases/tasktune/2.3.0.yml +++ /dev/null @@ -1,46 +0,0 @@ -project: tasktune -version: 2.3.0 -tag: tasktune/v2.3.0 -previous: tasktune/v2.2.3 -date: 2026-09-23 -title: Correctness pass -summary: > - A full audit fixes fullscreen hiding, the mini player, session handling, click timing, the - visualizer and seeking, and drops the Mica backgrounds that looked the same as a solid colour. -changes: - - type: fixed - text: Fullscreen hiding works, the no-media grace period expires and an empty media button list is honoured - commit: 278e13e - author: AmrMsCLL - - type: fixed - text: Mini player rows rebuild when sessions change, so each row controls the session it shows, and the flyout centres for the centre screen placement - commit: 8c92c6d - author: AmrMsCLL - - type: fixed - text: The mini player's previous, play, next, mute and volume controls have accessible names and tooltips - commit: ee96785 - author: AmrMsCLL - - type: fixed - text: Session attaching no longer races, stale media fetches no longer overwrite newer details, and cover art appears at once for a browser session that is already playing - commit: a942f77 - author: AmrMsCLL - - type: fixed - text: A single click acts at once when no double-click action can follow, the ignore list and web app names match correctly, and idle hiding counts real time - commit: 3cd31f8 - author: AmrMsCLL - - type: fixed - text: Visualizer bars fall on silence, the EQ preset applies once, and a visualizer shown again keeps capturing - commit: "1e42627" - author: AmrMsCLL - - type: fixed - text: Rewind and forward start from the live position, two sessions of one app can be cycled, and each volume wheel notch changes the system volume once - commit: 24f99f1 - author: AmrMsCLL - - type: removed - text: The Mica and Mica Alt backgrounds, which drew the same as Solid color; stored values load as solid - commit: afe22fd - author: AmrMsCLL - - type: changed - text: Menu labels use sentence case, and setting labels, group names and colour hints are corrected - commit: 37f3d75 - author: AmrMsCLL diff --git a/releases/tasktune/2.3.1.yml b/releases/tasktune/2.3.1.yml deleted file mode 100644 index 0edac9a..0000000 --- a/releases/tasktune/2.3.1.yml +++ /dev/null @@ -1,13 +0,0 @@ -project: tasktune -version: 2.3.1 -tag: tasktune/v2.3.1 -previous: tasktune/v2.3.0 -date: 2026-09-25 -title: Scrolling text width -summary: > - Scrolling text uses the player's full width again when the visualizer sits on the left. -changes: - - type: fixed - text: Scrolling text is no longer clamped to about the album-art width when a left visualizer and a maximum player width are set - commit: 43cc5a8 - author: AmrMsCLL diff --git a/releases/tasktune/2.4.0.yml b/releases/tasktune/2.4.0.yml deleted file mode 100644 index 3b8abbb..0000000 --- a/releases/tasktune/2.4.0.yml +++ /dev/null @@ -1,31 +0,0 @@ -project: tasktune -version: 2.4.0 -tag: tasktune/v2.4.0 -previous: tasktune/v2.3.1 -date: 2026-09-25 -title: Immediate clicks and safer threading -summary: > - Clicks act immediately now that the double-click launch is gone, the context menu's Open media app - entry starts a configured app when nothing is playing, and media-session calls can no longer - deadlock the taskbar. -changes: - - type: removed - text: Double-click click types, the App opened by double-click setting and the single-click delay, so every click acts immediately; saved double-click entries are no longer read - commit: 3ca9684 - author: AmrMsCLL - - type: removed - text: Open media app as a configurable click action; the context menu entry remains - commit: 87b9f9f - author: AmrMsCLL - - type: added - text: When no media session or matching window exists, the context menu's Open media app runs the App opened from the context menu command, Spotify by default, keeping previously saved commands - commit: 6dc3fbf - author: AmrMsCLL - - type: fixed - text: The taskbar can no longer deadlock while TaskTune attaches, detaches or releases media sessions - commit: 4dd2daa - author: AmrMsCLL - - type: fixed - text: Settings reload on the taskbar thread, so a settings change no longer races click and pointer handlers - commit: daf9373 - author: AmrMsCLL diff --git a/releases/windhawk-mods/1.0.0.yml b/releases/windhawk-mods/1.0.0.yml deleted file mode 100644 index e064d58..0000000 --- a/releases/windhawk-mods/1.0.0.yml +++ /dev/null @@ -1,31 +0,0 @@ -project: windhawk-mods -version: 1.0.0 -tag: v1.0.0 -previous: null -date: 2026-09-23 -title: Collection layout and checks -summary: > - The repository becomes a collection with one folder per mod in the official windhawk-mods layout, - an MIT license, metadata checks on every change and automatic Project Graphite registration. -changes: - - type: added - text: Official windhawk-mods layout with every mod at mods//.wh.cpp beside its readme, and the upstream .clang-format - commit: bed7234 - author: AmrMsCLL - - type: added - text: MIT license for the collection - commit: 7fd69c8 - author: AmrMsCLL - - type: added - text: Quality workflow that checks each mod's folder, filename, readme, @id and required metadata on every push and pull request without reformatting sources - commit: 4fe4801 - author: AmrMsCLL - - type: added - text: Project Graphite manifest listing Windhawk Mods, StackSwitch and TaskTune - commit: 27e120b - author: AmrMsCLL - - type: added - text: The Project Graphite registry syncs whenever the manifest changes on main - commit: c00f90f - pr: 2 - author: AmrMsCLL diff --git a/site/scripts/compile.mjs b/site/scripts/compile.mjs deleted file mode 100644 index 294e2ec..0000000 --- a/site/scripts/compile.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import { loadRegistry, loadReleases } from './releases.mjs'; - -const OUTPUT = new URL('../src/data/changelog.json', import.meta.url); - -const { projects } = await loadRegistry(); -const { releases, problems } = await loadReleases(projects); - -if (problems.length > 0) { - for (const problem of problems) { - console.error(`error: ${problem}`); - } - process.exit(1); -} - -await writeFile(OUTPUT, JSON.stringify({ releases }, null, 2) + '\n', 'utf8'); -console.log(`Compiled ${releases.length} releases to src/data/changelog.json.`); diff --git a/site/scripts/releases.mjs b/site/scripts/releases.mjs deleted file mode 100644 index e164ff8..0000000 --- a/site/scripts/releases.mjs +++ /dev/null @@ -1,152 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises'; -import { parse } from 'yaml'; -import { CHANGE_TYPES, compareVersions, isVersion, parseTag, tagsFor } from './rules.mjs'; - -export const RELEASES = new URL('../../releases/', import.meta.url); -export const REGISTRY = new URL('../src/data/registry.json', import.meta.url); - -const FIELDS = new Set([ - 'project', - 'version', - 'tag', - 'previous', - 'date', - 'title', - 'summary', - 'changes', -]); -const CHANGE_FIELDS = new Set(['type', 'text', 'commit', 'pr', 'author']); -const DATE = /^\d{4}-\d{2}-\d{2}$/; -const COMMIT = /^[0-9a-f]{7,40}$/; - -const isText = (value) => typeof value === 'string' && value.trim().length > 0; -const isOptionalText = (value) => value === undefined || value === null || isText(value); - -export async function loadRegistry() { - return JSON.parse(await readFile(REGISTRY, 'utf8')); -} - -function changeProblems(change, index) { - if (typeof change !== 'object' || change === null || Array.isArray(change)) { - return [`changes[${index}] must be a mapping`]; - } - - return [ - ...Object.keys(change) - .filter((key) => !CHANGE_FIELDS.has(key)) - .map((key) => `changes[${index}] has an unknown field ${key}`), - ...(CHANGE_TYPES.includes(change.type) - ? [] - : [`changes[${index}].type must be one of ${CHANGE_TYPES.join(', ')}`]), - ...(isText(change.text) ? [] : [`changes[${index}].text must be non-empty text`]), - ...(typeof change.commit === 'string' && COMMIT.test(change.commit) - ? [] - : [`changes[${index}].commit must be a 7 to 40 character commit SHA`]), - ...(change.pr === undefined || - change.pr === null || - (Number.isInteger(change.pr) && change.pr > 0) - ? [] - : [`changes[${index}].pr must be a pull request number`]), - ...(isOptionalText(change.author) ? [] : [`changes[${index}].author must be text`]), - ]; -} - -function releaseProblems(entry, slug, version, project) { - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - return ['must be a mapping']; - } - - const changes = Array.isArray(entry.changes) ? entry.changes : []; - - return [ - ...Object.keys(entry) - .filter((key) => !FIELDS.has(key)) - .map((key) => `has an unknown field ${key}`), - ...(project ? [] : [`${slug} is not a listed project in the registry`]), - ...(entry.project === slug ? [] : [`project must be ${slug}, the name of its folder`]), - ...(isVersion(version) ? [] : [`${version} is not a semantic version such as 1.4.0`]), - ...(entry.version === version ? [] : [`version must be ${version}, the name of its file`]), - ...(project && isVersion(version) && !tagsFor(project, version).includes(entry.tag) - ? [`tag must be ${tagsFor(project, version).join(' or ')}`] - : []), - ...(entry.previous === undefined || entry.previous === null || parseTag(String(entry.previous)) - ? [] - : ['previous must be a version tag or null']), - ...(typeof entry.date === 'string' && DATE.test(entry.date) ? [] : ['date must be YYYY-MM-DD']), - ...(isOptionalText(entry.title) ? [] : ['title must be text or null']), - ...(isOptionalText(entry.summary) ? [] : ['summary must be text or null']), - ...(Array.isArray(entry.changes) ? [] : ['changes must be a list']), - ...changes.flatMap(changeProblems), - ...(changes.length > 0 || isText(entry.summary) - ? [] - : ['needs at least one change or a summary']), - ]; -} - -export async function loadReleases(projects) { - const releases = []; - const problems = []; - const folders = (await readdir(RELEASES, { withFileTypes: true })).filter((entry) => - entry.isDirectory(), - ); - - for (const folder of folders) { - const slug = folder.name; - const project = projects.find((candidate) => candidate.slug === slug); - - for (const file of await readdir(new URL(`${slug}/`, RELEASES))) { - const path = `releases/${slug}/${file}`; - - if (!file.endsWith('.yml')) { - problems.push(`${path}: release files use the .yml extension`); - continue; - } - - let entry; - try { - entry = parse(await readFile(new URL(`${slug}/${file}`, RELEASES), 'utf8')); - } catch (error) { - problems.push(`${path}: is not valid YAML: ${error.message}`); - continue; - } - - const version = file.slice(0, -'.yml'.length); - const found = releaseProblems(entry, slug, version, project); - if (found.length > 0) { - problems.push(...found.map((problem) => `${path}: ${problem}`)); - continue; - } - - releases.push({ - id: `${slug}-v${version}`, - project: slug, - version, - tag: entry.tag, - previous: entry.previous ?? null, - date: entry.date, - title: entry.title?.trim() ?? null, - summary: entry.summary?.trim() ?? null, - groups: CHANGE_TYPES.map((type) => ({ - type, - changes: entry.changes - .filter((change) => change.type === type) - .map((change) => ({ - text: change.text.trim(), - commit: change.commit, - pr: change.pr ?? null, - author: change.author ?? null, - })), - })).filter((group) => group.changes.length > 0), - }); - } - } - - releases.sort( - (a, b) => - b.date.localeCompare(a.date) || - compareVersions(b.version, a.version) || - a.project.localeCompare(b.project), - ); - - return { releases, problems }; -} diff --git a/site/scripts/sync.mjs b/site/scripts/sync.mjs index e22ae95..e16cf2a 100644 --- a/site/scripts/sync.mjs +++ b/site/scripts/sync.mjs @@ -1,6 +1,4 @@ -import { access, mkdir, writeFile } from 'node:fs/promises'; -import { stringify } from 'yaml'; -import { loadRegistry, RELEASES } from './releases.mjs'; +import { parse } from 'yaml'; import { classify, compareVersions, @@ -15,17 +13,18 @@ if (!token) { throw new Error('GH_TOKEN is required'); } +const PLATFORM = 'Project-Graphite/platform'; const cut = process.argv.includes('--cut'); const only = process.argv .find((argument) => argument.startsWith('--repo=')) ?.slice('--repo='.length) .toLowerCase(); -async function github(path, init = {}) { +async function github(path, init = {}, accept = 'application/vnd.github+json') { const response = await fetch(`https://api.github.com${path}`, { ...init, headers: { - Accept: 'application/vnd.github+json', + Accept: accept, Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'project-graphite-changelog-sync', @@ -37,7 +36,7 @@ async function github(path, init = {}) { throw new Error(`GitHub answered ${response.status} for ${path}`); } - return response.json(); + return accept === 'application/vnd.github+json' ? response.json() : response.text(); } async function paginate(path, select = (page) => page) { @@ -53,34 +52,49 @@ async function paginate(path, select = (page) => page) { } } -async function exists(url) { - try { - await access(url); - return true; - } catch { - return false; - } +async function listedProjects() { + const files = await github(`/repos/${PLATFORM}/contents/projects`); + const entries = await Promise.all( + files + .filter((file) => file.name.endsWith('.yml')) + .map(async (file) => + parse( + await github( + `/repos/${PLATFORM}/contents/projects/${file.name}`, + {}, + 'application/vnd.github.raw+json', + ), + ), + ), + ); + return entries + .filter((entry) => entry.listed !== false) + .map((entry) => ({ + slug: entry.slug, + parent: entry.parent ?? null, + repo: entry.repo, + paths: entry.paths ?? [], + releases: entry.releases ?? 'auto', + })); } -function releasesByProject(inRepo, tags) { +function latestByProject(inRepo, tags) { const topLevel = inRepo.find((project) => !project.parent); - const found = new Map(inRepo.map((project) => [project, new Map()])); + const found = new Map(inRepo.map((project) => [project, []])); for (const tag of tags) { const parsed = parseTag(tag.name); const project = parsed?.slug ? inRepo.find((candidate) => candidate.slug === parsed.slug) : topLevel; - if (parsed && project && !found.get(project).has(parsed.version)) { - found.get(project).set(parsed.version, { tag: tag.name, sha: tag.commit.sha }); + if (parsed && project) { + found.get(project).push({ version: parsed.version, tag: tag.name }); } } return [...found].map(([project, versions]) => [ project, - [...versions] - .sort(([a], [b]) => compareVersions(a, b)) - .map(([version, { tag, sha }]) => ({ version, tag, sha })), + versions.sort((a, b) => compareVersions(a.version, b.version)).at(-1), ]); } @@ -128,39 +142,6 @@ async function attribute(project, inRepo, commits) { ); } -async function draft(project, inRepo, release, previous) { - const [head, commits] = await Promise.all([ - github(`/repos/${project.repo}/commits/${release.sha}`), - commitsBetween(project.repo, previous, release.sha), - ]); - - const changes = (await attribute(project, inRepo, commits)).flatMap((commit) => { - const change = classify(commit.commit.message); - return change - ? [ - { - type: change.type, - text: change.text, - commit: commit.sha.slice(0, 7), - ...(change.pr ? { pr: change.pr } : {}), - author: commit.author?.login ?? commit.commit.author.name, - }, - ] - : []; - }); - - return { - project: project.slug, - version: release.version, - tag: release.tag, - previous, - date: head.commit.committer.date.slice(0, 10), - title: null, - summary: changes.length > 0 ? null : 'No user-facing changes.', - changes, - }; -} - async function nextRelease(project, inRepo, latest, branch) { const commits = await attribute( project, @@ -173,14 +154,13 @@ async function nextRelease(project, inRepo, latest, branch) { } const version = nextVersion(latest?.version ?? null, types); - return { version, tag: tagsFor(project, version)[0], sha: commits[0].sha }; + return { tag: tagsFor(project, version)[0], sha: commits[0].sha }; } -const { projects } = await loadRegistry(); +const projects = await listedProjects(); const repos = [...new Set(projects.map((project) => project.repo.toLowerCase()))] .filter((repo) => !only || repo === only) .sort(); -const drafted = []; const tagged = []; const failed = []; @@ -192,31 +172,19 @@ for (const repo of repos) { github(`/repos/${repo}`), ]); - for (const [project, releases] of releasesByProject(inRepo, tags)) { - if (project.releases === 'auto' && !(project.parent && project.paths.length === 0)) { - const release = await nextRelease(project, inRepo, releases.at(-1), branch); - if (release && cut) { - await github(`/repos/${repo}/git/refs`, { - method: 'POST', - body: JSON.stringify({ ref: `refs/tags/${release.tag}`, sha: release.sha }), - }); - releases.push(release); - tagged.push(`${repo}@${release.tag}`); - } else if (release) { - tagged.push(`${repo}@${release.tag} (not created without --cut)`); - } + for (const [project, latest] of latestByProject(inRepo, tags)) { + if (project.releases !== 'auto' || (project.parent && project.paths.length === 0)) { + continue; } - - for (const [index, release] of releases.entries()) { - const file = new URL(`${project.slug}/${release.version}.yml`, RELEASES); - if (await exists(file)) { - continue; - } - - const entry = await draft(project, inRepo, release, releases[index - 1]?.tag ?? null); - await mkdir(new URL(`${project.slug}/`, RELEASES), { recursive: true }); - await writeFile(file, stringify(entry, { lineWidth: 100 }), 'utf8'); - drafted.push(`${project.slug}@${release.version}`); + const release = await nextRelease(project, inRepo, latest, branch); + if (release && cut) { + await github(`/repos/${repo}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/tags/${release.tag}`, sha: release.sha }), + }); + tagged.push(`${repo}@${release.tag}`); + } else if (release) { + tagged.push(`${repo}@${release.tag} (not created without --cut)`); } } } catch (error) { @@ -228,13 +196,8 @@ for (const repo of repos) { console.log( tagged.length > 0 ? `Tagged ${tagged.join(', ')}.` : 'No project has untagged changes.', ); -console.log( - drafted.length > 0 - ? `Drafted ${drafted.length} releases: ${drafted.join(', ')}.` - : 'Every tagged version already has a release entry.', -); if (failed.length > 0) { - console.error(`Could not read ${failed.join(', ')}; their releases were not drafted.`); + console.error(`Could not read ${failed.join(', ')}; their versions were not cut.`); process.exit(1); } diff --git a/site/server/changelog.test.ts b/site/server/changelog.test.ts new file mode 100644 index 0000000..6aed3c3 --- /dev/null +++ b/site/server/changelog.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { groupNotes, importChangelog, projectForTag, recordRelease } from './changelog.ts'; +import type { RegistryEntry } from './registry.ts'; +import { memoryDatabase, recordingBus } from './testing.ts'; + +const entry = (slug: string, repo: string, parent?: string): RegistryEntry => ({ + slug, + parent, + kind: 'project', + name: slug, + summary: 'A project summary long enough to pass.', + repo, + owner: 'amrmscll', + authors: ['AmrMsCLL'], + status: 'live', + deploy: { kind: 'none' }, +}); + +const entries = [ + entry('strata', 'project-graphite/strata'), + entry('windhawk-mods', 'project-graphite/windhawk-mods'), + entry('tasktune', 'project-graphite/windhawk-mods', 'windhawk-mods'), +]; + +const notes = [ + "## What's Changed", + '* feat: shared boards by @AmrMsCLL in https://github.com/project-graphite/strata/pull/41', + '* fix(agenda): keep the week in view by @BluoCaroot in https://github.com/project-graphite/strata/pull/42', + '* chore: bump deps by @dependabot in https://github.com/project-graphite/strata/pull/43', + '', + '**Full Changelog**: https://github.com/project-graphite/strata/compare/v1.1.0...v1.2.0', +].join('\n'); + +describe('groupNotes', () => { + it('classifies generated release notes into change groups', () => { + assert.deepEqual(groupNotes(notes), [ + { + type: 'added', + changes: [{ text: 'Shared boards', commit: null, pr: 41, author: 'AmrMsCLL' }], + }, + { + type: 'fixed', + changes: [{ text: 'Keep the week in view', commit: null, pr: 42, author: 'BluoCaroot' }], + }, + ]); + }); +}); + +describe('projectForTag', () => { + it('maps a plain tag to the top-level project and a prefixed tag to the child', () => { + assert.equal( + projectForTag(entries, 'Project-Graphite/strata', 'v1.2.0')?.project.slug, + 'strata', + ); + assert.equal( + projectForTag(entries, 'project-graphite/windhawk-mods', 'v2.0.0')?.project.slug, + 'windhawk-mods', + ); + assert.equal( + projectForTag(entries, 'project-graphite/windhawk-mods', 'tasktune/v1.1.0')?.version, + '1.1.0', + ); + assert.equal(projectForTag(entries, 'project-graphite/strata', 'nightly'), undefined); + }); +}); + +describe('changelog', () => { + it('starts every project at 1.0.0 once, then records published releases after the import', async () => { + const db = memoryDatabase(); + const { bus, recorded } = recordingBus(); + await importChangelog(db, bus, entries); + await importChangelog(db, bus, entries); + assert.equal( + db.prepare("SELECT count(*) AS n FROM releases WHERE version = '1.0.0'").get()?.n, + 3, + ); + + const later = new Date(Date.now() + 60_000).toISOString(); + const release = { + tag_name: 'v1.2.0', + name: 'v1.2.0', + body: notes, + draft: false, + prerelease: false, + published_at: later, + }; + await recordRelease(db, bus, entries, 'project-graphite/strata', release); + await recordRelease(db, bus, entries, 'project-graphite/strata', release); + await recordRelease(db, bus, entries, 'project-graphite/strata', { + ...release, + tag_name: 'v0.9.0', + published_at: '2020-01-01T00:00:00Z', + }); + + const rows = db + .prepare( + "SELECT version, title, groups FROM releases WHERE project = 'strata' ORDER BY version", + ) + .all(); + assert.deepEqual( + rows.map((row) => row.version), + ['1.0.0', '1.2.0'], + ); + assert.equal(rows[1].title, null); + assert.equal(recorded.filter((event) => event.kind === 'release').length, 1); + }); +}); diff --git a/site/server/changelog.ts b/site/server/changelog.ts new file mode 100644 index 0000000..cb03201 --- /dev/null +++ b/site/server/changelog.ts @@ -0,0 +1,141 @@ +import type { ChangeType, ReleaseChange, ReleaseGroup } from '../app/types.ts'; +import { CHANGE_TYPES, classify, compareVersions, parseTag } from '../scripts/rules.mjs'; +import type { Bus } from './bus.ts'; +import { transaction, type Database } from './db.ts'; +import { github } from './github.ts'; +import type { RegistryEntry } from './registry.ts'; + +export interface GitHubRelease { + tag_name: string; + name: string | null; + body: string | null; + draft: boolean; + prerelease: boolean; + published_at: string | null; +} + +const IMPORTED = 'changelog_imported_on'; +const NOTE = + /^[*-] (?.+?) by @(?<author>[A-Za-z0-9-]+) in https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(?<pr>\d+)$/; + +export function importedOn(db: Database) { + return db.prepare('SELECT value FROM state WHERE key = ?').get(IMPORTED)?.value as + string | undefined; +} + +export async function importChangelog(db: Database, bus: Bus, entries: RegistryEntry[]) { + if (importedOn(db)) { + return; + } + const now = new Date().toISOString(); + const day = now.slice(0, 10); + transaction(db, () => { + const insert = db.prepare( + `INSERT OR IGNORE INTO releases (id, project, version, tag, previous, date, title, summary, groups) + VALUES (?, ?, '1.0.0', NULL, NULL, ?, NULL, ?, '[]')`, + ); + for (const entry of entries) { + insert.run( + `${entry.slug}-v1.0.0`, + entry.slug, + day, + 'Recorded as 1.0.0 when the changelog started fresh.', + ); + } + db.prepare('INSERT INTO state (key, value) VALUES (?, ?)').run(IMPORTED, day); + }); + await bus.record({ + topic: 'releases', + kind: 'sync', + public: true, + summary: `Changelog started: ${entries.length} projects at 1.0.0`, + detail: { projects: entries.length }, + at: now, + }); +} + +export function groupNotes(body: string) { + const changes = new Map<string, ReleaseChange[]>(); + for (const line of body.split(/\r?\n/)) { + const note = NOTE.exec(line.trim())?.groups; + const change = note && classify(note.title); + if (note && change) { + changes.set(change.type, [ + ...(changes.get(change.type) ?? []), + { text: change.text, commit: null, pr: Number(note.pr), author: note.author }, + ]); + } + } + return (CHANGE_TYPES as ChangeType[]).flatMap((type): ReleaseGroup[] => + changes.has(type) ? [{ type, changes: changes.get(type)! }] : [], + ); +} + +export function projectForTag(entries: RegistryEntry[], repo: string, tag: string) { + const parsed = parseTag(tag); + const inRepo = entries.filter((entry) => entry.repo.toLowerCase() === repo.toLowerCase()); + const project = parsed?.slug + ? inRepo.find((entry) => entry.slug === parsed.slug) + : inRepo.find((entry) => !entry.parent); + return parsed && project ? { project, version: parsed.version } : undefined; +} + +export async function recordRelease( + db: Database, + bus: Bus, + entries: RegistryEntry[], + repo: string, + release: GitHubRelease, +) { + const found = projectForTag(entries, repo, release.tag_name); + const since = importedOn(db); + if (!found || release.draft || !release.published_at || !since || release.published_at < since) { + return; + } + const { project, version } = found; + const previous = db + .prepare('SELECT version, tag FROM releases WHERE project = ? AND tag IS NOT NULL') + .all(project.slug) + .filter((row) => compareVersions(row.version as string, version) < 0) + .sort((a, b) => compareVersions(b.version as string, a.version as string))[0]; + const groups = groupNotes(release.body ?? ''); + const { changes } = db + .prepare( + `INSERT OR IGNORE INTO releases (id, project, version, tag, previous, date, title, summary, groups) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + `${project.slug}-v${version}`, + project.slug, + version, + release.tag_name, + (previous?.tag as string | undefined) ?? null, + release.published_at.slice(0, 10), + release.name && release.name !== release.tag_name ? release.name : null, + groups.length > 0 ? null : 'No user-facing changes.', + JSON.stringify(groups), + ); + if (changes > 0) { + await bus.record({ + topic: 'releases', + key: project.slug, + kind: 'release', + public: true, + summary: `${project.name} ${version}`, + detail: { + project: project.slug, + version, + counts: Object.fromEntries(groups.map((group) => [group.type, group.changes.length])), + }, + at: release.published_at, + }); + } +} + +export async function syncReleases(db: Database, bus: Bus, entries: RegistryEntry[]) { + for (const repo of new Set(entries.map((entry) => entry.repo.toLowerCase()))) { + for (const release of await github<GitHubRelease[]>(`/repos/${repo}/releases?per_page=30`)) { + await recordRelease(db, bus, entries, repo, release); + } + } +} From 6ccc86f4b5aaef17db90effbacf94bc9410713ea Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:13 +0200 Subject: [PATCH 07/12] feat: deploy reports, uptime pings, public console events and internal metrics --- site/server/console/public.test.ts | 82 ++++++++++++++++++++++++++++++ site/server/console/public.ts | 65 +++++++++++++++++++++++ site/server/deploys.ts | 67 ++++++++++++++++++++++++ site/server/metrics.ts | 43 ++++++++++++++++ site/server/uptime.ts | 81 +++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 site/server/console/public.test.ts create mode 100644 site/server/console/public.ts create mode 100644 site/server/deploys.ts create mode 100644 site/server/metrics.ts create mode 100644 site/server/uptime.ts diff --git a/site/server/console/public.test.ts b/site/server/console/public.test.ts new file mode 100644 index 0000000..b1c90df --- /dev/null +++ b/site/server/console/public.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { HubEvent } from '../bus.ts'; +import { toPublicEvent } from './public.ts'; + +const FORBIDDEN = { + host: 'strata.project-graphite.com', + path: '/srv/graphite/strata/compose.yaml', + container: 'strata-app-1', + environment: 'production', + env: 'DATABASE_URL=postgres://user:pass@db/strata', + token: 'ghs_secretvalue', + ip: '203.0.113.7', + address: '203.0.113.7', + title: 'Draft: unannounced plans', + draftTitle: 'Draft: unannounced plans', +}; + +const FORBIDDEN_VALUES = Object.values(FORBIDDEN); + +function event(kind: string, detail: Record<string, unknown>, isPublic = true): HubEvent { + return { + topic: 'console', + kind, + public: isPublic, + summary: `${kind} happened`, + detail: { ...detail, ...FORBIDDEN, nested: FORBIDDEN }, + at: '2026-11-05T00:00:00.000Z', + }; +} + +function assertClean(value: unknown) { + const serialised = JSON.stringify(value); + for (const key of Object.keys(FORBIDDEN)) { + assert.ok(!serialised.includes(`"${key}"`), `${key} reached the public console`); + } + for (const forbidden of FORBIDDEN_VALUES) { + assert.ok(!serialised.includes(forbidden), `${forbidden} reached the public console`); + } +} + +describe('toPublicEvent', () => { + it('keeps only the allowed deploy fields, with a short tag', () => { + const projected = toPublicEvent( + event('deploy', { + project: 'strata', + tag: 'sha-00e840f1234', + ok: true, + durationMs: 4200, + healthStatus: 200, + healthMs: 38, + }), + ); + assert.deepEqual(projected?.detail, { + project: 'strata', + tag: '00e840f', + ok: true, + durationMs: 4200, + healthStatus: 200, + healthMs: 38, + }); + assertClean(projected); + }); + + for (const [kind, detail] of Object.entries({ + release: { project: 'strata', version: '1.2.0', counts: { added: 2, host: 'x' } }, + sync: { added: ['strata'], changed: [], removed: [], stars: { 'project-graphite/strata': 1 } }, + uptime: { up: 5, total: 6, p95Ms: 120 }, + })) { + it(`never lets a forbidden field through on ${kind}`, () => { + const projected = toPublicEvent(event(kind, detail)); + assert.ok(projected); + assertClean(projected); + }); + } + + it('hides private events and kinds without a projection', () => { + assert.equal(toPublicEvent(event('deploy', { project: 'strata' }, false)), null); + assert.equal(toPublicEvent(event('lifecycle', {})), null); + assert.equal(toPublicEvent(event('error', {})), null); + }); +}); diff --git a/site/server/console/public.ts b/site/server/console/public.ts new file mode 100644 index 0000000..33b1020 --- /dev/null +++ b/site/server/console/public.ts @@ -0,0 +1,65 @@ +import type { HubEvent } from '../bus.ts'; + +export interface PublicEvent { + kind: string; + at: string; + summary: string; + detail: Record<string, string | number | boolean | Record<string, number> | string[]>; +} + +type Detail = Record<string, unknown>; + +const number = (value: unknown) => (typeof value === 'number' ? value : undefined); +const text = (value: unknown) => (typeof value === 'string' ? value : undefined); +const counts = (value: unknown) => + Object.fromEntries( + Object.entries((value ?? {}) as Detail).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number', + ), + ); +const slugs = (value: unknown) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined; + +function defined(detail: PublicEvent['detail'] | Record<string, unknown>) { + return Object.fromEntries( + Object.entries(detail).filter(([, value]) => value !== undefined), + ) as PublicEvent['detail']; +} + +const projections: Record<string, (detail: Detail) => PublicEvent['detail']> = { + deploy: (detail) => + defined({ + project: text(detail.project), + tag: text(detail.tag)?.replace(/^sha-/, '').slice(0, 7), + ok: detail.ok === true, + durationMs: number(detail.durationMs), + healthStatus: number(detail.healthStatus), + healthMs: number(detail.healthMs), + }), + release: (detail) => + defined({ + project: text(detail.project), + version: text(detail.version), + counts: counts(detail.counts), + }), + sync: (detail) => + defined({ + added: slugs(detail.added), + changed: slugs(detail.changed), + removed: slugs(detail.removed), + projects: number(detail.projects), + stars: detail.stars === undefined ? undefined : counts(detail.stars), + }), + uptime: (detail) => + defined({ up: number(detail.up), total: number(detail.total), p95Ms: number(detail.p95Ms) }), +}; + +export function toPublicEvent(event: HubEvent): PublicEvent | null { + const project = projections[event.kind]; + if (!event.public || !project) { + return null; + } + return { kind: event.kind, at: event.at, summary: event.summary, detail: project(event.detail) }; +} diff --git a/site/server/deploys.ts b/site/server/deploys.ts new file mode 100644 index 0000000..a915066 --- /dev/null +++ b/site/server/deploys.ts @@ -0,0 +1,67 @@ +import type { Bus } from './bus.ts'; +import type { Database } from './db.ts'; + +export interface DeployReport { + project: string; + tag: string; + startedAt: string; + finishedAt: string; + ok: boolean; + healthStatus?: number; + healthMs?: number; + by?: string; +} + +const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; + +export function validReport(value: unknown): value is DeployReport { + const report = value as DeployReport; + return ( + typeof report === 'object' && + report !== null && + SLUG.test(String(report.project)) && + typeof report.tag === 'string' && + !Number.isNaN(Date.parse(report.startedAt)) && + !Number.isNaN(Date.parse(report.finishedAt)) && + typeof report.ok === 'boolean' + ); +} + +export async function recordDeploy(db: Database, bus: Bus, report: DeployReport) { + const { changes } = db + .prepare( + `INSERT OR IGNORE INTO deploys (project, tag, started_at, finished_at, ok, health_status, health_ms, by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + report.project, + report.tag, + report.startedAt, + report.finishedAt, + report.ok ? 1 : 0, + report.healthStatus ?? null, + report.healthMs ?? null, + report.by ?? null, + ); + if (changes === 0) { + return; + } + const durationMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt); + await bus.record({ + topic: 'deploys', + key: report.project, + kind: 'deploy', + public: true, + actor: report.by, + summary: `${report.project} ${report.ok ? 'deployed' : 'failed to deploy'} ${report.tag.replace(/^sha-/, '').slice(0, 7)}`, + detail: { + project: report.project, + tag: report.tag, + ok: report.ok, + durationMs, + healthStatus: report.healthStatus, + healthMs: report.healthMs, + }, + at: report.finishedAt, + }); +} diff --git a/site/server/metrics.ts b/site/server/metrics.ts new file mode 100644 index 0000000..8b894aa --- /dev/null +++ b/site/server/metrics.ts @@ -0,0 +1,43 @@ +import { percentile } from './uptime.ts'; + +const WINDOW_MINUTES = 5; + +export function createMetrics(now = () => Date.now()) { + const minutes = new Map<number, { requests: number; errors: number; durations: number[] }>(); + + function prune(current: number) { + for (const minute of minutes.keys()) { + if (minute <= current - WINDOW_MINUTES) { + minutes.delete(minute); + } + } + } + + return { + observe(status: number, ms: number) { + const minute = Math.floor(now() / 60_000); + prune(minute); + const bucket = minutes.get(minute) ?? { requests: 0, errors: 0, durations: [] }; + bucket.requests += 1; + bucket.errors += status >= 500 ? 1 : 0; + bucket.durations.push(ms); + minutes.set(minute, bucket); + }, + snapshot(liveConnections: number) { + prune(Math.floor(now() / 60_000)); + const buckets = [...minutes.values()]; + const requests = buckets.reduce((sum, bucket) => sum + bucket.requests, 0); + const errors = buckets.reduce((sum, bucket) => sum + bucket.errors, 0); + return { + windowMinutes: WINDOW_MINUTES, + requests, + errorRate: requests === 0 ? 0 : errors / requests, + p95Ms: percentile( + buckets.flatMap((bucket) => bucket.durations), + 0.95, + ), + liveConnections, + }; + }, + }; +} diff --git a/site/server/uptime.ts b/site/server/uptime.ts new file mode 100644 index 0000000..6ba93ef --- /dev/null +++ b/site/server/uptime.ts @@ -0,0 +1,81 @@ +import type { Bus } from './bus.ts'; +import type { Database } from './db.ts'; +import { storedEntries } from './registry.ts'; + +export function liveHosts(db: Database) { + return storedEntries(db).flatMap((entry) => + entry.status === 'live' && entry.deploy.kind !== 'none' + ? [{ project: entry.slug, host: entry.deploy.host }] + : [], + ); +} + +async function probe(host: string) { + const started = performance.now(); + try { + const response = await fetch(`https://${host}/`, { + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + headers: { 'User-Agent': 'project-graphite-hub-uptime' }, + }); + await response.body?.cancel(); + return { ok: response.status < 500, ms: Math.round(performance.now() - started) }; + } catch { + return { ok: false, ms: null }; + } +} + +export async function pingAll(db: Database, bus: Bus) { + const minute = new Date().toISOString().slice(0, 16); + const upsert = db.prepare( + `INSERT INTO uptime (project, minute, ok, ms) VALUES (?, ?, ?, ?) + ON CONFLICT (project, minute) DO UPDATE SET ok = min(ok, excluded.ok), ms = max(coalesce(ms, 0), coalesce(excluded.ms, 0))`, + ); + await Promise.all( + liveHosts(db).map(async ({ project, host }) => { + const { ok, ms } = await probe(host); + upsert.run(project, minute, ok ? 1 : 0, ms); + await bus.publish({ + topic: 'uptime', + key: project, + kind: 'ping', + public: true, + summary: `${project} ${ok ? 'up' : 'down'}`, + detail: { project, ok, ms }, + at: new Date().toISOString(), + }); + }), + ); +} + +export function percentile(values: number[], fraction: number) { + const sorted = values.toSorted((a, b) => a - b); + return sorted.length === 0 + ? null + : sorted[Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1)]; +} + +export async function recordUptimeSummary(db: Database, bus: Bus) { + const since = new Date(Date.now() - 3_600_000).toISOString().slice(0, 16); + const rows = db.prepare('SELECT project, ok, ms FROM uptime WHERE minute >= ?').all(since); + if (rows.length === 0) { + return; + } + const projects = new Set(rows.map((row) => row.project as string)); + const down = new Set(rows.filter((row) => row.ok === 0).map((row) => row.project as string)); + await bus.record({ + topic: 'uptime', + kind: 'uptime', + public: true, + summary: `${projects.size - down.size} of ${projects.size} up over the last hour`, + detail: { + up: projects.size - down.size, + total: projects.size, + p95Ms: percentile( + rows.flatMap((row) => (row.ms === null ? [] : [row.ms as number])), + 0.95, + ), + }, + at: new Date().toISOString(), + }); +} From d9261dbc5014cb85cbfacae6c4e0d20ad859fc06 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:14 +0200 Subject: [PATCH 08/12] feat: live stream with replay, the nightly lifecycle job and the server entry --- site/app/live.ts | 34 ++++++ site/server/app.ts | 234 +++++++++++++++++++++++++++++++++++++++ site/server/lifecycle.ts | 62 +++++++++++ site/server/live.test.ts | 102 +++++++++++++++++ site/server/live.ts | 136 +++++++++++++++++++++++ site/server/main.ts | 96 ++++++++++++++++ 6 files changed, 664 insertions(+) create mode 100644 site/app/live.ts create mode 100644 site/server/app.ts create mode 100644 site/server/lifecycle.ts create mode 100644 site/server/live.test.ts create mode 100644 site/server/live.ts create mode 100644 site/server/main.ts diff --git a/site/app/live.ts b/site/app/live.ts new file mode 100644 index 0000000..9538f34 --- /dev/null +++ b/site/app/live.ts @@ -0,0 +1,34 @@ +import { useEffect, useEffectEvent } from 'react'; +import { useMatches, useRevalidator } from 'react-router'; + +const COALESCE_MS = 100; + +export function useLive() { + const topics = [ + ...new Set( + useMatches().flatMap( + (match) => (match.handle as { topics?: string[] } | undefined)?.topics ?? [], + ), + ), + ] + .sort() + .join(','); + const { revalidate } = useRevalidator(); + const refresh = useEffectEvent(() => revalidate()); + + useEffect(() => { + if (!topics) { + return; + } + const source = new EventSource(`/live?topics=${encodeURIComponent(topics)}`); + let timer: ReturnType<typeof setTimeout> | undefined; + source.onmessage = () => { + clearTimeout(timer); + timer = setTimeout(refresh, COALESCE_MS); + }; + return () => { + clearTimeout(timer); + source.close(); + }; + }, [topics]); +} diff --git a/site/server/app.ts b/site/server/app.ts new file mode 100644 index 0000000..e223dca --- /dev/null +++ b/site/server/app.ts @@ -0,0 +1,234 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { createRequestHandler } from '@react-router/express'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import { RouterContextProvider, type RouterContext, type ServerBuild } from 'react-router'; +import type { Hub } from '../app/context.ts'; +import type { Auth } from './auth.ts'; +import type { Bus } from './bus.ts'; +import { config } from './config.ts'; +import type { Database } from './db.ts'; +import { recordDeploy, validReport } from './deploys.ts'; +import type { Capture } from './errors.ts'; +import { validSignature } from './github.ts'; +import { contentSecurityPolicy, securityHeaders } from './headers.ts'; +import { createVerifier } from './internal.ts'; +import type { Live } from './live.ts'; +import { log } from './log.ts'; +import type { createMetrics } from './metrics.ts'; +import { slidingLimit, type Redis } from './redis.ts'; +import type { Snapshot } from './snapshot.ts'; +import type { Sync } from './sync.ts'; + +export interface Services { + db: Database; + redis: Redis; + bus: Bus; + snapshot: Snapshot; + auth: Auth; + live: Live; + sync: Sync; + capture: Capture; + metrics: ReturnType<typeof createMetrics>; +} + +const MOVED: Record<string, string> = { + '/amr': '/members/amrmscll/', + '/members/amr': '/members/amrmscll/', + '/amr/windhawk-mods': '/epics/windhawk-mods/', + '/projects/windhawk-mods': '/epics/windhawk-mods/', + '/projects/windhawk-mods/stack-switch': '/epics/windhawk-mods/stack-switch/', + '/projects/windhawk-mods/tasktune': '/epics/windhawk-mods/tasktune/', +}; + +export function createApp( + services: Services, + loadBuild: () => Promise<ServerBuild>, + assets?: express.RequestHandler, +) { + const { db, redis, bus, snapshot, auth, live, sync, capture, metrics } = services; + const verified = createVerifier(config.deployReportSecret); + const app = express(); + app.disable('x-powered-by'); + + app.use((request: Request, response: Response, next: NextFunction) => { + const requestId = randomUUID(); + const started = performance.now(); + response.locals.requestId = requestId; + response.setHeader('X-Request-Id', requestId); + response.on('finish', () => { + const ms = Math.round(performance.now() - started); + const internal = request.path.startsWith('/internal/'); + if (request.path !== '/live' && !request.path.startsWith('/health/')) { + metrics.observe(response.statusCode, ms); + } + log.info({ + requestId, + method: request.method, + route: request.path, + status: response.statusCode, + ms, + ...(internal + ? { delivery: request.headers['x-github-delivery'] } + : { member: response.locals.member }), + }); + }); + next(); + }); + app.use(securityHeaders); + + app.get('/health/live', (_request, response) => { + response.type('text').send('ok'); + }); + app.get('/health/ready', async (_request, response) => { + try { + db.prepare('SELECT 1').get(); + await redis.ping(); + snapshot.get(); + response.type('text').send('ready'); + } catch (error) { + log.warn({ err: error }, 'not ready'); + response.status(503).type('text').send('not ready'); + } + }); + + app.post( + '/internal/github', + express.raw({ type: 'application/json', limit: '5mb' }), + async (request, response) => { + const delivery = String(request.headers['x-github-delivery'] ?? ''); + const event = String(request.headers['x-github-event'] ?? ''); + if (!(await slidingLimit(redis, 'webhook', 600, 60_000))) { + response.status(429).end(); + return; + } + if ( + !config.webhookSecret || + !delivery || + !validSignature( + config.webhookSecret, + request.body, + request.headers['x-hub-signature-256'] as string | undefined, + ) + ) { + response.status(404).end(); + return; + } + const { changes } = db + .prepare( + 'INSERT OR IGNORE INTO webhook_deliveries (delivery_id, event, received_at) VALUES (?, ?, ?)', + ) + .run(delivery, event, new Date().toISOString()); + response.status(changes > 0 ? 202 : 200).end(); + if (changes > 0) { + sync + .webhook(event, JSON.parse(request.body.toString('utf8'))) + .then(() => + db + .prepare('UPDATE webhook_deliveries SET processed_at = ? WHERE delivery_id = ?') + .run(new Date().toISOString(), delivery), + ) + .catch((error: unknown) => + capture(error, { + route: `/internal/github ${event}`, + requestId: response.locals.requestId, + }), + ); + } + }, + ); + + app.post( + '/internal/deploys', + express.raw({ type: 'application/json', limit: '64kb' }), + async (request, response) => { + if (!verified(request, request.body)) { + response.status(404).end(); + return; + } + const report: unknown = JSON.parse(request.body.toString('utf8')); + if (!validReport(report)) { + response.status(400).type('text').send('Not a deploy report.'); + return; + } + await recordDeploy(db, bus, report); + response.status(204).end(); + }, + ); + + app.get('/internal/metrics', (request, response) => { + if (!verified(request, Buffer.alloc(0))) { + response.status(404).end(); + return; + } + response.json(metrics.snapshot(live.connections)); + }); + + app.use((request, response, next) => { + const target = MOVED[request.path.replace(/(.)\/$/, '$1')]; + if (target) { + response.redirect(301, target); + } else { + next(); + } + }); + + app.use(auth.router); + app.get('/live', live.handler); + + if (assets) { + app.use(assets); + } else { + app.use( + '/assets', + express.static('build/client/assets', { immutable: true, maxAge: '1y', index: false }), + ); + app.use(express.static('build/client', { maxAge: '1h', index: false })); + } + + app.all( + '/{*path}', + createRequestHandler({ + build: loadBuild, + async getLoadContext(request, response) { + const build = await loadBuild(); + const signedIn = await auth.current(request, response); + const nonce = randomBytes(16).toString('base64'); + response.locals.member = signedIn?.session.login; + if (config.production) { + response.setHeader('Content-Security-Policy', contentSecurityPolicy(nonce)); + } + const context = new RouterContextProvider(); + context.set( + (build.entry.module as unknown as { hubContext: RouterContext<Hub> }).hubContext, + { + directory: snapshot.get(), + viewer: signedIn + ? { + login: signedIn.session.login, + avatarUrl: signedIn.session.avatarUrl, + owner: signedIn.session.owner, + } + : null, + csrf: signedIn?.session.csrf ?? null, + nonce, + requestId: response.locals.requestId, + capture, + }, + ); + return context; + }, + }), + ); + + app.use((error: unknown, request: Request, response: Response, _next: NextFunction) => { + capture(error, { route: request.path, requestId: response.locals.requestId }); + if (!response.headersSent) { + response + .status(500) + .type('text') + .send(`Something went wrong. Request ${response.locals.requestId}.`); + } + }); + + return app; +} diff --git a/site/server/lifecycle.ts b/site/server/lifecycle.ts new file mode 100644 index 0000000..b1b66b2 --- /dev/null +++ b/site/server/lifecycle.ts @@ -0,0 +1,62 @@ +import type { Bus } from './bus.ts'; +import { transaction, type Database } from './db.ts'; +import type { Redis } from './redis.ts'; +import { percentile } from './uptime.ts'; + +const DAY_MS = 86_400_000; + +function before(days: number, now: number) { + return new Date(now - days * DAY_MS).toISOString(); +} + +export function applyRetention(db: Database, now = Date.now()) { + return transaction(db, () => { + const cutoff = before(30, now).slice(0, 10); + const days = new Map<string, { project: string; day: string; ok: number[]; ms: number[] }>(); + for (const row of db + .prepare('SELECT project, minute, ok, ms FROM uptime WHERE minute < ?') + .all(cutoff)) { + const day = (row.minute as string).slice(0, 10); + const key = `${row.project as string} ${day}`; + const entry = days.get(key) ?? { project: row.project as string, day, ok: [], ms: [] }; + entry.ok.push(row.ok as number); + if (row.ms !== null) { + entry.ms.push(row.ms as number); + } + days.set(key, entry); + } + const rollUp = db.prepare( + `INSERT INTO uptime_days (project, day, minutes, up, p95_ms) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (project, day) DO UPDATE SET minutes = minutes + excluded.minutes, up = up + excluded.up, + p95_ms = max(coalesce(p95_ms, 0), coalesce(excluded.p95_ms, 0))`, + ); + for (const { project, day, ok, ms } of days.values()) { + rollUp.run(project, day, ok.length, ok.filter(Boolean).length, percentile(ms, 0.95)); + } + return { + uptime: db.prepare('DELETE FROM uptime WHERE minute < ?').run(cutoff).changes, + activity: db + .prepare("DELETE FROM activity WHERE at < ? AND kind NOT IN ('deploy', 'release')") + .run(before(180, now)).changes, + errors: db.prepare('DELETE FROM errors WHERE last_at < ?').run(before(30, now)).changes, + webhookDeliveries: db + .prepare('DELETE FROM webhook_deliveries WHERE received_at < ?') + .run(before(7, now)).changes, + }; + }); +} + +export async function runLifecycle(db: Database, redis: Redis, bus: Bus) { + const removed = { + ...applyRetention(db), + streamEntries: await redis.xtrim('events', 'MINID', String(Date.now() - 7 * DAY_MS)), + }; + await bus.record({ + topic: 'console', + kind: 'lifecycle', + public: false, + summary: 'Nightly retention ran', + detail: removed, + at: new Date().toISOString(), + }); +} diff --git a/site/server/live.test.ts b/site/server/live.test.ts new file mode 100644 index 0000000..434a3a8 --- /dev/null +++ b/site/server/live.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import type { HubEvent } from './bus.ts'; +import { createClient, matches, newer, parseTopics } from './live.ts'; + +const event = (topic: string, key?: string, isPublic = true): HubEvent => ({ + topic, + key, + kind: 'release', + public: isPublic, + summary: 's', + detail: {}, + at: '2026-11-05T00:00:00.000Z', +}); + +describe('parseTopics', () => { + it('accepts up to twelve well-formed topics, deduplicated', () => { + assert.deepEqual(parseTopics('releases,releases:strata,releases'), [ + 'releases', + 'releases:strata', + ]); + }); + + it('refuses none, too many, or malformed topics', () => { + assert.equal(parseTopics(''), undefined); + assert.equal( + parseTopics(Array.from({ length: 13 }, (_, i) => `t${'a'.repeat(i)}`).join(',')), + undefined, + ); + assert.equal(parseTopics('Releases'), undefined); + assert.equal(parseTopics('releases:../x'), undefined); + }); +}); + +describe('matches', () => { + it('matches a whole topic or one key of it', () => { + assert.ok(matches(['releases'], event('releases', 'strata'))); + assert.ok(matches(['releases:strata'], event('releases', 'strata'))); + assert.ok(!matches(['releases:tracker'], event('releases', 'strata'))); + assert.ok(!matches(['deploys'], event('releases', 'strata'))); + }); +}); + +describe('newer', () => { + it('orders stream ids by time, then sequence', () => { + assert.ok(newer('2-0', '1-9')); + assert.ok(newer('1-10', '1-9')); + assert.ok(!newer('1-9', '1-9')); + assert.ok(newer('1-0', undefined)); + }); +}); + +describe('createClient', () => { + function client(member: boolean, lastId?: string) { + const frames: string[] = []; + return { + frames, + client: createClient((chunk) => frames.push(chunk), ['releases'], member, lastId), + }; + } + + it('coalesces a burst on one topic into one frame carrying the latest id', () => { + mock.timers.enable({ apis: ['setTimeout'] }); + const { frames, client: live } = client(false); + live.offer({ id: '1-0', event: event('releases', 'strata') }); + live.offer({ id: '1-1', event: event('releases', 'strata') }); + mock.timers.tick(100); + mock.timers.reset(); + assert.equal(frames.length, 1); + assert.match(frames[0], /^id: 1-1\n/); + }); + + it('skips replayed events at or before the last id it has sent', () => { + mock.timers.enable({ apis: ['setTimeout'] }); + const { frames, client: live } = client(false, '5-0'); + live.offer({ id: '4-0', event: event('releases', 'a') }); + live.offer({ id: '5-0', event: event('releases', 'b') }); + live.offer({ id: '6-0', event: event('releases', 'c') }); + mock.timers.tick(100); + mock.timers.reset(); + assert.equal(frames.length, 1); + assert.match(frames[0], /^id: 6-0\n/); + }); + + it('sends private events to members only, and only the public projection to visitors', () => { + mock.timers.enable({ apis: ['setTimeout'] }); + const visitor = client(false); + const member = client(true); + for (const live of [visitor.client, member.client]) { + live.offer({ id: '1-0', event: event('releases', 'private', false) }); + live.offer({ + id: '1-1', + event: { ...event('releases', 'public'), detail: { project: 'strata', host: 'x' } }, + }); + } + mock.timers.tick(100); + mock.timers.reset(); + assert.equal(visitor.frames.length, 1); + assert.ok(!visitor.frames[0].includes('host')); + assert.equal(member.frames.length, 2); + }); +}); diff --git a/site/server/live.ts b/site/server/live.ts new file mode 100644 index 0000000..b63143d --- /dev/null +++ b/site/server/live.ts @@ -0,0 +1,136 @@ +import type { Request, Response } from 'express'; +import type { Auth } from './auth.ts'; +import type { Bus, Delivery, HubEvent } from './bus.ts'; +import { toPublicEvent } from './console/public.ts'; + +const MAX_TOPICS = 12; +const TOPIC = /^[a-z]+(?::[a-z0-9-]+)?$/; +const HEARTBEAT_MS = 25_000; +const COALESCE_MS = 100; + +export function parseTopics(value: unknown) { + const topics = [ + ...new Set( + String(value ?? '') + .split(',') + .filter(Boolean), + ), + ]; + return topics.length > 0 && + topics.length <= MAX_TOPICS && + topics.every((topic) => TOPIC.test(topic)) + ? topics + : undefined; +} + +export function matches(topics: string[], event: HubEvent) { + return topics.some((topic) => topic === event.topic || topic === `${event.topic}:${event.key}`); +} + +export function newer(id: string, than: string | undefined) { + if (!than) { + return true; + } + const [ms, seq] = id.split('-').map(Number); + const [lastMs, lastSeq] = than.split('-').map(Number); + return ms > lastMs || (ms === lastMs && seq > lastSeq); +} + +export function payload(event: HubEvent, member: boolean) { + return member + ? { topic: event.topic, key: event.key, kind: event.kind, event } + : { topic: event.topic, key: event.key, kind: event.kind, console: toPublicEvent(event) }; +} + +export function createClient( + write: (chunk: string) => void, + topics: string[], + member: boolean, + lastId: string | undefined, +) { + let last = lastId; + let timer: ReturnType<typeof setTimeout> | undefined; + const pending = new Map<string, Delivery>(); + + function flush() { + timer = undefined; + for (const { id, event } of pending.values()) { + write(`${id ? `id: ${id}\n` : ''}data: ${JSON.stringify(payload(event, member))}\n\n`); + } + pending.clear(); + } + + return { + offer(delivery: Delivery) { + const { id, event } = delivery; + if (!matches(topics, event) || (!event.public && !member) || (id && !newer(id, last))) { + return; + } + if (id) { + last = id; + } + pending.set(`${event.topic}:${event.key ?? ''}:${event.kind}`, delivery); + timer ??= setTimeout(flush, COALESCE_MS); + }, + close() { + clearTimeout(timer); + }, + }; +} + +export function createLive(bus: Bus, auth: Auth) { + const clients = new Set<Pick<ReturnType<typeof createClient>, 'offer'>>(); + bus.listen((delivery) => { + for (const client of clients) { + client.offer(delivery); + } + }); + + async function handler(request: Request, response: Response) { + const topics = parseTopics(request.query.topics); + if (!topics) { + response.status(400).type('text').send(`Ask for 1 to ${MAX_TOPICS} topics.`); + return; + } + const member = Boolean(await auth.current(request, response)); + const header = String(request.headers['last-event-id'] ?? ''); + const lastId = /^\d+-\d+$/.test(header) ? header : undefined; + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + response.write('retry: 3000\n\n'); + const held: Delivery[] = []; + const holder = { offer: (delivery: Delivery) => held.push(delivery) }; + const client = createClient((chunk) => response.write(chunk), topics, member, lastId); + const heartbeat = setInterval(() => response.write(': ping\n\n'), HEARTBEAT_MS); + let open = true; + request.on('close', () => { + open = false; + clearInterval(heartbeat); + client.close(); + clients.delete(holder); + clients.delete(client); + }); + clients.add(holder); + const replay = lastId ? await bus.since(lastId) : []; + clients.delete(holder); + if (open) { + for (const delivery of [...replay, ...held]) { + client.offer(delivery); + } + clients.add(client); + } + } + + return { + handler, + get connections() { + return clients.size; + }, + }; +} + +export type Live = ReturnType<typeof createLive>; diff --git a/site/server/main.ts b/site/server/main.ts new file mode 100644 index 0000000..a43055f --- /dev/null +++ b/site/server/main.ts @@ -0,0 +1,96 @@ +import path from 'node:path'; +import type { RequestHandler } from 'express'; +import type { ServerBuild } from 'react-router'; +import { refreshCloudflareRanges } from './address.ts'; +import { createApp } from './app.ts'; +import { createAuth } from './auth.ts'; +import { createBus } from './bus.ts'; +import { config } from './config.ts'; +import { openDatabase } from './db.ts'; +import { createErrors } from './errors.ts'; +import { runLifecycle } from './lifecycle.ts'; +import { createLive } from './live.ts'; +import { log } from './log.ts'; +import { alertOwner } from './mail.ts'; +import { createMetrics } from './metrics.ts'; +import { connectRedis } from './redis.ts'; +import { createSessions } from './sessions.ts'; +import { createSnapshot } from './snapshot.ts'; +import { createSync } from './sync.ts'; +import { pingAll } from './uptime.ts'; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +const db = openDatabase(path.join(config.dataDir, 'hub.db')); +const { command: redis, subscriber } = connectRedis(config.redisUrl); +const bus = createBus(db, redis, subscriber); +await bus.ready; +const capture = createErrors(db, bus, alertOwner); +const sessions = createSessions(redis, config.sessionSecret); +const auth = createAuth(sessions, redis); +const live = createLive(bus, auth); +const sync = createSync(db, bus, sessions); +const snapshot = createSnapshot(db, bus); + +process.on('unhandledRejection', (reason) => capture(reason, { route: 'process' })); +process.on('uncaughtExceptionMonitor', (error) => capture(error, { route: 'process' })); + +let loadBuild: () => Promise<ServerBuild>; +let assets: RequestHandler | undefined; +if (config.production) { + const build = (await import( + new URL('../build/server/index.js', import.meta.url).href + )) as ServerBuild; + loadBuild = async () => build; +} else { + const vite = await import('vite').then(({ createServer }) => + createServer({ server: { middlewareMode: true } }), + ); + loadBuild = () => vite.ssrLoadModule('virtual:react-router/server-build') as Promise<ServerBuild>; + assets = vite.middlewares; +} + +const app = createApp( + { db, redis, bus, snapshot, auth, live, sync, capture, metrics: createMetrics() }, + loadBuild, + assets, +); +const server = app.listen(config.port, () => log.info({ port: config.port }, 'listening')); + +function every(name: string, ms: number, job: () => Promise<unknown>) { + const run = () => job().catch((error: unknown) => capture(error, { route: `job:${name}` })); + run(); + return setInterval(run, ms); +} + +function nightly(name: string, hourUtc: number, job: () => Promise<unknown>) { + const next = new Date(); + next.setUTCHours(hourUtc, 30, 0, 0); + if (next.getTime() <= Date.now()) { + next.setTime(next.getTime() + DAY); + } + const start = setTimeout(() => { + timers.push(every(name, DAY, job)); + }, next.getTime() - Date.now()); + return start; +} + +const timers = [ + every('cloudflare-ranges', DAY, refreshCloudflareRanges), + every('full-sync', HOUR, sync.full), + every('uptime', 30_000, () => pingAll(db, bus)), + nightly('lifecycle', 3, () => runLifecycle(db, redis, bus)), +]; + +process.on('SIGTERM', () => { + log.info('shutting down'); + timers.forEach(clearTimeout); + server.close(); + server.closeAllConnections(); + Promise.allSettled([redis.quit(), subscriber.quit()]).then(() => { + db.close(); + process.exit(0); + }); +}); From b76fac6ecf27eb40a99ae244adfa2e1bc052a7a5 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:15 +0200 Subject: [PATCH 09/12] feat: serve every public route from loaders with the current markup --- site/{src => app}/components/AuthorLink.tsx | 4 +- site/{src => app}/components/Browse.tsx | 21 +- .../components/ChangelogPreview.tsx | 4 +- site/{src => app}/components/EpicCard.tsx | 0 site/{src => app}/components/MemberCard.tsx | 3 +- .../pages => app/components}/NotFound.tsx | 0 site/{src => app}/components/ProjectCard.tsx | 0 site/{src => app}/components/ProjectTile.tsx | 0 site/{src => app}/components/ReleaseCard.tsx | 38 +- site/{src => app}/components/Section.tsx | 0 site/{src => app}/components/Shell.tsx | 54 +- site/{src => app}/components/Status.tsx | 14 +- site/{src => app}/components/TagList.tsx | 0 site/{src => app}/components/Timeline.tsx | 4 +- site/app/context.ts | 17 + site/app/entry.server.tsx | 62 ++ site/app/hub.ts | 25 + site/{src => app}/meta.ts | 70 +- site/app/model.ts | 90 +++ site/app/root.tsx | 80 +++ site/app/routes.ts | 22 + .../routes/changelog.tsx} | 3 +- .../pages/Epics.tsx => app/routes/epics.tsx} | 3 +- .../pages/Index.tsx => app/routes/home.tsx} | 5 +- site/app/routes/llms.ts | 48 ++ .../routes/member-changelog.tsx} | 20 +- .../Member.tsx => app/routes/member.tsx} | 40 +- .../Members.tsx => app/routes/members.tsx} | 3 +- site/app/routes/not-found.tsx | 10 + .../routes/project-changelog.tsx} | 41 +- .../Project.tsx => app/routes/project.tsx} | 35 +- .../Projects.tsx => app/routes/projects.tsx} | 3 +- site/app/routes/sitemap.ts | 27 + site/{src => app}/styles/global.css | 0 site/index.html | 20 - site/public/404.css | 54 -- site/public/404.html | 19 - site/public/_headers | 10 - site/public/_redirects | 12 - site/scripts/check-routes.mjs | 52 -- site/scripts/fetch-github.mjs | 55 -- site/scripts/prerender.mjs | 112 ---- site/src/App.tsx | 35 - site/src/data/github.json | 14 - site/src/data/registry.json | 602 ------------------ site/src/entry-server.tsx | 16 - site/src/main.tsx | 14 - site/src/registry.ts | 108 ---- site/src/types.ts | 90 --- site/src/vite-env.d.ts | 1 - 50 files changed, 581 insertions(+), 1379 deletions(-) rename site/{src => app}/components/AuthorLink.tsx (81%) rename site/{src => app}/components/Browse.tsx (91%) rename site/{src => app}/components/ChangelogPreview.tsx (94%) rename site/{src => app}/components/EpicCard.tsx (100%) rename site/{src => app}/components/MemberCard.tsx (91%) rename site/{src/pages => app/components}/NotFound.tsx (100%) rename site/{src => app}/components/ProjectCard.tsx (100%) rename site/{src => app}/components/ProjectTile.tsx (100%) rename site/{src => app}/components/ReleaseCard.tsx (81%) rename site/{src => app}/components/Section.tsx (100%) rename site/{src => app}/components/Shell.tsx (64%) rename site/{src => app}/components/Status.tsx (72%) rename site/{src => app}/components/TagList.tsx (100%) rename site/{src => app}/components/Timeline.tsx (86%) create mode 100644 site/app/context.ts create mode 100644 site/app/entry.server.tsx create mode 100644 site/app/hub.ts rename site/{src => app}/meta.ts (61%) create mode 100644 site/app/model.ts create mode 100644 site/app/root.tsx create mode 100644 site/app/routes.ts rename site/{src/pages/Changelog.tsx => app/routes/changelog.tsx} (89%) rename site/{src/pages/Epics.tsx => app/routes/epics.tsx} (91%) rename site/{src/pages/Index.tsx => app/routes/home.tsx} (94%) create mode 100644 site/app/routes/llms.ts rename site/{src/pages/MemberChangelog.tsx => app/routes/member-changelog.tsx} (72%) rename site/{src/pages/Member.tsx => app/routes/member.tsx} (82%) rename site/{src/pages/Members.tsx => app/routes/members.tsx} (91%) create mode 100644 site/app/routes/not-found.tsx rename site/{src/pages/ProjectChangelog.tsx => app/routes/project-changelog.tsx} (74%) rename site/{src/pages/Project.tsx => app/routes/project.tsx} (81%) rename site/{src/pages/Projects.tsx => app/routes/projects.tsx} (92%) create mode 100644 site/app/routes/sitemap.ts rename site/{src => app}/styles/global.css (100%) delete mode 100644 site/index.html delete mode 100644 site/public/404.css delete mode 100644 site/public/404.html delete mode 100644 site/public/_headers delete mode 100644 site/public/_redirects delete mode 100644 site/scripts/check-routes.mjs delete mode 100644 site/scripts/fetch-github.mjs delete mode 100644 site/scripts/prerender.mjs delete mode 100644 site/src/App.tsx delete mode 100644 site/src/data/github.json delete mode 100755 site/src/data/registry.json delete mode 100644 site/src/entry-server.tsx delete mode 100644 site/src/main.tsx delete mode 100644 site/src/registry.ts delete mode 100644 site/src/types.ts delete mode 100644 site/src/vite-env.d.ts diff --git a/site/src/components/AuthorLink.tsx b/site/app/components/AuthorLink.tsx similarity index 81% rename from site/src/components/AuthorLink.tsx rename to site/app/components/AuthorLink.tsx index 60dcbf1..e744e0d 100644 --- a/site/src/components/AuthorLink.tsx +++ b/site/app/components/AuthorLink.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router'; -import { memberByGithub } from '../registry'; +import { useHub } from '../hub'; interface Props { github: string; @@ -7,7 +7,7 @@ interface Props { } export default function AuthorLink({ github, className }: Props) { - const member = memberByGithub(github); + const member = useHub().memberByGithub(github); return member ? ( <Link to={member.path} className={className}> diff --git a/site/src/components/Browse.tsx b/site/app/components/Browse.tsx similarity index 91% rename from site/src/components/Browse.tsx rename to site/app/components/Browse.tsx index a47936a..669a36c 100644 --- a/site/src/components/Browse.tsx +++ b/site/app/components/Browse.tsx @@ -1,15 +1,7 @@ import type { ReactNode } from 'react'; import { NavLink } from 'react-router'; -import { - changelogPath, - childProjects, - epics, - members, - projectsOwnedBy, - releasesForProject, - releasesOwnedBy, - rootProjects, -} from '../registry'; +import { useHub } from '../hub'; +import { changelogPath } from '../model'; interface EntryProps { to: string; @@ -45,6 +37,15 @@ function Group({ label, children }: { label: string; children: ReactNode }) { } export default function Browse() { + const { + childProjects, + epics, + members, + projectsOwnedBy, + releasesForProject, + releasesOwnedBy, + rootProjects, + } = useHub(); const owners = members.filter((member) => projectsOwnedBy(member).length > 0); return ( diff --git a/site/src/components/ChangelogPreview.tsx b/site/app/components/ChangelogPreview.tsx similarity index 94% rename from site/src/components/ChangelogPreview.tsx rename to site/app/components/ChangelogPreview.tsx index 92c2b43..744a619 100644 --- a/site/src/components/ChangelogPreview.tsx +++ b/site/app/components/ChangelogPreview.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router'; -import { changelogPath, projectBySlug, releases, releasesForProject } from '../registry'; +import { useHub } from '../hub'; +import { changelogPath } from '../model'; import type { Project } from '../types'; interface Props { @@ -7,6 +8,7 @@ interface Props { } export default function ChangelogPreview({ project }: Props) { + const { projectBySlug, releases, releasesForProject } = useHub(); const shown = (project ? releasesForProject(project) : releases).slice(0, 3); const showProject = !project || project.kind === 'epic'; diff --git a/site/src/components/EpicCard.tsx b/site/app/components/EpicCard.tsx similarity index 100% rename from site/src/components/EpicCard.tsx rename to site/app/components/EpicCard.tsx diff --git a/site/src/components/MemberCard.tsx b/site/app/components/MemberCard.tsx similarity index 91% rename from site/src/components/MemberCard.tsx rename to site/app/components/MemberCard.tsx index b46cfa4..79a4c2e 100644 --- a/site/src/components/MemberCard.tsx +++ b/site/app/components/MemberCard.tsx @@ -1,5 +1,4 @@ import { Link } from 'react-router'; -import { avatarFor } from '../registry'; import type { Member } from '../types'; interface Props { @@ -13,7 +12,7 @@ export default function MemberCard({ member }: Props) { className="flex items-center gap-4 rounded-lg border border-line-soft bg-surface p-4 text-ink no-underline transition-colors duration-200 hover:border-line" > <img - src={avatarFor(member)} + src={member.avatarUrl} alt="" width={48} height={48} diff --git a/site/src/pages/NotFound.tsx b/site/app/components/NotFound.tsx similarity index 100% rename from site/src/pages/NotFound.tsx rename to site/app/components/NotFound.tsx diff --git a/site/src/components/ProjectCard.tsx b/site/app/components/ProjectCard.tsx similarity index 100% rename from site/src/components/ProjectCard.tsx rename to site/app/components/ProjectCard.tsx diff --git a/site/src/components/ProjectTile.tsx b/site/app/components/ProjectTile.tsx similarity index 100% rename from site/src/components/ProjectTile.tsx rename to site/app/components/ProjectTile.tsx diff --git a/site/src/components/ReleaseCard.tsx b/site/app/components/ReleaseCard.tsx similarity index 81% rename from site/src/components/ReleaseCard.tsx rename to site/app/components/ReleaseCard.tsx index 8cb5889..43f2708 100644 --- a/site/src/components/ReleaseCard.tsx +++ b/site/app/components/ReleaseCard.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router'; -import { changelogPath, compareUrl, memberByHandle, projectBySlug } from '../registry'; +import { useHub } from '../hub'; +import { changelogPath, compareUrl } from '../model'; import type { ChangeType, Release } from '../types'; const DOT: Record<ChangeType, string> = { @@ -17,9 +18,10 @@ interface Props { } export default function ReleaseCard({ release, showProject }: Props) { + const { memberByHandle, projectBySlug } = useHub(); const project = projectBySlug(release.project)!; const epic = project.parent ? projectBySlug(project.parent) : undefined; - const owner = memberByHandle(project.owner)!; + const owner = memberByHandle(project.owner); return ( <article @@ -78,15 +80,17 @@ export default function ReleaseCard({ release, showProject }: Props) { <li key={index} className="text-muted"> {change.text} <span className="mono-sm ml-2 whitespace-nowrap text-faint"> - <a - href={`${project.repoUrl}/commit/${change.commit}`} - className="text-faint transition-colors duration-200 hover:text-ink" - > - {change.commit} - </a> + {change.commit && ( + <a + href={`${project.repoUrl}/commit/${change.commit}`} + className="text-faint transition-colors duration-200 hover:text-ink" + > + {change.commit} + </a> + )} {change.pr && ( <> - {' · '} + {change.commit && ' · '} <a href={`${project.repoUrl}/pull/${change.pr}`} className="text-faint transition-colors duration-200 hover:text-ink" @@ -106,12 +110,16 @@ export default function ReleaseCard({ release, showProject }: Props) { <footer className="mono-sm mt-6 flex flex-wrap items-center justify-between gap-3 text-faint"> <span> owned by{' '} - <Link - to={changelogPath(owner)} - className="text-faint transition-colors duration-200 hover:text-ink" - > - {owner.name} - </Link> + {owner ? ( + <Link + to={changelogPath(owner)} + className="text-faint transition-colors duration-200 hover:text-ink" + > + {owner.name} + </Link> + ) : ( + project.owner + )} </span> <span className="flex gap-4.5"> <a href={compareUrl(project, release)} className="rule-link"> diff --git a/site/src/components/Section.tsx b/site/app/components/Section.tsx similarity index 100% rename from site/src/components/Section.tsx rename to site/app/components/Section.tsx diff --git a/site/src/components/Shell.tsx b/site/app/components/Shell.tsx similarity index 64% rename from site/src/components/Shell.tsx rename to site/app/components/Shell.tsx index 92af037..887e2d6 100644 --- a/site/src/components/Shell.tsx +++ b/site/app/components/Shell.tsx @@ -1,23 +1,10 @@ -import { useEffect, useRef } from 'react'; -import { Link, Outlet, useLocation } from 'react-router'; -import { metaFor } from '../meta'; +import type { ReactNode } from 'react'; +import { Link, useLocation } from 'react-router'; +import { useRoot } from '../hub'; -export default function Shell() { - const { pathname, hash } = useLocation(); - const previousPathname = useRef(pathname); - - useEffect(() => { - if (pathname !== previousPathname.current) { - const target = hash ? document.getElementById(hash.slice(1)) : null; - if (target) { - target.scrollIntoView({ behavior: 'instant' }); - } else { - window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); - } - document.title = metaFor(pathname.replace(/(.)\/$/, '$1')).title; - previousPathname.current = pathname; - } - }, [pathname, hash]); +export default function Shell({ children }: { children: ReactNode }) { + const { pathname } = useLocation(); + const root = useRoot(); return ( <div className="flex min-h-screen flex-col"> @@ -76,13 +63,40 @@ export default function Shell() { > github ↗ </a> + {root?.viewer ? ( + <form method="post" action="/auth/sign-out" className="flex items-center gap-2.5"> + <input type="hidden" name="csrf" value={root.csrf ?? ''} /> + <img + src={root.viewer.avatarUrl} + alt={`Signed in as ${root.viewer.login}`} + width={22} + height={22} + className="size-5.5 rounded-full ring-2 ring-line" + /> + <button + type="submit" + className="cursor-pointer border-0 bg-transparent p-0 font-mono text-muted transition-colors duration-200 hover:text-ink" + > + sign out + </button> + </form> + ) : ( + root?.signIn && ( + <a + href={`/auth/sign-in?return=${encodeURIComponent(pathname)}`} + className="text-muted no-underline transition-colors duration-200 hover:text-ink" + > + sign in + </a> + ) + )} </nav> </div> </header> <main id="content" className="shell flex-1 pb-12"> <div key={pathname} className="page-transition"> - <Outlet /> + {children} </div> </main> diff --git a/site/src/components/Status.tsx b/site/app/components/Status.tsx similarity index 72% rename from site/src/components/Status.tsx rename to site/app/components/Status.tsx index 078b876..d2c75db 100644 --- a/site/src/components/Status.tsx +++ b/site/app/components/Status.tsx @@ -1,14 +1,8 @@ -import type { Project } from '../types'; - -const LABEL: Record<Project['status'], string> = { - live: 'live', - wip: 'in progress', - archived: 'archived', - 'source-only': 'source only', -}; +import { STATUS_LABEL } from '../model'; +import type { ProjectStatus } from '../types'; interface Props { - status: Project['status']; + status: ProjectStatus; } export default function Status({ status }: Props) { @@ -23,7 +17,7 @@ export default function Status({ status }: Props) { )} <span className="relative size-1.75 rounded-full bg-dormant group-data-[status=live]:bg-live group-data-[status=wip]:bg-wip" /> </span> - {LABEL[status]} + {STATUS_LABEL[status]} </span> ); } diff --git a/site/src/components/TagList.tsx b/site/app/components/TagList.tsx similarity index 100% rename from site/src/components/TagList.tsx rename to site/app/components/TagList.tsx diff --git a/site/src/components/Timeline.tsx b/site/app/components/Timeline.tsx similarity index 86% rename from site/src/components/Timeline.tsx rename to site/app/components/Timeline.tsx index 01b4c63..1a3bad3 100644 --- a/site/src/components/Timeline.tsx +++ b/site/app/components/Timeline.tsx @@ -18,8 +18,8 @@ export default function Timeline({ releases, showProject }: Props) { <div className="rounded-lg border border-dashed border-line p-6"> <p className="m-0 text-muted">No releases recorded yet.</p> <p className="mono-sm mt-2 mb-0 text-faint"> - Push a vX.Y.Z tag, or child-slug/vX.Y.Z for a project inside an epic, and the daily - sync drafts the entry from the commits since the previous version. + Publish a GitHub release tagged vX.Y.Z, or child-slug/vX.Y.Z for a project inside an + epic, and it appears here within seconds. </p> </div> ) : ( diff --git a/site/app/context.ts b/site/app/context.ts new file mode 100644 index 0000000..910118d --- /dev/null +++ b/site/app/context.ts @@ -0,0 +1,17 @@ +import { createContext as createReactContext } from 'react'; +import { createContext } from 'react-router'; +import type { Capture } from '../server/errors.ts'; +import type { Directory, Viewer } from './types'; + +export interface Hub { + directory: Directory; + viewer: Viewer | null; + csrf: string | null; + nonce: string; + requestId: string; + capture: Capture; +} + +export const hubContext = createContext<Hub>(); + +export const NonceContext = createReactContext<string | undefined>(undefined); diff --git a/site/app/entry.server.tsx b/site/app/entry.server.tsx new file mode 100644 index 0000000..5090cfa --- /dev/null +++ b/site/app/entry.server.tsx @@ -0,0 +1,62 @@ +import { PassThrough } from 'node:stream'; +import { createReadableStreamFromReadable } from '@react-router/node'; +import { renderToPipeableStream } from 'react-dom/server'; +import { + ServerRouter, + type EntryContext, + type HandleErrorFunction, + type RouterContextProvider, +} from 'react-router'; +import { hubContext, NonceContext } from './context'; + +export { hubContext }; + +export const streamTimeout = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: RouterContextProvider, +) { + if (request.method.toUpperCase() === 'HEAD') { + return new Response(null, { status: responseStatusCode, headers: responseHeaders }); + } + const hub = loadContext.get(hubContext); + let status = responseStatusCode; + return new Promise<Response>((resolve, reject) => { + const { pipe, abort } = renderToPipeableStream( + <NonceContext value={hub.nonce}> + <ServerRouter context={routerContext} url={request.url} nonce={hub.nonce} /> + </NonceContext>, + { + nonce: hub.nonce, + onAllReady() { + const body = new PassThrough(); + responseHeaders.set('Content-Type', 'text/html'); + resolve( + new Response(createReadableStreamFromReadable(body), { + headers: responseHeaders, + status, + }), + ); + pipe(body); + }, + onShellError: reject, + onError(error) { + status = 500; + hub.capture(error, { route: new URL(request.url).pathname, requestId: hub.requestId }); + }, + }, + ); + setTimeout(abort, streamTimeout + 1_000); + }); +} + +export const handleError: HandleErrorFunction = (error, { request, context }) => { + if (!request.signal.aborted) { + const hub = context.get(hubContext); + hub.capture(error, { route: new URL(request.url).pathname, requestId: hub.requestId }); + } +}; diff --git a/site/app/hub.ts b/site/app/hub.ts new file mode 100644 index 0000000..55b4eeb --- /dev/null +++ b/site/app/hub.ts @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; +import { data, useRouteLoaderData, type RouterContextProvider } from 'react-router'; +import { hubContext } from './context'; +import { createModel } from './model'; +import type { loader } from './root'; + +export function useRoot() { + return useRouteLoaderData<typeof loader>('root'); +} + +export function useHub() { + const directory = useRoot()!.directory; + return useMemo(() => createModel(directory), [directory]); +} + +export function modelFrom(context: Readonly<RouterContextProvider>) { + return createModel(context.get(hubContext).directory); +} + +export function found<T>(value: T | undefined): T { + if (value === undefined) { + throw data(null, { status: 404 }); + } + return value; +} diff --git a/site/src/meta.ts b/site/app/meta.ts similarity index 61% rename from site/src/meta.ts rename to site/app/meta.ts index f2bf5c5..9142bbc 100644 --- a/site/src/meta.ts +++ b/site/app/meta.ts @@ -1,7 +1,8 @@ -import { changelogPath, memberByHandle, projectByRoute } from './registry'; +import type { MetaDescriptor } from 'react-router'; +import { changelogPath, type Model } from './model'; -const SITE = 'Project Graphite'; -const ORIGIN = 'https://project-graphite.com'; +export const SITE = 'Project Graphite'; +export const ORIGIN = 'https://project-graphite.com'; function canonicalFor(path: string) { return path === '/' ? `${ORIGIN}/` : `${ORIGIN}${path}/`; @@ -13,7 +14,7 @@ export interface PageMeta { canonical: string; } -const INDEX: PageMeta = { +export const INDEX: PageMeta = { title: SITE, description: 'A shared portfolio for Project Graphite members, their work and the applications they deploy.', @@ -45,32 +46,33 @@ export const CHANGELOG: PageMeta = { canonical: `${ORIGIN}/changelog/`, }; -export function metaFor(path: string): PageMeta { - if (path === '/') { - return INDEX; - } - - if (path === '/members') { - return MEMBERS; - } - - if (path === '/epics') { - return EPICS; - } - - if (path === '/projects') { - return PROJECTS; +const NOT_FOUND = (path: string): PageMeta => ({ + title: `Not found — ${SITE}`, + description: 'That page does not exist.', + canonical: canonicalFor(path), +}); + +export function metaFor(model: Model | undefined, rawPath: string): PageMeta { + const path = rawPath.replace(/(.)\/$/, '$1'); + const fixed: Record<string, PageMeta> = { + '/': INDEX, + '/members': MEMBERS, + '/epics': EPICS, + '/projects': PROJECTS, + '/changelog': CHANGELOG, + }; + if (fixed[path]) { + return fixed[path]; } - - if (path === '/changelog') { - return CHANGELOG; + if (!model) { + return NOT_FOUND(path); } const changelog = path.startsWith('/changelog/'); const [section, slug, childSlug] = path.replace(/^\/(changelog\/)?/, '').split('/'); if ((section === 'projects' || section === 'epics') && slug) { - const project = projectByRoute(childSlug ? slug : undefined, childSlug ?? slug); + const project = model.projectByRoute(childSlug ? slug : undefined, childSlug ?? slug); if (project && changelog) { return { title: `${project.name} changelog — ${SITE}`, @@ -88,7 +90,7 @@ export function metaFor(path: string): PageMeta { } if (section === 'members' && slug) { - const member = memberByHandle(slug); + const member = model.memberByHandle(slug); if (member && changelog) { return { title: `${member.name} changelog — ${SITE}`, @@ -105,9 +107,19 @@ export function metaFor(path: string): PageMeta { } } - return { - title: `Not found — ${SITE}`, - description: 'That page does not exist.', - canonical: canonicalFor(path), - }; + return NOT_FOUND(path); +} + +export function metaTags({ title, description, canonical }: PageMeta): MetaDescriptor[] { + return [ + { title }, + { name: 'description', content: description }, + { tagName: 'link', rel: 'canonical', href: canonical }, + { property: 'og:title', content: title }, + { property: 'og:description', content: description }, + { property: 'og:url', content: canonical }, + { property: 'og:type', content: 'website' }, + { property: 'og:site_name', content: SITE }, + { name: 'twitter:card', content: 'summary' }, + ]; } diff --git a/site/app/model.ts b/site/app/model.ts new file mode 100644 index 0000000..95ddbe8 --- /dev/null +++ b/site/app/model.ts @@ -0,0 +1,90 @@ +import type { Directory, Member, Project, ProjectStatus, Release } from './types'; + +export const STATUS_LABEL: Record<ProjectStatus, string> = { + live: 'live', + wip: 'in progress', + archived: 'archived', + 'source-only': 'source only', +}; + +export function changelogPath(entry: { path: string }) { + return `/changelog${entry.path}`; +} + +export function compareUrl(project: Project, release: Release) { + if (!release.tag) { + return `${project.repoUrl}/commits`; + } + return release.previous + ? `${project.repoUrl}/compare/${release.previous}...${release.tag}` + : `${project.repoUrl}/commits/${release.tag}`; +} + +export function createModel({ members, projects, releases }: Directory) { + const rootProjects = projects.filter((project) => project.kind === 'project' && !project.parent); + const epics = projects.filter((project) => project.kind === 'epic'); + + const memberByHandle = (handle: string): Member | undefined => + members.find((member) => member.handle.toLowerCase() === handle.toLowerCase()); + + const memberByGithub = (github: string): Member | undefined => + members.find((member) => member.github.toLowerCase() === github.toLowerCase()); + + const projectBySlug = (slug: string): Project | undefined => + projects.find((project) => project.slug === slug); + + const projectByRoute = (parent: string | undefined, slug: string): Project | undefined => + projects.find((project) => project.parent === (parent ?? null) && project.slug === slug); + + const childProjects = (parent: string): Project[] => + projects.filter((project) => project.parent === parent); + + const isCredited = (project: Project, member: Member) => + project.owner === member.handle || + project.authors.some((author) => author.toLowerCase() === member.github.toLowerCase()); + + const releasesForProject = (project: Project): Release[] => { + const slugs = new Set([ + project.slug, + ...childProjects(project.slug).map((child) => child.slug), + ]); + return releases.filter((release) => slugs.has(release.project)); + }; + + const projectsOwnedBy = (member: Member): Project[] => + projects.filter((project) => project.owner === member.handle); + + return { + members, + projects, + releases, + rootProjects, + epics, + memberByHandle, + memberByGithub, + projectBySlug, + projectByRoute, + childProjects, + releasesForProject, + projectsOwnedBy, + projectsForMember: (member: Member) => + rootProjects.filter((project) => isCredited(project, member)), + epicsForMember: (member: Member) => + epics.filter( + (epic) => + isCredited(epic, member) || + childProjects(epic.slug).some((project) => isCredited(project, member)), + ), + contributionForMember: (project: Project, member: Member) => + project.showcase?.contributions.find( + (contribution) => contribution.github.toLowerCase() === member.github.toLowerCase(), + ), + lastPublishedOn: (project: Project) => releasesForProject(project)[0]?.date ?? '', + releasesOwnedBy: (member: Member) => { + const slugs = new Set(projectsOwnedBy(member).map((project) => project.slug)); + return releases.filter((release) => slugs.has(release.project)); + }, + }; +} + +export type Model = ReturnType<typeof createModel>; diff --git a/site/app/root.tsx b/site/app/root.tsx new file mode 100644 index 0000000..d39c12a --- /dev/null +++ b/site/app/root.tsx @@ -0,0 +1,80 @@ +import { useContext, type ReactNode } from 'react'; +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from 'react-router'; +import type { Route } from './+types/root'; +import NotFound from './components/NotFound'; +import Shell from './components/Shell'; +import { hubContext, NonceContext } from './context'; +import { useLive } from './live'; +import { metaFor, metaTags } from './meta'; +import { createModel } from './model'; +import './styles/global.css'; + +export const handle = { topics: ['members', 'profiles', 'projects', 'releases'] }; + +export function loader({ context, request }: Route.LoaderArgs) { + const { directory, viewer, csrf } = context.get(hubContext); + return { + directory, + viewer, + csrf, + signIn: new URL(request.url).searchParams.has('graphite'), + }; +} + +export function meta({ loaderData, location }: Route.MetaArgs) { + return metaTags(metaFor(loaderData && createModel(loaderData.directory), location.pathname)); +} + +export function Layout({ children }: { children: ReactNode }) { + const nonce = useContext(NonceContext); + return ( + <html lang="en"> + <head> + <meta charSet="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> + <meta name="theme-color" content="#0c0c0e" /> + <Meta /> + <Links /> + </head> + <body> + {children} + <ScrollRestoration nonce={nonce} /> + <Scripts nonce={nonce} /> + </body> + </html> + ); +} + +export default function App() { + useLive(); + return ( + <Shell> + <Outlet /> + </Shell> + ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + return ( + <Shell> + {isRouteErrorResponse(error) && error.status === 404 ? ( + <NotFound /> + ) : ( + <section className="pt-22 pb-5"> + <h1 className="page-title">Something broke.</h1> + <p className="mt-5.5 mb-0 max-w-[52ch] text-muted"> + The page could not be rendered. It has been recorded; try again in a moment. + </p> + </section> + )} + </Shell> + ); +} diff --git a/site/app/routes.ts b/site/app/routes.ts new file mode 100644 index 0000000..69172f2 --- /dev/null +++ b/site/app/routes.ts @@ -0,0 +1,22 @@ +import { index, route, type RouteConfig } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + route('members', 'routes/members.tsx'), + route('members/:handle', 'routes/member.tsx'), + route('epics', 'routes/epics.tsx'), + route('epics/:parentSlug/:slug', 'routes/project.tsx', { id: 'epic-project' }), + route('epics/:slug', 'routes/project.tsx', { id: 'epic' }), + route('projects', 'routes/projects.tsx'), + route('projects/:slug', 'routes/project.tsx'), + route('changelog', 'routes/changelog.tsx'), + route('changelog/members/:handle', 'routes/member-changelog.tsx'), + route('changelog/epics/:parentSlug/:slug', 'routes/project-changelog.tsx', { + id: 'epic-project-changelog', + }), + route('changelog/epics/:slug', 'routes/project-changelog.tsx', { id: 'epic-changelog' }), + route('changelog/projects/:slug', 'routes/project-changelog.tsx'), + route('sitemap.xml', 'routes/sitemap.ts'), + route('llms.txt', 'routes/llms.ts'), + route('*', 'routes/not-found.tsx'), +] satisfies RouteConfig; diff --git a/site/src/pages/Changelog.tsx b/site/app/routes/changelog.tsx similarity index 89% rename from site/src/pages/Changelog.tsx rename to site/app/routes/changelog.tsx index ef7f7fe..fe69f51 100644 --- a/site/src/pages/Changelog.tsx +++ b/site/app/routes/changelog.tsx @@ -1,8 +1,9 @@ import Timeline from '../components/Timeline'; import { CHANGELOG } from '../meta'; -import { releases } from '../registry'; +import { useHub } from '../hub'; export default function Changelog() { + const { releases } = useHub(); return ( <> <section className="pt-22 pb-5"> diff --git a/site/src/pages/Epics.tsx b/site/app/routes/epics.tsx similarity index 91% rename from site/src/pages/Epics.tsx rename to site/app/routes/epics.tsx index ababd2c..dba4fe4 100644 --- a/site/src/pages/Epics.tsx +++ b/site/app/routes/epics.tsx @@ -1,9 +1,10 @@ import EpicCard from '../components/EpicCard'; import Section from '../components/Section'; import { EPICS } from '../meta'; -import { childProjects, epics } from '../registry'; +import { useHub } from '../hub'; export default function Epics() { + const { childProjects, epics } = useHub(); return ( <> <section className="pt-22 pb-5"> diff --git a/site/src/pages/Index.tsx b/site/app/routes/home.tsx similarity index 94% rename from site/src/pages/Index.tsx rename to site/app/routes/home.tsx index c744d9b..21ff156 100644 --- a/site/src/pages/Index.tsx +++ b/site/app/routes/home.tsx @@ -4,9 +4,10 @@ import EpicCard from '../components/EpicCard'; import MemberCard from '../components/MemberCard'; import ProjectCard from '../components/ProjectCard'; import Section from '../components/Section'; -import { childProjects, epics, lastPublishedOn, members, rootProjects } from '../registry'; +import { useHub } from '../hub'; -export default function Index() { +export default function Home() { + const { childProjects, epics, lastPublishedOn, members, rootProjects } = useHub(); return ( <> <div className="grid gap-10 pt-22 pb-5 lg:grid-cols-[minmax(0,1fr)_18rem]"> diff --git a/site/app/routes/llms.ts b/site/app/routes/llms.ts new file mode 100644 index 0000000..99cf339 --- /dev/null +++ b/site/app/routes/llms.ts @@ -0,0 +1,48 @@ +import type { Route } from './+types/llms'; +import { modelFrom } from '../hub'; +import { INDEX, ORIGIN } from '../meta'; +import { STATUS_LABEL } from '../model'; +import type { Project } from '../types'; + +function projectLine(project: Project) { + return [ + `- [${project.name}](${ORIGIN}${project.path}/): ${project.summary}`, + project.url && project.status === 'live' + ? ` Open it at ${project.url}.` + : ` Status: ${STATUS_LABEL[project.status]}.`, + ` Source: ${project.repoUrl}`, + ].join(''); +} + +export function loader({ context }: Route.LoaderArgs) { + const { members, projects } = modelFrom(context); + const body = [ + '# Project Graphite', + '', + `> ${INDEX.description}`, + '', + 'Every project has its own repository. Live web apps run at their own subdomain of project-graphite.com and are open to use; source-only and in-progress work is labelled as such.', + '', + '## Live apps', + '', + ...projects.filter((project) => project.status === 'live').map(projectLine), + '', + '## Other projects', + '', + ...projects.filter((project) => project.status !== 'live').map(projectLine), + '', + '## Members', + '', + ...members.map( + (member) => + `- [${member.name}](${ORIGIN}${member.path}/): ${member.headline ?? `@${member.github}`}`, + ), + '', + '## Site', + '', + `- [Changelog](${ORIGIN}/changelog/): every release of every project`, + `- [Sitemap](${ORIGIN}/sitemap.xml): every page on the site`, + '', + ].join('\n'); + return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +} diff --git a/site/src/pages/MemberChangelog.tsx b/site/app/routes/member-changelog.tsx similarity index 72% rename from site/src/pages/MemberChangelog.tsx rename to site/app/routes/member-changelog.tsx index 2292fd8..42ed2c2 100644 --- a/site/src/pages/MemberChangelog.tsx +++ b/site/app/routes/member-changelog.tsx @@ -1,16 +1,16 @@ -import { Link, useParams } from 'react-router'; +import { Link } from 'react-router'; +import type { Route } from './+types/member-changelog'; import Timeline from '../components/Timeline'; -import { changelogPath, memberByHandle, projectsOwnedBy, releasesOwnedBy } from '../registry'; -import NotFound from './NotFound'; +import { found, modelFrom, useHub } from '../hub'; +import { changelogPath } from '../model'; -export default function MemberChangelog() { - const { handle } = useParams(); - const member = handle ? memberByHandle(handle) : undefined; - - if (!member) { - return <NotFound />; - } +export function loader({ context, params }: Route.LoaderArgs) { + return { handle: found(modelFrom(context).memberByHandle(params.handle)).handle }; +} +export default function MemberChangelog({ loaderData }: Route.ComponentProps) { + const { memberByHandle, projectsOwnedBy, releasesOwnedBy } = useHub(); + const member = memberByHandle(loaderData.handle)!; const owned = projectsOwnedBy(member); return ( diff --git a/site/src/pages/Member.tsx b/site/app/routes/member.tsx similarity index 82% rename from site/src/pages/Member.tsx rename to site/app/routes/member.tsx index e197fde..2c0d856 100644 --- a/site/src/pages/Member.tsx +++ b/site/app/routes/member.tsx @@ -1,29 +1,25 @@ import { Fragment } from 'react'; -import { useParams } from 'react-router'; +import type { Route } from './+types/member'; import EpicCard from '../components/EpicCard'; import ProjectCard from '../components/ProjectCard'; import Section from '../components/Section'; import TagList from '../components/TagList'; -import { - avatarFor, - childProjects, - contributionForMember, - epicsForMember, - githubProfiles, - memberByHandle, - projectsForMember, -} from '../registry'; -import NotFound from './NotFound'; +import { found, modelFrom, useHub } from '../hub'; -export default function Member() { - const { handle } = useParams(); - const member = handle ? memberByHandle(handle) : undefined; +export function loader({ context, params }: Route.LoaderArgs) { + return { handle: found(modelFrom(context).memberByHandle(params.handle)).handle }; +} - if (!member) { - return <NotFound />; - } +export default function Member({ loaderData }: Route.ComponentProps) { + const { + childProjects, + contributionForMember, + epicsForMember, + memberByHandle, + projectsForMember, + } = useHub(); + const member = memberByHandle(loaderData.handle)!; - const profile = githubProfiles[member.github]; const memberProjects = projectsForMember(member); const memberEpics = epicsForMember(member); @@ -34,7 +30,7 @@ export default function Member() { <div className="mt-3 flex items-center gap-5"> <img - src={avatarFor(member)} + src={member.avatarUrl} alt={`${member.name}'s GitHub avatar`} width={64} height={64} @@ -62,13 +58,13 @@ export default function Member() { <Section label="on github"> <dl className="mono-sm grid grid-cols-[auto_minmax(0,1fr)] gap-x-8 gap-y-3"> <dt className="text-faint">public repositories</dt> - <dd className="m-0">{profile?.publicRepos ?? '—'}</dd> + <dd className="m-0">{member.publicRepos ?? '—'}</dd> <dt className="text-faint">followers</dt> - <dd className="m-0">{profile?.followers ?? '—'}</dd> + <dd className="m-0">{member.followers ?? '—'}</dd> <dt className="text-faint">since</dt> - <dd className="m-0">{profile?.joined ?? '—'}</dd> + <dd className="m-0">{member.joined ?? '—'}</dd> <dt className="text-faint">profile</dt> <dd className="m-0"> diff --git a/site/src/pages/Members.tsx b/site/app/routes/members.tsx similarity index 91% rename from site/src/pages/Members.tsx rename to site/app/routes/members.tsx index 089bffd..ddd8a79 100644 --- a/site/src/pages/Members.tsx +++ b/site/app/routes/members.tsx @@ -1,9 +1,10 @@ import MemberCard from '../components/MemberCard'; import Section from '../components/Section'; import { MEMBERS } from '../meta'; -import { members } from '../registry'; +import { useHub } from '../hub'; export default function Members() { + const { members } = useHub(); return ( <> <section className="pt-22 pb-5"> diff --git a/site/app/routes/not-found.tsx b/site/app/routes/not-found.tsx new file mode 100644 index 0000000..c02b408 --- /dev/null +++ b/site/app/routes/not-found.tsx @@ -0,0 +1,10 @@ +import { data } from 'react-router'; +import NotFound from '../components/NotFound'; + +export function loader() { + return data(null, { status: 404 }); +} + +export default function NotFoundRoute() { + return <NotFound />; +} diff --git a/site/src/pages/ProjectChangelog.tsx b/site/app/routes/project-changelog.tsx similarity index 74% rename from site/src/pages/ProjectChangelog.tsx rename to site/app/routes/project-changelog.tsx index f54d425..17153ff 100644 --- a/site/src/pages/ProjectChangelog.tsx +++ b/site/app/routes/project-changelog.tsx @@ -1,26 +1,21 @@ -import { Link, useParams } from 'react-router'; +import { Link } from 'react-router'; +import type { Route } from './+types/project-changelog'; import Status from '../components/Status'; import TagList from '../components/TagList'; import Timeline from '../components/Timeline'; -import { - changelogPath, - childProjects, - memberByHandle, - projectByRoute, - projectBySlug, - releasesForProject, -} from '../registry'; -import NotFound from './NotFound'; +import { found, modelFrom, useHub } from '../hub'; +import { changelogPath } from '../model'; -export default function ProjectChangelog() { - const { parentSlug, slug } = useParams(); - const project = slug ? projectByRoute(parentSlug, slug) : undefined; - - if (!project) { - return <NotFound />; - } +export function loader({ context, params }: Route.LoaderArgs) { + const project = found(modelFrom(context).projectByRoute(params.parentSlug, params.slug)); + return { parent: project.parent ?? undefined, slug: project.slug }; +} - const owner = memberByHandle(project.owner)!; +export default function ProjectChangelog({ loaderData }: Route.ComponentProps) { + const { childProjects, memberByHandle, projectByRoute, projectBySlug, releasesForProject } = + useHub(); + const project = projectByRoute(loaderData.parent, loaderData.slug)!; + const owner = memberByHandle(project.owner); const epic = project.parent ? projectBySlug(project.parent) : undefined; const children = childProjects(project.slug); @@ -82,9 +77,13 @@ export default function ProjectChangelog() { <dt className="text-faint">owner</dt> <dd className="m-0"> - <Link to={changelogPath(owner)} className="text-ink hover:underline"> - {owner.name} - </Link> + {owner ? ( + <Link to={changelogPath(owner)} className="text-ink hover:underline"> + {owner.name} + </Link> + ) : ( + project.owner + )} </dd> <dt className="text-faint">source</dt> diff --git a/site/src/pages/Project.tsx b/site/app/routes/project.tsx similarity index 81% rename from site/src/pages/Project.tsx rename to site/app/routes/project.tsx index d10142b..cb6d2f2 100644 --- a/site/src/pages/Project.tsx +++ b/site/app/routes/project.tsx @@ -1,23 +1,24 @@ -import { Link, useParams } from 'react-router'; +import { Link } from 'react-router'; +import type { Route } from './+types/project'; import AuthorLink from '../components/AuthorLink'; import ChangelogPreview from '../components/ChangelogPreview'; import ProjectTile from '../components/ProjectTile'; import Section from '../components/Section'; import Status from '../components/Status'; import TagList from '../components/TagList'; -import { childProjects, memberByHandle, projectByRoute, projectBySlug } from '../registry'; -import NotFound from './NotFound'; +import { found, modelFrom, useHub } from '../hub'; -export default function Project() { - const { parentSlug, slug } = useParams(); - const project = slug ? projectByRoute(parentSlug, slug) : undefined; - const owner = project ? memberByHandle(project.owner) : undefined; - const parent = project?.parent ? projectBySlug(project.parent) : undefined; - const children = project ? childProjects(project.slug) : []; +export function loader({ context, params }: Route.LoaderArgs) { + const project = found(modelFrom(context).projectByRoute(params.parentSlug, params.slug)); + return { parent: project.parent ?? undefined, slug: project.slug }; +} - if (!project || !owner) { - return <NotFound />; - } +export default function Project({ loaderData }: Route.ComponentProps) { + const { childProjects, memberByHandle, projectByRoute, projectBySlug } = useHub(); + const project = projectByRoute(loaderData.parent, loaderData.slug)!; + const owner = memberByHandle(project.owner); + const parent = project.parent ? projectBySlug(project.parent) : undefined; + const children = childProjects(project.slug); return ( <article> @@ -94,9 +95,13 @@ export default function Project() { <dt className="text-faint">maintained by</dt> <dd className="m-0"> - <Link to={owner.path} className="text-ink hover:underline"> - {owner.name} - </Link> + {owner ? ( + <Link to={owner.path} className="text-ink hover:underline"> + {owner.name} + </Link> + ) : ( + project.owner + )} </dd> {project.url && ( diff --git a/site/src/pages/Projects.tsx b/site/app/routes/projects.tsx similarity index 92% rename from site/src/pages/Projects.tsx rename to site/app/routes/projects.tsx index 14281c1..155a19b 100644 --- a/site/src/pages/Projects.tsx +++ b/site/app/routes/projects.tsx @@ -1,9 +1,10 @@ import ProjectCard from '../components/ProjectCard'; import Section from '../components/Section'; import { PROJECTS } from '../meta'; -import { rootProjects } from '../registry'; +import { useHub } from '../hub'; export default function Projects() { + const { rootProjects } = useHub(); return ( <> <section className="pt-22 pb-5"> diff --git a/site/app/routes/sitemap.ts b/site/app/routes/sitemap.ts new file mode 100644 index 0000000..fc93c17 --- /dev/null +++ b/site/app/routes/sitemap.ts @@ -0,0 +1,27 @@ +import type { Route } from './+types/sitemap'; +import { modelFrom } from '../hub'; +import { ORIGIN } from '../meta'; +import { changelogPath } from '../model'; + +export function loader({ context }: Route.LoaderArgs) { + const { members, projects } = modelFrom(context); + const paths = [ + '/', + '/members', + '/epics', + '/projects', + ...members.map((member) => member.path), + ...projects.map((project) => project.path), + '/changelog', + ...members.map(changelogPath), + ...projects.map(changelogPath), + ]; + const body = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + ...paths.map((path) => ` <url><loc>${ORIGIN}${path === '/' ? path : `${path}/`}</loc></url>`), + '</urlset>', + '', + ].join('\n'); + return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } }); +} diff --git a/site/src/styles/global.css b/site/app/styles/global.css similarity index 100% rename from site/src/styles/global.css rename to site/app/styles/global.css diff --git a/site/index.html b/site/index.html deleted file mode 100644 index 7ce9e89..0000000 --- a/site/index.html +++ /dev/null @@ -1,20 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> - <meta name="theme-color" content="#0c0c0e" /> - <!--head--> - <title>Project Graphite - - - - -
- - - diff --git a/site/public/404.css b/site/public/404.css deleted file mode 100644 index 8ef82a4..0000000 --- a/site/public/404.css +++ /dev/null @@ -1,54 +0,0 @@ -:root { - color-scheme: dark; - --paper: #0c0c0e; - --ink: #e8e8ea; - --muted: #8b8b95; - --line: #26262c; -} -body { - margin: 0; - min-height: 100dvh; - display: grid; - place-items: center; - padding: 24px; - box-sizing: border-box; - background: var(--paper); - color: var(--ink); - font-family: - ui-sans-serif, - system-ui, - -apple-system, - 'Segoe UI', - Roboto, - sans-serif; - line-height: 1.55; - -webkit-font-smoothing: antialiased; -} -main { - max-width: 34rem; -} -h1 { - margin: 0 0 14px; - font-size: clamp(1.6rem, 4vw, 2.2rem); - letter-spacing: -0.03em; - font-weight: 640; -} -p { - margin: 0 0 24px; - color: var(--muted); -} -a { - color: var(--ink); - text-decoration: none; - border-bottom: 1px solid var(--line); - padding-bottom: 1px; - font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; - font-size: 0.85em; -} -a:hover { - border-bottom-color: var(--ink); -} -:focus-visible { - outline: 2px solid var(--ink); - outline-offset: 3px; -} diff --git a/site/public/404.html b/site/public/404.html deleted file mode 100644 index 718a1f3..0000000 --- a/site/public/404.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - Not found — Project Graphite - - - - - - -
-

Nothing here.

-

That page does not exist. Deployed projects live on their own subdomains.

- back to the index -
- - diff --git a/site/public/_headers b/site/public/_headers deleted file mode 100644 index 355a592..0000000 --- a/site/public/_headers +++ /dev/null @@ -1,10 +0,0 @@ -/* - Content-Security-Policy: default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' https://avatars.githubusercontent.com https://github.com; object-src 'none'; script-src 'self'; style-src 'self'; upgrade-insecure-requests - Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=() - Referrer-Policy: strict-origin-when-cross-origin - Strict-Transport-Security: max-age=31536000 - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - -/assets/* - Cache-Control: public, max-age=31536000, immutable diff --git a/site/public/_redirects b/site/public/_redirects deleted file mode 100644 index fb0dd0c..0000000 --- a/site/public/_redirects +++ /dev/null @@ -1,12 +0,0 @@ -/amr /members/amrmscll/ 301 -/amr/ /members/amrmscll/ 301 -/members/amr /members/amrmscll/ 301 -/members/amr/ /members/amrmscll/ 301 -/amr/windhawk-mods /epics/windhawk-mods/ 301 -/amr/windhawk-mods/ /epics/windhawk-mods/ 301 -/projects/windhawk-mods /epics/windhawk-mods/ 301 -/projects/windhawk-mods/ /epics/windhawk-mods/ 301 -/projects/windhawk-mods/stack-switch /epics/windhawk-mods/stack-switch/ 301 -/projects/windhawk-mods/stack-switch/ /epics/windhawk-mods/stack-switch/ 301 -/projects/windhawk-mods/tasktune /epics/windhawk-mods/tasktune/ 301 -/projects/windhawk-mods/tasktune/ /epics/windhawk-mods/tasktune/ 301 diff --git a/site/scripts/check-routes.mjs b/site/scripts/check-routes.mjs deleted file mode 100644 index 0c3d811..0000000 --- a/site/scripts/check-routes.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import { access, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -const registry = JSON.parse(await readFile('src/data/registry.json', 'utf8')); -const routes = [ - '/', - '/members', - '/epics', - '/projects', - ...registry.members.map((member) => member.path), - ...registry.projects.map((project) => project.path), - '/changelog', - ...registry.members.map((member) => `/changelog${member.path}`), - ...registry.projects.map((project) => `/changelog${project.path}`), -]; - -const missing = []; - -for (const route of routes) { - const file = route === '/' ? join('dist', 'index.html') : join('dist', route, 'index.html'); - try { - await access(file); - } catch { - missing.push(`${route} (expected ${file})`); - } -} - -for (const asset of ['404.html', 'robots.txt', '_headers', '_redirects', 'llms.txt']) { - try { - await access(join('dist', asset)); - } catch { - missing.push(`${asset} (expected dist/${asset})`); - } -} - -const sitemap = await readFile(join('dist', 'sitemap.xml'), 'utf8'); - -for (const route of routes) { - const location = `https://project-graphite.com${route === '/' ? route : `${route}/`}`; - if (!sitemap.includes(`${location}`)) { - missing.push(`sitemap entry for ${route}`); - } -} - -if (missing.length > 0) { - for (const entry of missing) { - console.error(`::error::Nothing rendered for ${entry}`); - } - process.exit(1); -} - -console.log(`All ${routes.length} registry routes and discovery files are present.`); diff --git a/site/scripts/fetch-github.mjs b/site/scripts/fetch-github.mjs deleted file mode 100644 index 1d1d5bf..0000000 --- a/site/scripts/fetch-github.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; - -const OUTPUT = 'src/data/github.json'; - -const registry = JSON.parse(await readFile('src/data/registry.json', 'utf8')); -const cached = JSON.parse(await readFile(OUTPUT, 'utf8')); - -const headers = { - Accept: 'application/vnd.github+json', - 'User-Agent': 'project-graphite-hub', -}; - -if (process.env.GITHUB_TOKEN) { - headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; -} - -const entries = await Promise.all( - registry.members.map(async (member) => { - try { - const response = await fetch(`https://api.github.com/users/${member.github}`, { - headers, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { - throw new Error(`GitHub answered ${response.status}`); - } - - const user = await response.json(); - return [ - member.github, - { - avatarUrl: user.avatar_url, - publicRepos: user.public_repos, - followers: user.followers, - joined: user.created_at.slice(0, 4), - }, - ]; - } catch (error) { - console.warn(`Could not refresh ${member.github}: ${error.message}. Using cached data.`); - return [ - member.github, - cached[member.github] ?? { - avatarUrl: `https://github.com/${member.github}.png?size=128`, - publicRepos: null, - followers: null, - joined: null, - }, - ]; - } - }), -); - -await writeFile(OUTPUT, JSON.stringify(Object.fromEntries(entries), null, 2) + '\n', 'utf8'); -console.log(`Prepared ${entries.length} GitHub profiles in ${OUTPUT}.`); diff --git a/site/scripts/prerender.mjs b/site/scripts/prerender.mjs deleted file mode 100644 index eff21b3..0000000 --- a/site/scripts/prerender.mjs +++ /dev/null @@ -1,112 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const DIST = 'dist'; -const OPEN = ''; -const CLOSE = ''; - -const escape = (value) => - value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - -const head = ({ title, description, canonical }) => - [ - `${escape(title)}`, - ``, - ``, - ``, - ``, - ``, - '', - '', - '', - ].join('\n '); - -const template = await readFile(join(DIST, 'index.html'), 'utf8'); -const open = template.indexOf(OPEN); -const close = template.indexOf(CLOSE); - -if (open === -1 || close === -1) { - throw new Error(`index.html is missing the ${OPEN} … ${CLOSE} markers`); -} - -const { render, routePaths } = await import( - pathToFileURL(join(process.cwd(), 'dist-ssr', 'entry-server.js')).href -); - -const locations = []; - -for (const route of routePaths) { - const { html, meta } = render(route); - locations.push(meta.canonical); - const page = template - .slice(0, open) - .concat(head(meta), template.slice(close + CLOSE.length)) - .replace('
', () => `
${html}
`); - - const file = route === '/' ? join(DIST, 'index.html') : join(DIST, route, 'index.html'); - await mkdir(dirname(file), { recursive: true }); - await writeFile(file, page, 'utf8'); - console.log(`prerendered ${route} -> ${file}`); -} - -const sitemap = [ - '', - '', - ...locations.map((location) => ` ${location}`), - '', - '', -].join('\n'); - -await writeFile(join(DIST, 'sitemap.xml'), sitemap, 'utf8'); -console.log('generated sitemap.xml'); - -const ORIGIN = 'https://project-graphite.com'; -const { members, projects } = JSON.parse( - await readFile(join('src', 'data', 'registry.json'), 'utf8'), -); -const statusLabel = { - live: 'live', - wip: 'in progress', - archived: 'archived', - 'source-only': 'source only', -}; -const projectLine = (project) => - [ - `- [${project.name}](${ORIGIN}${project.path}/): ${project.summary}`, - project.url && project.status === 'live' - ? ` Open it at ${project.url}.` - : ` Status: ${statusLabel[project.status]}.`, - ` Source: ${project.repoUrl}`, - ].join(''); - -const llms = [ - '# Project Graphite', - '', - '> A shared portfolio for Project Graphite members, their work and the applications they deploy.', - '', - 'Every project has its own repository. Live web apps run at their own subdomain of project-graphite.com and are open to use; source-only and in-progress work is labelled as such.', - '', - '## Live apps', - '', - ...projects.filter((project) => project.status === 'live').map(projectLine), - '', - '## Other projects', - '', - ...projects.filter((project) => project.status !== 'live').map(projectLine), - '', - '## Members', - '', - ...members.map( - (member) => `- [${member.name}](${ORIGIN}/members/${member.handle}/): ${member.headline}`, - ), - '', - '## Site', - '', - `- [Changelog](${ORIGIN}/changelog/): every release of every project`, - `- [Sitemap](${ORIGIN}/sitemap.xml): every page on the site`, - '', -].join('\n'); - -await writeFile(join(DIST, 'llms.txt'), llms, 'utf8'); -console.log('generated llms.txt'); diff --git a/site/src/App.tsx b/site/src/App.tsx deleted file mode 100644 index adb3a91..0000000 --- a/site/src/App.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Route, Routes } from 'react-router'; -import Shell from './components/Shell'; -import Changelog from './pages/Changelog'; -import Epics from './pages/Epics'; -import Index from './pages/Index'; -import Member from './pages/Member'; -import MemberChangelog from './pages/MemberChangelog'; -import Members from './pages/Members'; -import NotFound from './pages/NotFound'; -import Project from './pages/Project'; -import ProjectChangelog from './pages/ProjectChangelog'; -import Projects from './pages/Projects'; - -export default function App() { - return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -} diff --git a/site/src/data/github.json b/site/src/data/github.json deleted file mode 100644 index 15b77d4..0000000 --- a/site/src/data/github.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "AmrMsCLL": { - "avatarUrl": "https://avatars.githubusercontent.com/u/141947355?v=4", - "publicRepos": 6, - "followers": 13, - "joined": "2023" - }, - "BluoCaroot": { - "avatarUrl": "https://avatars.githubusercontent.com/u/114243953?v=4", - "publicRepos": 24, - "followers": 49, - "joined": "2022" - } -} diff --git a/site/src/data/registry.json b/site/src/data/registry.json deleted file mode 100755 index 162c6da..0000000 --- a/site/src/data/registry.json +++ /dev/null @@ -1,602 +0,0 @@ -{ - "members": [ - { - "handle": "nier0o", - "github": "Nier0o", - "name": "Ahmed Saber", - "headline": null, - "bio": null, - "stack": [], - "links": [ - { - "label": "github", - "url": "https://github.com/Nier0o" - } - ], - "path": "/members/nier0o" - }, - { - "handle": "amrmscll", - "github": "AmrMsCLL", - "name": "Amr El-Sheraey", - "headline": "Full-stack Software Engineer", - "bio": "Computer Science and AI graduate of FCAI, Cairo University, building document-intelligence software at Syntheia. A normal week touches a TypeScript API, a React front end, a Python ML service and a Helm chart. Most useful where those meet, and most interested in whichever part I have not done before.", - "stack": [ - { - "group": "languages", - "items": [ - "TypeScript", - "JavaScript", - "Python", - "C++", - "SQL" - ] - }, - { - "group": "backend", - "items": [ - "NestJS", - "Express", - "FastAPI", - "Django", - "Prisma", - "GraphQL" - ] - }, - { - "group": "frontend", - "items": [ - "React", - "Redux Toolkit", - "Tailwind", - "MUI", - "Vite", - "Office.js" - ] - }, - { - "group": "data", - "items": [ - "PostgreSQL", - "MongoDB", - "Redis", - "Qdrant", - "Milvus" - ] - }, - { - "group": "ml", - "items": [ - "PyTorch", - "Transformers", - "spaCy", - "scikit-learn", - "Celery" - ] - }, - { - "group": "platform", - "items": [ - "Docker", - "Kubernetes", - "Helm", - "Terraform", - "AWS", - "Azure" - ] - }, - { - "group": "testing", - "items": [ - "Jest", - "Pytest", - "Playwright", - "SonarQube" - ] - } - ], - "links": [ - { - "label": "github", - "url": "https://github.com/AmrMsCLL" - }, - { - "label": "linkedin", - "url": "https://www.linkedin.com/in/amrsherieycll" - }, - { - "label": "email", - "url": "mailto:amrsherieycll@gmail.com" - }, - { - "label": "discord", - "url": "https://discord.com/users/412317378337046529" - } - ], - "path": "/members/amrmscll" - }, - { - "handle": "bluocaroot", - "github": "BluoCaroot", - "name": "Assem Amr Abdelfattah", - "headline": "Software Engineer at Syntheia", - "bio": "Software engineer from Egypt, building document-intelligence software at Syntheia: a NestJS API, a React portal and an Outlook add-in on Office.js. Came up through competitive programming, which still shapes how I approach problems, and outside work I build tools like Digital to Physical to learn the parts of the stack I have not touched yet.", - "stack": [ - { - "group": "languages", - "items": [ - "TypeScript", - "JavaScript", - "C++", - "Python", - "C", - "SQL" - ] - }, - { - "group": "backend", - "items": [ - "NestJS", - "Node.js", - "Express", - "GraphQL" - ] - }, - { - "group": "frontend", - "items": [ - "React", - "MUI", - "Tailwind", - "Vite", - "Office.js" - ] - }, - { - "group": "data", - "items": [ - "PostgreSQL", - "MongoDB" - ] - }, - { - "group": "platform", - "items": [ - "Docker", - "Azure", - "Linux", - "Git" - ] - }, - { - "group": "testing", - "items": [ - "Jest", - "SonarQube" - ] - } - ], - "links": [ - { - "label": "github", - "url": "https://github.com/BluoCaroot" - }, - { - "label": "linkedin", - "url": "https://www.linkedin.com/in/assem-amr-abdelfattah/" - }, - { - "label": "codeforces", - "url": "https://codeforces.com/profile/BluoCaroot" - } - ], - "path": "/members/bluocaroot" - }, - { - "handle": "mahmoudhanyfathalla", - "github": "MahmoudHanyFathalla", - "name": "Mahmoud Hany Fathalla", - "headline": null, - "bio": null, - "stack": [], - "links": [ - { - "label": "github", - "url": "https://github.com/MahmoudHanyFathalla" - } - ], - "path": "/members/mahmoudhanyfathalla" - }, - { - "handle": "omar-mohamed24", - "github": "Omar-Mohamed24", - "name": "Omar Mohamed", - "headline": "Backend & Full-stack Software Engineer", - "bio": "Software engineer who goes looking for the hard parts. Wrote a Game Boy emulator from scratch in C++20, compiled it to WebAssembly and shipped it as PixelPocket, where it passes all of Blargg's CPU tests and plays in any browser. Built GameForge, a ten-service platform where an LLM planner, an MCP asset pipeline and a Godot build step turn one sentence into a playable game. Codeforces Expert with 1000+ problems solved, CS graduate of FCAI, Cairo University, and formerly a prompt engineer at Scale AI.", - "stack": [ - { - "group": "languages", - "items": [ - "C++", - "JavaScript", - "Python", - "Java", - "C#" - ] - }, - { - "group": "backend", - "items": [ - "Node.js", - "Express", - "Django", - "REST APIs", - "Microservices" - ] - }, - { - "group": "frontend", - "items": [ - "React", - "WebAssembly", - "HTML", - "CSS" - ] - }, - { - "group": "data", - "items": [ - "MongoDB", - "MySQL", - "MinIO" - ] - }, - { - "group": "ai", - "items": [ - "LLM Integration", - "MCP", - "Stability AI", - "Prompt Engineering" - ] - }, - { - "group": "platform", - "items": [ - "Docker", - "Docker Compose", - "GitHub Actions", - "Linux", - "Git" - ] - } - ], - "links": [ - { - "label": "github", - "url": "https://github.com/Omar-Mohamed24" - }, - { - "label": "linkedin", - "url": "https://www.linkedin.com/in/omar-mohamed-8034032a3" - }, - { - "label": "email", - "url": "mailto:omarmohamed67371@gmail.com" - }, - { - "label": "codeforces", - "url": "https://codeforces.com/profile/Maliiketh" - } - ], - "path": "/members/omar-mohamed24" - } - ], - "projects": [ - { - "slug": "digital-to-physical", - "parent": null, - "kind": "project", - "name": "Digital to Physical", - "summary": "A private, client-side tool that turns digital comic archives into print-ready PDFs with spread alignment, booklet padding, flexible back covers and a live pre-print audit.", - "tags": [ - "react", - "vite", - "typescript", - "webassembly", - "pdf" - ], - "repo": "project-graphite/digital-to-physical", - "repoUrl": "https://github.com/project-graphite/digital-to-physical", - "owner": "bluocaroot", - "authors": [ - "BluoCaroot" - ], - "status": "live", - "paths": [], - "releases": "auto", - "pinned": true, - "listedOn": "2026-09-17", - "showcase": null, - "url": "https://digital-to-physical.project-graphite.com", - "path": "/projects/digital-to-physical" - }, - { - "slug": "graphite-message-lab", - "parent": null, - "kind": "project", - "name": "Graphite Message Lab", - "summary": "A public message form with private-by-default moderation, backed by Django, PostgreSQL, Redis and Celery and deployed automatically through the Project Graphite hosting path.", - "tags": [ - "django", - "postgresql", - "redis", - "celery", - "docker" - ], - "repo": "project-graphite/graphite-message-lab", - "repoUrl": "https://github.com/project-graphite/graphite-message-lab", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "live", - "paths": [], - "releases": "auto", - "pinned": false, - "listedOn": "2026-09-16", - "showcase": { - "problem": "Project Graphite needed an approachable application that visitors could use while proving the complete path from a browser through an API, persistent database and background worker.", - "approach": "Visitors submit public or private messages through Django. PostgreSQL stores them, Redis and Celery process them asynchronously, and moderation keeps messages private until an admin chooses to publish them. Each main build publishes the images and records their tag in the platform repository, which deploys them.", - "outcome": "The live lab demonstrates routing, TLS, application health, persistence, asynchronous work, administrator review and automatic deployment through one interaction visitors can understand.", - "contributions": [ - { - "github": "AmrMsCLL", - "summary": "Defined the test scenario and owns the deployment, infrastructure and acceptance criteria used to validate the Graphite hosting path." - } - ] - }, - "url": "https://message-lab.project-graphite.com", - "path": "/projects/graphite-message-lab" - }, - { - "slug": "graphite-tracker", - "parent": null, - "kind": "project", - "name": "Graphite Tracker", - "summary": "A universal entertainment tracker for movies, television, anime, manga, manhwa and games.", - "tags": [ - "react", - "nestjs", - "prisma", - "postgresql", - "tmdb", - "rawg" - ], - "repo": "project-graphite/graphite-tracker", - "repoUrl": "https://github.com/project-graphite/graphite-tracker", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "live", - "paths": [], - "releases": "auto", - "pinned": false, - "listedOn": "2026-09-23", - "showcase": { - "problem": "Films, series, anime, manga and games are usually tracked in separate apps, so a reader's progress, ratings and upcoming releases end up split across several services.", - "approach": "One NestJS API pulls TMDB, MangaDex and IGDB or RAWG data into a shared catalogue and a library with per-category progress. Readers import Mihon and AniYomi backups, rate and review finished titles, and get in-app and email alerts for new episodes, chapters and releases. Adult content stays filtered unless a reader opts in.", - "outcome": "A live, phone-ready tracker where one account holds every category's library, progress, reviews and release alerts, behind a public or private profile and administrator moderation.", - "contributions": [ - { - "github": "AmrMsCLL", - "summary": "Designed and built the NestJS API, Prisma data model, React frontend, source integrations, backup import, release notifications and moderation, and owns the CI and platform deployment." - } - ] - }, - "url": "https://graphite-tracker.project-graphite.com", - "path": "/projects/graphite-tracker" - }, - { - "slug": "graphite-ui", - "parent": null, - "kind": "project", - "name": "Graphite UI", - "summary": "The shared Project Graphite design tokens, dark theme and React components used by every Graphite web app.", - "tags": [ - "react", - "typescript", - "tailwind" - ], - "repo": "project-graphite/graphite-ui", - "repoUrl": "https://github.com/project-graphite/graphite-ui", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "wip", - "paths": [], - "releases": "manual", - "pinned": false, - "listedOn": "2026-10-01", - "showcase": null, - "url": null, - "path": "/projects/graphite-ui" - }, - { - "slug": "pixelpocket", - "parent": null, - "kind": "project", - "name": "PixelPocket", - "summary": "Play Game Boy games in your browser. A custom C++ Game Boy emulator compiled to WebAssembly, with a React frontend and a Node.js API.", - "tags": [ - "c++", - "webassembly", - "emulator", - "react", - "nodejs", - "docker" - ], - "repo": "project-graphite/pixelpocket", - "repoUrl": "https://github.com/project-graphite/pixelpocket", - "owner": "omar-mohamed24", - "authors": [ - "Omar-Mohamed24" - ], - "status": "live", - "paths": [], - "releases": "auto", - "pinned": true, - "listedOn": "2026-10-10", - "showcase": { - "problem": "Playing Game Boy games usually means installing and configuring a desktop emulator, and save files stay stuck on the machine they were made on. There was no simple way to pick a game from a library, play it in the browser on any device and keep the same progress.", - "approach": "A Game Boy (DMG) emulator written from scratch in C++20 (CPU, graphics, sound, timers and MBC1/MBC3/MBC5 cartridges), compiled to WebAssembly with Emscripten and run inside a React app with Web Audio sound and keyboard, gamepad and touch input. An Express API on MongoDB and MinIO handles accounts, the game library, uploads and battery saves, checking ROM headers and re-encoding cover images before storing them. Docker Compose runs the stack.", - "outcome": "Visitors pick a game and play it right on the page, with no install and no server round-trip during play, and signed-in players' saves follow them to any device. The core passes all 11 of Blargg's cpu_instrs tests and renders dmg-acid2 correctly.", - "contributions": [ - { - "github": "Omar-Mohamed24", - "summary": "Built the whole platform: the C++ emulator core and its WebAssembly build, the React frontend, the Express API with its storage, and the Docker Compose deployment." - } - ] - }, - "url": "https://pixelpocket.project-graphite.com", - "path": "/projects/pixelpocket" - }, - { - "slug": "stack-switch", - "parent": "windhawk-mods", - "kind": "project", - "name": "StackSwitch", - "summary": "A Windhawk taskbar launcher that starts and stops configurable working sets of applications, folders, editor workspaces, URLs and terminal commands with one action.", - "tags": [ - "cpp", - "windows", - "windhawk", - "win32" - ], - "repo": "project-graphite/windhawk-mods", - "repoUrl": "https://github.com/project-graphite/windhawk-mods", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "source-only", - "paths": [ - "mods/stack-switch" - ], - "releases": "auto", - "pinned": false, - "listedOn": "2026-09-17", - "showcase": { - "problem": "A development session can mean opening the same mix of applications, workspaces, folders, URLs and terminal commands, then finding and closing them all again in the right order.", - "approach": "StackSwitch adds a native taskbar panel with persistent selections, named profiles, grouped items, launch delays and wait-for-exit behavior. It closes the same selection in reverse order and avoids fragile Windows symbol resolution by using a layered child window.", - "outcome": "One taskbar action can start or stop a repeatable working environment while optional tools stay disabled until needed.", - "contributions": [ - { - "github": "AmrMsCLL", - "summary": "Designed and implemented the Win32 taskbar integration, configuration model, process orchestration, profiles, persisted selections and user-facing launcher panel." - } - ] - }, - "url": null, - "path": "/epics/windhawk-mods/stack-switch" - }, - { - "slug": "strata", - "parent": null, - "kind": "project", - "name": "Strata", - "summary": "A personal workspace for your dashboard, agenda, notes, whiteboards, subscriptions and files.", - "tags": [ - "react", - "nestjs", - "prisma", - "postgresql", - "redis", - "typescript" - ], - "repo": "project-graphite/strata", - "repoUrl": "https://github.com/project-graphite/strata", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "live", - "paths": [], - "releases": "auto", - "pinned": true, - "listedOn": "2026-10-01", - "showcase": null, - "url": "https://strata.project-graphite.com", - "path": "/projects/strata" - }, - { - "slug": "tasktune", - "parent": "windhawk-mods", - "kind": "project", - "name": "TaskTune", - "summary": "A configurable Windhawk media player embedded in the Windows 11 taskbar, with media-session controls, per-app volume, audio-only application support and an optional spectrum visualizer.", - "tags": [ - "cpp", - "windows", - "windhawk", - "winrt", - "audio" - ], - "repo": "project-graphite/windhawk-mods", - "repoUrl": "https://github.com/project-graphite/windhawk-mods", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "source-only", - "paths": [ - "mods/tasktune" - ], - "releases": "auto", - "pinned": false, - "listedOn": "2026-09-18", - "showcase": { - "problem": "Checking playback details or controlling several media and audio applications usually means opening a player window or a separate volume interface and leaving the current workflow.", - "approach": "The mod injects a configurable Fluent UI player into the Windows 11 taskbar, reads Windows media sessions, falls back to active audio sessions, and exposes playback, session and per-application volume controls beside an optional WASAPI visualizer.", - "outcome": "Media status and the most common playback and volume actions remain available at a glance without adding a separate background application or replacing Windows system files.", - "contributions": [ - { - "github": "AmrMsCLL", - "summary": "Designed and maintains the media-session integration, taskbar player, per-application audio controls, visualizer, customization model and Project Graphite packaging." - } - ] - }, - "url": null, - "path": "/epics/windhawk-mods/tasktune" - }, - { - "slug": "windhawk-mods", - "parent": null, - "kind": "epic", - "name": "Windhawk Mods", - "summary": "Windhawk mods that add a launcher and a media player to the Windows 11 taskbar.", - "tags": [ - "cpp", - "windows", - "windhawk", - "win32" - ], - "repo": "project-graphite/windhawk-mods", - "repoUrl": "https://github.com/project-graphite/windhawk-mods", - "owner": "amrmscll", - "authors": [ - "AmrMsCLL" - ], - "status": "source-only", - "paths": [], - "releases": "auto", - "pinned": false, - "listedOn": "2026-09-15", - "showcase": null, - "url": null, - "path": "/epics/windhawk-mods" - } - ] -} diff --git a/site/src/entry-server.tsx b/site/src/entry-server.tsx deleted file mode 100644 index db6d738..0000000 --- a/site/src/entry-server.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { renderToString } from 'react-dom/server'; -import { StaticRouter } from 'react-router'; -import App from './App'; -import { metaFor } from './meta'; - -export { routePaths } from './registry'; - -export function render(path: string) { - const html = renderToString( - - - , - ); - - return { html, meta: metaFor(path) }; -} diff --git a/site/src/main.tsx b/site/src/main.tsx deleted file mode 100644 index 01f1471..0000000 --- a/site/src/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { StrictMode } from 'react'; -import { hydrateRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router'; -import App from './App'; -import './styles/global.css'; - -hydrateRoot( - document.getElementById('root')!, - - - - - , -); diff --git a/site/src/registry.ts b/site/src/registry.ts deleted file mode 100644 index 7ef47a5..0000000 --- a/site/src/registry.ts +++ /dev/null @@ -1,108 +0,0 @@ -import changelogData from './data/changelog.json'; -import githubData from './data/github.json'; -import data from './data/registry.json'; -import type { Changelog, GithubProfile, Member, Project, Registry, Release } from './types'; - -const registry = data as Registry; - -export const releases: Release[] = (changelogData as Changelog).releases; - -export const members: Member[] = registry.members; -export const projects: Project[] = registry.projects; -export const rootProjects: Project[] = projects.filter( - (project) => project.kind === 'project' && !project.parent, -); -export const epics: Project[] = projects.filter((project) => project.kind === 'epic'); -export const githubProfiles = githubData as Record; - -export const routePaths: string[] = [ - '/', - '/members', - '/epics', - '/projects', - ...members.map((member) => member.path), - ...projects.map((project) => project.path), - '/changelog', - ...members.map(changelogPath), - ...projects.map(changelogPath), -]; - -export function changelogPath(entry: { path: string }) { - return `/changelog${entry.path}`; -} - -export function memberByHandle(handle: string): Member | undefined { - return members.find((member) => member.handle.toLowerCase() === handle.toLowerCase()); -} - -export function memberByGithub(github: string): Member | undefined { - return members.find((member) => member.github.toLowerCase() === github.toLowerCase()); -} - -export function avatarFor(member: Member) { - return ( - githubProfiles[member.github]?.avatarUrl ?? `https://github.com/${member.github}.png?size=128` - ); -} - -function isCredited(project: Project, member: Member) { - return ( - project.owner === member.handle || - project.authors.some((author) => author.toLowerCase() === member.github.toLowerCase()) - ); -} - -export function projectsForMember(member: Member): Project[] { - return rootProjects.filter((project) => isCredited(project, member)); -} - -export function epicsForMember(member: Member): Project[] { - return epics.filter( - (epic) => - isCredited(epic, member) || - childProjects(epic.slug).some((project) => isCredited(project, member)), - ); -} - -export function contributionForMember(project: Project, member: Member) { - return project.showcase?.contributions.find( - (contribution) => contribution.github.toLowerCase() === member.github.toLowerCase(), - ); -} - -export function projectBySlug(slug: string): Project | undefined { - return projects.find((project) => project.slug === slug); -} - -export function projectByRoute(parent: string | undefined, slug: string): Project | undefined { - return projects.find((project) => project.parent === (parent ?? null) && project.slug === slug); -} - -export function childProjects(parent: string): Project[] { - return projects.filter((project) => project.parent === parent); -} - -export function releasesForProject(project: Project): Release[] { - const slugs = new Set([project.slug, ...childProjects(project.slug).map((child) => child.slug)]); - return releases.filter((release) => slugs.has(release.project)); -} - -export function lastPublishedOn(project: Project) { - const released = releasesForProject(project)[0]?.date ?? ''; - return released > project.listedOn ? released : project.listedOn; -} - -export function projectsOwnedBy(member: Member): Project[] { - return projects.filter((project) => project.owner === member.handle); -} - -export function releasesOwnedBy(member: Member): Release[] { - const slugs = new Set(projectsOwnedBy(member).map((project) => project.slug)); - return releases.filter((release) => slugs.has(release.project)); -} - -export function compareUrl(project: Project, release: Release) { - return release.previous - ? `${project.repoUrl}/compare/${release.previous}...${release.tag}` - : `${project.repoUrl}/commits/${release.tag}`; -} diff --git a/site/src/types.ts b/site/src/types.ts deleted file mode 100644 index cdcb1f9..0000000 --- a/site/src/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -export interface Project { - slug: string; - parent: string | null; - kind: 'project' | 'epic'; - name: string; - summary: string; - tags: string[]; - repo: string; - repoUrl: string; - owner: string; - authors: string[]; - status: 'live' | 'wip' | 'archived' | 'source-only'; - paths: string[]; - releases: 'auto' | 'manual'; - pinned: boolean; - listedOn: string; - showcase: ProjectShowcase | null; - url: string | null; - path: string; -} - -export interface ProjectContribution { - github: string; - summary: string; -} - -export interface ProjectShowcase { - problem: string; - approach: string; - outcome: string; - contributions: ProjectContribution[]; -} - -export interface MemberLink { - label: string; - url: string; -} - -export interface StackGroup { - group: string; - items: string[]; -} - -export interface Member { - handle: string; - github: string; - name: string; - headline: string | null; - bio: string | null; - stack: StackGroup[]; - links: MemberLink[]; - path: string; -} - -export interface GithubProfile { - avatarUrl: string; - publicRepos: number | null; - followers: number | null; - joined: string | null; -} - -export interface Registry { - members: Member[]; - projects: Project[]; -} - -export type ChangeType = 'breaking' | 'security' | 'added' | 'changed' | 'fixed' | 'removed'; - -export interface ReleaseChange { - text: string; - commit: string; - pr: number | null; - author: string | null; -} - -export interface Release { - id: string; - project: string; - version: string; - tag: string; - previous: string | null; - date: string; - title: string | null; - summary: string | null; - groups: { type: ChangeType; changes: ReleaseChange[] }[]; -} - -export interface Changelog { - releases: Release[]; -} diff --git a/site/src/vite-env.d.ts b/site/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/site/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// From 617c0d9253d954371fd8c9eced0728d655a1b731 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:04:16 +0200 Subject: [PATCH 10/12] ci: one pipeline that builds the hub image, with a client bundle budget --- .github/CODEOWNERS | 1 - .github/workflows/main.yml | 47 ------------------ .github/workflows/{pr.yml => pipeline.yml} | 44 ++++++++-------- site/scripts/check-size.mjs | 58 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 70 deletions(-) delete mode 100644 .github/workflows/main.yml rename .github/workflows/{pr.yml => pipeline.yml} (55%) create mode 100644 site/scripts/check-size.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 95b9e57..c98212f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ /.github/ @project-graphite/maintainers -/site/src/data/ @project-graphite/maintainers diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 0bb3487..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Main - -on: - push: - branches: [main] - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - build: - name: Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: site - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: "24" - cache: npm - cache-dependency-path: site/package-lock.json - - run: npm ci - - run: npm run lint - - run: npm run format:check - - run: npm test - - run: npm run build - env: - GITHUB_TOKEN: ${{ github.token }} - - run: node scripts/check-routes.mjs - - deploy: - name: Deploy - needs: build - uses: project-graphite/actions/.github/workflows/reusable-deploy.yml@main - with: - services: '["site"]' - secrets: inherit - - secrets: - name: Secrets - uses: project-graphite/actions/.github/workflows/reusable-gitleaks.yml@main diff --git a/.github/workflows/pr.yml b/.github/workflows/pipeline.yml similarity index 55% rename from .github/workflows/pr.yml rename to .github/workflows/pipeline.yml index d5cae4e..fd98b11 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pipeline.yml @@ -1,17 +1,32 @@ -name: PR +name: Pipeline -on: pull_request +on: + push: + branches: [main] + pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read jobs: - build: - name: Build + pipeline: + name: Pipeline + permissions: + contents: read + packages: write + issues: write + pull-requests: write + uses: project-graphite/actions/.github/workflows/reusable-pipeline.yml@main + with: + changelog: false + secrets: inherit + + budget: + name: Types, format and bundle size runs-on: ubuntu-latest defaults: run: @@ -24,22 +39,7 @@ jobs: cache: npm cache-dependency-path: site/package-lock.json - run: npm ci - - run: npm run lint - run: npm run format:check - - run: npm test + - run: npm run typecheck - run: npm run build - env: - GITHUB_TOKEN: ${{ github.token }} - - run: node scripts/check-routes.mjs - - secrets: - name: Secrets - uses: project-graphite/actions/.github/workflows/reusable-gitleaks.yml@main - - conventions: - name: Conventions - permissions: - contents: read - issues: write - pull-requests: write - uses: project-graphite/actions/.github/workflows/reusable-pr-checks.yml@main + - run: npm run size diff --git a/site/scripts/check-size.mjs b/site/scripts/check-size.mjs new file mode 100644 index 0000000..5ad1dea --- /dev/null +++ b/site/scripts/check-size.mjs @@ -0,0 +1,58 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +const BUDGET_KB = 90; +const CLIENT = join('build', 'client'); +const ASSETS = join(CLIENT, 'assets'); + +const manifestFile = (await readdir(ASSETS)).find((file) => /^manifest-.*\.js$/.test(file)); +const manifest = JSON.parse( + (await readFile(join(ASSETS, manifestFile), 'utf8')).replace( + /^window\.__reactRouterManifest=|;$/g, + '', + ), +); + +const compressed = new Map(); +async function size(asset) { + if (!compressed.has(asset)) { + compressed.set(asset, gzipSync(await readFile(join(CLIENT, asset))).length); + } + return compressed.get(asset); +} + +function chain(route) { + return route ? [...chain(manifest.routes[route.parentId]), route] : []; +} + +const pages = []; +for (const route of Object.values(manifest.routes)) { + if (!route.hasDefaultExport || route.id === 'root') { + continue; + } + const assets = new Set([ + `/assets/${manifestFile}`, + manifest.entry.module, + ...manifest.entry.imports, + ...chain(route).flatMap((step) => [step.module, ...(step.imports ?? [])]), + ]); + const bytes = (await Promise.all([...assets].map((asset) => size(asset)))).reduce( + (sum, value) => sum + value, + 0, + ); + pages.push({ id: route.id, kilobytes: bytes / 1024 }); +} + +pages.sort((a, b) => b.kilobytes - a.kilobytes); +for (const { id, kilobytes } of pages) { + console.log(`${kilobytes.toFixed(1).padStart(6)} KB ${id}`); +} +const [largest] = pages; +if (largest.kilobytes > BUDGET_KB) { + console.error( + `::error::${largest.id} loads ${largest.kilobytes.toFixed(1)} KB of compressed JavaScript, over the ${BUDGET_KB} KB budget.`, + ); + process.exit(1); +} +console.log(`Every page is within the ${BUDGET_KB} KB budget.`); From f319fc807c5b82b3f54f56ece30ebd9e5ac53864 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:17:57 +0200 Subject: [PATCH 11/12] ci: set the client bundle budget to 150 KB per page --- site/scripts/check-size.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/scripts/check-size.mjs b/site/scripts/check-size.mjs index 5ad1dea..b4fc95b 100644 --- a/site/scripts/check-size.mjs +++ b/site/scripts/check-size.mjs @@ -2,7 +2,7 @@ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { gzipSync } from 'node:zlib'; -const BUDGET_KB = 90; +const BUDGET_KB = 150; const CLIENT = join('build', 'client'); const ASSETS = join(CLIENT, 'assets'); From d4f5095170a9d5c004dd70408fee5f36eb793b17 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:02:01 +0200 Subject: [PATCH 12/12] fix: a session whose token no longer decrypts is signed out instead of failing every request --- site/server/auth.test.ts | 20 +++++++++++++++++++- site/server/auth.ts | 3 ++- site/server/sessions.ts | 6 +++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/site/server/auth.test.ts b/site/server/auth.test.ts index 605d093..11ab32a 100644 --- a/site/server/auth.test.ts +++ b/site/server/auth.test.ts @@ -4,7 +4,8 @@ import { describe, it } from 'node:test'; import { clientAddress, cloudflareRanges } from './address.ts'; import { cookies, safeReturn, sameOrigin } from './auth.ts'; import { createVerifier, sign } from './internal.ts'; -import { createSealer } from './sessions.ts'; +import type { Redis } from './redis.ts'; +import { createSealer, createSessions, type Session } from './sessions.ts'; function request(headers: Record, remoteAddress = '172.18.0.5') { return { headers, socket: { remoteAddress } } as unknown as IncomingMessage; @@ -27,6 +28,23 @@ describe('session sealing', () => { }); }); +describe('a session after SESSION_SECRET changes', () => { + it('yields no GitHub token, so the session is ended instead of failing every request', () => { + const session: Session = { + login: 'AmrMsCLL', + owner: true, + avatarUrl: 'https://avatars.githubusercontent.com/u/1', + token: createSealer('old-secret').seal('gho_token'), + csrf: 'csrf', + createdAt: '2026-11-05T00:00:00.000Z', + checkedAt: 0, + }; + const redis = {} as Redis; + assert.equal(createSessions(redis, 'old-secret').githubToken(session), 'gho_token'); + assert.equal(createSessions(redis, 'new-secret').githubToken(session), undefined); + }); +}); + describe('sign-in helpers', () => { it('returns only to a path on this site', () => { assert.equal(safeReturn('/members/amrmscll'), '/members/amrmscll'); diff --git a/site/server/auth.ts b/site/server/auth.ts index f8bf365..108d129 100644 --- a/site/server/auth.ts +++ b/site/server/auth.ts @@ -60,7 +60,8 @@ export function createAuth(sessions: Sessions, redis: Redis) { if (config.dev || Date.now() - session.checkedAt < RECHECK_MS) { return { id, session }; } - const member = await membership(sessions.githubToken(session)).catch(() => session); + const token = sessions.githubToken(session); + const member = token && (await membership(token).catch(() => session)); if (!member) { await sessions.destroy(id, session.login); setCookie(response, SESSION_COOKIE, '', 0); diff --git a/site/server/sessions.ts b/site/server/sessions.ts index 388aa12..de9905f 100644 --- a/site/server/sessions.ts +++ b/site/server/sessions.ts @@ -86,7 +86,11 @@ export function createSessions(redis: Redis, secret: string) { return session; }, githubToken(session: Session) { - return sealer.open(session.token); + try { + return sealer.open(session.token); + } catch { + return undefined; + } }, async update(id: string, session: Session) { await redis.set(sessionKey(id), JSON.stringify(session), 'KEEPTTL');