diff --git a/.claude/hooks/format-lint.sh b/.claude/hooks/format-lint.sh new file mode 100755 index 0000000..1bc7db4 --- /dev/null +++ b/.claude/hooks/format-lint.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Formats and lints one edited file with whichever toolchain owns its extension +# (docs/tooling.md). Lint failures exit 2 so the findings go back to the agent. +set -uo pipefail + +repo_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}" +[[ -n "$repo_root" ]] || exit 0 + +payload="$(cat)" +file="$(printf '%s' "$payload" | jq -r '.tool_response.filePath // .tool_input.file_path // empty')" +[[ -n "$file" ]] || exit 0 +[[ -f "$file" ]] || exit 0 + +# Absolute, and inside this repo. +case "$file" in + /*) ;; + *) file="$repo_root/$file" ;; +esac +case "$file" in + "$repo_root"/*) ;; + *) exit 0 ;; +esac + +rel="${file#"$repo_root"/}" +case "$rel" in + legacy/* | dist/* | .astro/* | node_modules/* | plan/* | tools/lint/anti-slop/* | pnpm-lock.yaml) + exit 0 + ;; +esac + +cd "$repo_root" || exit 0 + +# Use the installed binaries directly so the hook works without pnpm on PATH. +# Status 127 means the tool itself is missing, which callers must not report as +# lint findings: on a fresh clone, before `pnpm install`, every edit would +# otherwise be rejected with an empty message. +readonly TOOL_MISSING=127 + +run() { + local tool="$1"; shift + local bin="$repo_root/node_modules/.bin/$tool" + if [[ -x "$bin" ]]; then + "$bin" "$@" 2>&1 + return + fi + command -v pnpm >/dev/null || return "$TOOL_MISSING" + [[ -d "$repo_root/node_modules" ]] || return "$TOOL_MISSING" + pnpm exec "$tool" "$@" 2>&1 +} + +# Runs a linter and exits 2 with its findings. A missing toolchain exits 0 so the +# edit is allowed through un-linted rather than blocked with nothing to act on. +lint() { + local findings status + findings="$(run "$@")" + status=$? + case "$status" in + 0) return 0 ;; + "$TOOL_MISSING") exit 0 ;; + *) + printf '%s\n' "$findings" >&2 + exit 2 + ;; + esac +} + +case "${file##*.}" in + ts | tsx | js | jsx | mjs | cjs | json | jsonc | json5 | css) + # Without --ignore-path, oxfmt also reads .prettierignore, which excludes + # every extension oxfmt owns. + run oxfmt --ignore-path .gitignore "$file" >/dev/null + lint oxlint --type-aware "$file" + ;; + # oxfmt's directory scan formats these too, so `pnpm check` fails on an unformatted one — + # but oxlint has no rules for them, so they are formatted and not linted. + yaml | yml | toml) + run oxfmt --ignore-path .gitignore "$file" >/dev/null + ;; + astro) + run prettier --write "$file" >/dev/null + lint eslint --max-warnings 0 "$file" + ;; + md) + run prettier --write "$file" >/dev/null + ;; +esac + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cb0b19c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-lint.sh", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..879a99a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# For what neither oxfmt nor Prettier covers (yaml, toml, shell, gitignore-likes). +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e1cfde1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check + Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install mise tools + uses: jdx/mise-action@v4 + with: + version: 2026.8.14 + install: true + cache: true + + # mise-action's cache covers mise-managed tool binaries (node, pnpm), not + # pnpm's content-addressable store, so without this every run re-downloads + # the whole tree. + - name: Locate pnpm store + run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV" + + - name: Cache pnpm store + uses: actions/cache@v6 + with: + path: ${{ env.PNPM_STORE }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-store-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Check (typecheck + lint + format + knip) + run: pnpm check + + - name: Build + run: pnpm build + + # --root-dir is required for the root-relative hrefs Astro emits (/_astro/...); + # without it lychee cannot resolve them in local files and errors on every page. + - name: Link check + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline --include-fragments --no-progress + --root-dir ${{ github.workspace }}/dist + 'dist/**/*.html' + fail: true diff --git a/.gitignore b/.gitignore index fd3dbb5..b8a7d65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,10 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage +node_modules +.pnpm-store -# next.js -/.next/ -/out/ - -# production -/build +# astro +.astro +dist # misc .DS_Store @@ -22,15 +12,10 @@ # debug npm-debug.log* -yarn-debug.log* -yarn-error.log* +pnpm-debug.log* # local env files .env*.local -# vercel -.vercel - # typescript *.tsbuildinfo -next-env.d.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..4ceca93 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,24 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "sortImports": true, + "sortPackageJson": true, + "sortTailwindcss": true, + "trailingComma": "all", + "overrides": [ + { + "files": ["**/*.json", "**/*.jsonc", "**/*.json5"], + "options": { + "trailingComma": "none" + } + } + ], + "ignorePatterns": [ + "legacy/**", + "dist/**", + ".astro/**", + ".claude/**", + "tools/lint/anti-slop/**", + "**/*.md", + "**/*.astro" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..0a0c905 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,68 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["./tools/lint/nkzw/oxlintrc.json"], + "plugins": ["typescript", "unicorn", "oxc", "import"], + "options": { + "typeAware": true, + "typeCheck": true + }, + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error", + "style": "off" + }, + "jsPlugins": [ + { + "name": "anti-slop", + "specifier": "./tools/lint/anti-slop/index.ts" + } + ], + "rules": { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["legacy/**", "**/legacy/**"], + "message": "legacy/ is reference only \u2014 never import from it (plan/00-overview.md)" + } + ] + } + ], + "perfectionist/sort-objects": "off" + }, + "overrides": [ + { + "files": ["functions/**"], + "rules": { + "no-console": "off" + } + } + ], + "ignorePatterns": [ + "**/*.astro", + "legacy/**", + "dist/**", + ".astro/**", + "tools/lint/anti-slop/**", + ".claude/**", + "plan/**", + "node_modules/**" + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..61fd6f4 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# Prettier is invoked against an explicit `**/*.{astro,md}` glob (see package.json), +# because a blanket `*` ignore prunes directories and cannot be undone per-file. +# oxfmt owns every other extension — see docs/tooling.md. +legacy/** +dist/** +.astro/** +node_modules/** +tools/lint/anti-slop/** + +# Hand-formatted markdown: tables, checkboxes, and line breaks here are deliberate. +plan/** +docs/adr/** +DESIGN.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..dc82700 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,13 @@ +{ + "printWidth": 100, + "trailingComma": "all", + "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.astro", + "options": { + "parser": "astro" + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e11f647..1bba082 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,11 @@ { - "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] + "recommendations": [ + "astro-build.astro-vscode", + "oxc.oxc-vscode", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "hverlin.mise-vscode", + "dbaeumer.vscode-eslint", + "typescriptteam.native-preview" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index bae114e..5af3ec6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,25 +1,57 @@ { - "editor.defaultFormatter": "biomejs.biome", - "editor.tabSize": 2, "editor.formatOnSave": true, + "editor.defaultFormatter": "oxc.oxc-vscode", "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit" - }, - "js/ts.preferences.importModuleSpecifier": "non-relative", - "tailwindCSS.experimental.classRegex": [ - [ - "clsx\\(([^)]*)\\)", - "(?:'|\"|`)([^']*)(?:'|\"|`)", - "cn\\(([^)]*)\\)", - "classNames\\(([^)]+)\\)" - ] - ], - "js/ts.tsdk.path": "./node_modules/typescript/lib", - "workbench.editor.customLabels.enabled": true, - "workbench.editor.customLabels.patterns": { - "**/app/**/page.tsx": "${dirname} - Page", - "**/app/**/layout.tsx": "${dirname} - Layout", - "**/app/**/template.tsx": "${dirname} - Template" - }, - "css.lint.unknownAtRules": "ignore" + "source.fixAll.oxc": "explicit", + "source.organizeImports.oxc": "explicit" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[css]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[toml]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[astro]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "eslint.validate": ["astro"], + "eslint.useFlatConfig": true, + "files.associations": { + "*.css": "tailwindcss" + }, + "tailwindCSS.classFunctions": ["cn", "cva"], + "tailwindCSS.experimental.classRegex": [["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]], + "search.exclude": { + "legacy/**": true, + "dist/**": true, + "pnpm-lock.yaml": true, + "node_modules/**": true + }, + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.preferences.importModuleSpecifier": "non-relative" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..447f693 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +## What this is + +The website of the South Central STEM Collective (SC2), a 501(c)(3) running FIRST robotics +and hands-on STEM programs in Franklin County, PA. Static Astro site, deployed by Cloudflare +Pages. **Zero client-side framework runtime** — `.astro` components and plain `