From 953b9cc8edf714402e642e8308de29fdb3d4ee82 Mon Sep 17 00:00:00 2001 From: JxM Date: Tue, 21 Apr 2026 14:04:42 +0200 Subject: [PATCH 1/7] feat(docs): add feedback widget --- docs/.env.example | 3 + docs/.gitignore | 2 + docs/README_FEEDBACK_INTEGRATION.md | 17 + docs/astro.config.mjs | 42 ++- docs/netlify.toml | 10 + docs/netlify/functions/feedback.ts | 141 ++++++++ docs/package.json | 4 +- docs/src/components/FeedbackWidget.astro | 401 +++++++++++++++++++++++ docs/src/components/Footer.astro | 17 + pnpm-lock.yaml | 94 +++--- 10 files changed, 680 insertions(+), 51 deletions(-) create mode 100644 docs/.env.example create mode 100644 docs/README_FEEDBACK_INTEGRATION.md create mode 100644 docs/netlify.toml create mode 100644 docs/netlify/functions/feedback.ts create mode 100644 docs/src/components/FeedbackWidget.astro create mode 100644 docs/src/components/Footer.astro diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 00000000..10a3318c --- /dev/null +++ b/docs/.env.example @@ -0,0 +1,3 @@ +# Local direct-submit config for Netlify dev. +GITHUB_TOKEN= +PUBLIC_FEEDBACK_FUNCTION_URL=/.netlify/functions/feedback diff --git a/docs/.gitignore b/docs/.gitignore index 6240da8b..501e1ec5 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -19,3 +19,5 @@ pnpm-debug.log* # macOS-specific files .DS_Store + +.netlify diff --git a/docs/README_FEEDBACK_INTEGRATION.md b/docs/README_FEEDBACK_INTEGRATION.md new file mode 100644 index 00000000..047cad5e --- /dev/null +++ b/docs/README_FEEDBACK_INTEGRATION.md @@ -0,0 +1,17 @@ +# Feedback Widget + +The docs footer can either: + +- open a prefilled issue in `interledger/open-payments-docs-feedback` +- post directly to GitHub through `docs/netlify/functions/feedback.ts` + +`openpayments.dev` is a GitHub Pages deploy, so production uses the issue-form fallback +unless you point `PUBLIC_FEEDBACK_FUNCTION_URL` at another hosted endpoint. + +## Local dev + +1. Copy `docs/.env.example` to `docs/.env`. +2. Run `pnpm dev:netlify` from `docs/`, or `pnpm --filter docs dev:netlify` from the repo root. +3. Open the Netlify URL shown as `Local dev server ready`. + +Plain `pnpm start` only exercises the fallback path. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index b6abf054..17ec4a1d 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -1,3 +1,5 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { defineConfig } from 'astro/config' import starlight from '@astrojs/starlight' import starlightOpenAPI from 'starlight-openapi' @@ -5,6 +7,32 @@ import starlightLinksValidator from 'starlight-links-validator' import starlightFullViewMode from 'starlight-fullview-mode' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' +import { loadEnv } from 'vite' + +const docsRoot = path.dirname(fileURLToPath(import.meta.url)) +const envFromFile = loadEnv( + process.env.NODE_ENV === 'production' ? 'production' : 'development', + docsRoot, + '' +) + +const isNetlifyDev = + process.env.NETLIFY_DEV === 'true' || process.env.NETLIFY_DEV === '1' + +const netlifyDevFunctionsProxyTarget = (() => { + const raw = + process.env.URL || + process.env.DEPLOY_URL || + envFromFile.NETLIFY_FUNCTIONS_PROXY_TARGET + if (!raw) { + return null + } + try { + return new URL(raw).origin + } catch { + return null + } +})() // https://astro.build/config export default defineConfig({ @@ -31,7 +59,8 @@ export default defineConfig({ ], components: { Header: './src/components/Header.astro', - PageSidebar: './src/components/PageSidebar.astro' + PageSidebar: './src/components/PageSidebar.astro', + Footer: './src/components/Footer.astro' }, customCss: [ './node_modules/@interledger/docs-design-system/src/styles/teal-theme.css', @@ -556,6 +585,15 @@ export default defineConfig({ '/sdk/grant-create': '/sdk/grant-create-incoming' }, server: { - port: 1104 + port: 1104, + ...(isNetlifyDev && + netlifyDevFunctionsProxyTarget && { + proxy: { + '/.netlify/functions': { + target: netlifyDevFunctionsProxyTarget, + changeOrigin: true + } + } + }) } }) diff --git a/docs/netlify.toml b/docs/netlify.toml new file mode 100644 index 00000000..402cac09 --- /dev/null +++ b/docs/netlify.toml @@ -0,0 +1,10 @@ +[build] + command = "pnpm --dir docs build" + publish = "docs/dist" + +[functions] + directory = "docs/netlify/functions" + +[dev] + command = "pnpm exec astro dev --host --port 1104 --strictPort" + targetPort = 1104 diff --git a/docs/netlify/functions/feedback.ts b/docs/netlify/functions/feedback.ts new file mode 100644 index 00000000..077ba24c --- /dev/null +++ b/docs/netlify/functions/feedback.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +type FeedbackChoice = 'yes' | 'no' + +type FeedbackRequest = { + type?: FeedbackChoice + page?: string + message?: string +} + +type NetlifyEvent = { + httpMethod: string + body: string | null +} + +const GITHUB_REPO = 'interledger/open-payments-docs-feedback' + +const readLocalEnvVar = (name: string) => { + try { + const envFile = readFileSync(path.join(process.cwd(), '.env'), 'utf8') + for (const line of envFile.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex === -1) { + continue + } + + const key = trimmed.slice(0, separatorIndex).trim() + if (key !== name) { + continue + } + + return trimmed.slice(separatorIndex + 1).trim() + } + } catch { + return undefined + } + + return undefined +} + +const createResponse = (statusCode: number, body: Record) => ({ + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) +}) + +export const handler = async (event: NetlifyEvent) => { + if (event.httpMethod !== 'POST') { + return createResponse(405, { + success: false, + error: 'Method not allowed' + }) + } + + const githubToken = process.env.GITHUB_TOKEN || readLocalEnvVar('GITHUB_TOKEN') + + if (!githubToken) { + return createResponse(500, { + success: false, + error: 'GitHub token not configured' + }) + } + + let payload: FeedbackRequest + + try { + payload = JSON.parse(event.body || '{}') as FeedbackRequest + } catch { + return createResponse(400, { + success: false, + error: 'Invalid JSON payload' + }) + } + + const { type, page, message } = payload + + if ((type !== 'yes' && type !== 'no') || !page) { + return createResponse(400, { + success: false, + error: 'Missing required feedback fields' + }) + } + + const emoji = type === 'yes' ? '👍' : '👎' + const sentiment = type === 'yes' ? 'Positive' : 'Negative' + const issueTitle = `[Feedback] ${emoji} ${page}` + const issueBody = `**Page:** ${page} +**Feedback Type:** ${sentiment} ${emoji} +**User Message:** + +${message || '_No additional feedback provided_'} + +--- +_Submitted via feedback widget on ${new Date().toISOString()}_` + + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/issues`, + { + method: 'POST', + headers: { + Authorization: `token ${githubToken}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + 'User-Agent': 'OpenPayments-Docs-Feedback-Widget' + }, + body: JSON.stringify({ + title: issueTitle, + body: issueBody, + labels: [ + 'feedback', + 'docs', + type === 'yes' ? 'feedback-positive' : 'feedback-negative' + ] + }) + } + ) + + if (!response.ok) { + return createResponse(response.status, { + success: false, + error: 'GitHub API request failed' + }) + } + + const issue = (await response.json()) as { + html_url?: string + number?: number + } + + return createResponse(200, { + success: true, + issueUrl: issue.html_url, + issueNumber: issue.number + }) +} diff --git a/docs/package.json b/docs/package.json index a5d3988a..21ee12f2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,6 +5,7 @@ "version": "0.0.1", "scripts": { "start": "astro dev", + "dev:netlify": "pnpm dlx netlify-cli@25.1.0 dev", "build": "astro build", "preview": "astro preview", "astro": "astro", @@ -37,6 +38,7 @@ "globals": "^17.2.0", "prettier": "3.8.1", "prettier-plugin-astro": "0.14.1", - "typescript-eslint": "^8.54.0" + "typescript-eslint": "^8.54.0", + "vite": "^6.4.1" } } diff --git a/docs/src/components/FeedbackWidget.astro b/docs/src/components/FeedbackWidget.astro new file mode 100644 index 00000000..f97e6aba --- /dev/null +++ b/docs/src/components/FeedbackWidget.astro @@ -0,0 +1,401 @@ +--- +const { lang = 'en' } = Astro.props + +const translations = { + en: { + question: 'Was this page helpful?', + yes: 'Yes', + no: 'No', + thanks: 'Thanks for your feedback!', + improve: 'Help us improve', + improvePlaceholder: 'What can we improve?', + submit: 'Submit', + submitting: 'Submitting...' + }, + es: { + question: '¿Fue útil esta página?', + yes: 'Sí', + no: 'No', + thanks: '¡Gracias por tu comentario!', + improve: 'Ayúdanos a mejorar', + improvePlaceholder: '¿Qué podemos mejorar?', + submit: 'Enviar', + submitting: 'Enviando...' + } +} + +const t = translations[lang as keyof typeof translations] || translations.en +const functionUrl = + typeof import.meta.env.PUBLIC_FEEDBACK_FUNCTION_URL === 'string' + ? import.meta.env.PUBLIC_FEEDBACK_FUNCTION_URL.trim() + : '' +--- + + + + + + diff --git a/docs/src/components/Footer.astro b/docs/src/components/Footer.astro new file mode 100644 index 00000000..18ecdd19 --- /dev/null +++ b/docs/src/components/Footer.astro @@ -0,0 +1,17 @@ +--- +import Default from '@astrojs/starlight/components/Footer.astro' +import FeedbackWidget from './FeedbackWidget.astro' + +const lang = Astro.currentLocale === 'es' ? 'es' : 'en' +const pathname = Astro.url.pathname +const routeId = Astro.locals.starlightRoute?.id + +const normalizedPathname = + pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname +const isIndexPage = normalizedPathname === '/' || normalizedPathname === '/es' +const is404Page = routeId === '404' +--- + +{!isIndexPage && !is404Page && } + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8a9edce..64b968c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: typescript-eslint: specifier: ^8.54.0 version: 8.54.0(eslint@9.39.2)(typescript@5.9.3) + vite: + specifier: ^6.4.1 + version: 6.4.1 packages: @@ -395,7 +398,6 @@ packages: cpu: [ppc64] os: [aix] requiresBuild: true - dev: false optional: true /@esbuild/android-arm64@0.27.7: @@ -404,7 +406,6 @@ packages: cpu: [arm64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-arm@0.27.7: @@ -413,7 +414,6 @@ packages: cpu: [arm] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-x64@0.27.7: @@ -422,7 +422,6 @@ packages: cpu: [x64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/darwin-arm64@0.27.7: @@ -431,7 +430,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/darwin-x64@0.27.7: @@ -440,7 +438,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-arm64@0.27.7: @@ -449,7 +446,6 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-x64@0.27.7: @@ -458,7 +454,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm64@0.27.7: @@ -467,7 +462,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm@0.27.7: @@ -476,7 +470,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ia32@0.27.7: @@ -485,7 +478,6 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-loong64@0.27.7: @@ -494,7 +486,6 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-mips64el@0.27.7: @@ -503,7 +494,6 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ppc64@0.27.7: @@ -512,7 +502,6 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-riscv64@0.27.7: @@ -521,7 +510,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-s390x@0.27.7: @@ -530,7 +518,6 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-x64@0.27.7: @@ -539,7 +526,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/netbsd-arm64@0.27.7: @@ -548,7 +534,6 @@ packages: cpu: [arm64] os: [netbsd] requiresBuild: true - dev: false optional: true /@esbuild/netbsd-x64@0.27.7: @@ -557,7 +542,6 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true - dev: false optional: true /@esbuild/openbsd-arm64@0.27.7: @@ -566,7 +550,6 @@ packages: cpu: [arm64] os: [openbsd] requiresBuild: true - dev: false optional: true /@esbuild/openbsd-x64@0.27.7: @@ -575,7 +558,6 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true - dev: false optional: true /@esbuild/openharmony-arm64@0.27.7: @@ -593,7 +575,6 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true - dev: false optional: true /@esbuild/win32-arm64@0.27.7: @@ -602,7 +583,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-ia32@0.27.7: @@ -611,7 +591,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-x64@0.27.7: @@ -620,7 +599,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@eslint-community/eslint-utils@4.7.0(eslint@8.57.1): @@ -1283,7 +1261,6 @@ packages: cpu: [arm] os: [android] requiresBuild: true - dev: false optional: true /@rollup/rollup-android-arm64@4.60.2: @@ -1291,7 +1268,6 @@ packages: cpu: [arm64] os: [android] requiresBuild: true - dev: false optional: true /@rollup/rollup-darwin-arm64@4.60.2: @@ -1299,7 +1275,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@rollup/rollup-darwin-x64@4.60.2: @@ -1307,7 +1282,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@rollup/rollup-freebsd-arm64@4.60.2: @@ -1315,7 +1289,6 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true - dev: false optional: true /@rollup/rollup-freebsd-x64@4.60.2: @@ -1323,7 +1296,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-arm-gnueabihf@4.60.2: @@ -1331,7 +1303,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-arm-musleabihf@4.60.2: @@ -1339,7 +1310,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-arm64-gnu@4.60.2: @@ -1347,7 +1317,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-arm64-musl@4.60.2: @@ -1355,7 +1324,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-loong64-gnu@4.60.2: @@ -1363,7 +1331,6 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-loong64-musl@4.60.2: @@ -1379,7 +1346,6 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-ppc64-musl@4.60.2: @@ -1395,7 +1361,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-riscv64-musl@4.60.2: @@ -1403,7 +1368,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-s390x-gnu@4.60.2: @@ -1411,7 +1375,6 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-x64-gnu@4.60.2: @@ -1419,7 +1382,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-linux-x64-musl@4.60.2: @@ -1427,7 +1389,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@rollup/rollup-openbsd-x64@4.60.2: @@ -1451,7 +1412,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@rollup/rollup-win32-ia32-msvc@4.60.2: @@ -1459,7 +1419,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@rollup/rollup-win32-x64-gnu@4.60.2: @@ -1467,7 +1426,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@rollup/rollup-win32-x64-msvc@4.60.2: @@ -3212,6 +3170,7 @@ packages: hasBin: true requiresBuild: true optionalDependencies: +<<<<<<< HEAD '@esbuild/aix-ppc64': 0.27.7 '@esbuild/android-arm': 0.27.7 '@esbuild/android-arm64': 0.27.7 @@ -3239,6 +3198,33 @@ packages: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 dev: false +======= + '@esbuild/aix-ppc64': 0.25.2 + '@esbuild/android-arm': 0.25.2 + '@esbuild/android-arm64': 0.25.2 + '@esbuild/android-x64': 0.25.2 + '@esbuild/darwin-arm64': 0.25.2 + '@esbuild/darwin-x64': 0.25.2 + '@esbuild/freebsd-arm64': 0.25.2 + '@esbuild/freebsd-x64': 0.25.2 + '@esbuild/linux-arm': 0.25.2 + '@esbuild/linux-arm64': 0.25.2 + '@esbuild/linux-ia32': 0.25.2 + '@esbuild/linux-loong64': 0.25.2 + '@esbuild/linux-mips64el': 0.25.2 + '@esbuild/linux-ppc64': 0.25.2 + '@esbuild/linux-riscv64': 0.25.2 + '@esbuild/linux-s390x': 0.25.2 + '@esbuild/linux-x64': 0.25.2 + '@esbuild/netbsd-arm64': 0.25.2 + '@esbuild/netbsd-x64': 0.25.2 + '@esbuild/openbsd-arm64': 0.25.2 + '@esbuild/openbsd-x64': 0.25.2 + '@esbuild/sunos-x64': 0.25.2 + '@esbuild/win32-arm64': 0.25.2 + '@esbuild/win32-ia32': 0.25.2 + '@esbuild/win32-x64': 0.25.2 +>>>>>>> d72e9e9 (feat(docs): add feedback widget) /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -3691,7 +3677,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: false optional: true /github-slugger@2.0.0: @@ -5089,7 +5074,10 @@ packages: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true +<<<<<<< HEAD dev: false +======= +>>>>>>> d72e9e9 (feat(docs): add feedback widget) /nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} @@ -5398,7 +5386,10 @@ packages: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 +<<<<<<< HEAD dev: false +======= +>>>>>>> d72e9e9 (feat(docs): add feedback widget) /postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} @@ -5787,7 +5778,6 @@ packages: '@rollup/rollup-win32-x64-gnu': 4.60.2 '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 - dev: false /roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -6520,15 +6510,23 @@ packages: yaml: optional: true dependencies: +<<<<<<< HEAD esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.10 rollup: 4.60.2 tinyglobby: 0.2.16 +======= + esbuild: 0.25.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.10 + rollup: 4.40.2 + tinyglobby: 0.2.15 +>>>>>>> d72e9e9 (feat(docs): add feedback widget) optionalDependencies: fsevents: 2.3.3 - dev: false /vitefu@1.1.3(vite@7.3.2): resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} From 5e67fac5ddac9121ff42769a4e275c6cd677ddd3 Mon Sep 17 00:00:00 2001 From: JxM Date: Tue, 21 Apr 2026 14:08:46 +0200 Subject: [PATCH 2/7] pnpm format --- docs/netlify/functions/feedback.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/netlify/functions/feedback.ts b/docs/netlify/functions/feedback.ts index 077ba24c..dd95fc59 100644 --- a/docs/netlify/functions/feedback.ts +++ b/docs/netlify/functions/feedback.ts @@ -58,7 +58,8 @@ export const handler = async (event: NetlifyEvent) => { }) } - const githubToken = process.env.GITHUB_TOKEN || readLocalEnvVar('GITHUB_TOKEN') + const githubToken = + process.env.GITHUB_TOKEN || readLocalEnvVar('GITHUB_TOKEN') if (!githubToken) { return createResponse(500, { From 5e7bd614daa8f6f69b577d43714e2a80c371019a Mon Sep 17 00:00:00 2001 From: JxM Date: Tue, 21 Apr 2026 21:38:55 +0200 Subject: [PATCH 3/7] update emojis --- docs/netlify/functions/feedback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/netlify/functions/feedback.ts b/docs/netlify/functions/feedback.ts index dd95fc59..e1cdfbe7 100644 --- a/docs/netlify/functions/feedback.ts +++ b/docs/netlify/functions/feedback.ts @@ -88,7 +88,7 @@ export const handler = async (event: NetlifyEvent) => { }) } - const emoji = type === 'yes' ? '👍' : '👎' + const emoji = type === 'yes' ? '🙂' : '🙁' const sentiment = type === 'yes' ? 'Positive' : 'Negative' const issueTitle = `[Feedback] ${emoji} ${page}` const issueBody = `**Page:** ${page} From c3445a4e50291301655670902d41837e4d2245d1 Mon Sep 17 00:00:00 2001 From: Jonathan Matthey Date: Mon, 27 Apr 2026 14:02:25 +0200 Subject: [PATCH 4/7] feat(docs): use GitHub App auth for feedback and Netlify dev --- .gitignore | 3 + docs/.env.example | 7 +- docs/.gitignore | 4 + docs/netlify/functions/feedback.ts | 229 ++++++++++++++++++++--- docs/package.json | 2 +- docs/src/components/FeedbackWidget.astro | 72 ++++++- 6 files changed, 277 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 37679659..535e261b 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,6 @@ styles/write-good/TooWordy.yml styles/write-good/Weasel.yml .vale.ini vale + +# Local Netlify folder +.netlify diff --git a/docs/.env.example b/docs/.env.example index 10a3318c..82b044d7 100644 --- a/docs/.env.example +++ b/docs/.env.example @@ -1,3 +1,8 @@ # Local direct-submit config for Netlify dev. -GITHUB_TOKEN= +# GitHub App (installation must have "Issues: write" on interledger/open-payments-docs-feedback) +GITHUB_APP_ID= +GITHUB_APP_INSTALLATION_ID= +# Either set PEM inline (Netlify: use literal \n for newlines) or point to a local file +GITHUB_APP_PRIVATE_KEY= +GITHUB_APP_PRIVATE_KEY_PATH= PUBLIC_FEEDBACK_FUNCTION_URL=/.netlify/functions/feedback diff --git a/docs/.gitignore b/docs/.gitignore index 501e1ec5..b8dd3d00 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -17,6 +17,10 @@ pnpm-debug.log* .env .env.production +# GitHub App key (local dev only; never commit) +.github-app-private-key.pem +*.pem + # macOS-specific files .DS_Store diff --git a/docs/netlify/functions/feedback.ts b/docs/netlify/functions/feedback.ts index e1cdfbe7..358f30ea 100644 --- a/docs/netlify/functions/feedback.ts +++ b/docs/netlify/functions/feedback.ts @@ -1,3 +1,4 @@ +import { createSign } from 'node:crypto' import { readFileSync } from 'node:fs' import path from 'node:path' @@ -15,35 +16,193 @@ type NetlifyEvent = { } const GITHUB_REPO = 'interledger/open-payments-docs-feedback' +const GITHUB_API = 'https://api.github.com' -const readLocalEnvVar = (name: string) => { - try { - const envFile = readFileSync(path.join(process.cwd(), '.env'), 'utf8') - for (const line of envFile.split(/\r?\n/)) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) { - continue - } +const stripEnvValueQuotes = (value: string) => { + const t = value.trim() + if ( + (t.startsWith('"') && t.endsWith('"')) || + (t.startsWith("'") && t.endsWith("'")) + ) { + return t.slice(1, -1) + } + return t +} - const separatorIndex = trimmed.indexOf('=') - if (separatorIndex === -1) { - continue - } +const parseEnvFileContent = (content: string) => { + const map = new Map() + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex === -1) { + continue + } + const key = trimmed.slice(0, separatorIndex).trim() + const value = stripEnvValueQuotes(trimmed.slice(separatorIndex + 1)) + map.set(key, value) + } + return map +} - const key = trimmed.slice(0, separatorIndex).trim() - if (key !== name) { - continue +/** Netlify runs functions with an arbitrary cwd; walk up to find docs/.env or repo .env */ +const listEnvFileCandidates = (): string[] => { + const seen = new Set() + const out: string[] = [] + let dir = process.cwd() + for (let i = 0; i < 12; i++) { + for (const rel of ['.env', path.join('docs', '.env')]) { + const resolved = path.resolve(dir, rel) + if (!seen.has(resolved)) { + seen.add(resolved) + out.push(resolved) } + } + const parent = path.dirname(dir) + if (parent === dir) { + break + } + dir = parent + } + return out +} + +type LocalEnvCache = { map: Map; baseDir: string } | null +let localEnvCache: LocalEnvCache | undefined - return trimmed.slice(separatorIndex + 1).trim() +const getLocalEnvFromDisk = (): LocalEnvCache => { + if (localEnvCache !== undefined) { + return localEnvCache + } + for (const envPath of listEnvFileCandidates()) { + try { + const raw = readFileSync(envPath, 'utf8') + localEnvCache = { + map: parseEnvFileContent(raw), + baseDir: path.dirname(envPath) + } + return localEnvCache + } catch { + continue } - } catch { + } + localEnvCache = null + return localEnvCache +} + +const readLocalEnvVar = (name: string) => getLocalEnvFromDisk()?.map.get(name) + +const getEnv = (name: string) => process.env[name] || readLocalEnvVar(name) + +const normalizePrivateKey = (raw: string) => + raw.trim().replace(/\\n/g, '\n') + +const loadPrivateKeyPem = (): string | undefined => { + const inline = getEnv('GITHUB_APP_PRIVATE_KEY') + if (inline) { + return normalizePrivateKey(inline) + } + + const keyPath = getEnv('GITHUB_APP_PRIVATE_KEY_PATH') + if (!keyPath) { return undefined } + if (path.isAbsolute(keyPath)) { + try { + return normalizePrivateKey(readFileSync(keyPath, 'utf8')) + } catch { + return undefined + } + } + + const roots = new Set() + const local = getLocalEnvFromDisk() + if (local?.baseDir) { + roots.add(local.baseDir) + } + roots.add(process.cwd()) + roots.add(path.join(process.cwd(), 'docs')) + let walkDir = process.cwd() + for (let i = 0; i < 12; i++) { + roots.add(path.join(walkDir, 'docs')) + const parent = path.dirname(walkDir) + if (parent === walkDir) { + break + } + walkDir = parent + } + + for (const root of roots) { + try { + return normalizePrivateKey( + readFileSync(path.join(root, keyPath), 'utf8') + ) + } catch { + continue + } + } + return undefined } +const base64urlJson = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url') + +const createAppJwt = (appId: string, privateKeyPem: string) => { + const header = { alg: 'RS256', typ: 'JWT' } + const now = Math.floor(Date.now() / 1000) + const iat = now - 60 + const exp = iat + 9 * 60 + const payload = { iat, exp, iss: appId } + const unsigned = `${base64urlJson(header)}.${base64urlJson(payload)}` + const signer = createSign('RSA-SHA256') + signer.update(unsigned) + signer.end() + const signature = signer + .sign(privateKeyPem) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') + return `${unsigned}.${signature}` +} + +const githubHeaders = (auth: string, accept = 'application/vnd.github+json') => ({ + Authorization: auth, + Accept: accept, + 'Content-Type': 'application/json', + 'User-Agent': 'OpenPayments-Docs-Feedback-Widget', + 'X-GitHub-Api-Version': '2022-11-28' +}) + +const getInstallationAccessToken = async ( + appJwt: string, + installationId: string +) => { + const response = await fetch( + `${GITHUB_API}/app/installations/${installationId}/access_tokens`, + { + method: 'POST', + headers: githubHeaders(`Bearer ${appJwt}`), + body: '{}' + } + ) + + if (!response.ok) { + return { ok: false as const, status: response.status } + } + + const data = (await response.json()) as { token?: string } + if (!data.token) { + return { ok: false as const, status: response.status } + } + + return { ok: true as const, token: data.token } +} + const createResponse = (statusCode: number, body: Record) => ({ statusCode, headers: { 'Content-Type': 'application/json' }, @@ -58,13 +217,14 @@ export const handler = async (event: NetlifyEvent) => { }) } - const githubToken = - process.env.GITHUB_TOKEN || readLocalEnvVar('GITHUB_TOKEN') + const appId = getEnv('GITHUB_APP_ID') + const installationId = getEnv('GITHUB_APP_INSTALLATION_ID') + const privateKey = loadPrivateKeyPem() - if (!githubToken) { + if (!appId || !installationId || !privateKey) { return createResponse(500, { success: false, - error: 'GitHub token not configured' + error: 'GitHub App credentials not configured' }) } @@ -88,6 +248,24 @@ export const handler = async (event: NetlifyEvent) => { }) } + let appJwt: string + try { + appJwt = createAppJwt(appId, privateKey) + } catch { + return createResponse(500, { + success: false, + error: 'Could not sign GitHub App JWT' + }) + } + + const tokenResult = await getInstallationAccessToken(appJwt, installationId) + if (!tokenResult.ok) { + return createResponse(502, { + success: false, + error: 'Could not obtain GitHub installation token' + }) + } + const emoji = type === 'yes' ? '🙂' : '🙁' const sentiment = type === 'yes' ? 'Positive' : 'Negative' const issueTitle = `[Feedback] ${emoji} ${page}` @@ -101,15 +279,10 @@ ${message || '_No additional feedback provided_'} _Submitted via feedback widget on ${new Date().toISOString()}_` const response = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/issues`, + `${GITHUB_API}/repos/${GITHUB_REPO}/issues`, { method: 'POST', - headers: { - Authorization: `token ${githubToken}`, - Accept: 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - 'User-Agent': 'OpenPayments-Docs-Feedback-Widget' - }, + headers: githubHeaders(`Bearer ${tokenResult.token}`), body: JSON.stringify({ title: issueTitle, body: issueBody, diff --git a/docs/package.json b/docs/package.json index 21ee12f2..eb7d241f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,7 +5,7 @@ "version": "0.0.1", "scripts": { "start": "astro dev", - "dev:netlify": "pnpm dlx netlify-cli@25.1.0 dev", + "dev:netlify": "pnpm --package=netlify-cli@25.1.0 dlx netlify dev", "build": "astro build", "preview": "astro preview", "astro": "astro", diff --git a/docs/src/components/FeedbackWidget.astro b/docs/src/components/FeedbackWidget.astro index f97e6aba..3794549b 100644 --- a/docs/src/components/FeedbackWidget.astro +++ b/docs/src/components/FeedbackWidget.astro @@ -10,7 +10,9 @@ const translations = { improve: 'Help us improve', improvePlaceholder: 'What can we improve?', submit: 'Submit', - submitting: 'Submitting...' + submitting: 'Submitting...', + submitError: + 'Could not submit feedback. Check your connection and try again.' }, es: { question: '¿Fue útil esta página?', @@ -20,7 +22,9 @@ const translations = { improve: 'Ayúdanos a mejorar', improvePlaceholder: '¿Qué podemos mejorar?', submit: 'Enviar', - submitting: 'Enviando...' + submitting: 'Enviando...', + submitError: + 'No se pudo enviar el comentario. Comprueba tu conexión e inténtalo de nuevo.' } } @@ -36,6 +40,7 @@ const functionUrl = id="feedback-widget" data-function-url={functionUrl} data-issue-repo="interledger/open-payments-docs-feedback" + data-submit-error={t.submitError} >