diff --git a/.github/actions/node-setup/action.yaml b/.github/actions/node-setup/action.yaml new file mode 100644 index 0000000..02797c9 --- /dev/null +++ b/.github/actions/node-setup/action.yaml @@ -0,0 +1,21 @@ +name: "node setup" +description: "node setup" +runs: + using: "composite" + steps: + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # 4.0.1 + with: + install_args: "node" + - run: | + corepack enable + corepack prepare --activate + shell: bash + - id: yarn-cache-dir + run: echo "YARN_CACHE_DIR=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + shell: bash + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # 5.0.3 + with: + path: ${{ steps.yarn-cache-dir.outputs.YARN_CACHE_DIR }} + key: ${{ runner.os }}-node-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-node-yarn- diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9e0139f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: ci +on: + pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true +jobs: + check: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - name: Git checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1 + - name: Setup Node.js + uses: ./.github/actions/node-setup + - run: yarn install --immutable + - name: Run biome + run: yarn check + - name: Run typecheck + run: yarn typecheck + build: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - name: Git checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1 + - name: Setup Node.js + uses: ./.github/actions/node-setup + - run: yarn install --immutable + - name: Run build + run: yarn workspaces foreach -Apt run build diff --git a/.gitignore b/.gitignore index f03eaec..8de6bd0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,13 @@ node_modules #!.yarn/cache .pnp.* + +# IDE +.idea/ +.vscode/ + +# outputs +dist/ +dist-ssr/ +.tanstack/ +.wvb/ diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..caa687d --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,16 @@ +nodeLinker: node-modules + +preferReuse: true + +supportedArchitectures: + cpu: + - x64 + - ia32 + - arm64 + libc: + - glibc + - musl + os: + - darwin + - linux + - win32 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f9299c9 --- /dev/null +++ b/biome.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**/*.ts", + "**/*.cts", + "**/*.tsx", + "**/*.js", + "**/*.cjs", + "**/*.json", + "**/*.mjs", + "!**/*.js", + "!**/*.cjs", + "!**/*.d.ts", + "!**/*.d.cts", + "!**/routeTree.gen.ts", + "!**/dist", + "!**/dist-ssr" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "trailingNewline": true + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "suspicious": { + "noExplicitAny": "off", + "noEmptyInterface": "off", + "noArrayIndexKey": "off", + "noConfusingVoidType": "off" + }, + "style": { + "noNonNullAssertion": "off", + "noCommonJs": "error" + }, + "a11y": { + "noSvgWithoutTitle": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "correctness": { + "noUnusedImports": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5", + "arrowParentheses": "asNeeded" + } + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..4581550 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24.15.0" diff --git a/package.json b/package.json index b6a3b22..a50d858 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,17 @@ { "name": "webview-bundle-playground", - "packageManager": "yarn@4.14.1" + "private": true, + "workspaces": [ + "webviews/*" + ], + "packageManager": "yarn@4.16.0", + "scripts": { + "check": "biome check", + "check:fix": "biome check --write --unsafe", + "typecheck": "yarn workspaces foreach -Apt run typecheck" + }, + "devDependencies": { + "@biomejs/biome": "2.5.0", + "typescript": "6.0.3" + } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c36fb48 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true + } +} diff --git a/webviews/hacker-news/index.html b/webviews/hacker-news/index.html new file mode 100644 index 0000000..2d58b33 --- /dev/null +++ b/webviews/hacker-news/index.html @@ -0,0 +1,19 @@ + + + + + + + + + BUNDLE // news + + + +
+ + + diff --git a/webviews/hacker-news/package.json b/webviews/hacker-news/package.json new file mode 100644 index 0000000..8822493 --- /dev/null +++ b/webviews/hacker-news/package.json @@ -0,0 +1,32 @@ +{ + "name": "@wvb-playground-webview/hacker-news", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "yarn build:client && yarn build:ssr && yarn prerender", + "build:client": "vite build", + "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr --emptyOutDir", + "prerender": "node scripts/prerender.mjs", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@tanstack/react-router": "1.170.15", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.1", + "@tanstack/router-plugin": "1.168.18", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "6.0.2", + "tailwindcss": "4.3.1", + "typescript": "6.0.3", + "vite": "8.0.16" + } +} diff --git a/webviews/hacker-news/scripts/prerender.mjs b/webviews/hacker-news/scripts/prerender.mjs new file mode 100644 index 0000000..e9c58d3 --- /dev/null +++ b/webviews/hacker-news/scripts/prerender.mjs @@ -0,0 +1,37 @@ +// Static-site generation step. Runs after the client + SSR builds: +// 1. client build -> dist/ (hashed assets + index.html template) +// 2. ssr build -> dist-ssr/entry-server.js +// 3. this script -> writes dist//index.html for every known route +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { getStaticPaths, render } from '../dist-ssr/entry-server.js'; + +const distDir = resolve(process.cwd(), 'dist'); +const template = await readFile(join(distDir, 'index.html'), 'utf8'); + +function escapeHtml(value) { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function buildPage(appHtml, title) { + let html = template; + html = html.includes('') + ? html.replace('', appHtml) + : html.replace(/(
)(<\/div>)/, `$1${appHtml}$2`); + html = html.replace(/[\s\S]*?<\/title>/, `<title>${escapeHtml(title)}`); + return html; +} + +const paths = getStaticPaths(); +for (const { url, title } of paths) { + const appHtml = await render(url); + const outPath = url === '/' ? join(distDir, 'index.html') : join(distDir, url, 'index.html'); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, buildPage(appHtml, title), 'utf8'); +} + +// SPA fallback for any deep link that wasn't prerendered: ship an empty shell +// that the client boots and routes on its own. +await writeFile(join(distDir, '404.html'), buildPage('', 'BUNDLE // news'), 'utf8'); + +console.log(`✓ prerendered ${paths.length} routes + 404.html → dist/`); diff --git a/webviews/hacker-news/src/components/CommentTree.tsx b/webviews/hacker-news/src/components/CommentTree.tsx new file mode 100644 index 0000000..d869fad --- /dev/null +++ b/webviews/hacker-news/src/components/CommentTree.tsx @@ -0,0 +1,99 @@ +import { Link } from '@tanstack/react-router'; +import type { FlatComment } from '../data'; +import { ageLabel, flattenComments } from '../data'; +import { cn } from '../lib/cn'; +import { useAppState, useVote } from '../lib/store'; + +export function CommentTree() { + const { collapsed, toggleCollapse } = useAppState(); + const list = flattenComments(collapsed); + return ( +
+ {list.map(c => ( + toggleCollapse(c.id)} + /> + ))} +
+ ); +} + +function CommentItem({ + c, + collapsed, + onToggle, +}: { + c: FlatComment; + collapsed: boolean; + onToggle: () => void; +}) { + const v = useVote(`c:${c.id}`, c.base); + return ( +
0 ? 12 : 0, + borderLeft: c.depth > 0 ? '1px solid var(--border-1)' : 'none', + }} + > +
+ + + {c.author} + + {c.op && ( + + OP + + )} + + {v.score} pts · {ageLabel(c.age)} + + {collapsed && c.childCount > 0 && ( + · +{c.childCount} hidden + )} +
+ {!collapsed && ( + <> +
+ {c.body} +
+
+ + + reply +
+ + )} +
+ ); +} diff --git a/webviews/hacker-news/src/components/Composer.tsx b/webviews/hacker-news/src/components/Composer.tsx new file mode 100644 index 0000000..96c6534 --- /dev/null +++ b/webviews/hacker-news/src/components/Composer.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from '@tanstack/react-router'; + +const fieldClass = + 'w-full rounded-md border border-border-2 bg-bg-1 px-2.5 py-2 text-[13px] text-fg-1 outline-none transition focus:border-accent focus:ring-[3px] focus:ring-accent/15'; + +/** Inline "create a post" form, opened via the `?compose=true` search param. */ +export function Composer() { + const navigate = useNavigate(); + const close = () => navigate({ to: '/', search: prev => ({ ...prev, compose: undefined }) }); + + return ( +
{ + e.preventDefault(); + close(); + }} + className="mb-3.5 rounded-lg border border-border-1 bg-bg-2 p-3" + > +
create a post
+ +