Compare commits

..

26 Commits

Author SHA1 Message Date
8c2fbeae31 How do you forget the script?
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 13:38:01 -03:00
89052635d5 Pixel URI fix
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:50:44 -03:00
e52a210840 Refine tracking
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:37:36 -03:00
8baf017171 Umami tracking events
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:12:32 -03:00
d3f6747116 Umami setup for mifi-links
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 10:59:35 -03:00
f08784598d Gzip'in
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 13:15:59 -03:00
9201cb6f23 Typos
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-12 02:02:07 -03:00
f9223c2852 Pipeline edits 2026-02-12 02:00:29 -03:00
ebc7ebc229 Resolve GA issues
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-08 00:36:38 -03:00
3130661e65 BAM. Prettier and with QR codes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-08 00:00:23 -03:00
9bc51ff408 Add pipeline notifications
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 12:09:17 -03:00
c39fae5ec5 Handle backdrop close for safari
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 09:27:30 -03:00
88e355aa98 Added resumes
Some checks failed
ci/woodpecker/push/deploy unknown status
ci/woodpecker/push/ci Pipeline failed
2026-02-07 09:23:55 -03:00
ba2b3af650 Boneheaded mistake...
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 00:51:33 -03:00
b66ab0602e Resolve issues
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 00:40:39 -03:00
840c6cdeba Minification and compression
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-07 00:30:08 -03:00
93e2618dcf Fixes for deployment and Nginx config
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-06 23:49:39 -03:00
9db2592cf4 Robots, Trusted Types, and various other bits...
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-06 21:07:57 -03:00
a52938f6cf Mat now we will have interactivity?
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-06 20:36:38 -03:00
d2995e4a08 Fixes for Traefik
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-06 20:24:15 -03:00
3ed0c30f3c Fixes for Nginx errors
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-06 20:12:05 -03:00
886549f927 Everything is working now. Re-enable CI
Some checks failed
ci/woodpecker/push/deploy unknown status
ci/woodpecker/push/ci Pipeline failed
2026-02-06 20:09:59 -03:00
b81be70cbe Disable... differently.
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed
2026-02-06 20:04:30 -03:00
ab7b9fa70c Fixes. Temporarily disable the CI pipeline (I know it works and I'm saving time) 2026-02-06 20:03:03 -03:00
460e3f9139 Update the deploy pipeline and improve the dockerignore's
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed
2026-02-06 19:50:39 -03:00
de3ffc8eaa Fixes for e2e tests
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline failed
2026-02-06 19:29:49 -03:00
48 changed files with 1122 additions and 181 deletions

View File

@@ -1,18 +1,41 @@
# Dependencies and build output (installed/generated in image)
node_modules node_modules
build build
# Git and CI
.git .git
.gitignore .gitignore
*.md .woodpecker
.woodpecker.yml
# Dev / IDE / tooling (not needed at build time)
.cursor .cursor
.devcontainer .devcontainer
.vscode
*.md
docs
test.txt
# Lint/format config (build does not need these)
.eslintrc.cjs .eslintrc.cjs
.eslintignore
.prettierrc .prettierrc
.prettierignore .prettierignore
.eslintignore
# Test and E2E (Dockerfile only runs build + critical-css)
e2e e2e
playwright.config.ts playwright.config.ts
vitest.config.ts vitest.config.ts
test.txt playwright-report
docs coverage
.woodpecker.yml .nyc_output
.woodpecker
# Env and logs
.env
.env.*
*.log
# Local Docker (not needed inside image)
docker-compose.yml
Dockerfile
.dockerignore

View File

@@ -7,75 +7,194 @@ when:
- event: manual - event: manual
steps: steps:
install: - name: install
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- corepack enable - corepack enable
- corepack prepare pnpm@latest --activate - corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile - pnpm install --frozen-lockfile
lint: - name: lint
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- corepack enable - corepack enable && corepack prepare pnpm@latest --activate
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- pnpm run lint - pnpm run lint
depends_on: depends_on:
- install - install
check: - name: Send Lint Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Lint failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- lint
when:
- status: [failure]
- name: check
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- corepack enable - corepack enable && corepack prepare pnpm@latest --activate
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- pnpm run check - pnpm run check
depends_on: depends_on:
- install - lint
test: - name: Send Svelte Check Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Svelte Check failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- check
when:
- status: [failure]
- name: Unit Tests
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- corepack enable - corepack enable && corepack prepare pnpm@latest --activate
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- pnpm run test:run - pnpm run test:run
depends_on: depends_on:
- install - check
build: - name: Send Test Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Test failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Unit Tests
when:
- status: [failure]
- name: build
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- corepack enable - corepack enable && corepack prepare pnpm@latest --activate
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- pnpm run build - pnpm run build
depends_on: depends_on:
- install - Unit Tests
build-full: - name: Send Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- build
when:
- status: [failure]
- name: build-full
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- apt-get update - apt-get update
- apt-get install -y --no-install-recommends ca-certificates libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 libnss3 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 - apt-get install -y --no-install-recommends ca-certificates libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 libnss3 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2
- rm -rf /var/lib/apt/lists/* - rm -rf /var/lib/apt/lists/*
- corepack enable - corepack enable && corepack prepare pnpm@latest --activate
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- pnpm run critical-css:install - pnpm run critical-css:install
- pnpm run build:full - pnpm run build:full
depends_on: depends_on:
- install - build
e2e: - name: Send Build Full Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Build Full failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- build-full
when:
- status: [failure]
- name: E2E Tests
image: node:22-bookworm-slim image: node:22-bookworm-slim
commands: commands:
- corepack enable - corepack enable && corepack prepare pnpm@latest --activate
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- pnpm run build
- pnpm exec playwright install chromium --with-deps - pnpm exec playwright install chromium --with-deps
- pnpm run test:e2e - pnpm run test:e2e
depends_on: depends_on:
- build - build
- name: Send E2E Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] E2E failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- E2E Tests
when:
- status: [failure]
- name: Send CI Pipeline Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] CI pipeline success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- install
- lint
- check
- Unit Tests
- build
- build-full
- E2E Tests
when:
- status: [success]

View File

@@ -1,8 +1,10 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy. # Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds. # Runs on push/tag/manual to main only, after ci workflow succeeds.
when: when:
branch: main - branch: main
event: [push, tag, manual] event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on: depends_on:
- ci - ci
@@ -12,25 +14,77 @@ steps:
image: docker:latest image: docker:latest
environment: environment:
REGISTRY_REPO: git.mifi.dev/mifi-holdings/mifi-links REGISTRY_REPO: git.mifi.dev/mifi-holdings/mifi-links
DOCKER_API_VERSION: '1.43'
DOCKER_BUILDKIT: '1'
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
commands: commands:
- set -e - set -e
- echo "=== Building Docker image ===" - echo "=== Building Docker image (BuildKit) ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"' - 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"' - 'echo "Registry repo: $REGISTRY_REPO"'
- | - |
build() {
docker build \ docker build \
--progress=plain \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \ --tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \ --tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \ --label "git.commit=${CI_COMMIT_SHA}" \
--label "git.branch=${CI_COMMIT_BRANCH}" \ --label "git.branch=${CI_COMMIT_BRANCH}" \
. .
- echo "✓ Docker image built successfully" }
for attempt in 1 2 3; do
echo "Build attempt $attempt/3"
if build; then
echo "✓ Docker image built successfully"
exit 0
fi
echo "Build attempt $attempt failed, retrying in 30s..."
sleep 30
done
echo "All build attempts failed"
exit 1
- name: Send Docker Image Build Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Docker image build success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Docker image build
when:
- status: [success]
- name: Send Docker Image Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Docker image build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Docker image build
when:
- status: [failure]
- name: Push to registry - name: Push to registry
image: docker:latest image: docker:latest
environment: environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-holdings/mifi-links REGISTRY_REPO: git.mifi.dev/mifi-holdings/mifi-links
REGISTRY_USERNAME: REGISTRY_USERNAME:
@@ -54,6 +108,42 @@ steps:
depends_on: depends_on:
- Docker image build - Docker image build
- name: Send Push to Registry Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Push to registry success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Push to registry
when:
- status: [success]
- name: Send Push to Registry Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Push to registry failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Push to registry
when:
- status: [failure]
- name: Trigger Portainer stack redeploy - name: Trigger Portainer stack redeploy
image: curlimages/curl:latest image: curlimages/curl:latest
environment: environment:
@@ -73,3 +163,39 @@ steps:
echo "✓ Portainer redeploy triggered (HTTP $code)" echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on: depends_on:
- Push to registry - Push to registry
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

View File

@@ -13,7 +13,7 @@ This repo is a **one-page static** Linktree-style site for mifi.dev. It is **not
- **Critical CSS** via post-build script (`scripts/critical-css.mjs`); full build with critical CSS is `pnpm run build:full` (run `pnpm run critical-css:install` once to install Chromium) - **Critical CSS** via post-build script (`scripts/critical-css.mjs`); full build with critical CSS is `pnpm run build:full` (run `pnpm run critical-css:install` once to install Chromium)
- **CSP-safe scripts:** Post-build `scripts/externalize-inline-script.mjs` moves SvelteKits inline bootstrap script to `_app/immutable/bootstrap.[hash].js` so CSP can use `script-src 'self'` without `unsafe-inline` - **CSP-safe scripts:** Post-build `scripts/externalize-inline-script.mjs` moves SvelteKits inline bootstrap script to `_app/immutable/bootstrap.[hash].js` so CSP can use `script-src 'self'` without `unsafe-inline`
- **Content:** JSON in `src/lib/data/` (e.g. `links.json`), loaded in `+page.server.ts` at build time - **Content:** JSON in `src/lib/data/` (e.g. `links.json`), loaded in `+page.server.ts` at build time
- **CSP:** Set by Traefik middleware; do not add CSP in app code - **CSP:** Set by Traefik middleware; do not add CSP in app code. Middleware must not use `require-trusted-types-for 'script'` (Svelte hydration is incompatible).
## Conventions ## Conventions

View File

@@ -33,6 +33,9 @@ RUN pnpm run critical-css:install
COPY . . COPY . .
# Create output dirs and generate .svelte-kit so tsconfig.json extends resolves (avoids esbuild warning).
RUN mkdir -p /out && pnpm exec svelte-kit sync
# Build dev variant with critical CSS, move output, then build bio variant with critical CSS. # Build dev variant with critical CSS, move output, then build bio variant with critical CSS.
RUN set -e && \ RUN set -e && \
CONTENT_VARIANT=dev pnpm run build && pnpm run critical-css && \ CONTENT_VARIANT=dev pnpm run build && pnpm run critical-css && \
@@ -45,6 +48,7 @@ FROM nginx:alpine
COPY --from=builder /out/dev /usr/share/nginx/html/dev COPY --from=builder /out/dev /usr/share/nginx/html/dev
COPY --from=builder /out/bio /usr/share/nginx/html/bio COPY --from=builder /out/bio /usr/share/nginx/html/bio
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx/default.conf /etc/nginx/conf.d/default.conf COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/snippets/ /etc/nginx/snippets/ COPY nginx/snippets/ /etc/nginx/snippets/

View File

@@ -155,3 +155,5 @@ pyftsubset ~/Downloads/Fraunces/fraunces-variable-opsz-wght.ttf \
## CSP ## CSP
CSP is set via Traefik middleware, not in app code. CSP is set via Traefik middleware, not in app code.
**Trusted Types:** This app is not compatible with `require-trusted-types-for 'script'`. Sveltes runtime assigns to DOM sinks (e.g. `innerHTML`) during hydration, which that directive blocks. The Traefik middleware used for mifi.dev/mifi.bio must not include `require-trusted-types-for 'script'` (or the site will break with "This assignment requires a TrustedHTML").

View File

@@ -13,18 +13,22 @@ services:
labels: labels:
- 'traefik.enable=true' - 'traefik.enable=true'
- 'traefik.docker.network=marina-net' - 'traefik.docker.network=marina-net'
# Dev (mifi.dev)
- 'traefik.http.routers.mifi-dev.rule=Host(`mifi.dev`) || Host(`www.mifi.dev`)' - 'traefik.http.routers.mifi-dev.rule=Host(`mifi.dev`) || Host(`www.mifi.dev`)'
- 'traefik.http.routers.mifi-dev.entrypoints=websecure' - 'traefik.http.routers.mifi-dev.entrypoints=websecure'
- 'traefik.http.services.mifi-dev.loadbalancer.server.port=80' - 'traefik.http.routers.mifi-dev.service=mifi-dev'
- 'traefik.http.routers.mifi-dev.middlewares=security-supermax-with-analytics@file,redirect-www-to-non-www@file' - 'traefik.http.routers.mifi-dev.middlewares=gzip@file,security-supermax-with-analytics@file,redirect-www-to-non-www@file'
- 'traefik.http.routers.mifi-dev.tls=true' - 'traefik.http.routers.mifi-dev.tls=true'
- 'traefik.http.routers.mifi-dev.tls.certresolver=letsencrypt' - 'traefik.http.routers.mifi-dev.tls.certresolver=letsencrypt'
- 'traefik.http.services.mifi-dev.loadbalancer.server.port=80'
# Bio (mifi.bio)
- 'traefik.http.routers.mifi-bio.rule=Host(`mifi.bio`) || Host(`www.mifi.bio`)' - 'traefik.http.routers.mifi-bio.rule=Host(`mifi.bio`) || Host(`www.mifi.bio`)'
- 'traefik.http.routers.mifi-bio.entrypoints=websecure' - 'traefik.http.routers.mifi-bio.entrypoints=websecure'
- 'traefik.http.services.mifi-bio.loadbalancer.server.port=80' - 'traefik.http.routers.mifi-bio.service=mifi-bio'
- 'traefik.http.routers.mifi-bio.middlewares=security-supermax-with-analytics@file,redirect-www-to-non-www@file' - 'traefik.http.routers.mifi-bio.middlewares=gzip@file,security-supermax-with-analytics@file,redirect-www-to-non-www@file'
- 'traefik.http.routers.mifi-bio.tls=true' - 'traefik.http.routers.mifi-bio.tls=true'
- 'traefik.http.routers.mifi-bio.tls.certresolver=letsencrypt' - 'traefik.http.routers.mifi-bio.tls.certresolver=letsencrypt'
- 'traefik.http.services.mifi-bio.loadbalancer.server.port=80'
networks: networks:
- marina-net - marina-net

51
nginx.conf Normal file
View File

@@ -0,0 +1,51 @@
# Minimal nginx configuration for static site delivery
# Security headers are handled upstream by Traefik
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Gzip compression for text-based assets
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
# text/html is always gzipped by default; listing it again causes "duplicate MIME type" warning
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/rss+xml
application/atom+xml
image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -7,15 +7,24 @@ server {
root /usr/share/nginx/html/dev; root /usr/share/nginx/html/dev;
index index.html; index index.html;
# Map canonical .well-known paths to variant-specific files # Map canonical .well-known paths to variant-specific files (alias cannot use variables)
location = /.well-known/security.txt { location = /.well-known/security.txt {
alias $document_root/.well-known/dev.security.txt; alias /usr/share/nginx/html/dev/.well-known/dev.security.txt;
add_header Cache-Control "public, max-age=86400"; add_header Cache-Control "public, max-age=86400";
} }
location = /.well-known/appspecific/com.chrome.devtools.json { location = /.well-known/appspecific/com.chrome.devtools.json {
alias $document_root/.well-known/dev.com.chrome.devtools.json; alias /usr/share/nginx/html/dev/.well-known/dev.com.chrome.devtools.json;
add_header Cache-Control "public, max-age=86400"; add_header Cache-Control "public, max-age=86400";
} }
location = /robots.txt {
alias /usr/share/nginx/html/dev/robots-dev.txt;
add_header Cache-Control "public, max-age=86400";
}
location = /sitemap.xml {
alias /usr/share/nginx/html/dev/sitemap-dev.xml;
add_header Cache-Control "public, max-age=86400";
add_header Content-Type "application/xml; charset=utf-8";
}
include /etc/nginx/snippets/cache-rules.conf; include /etc/nginx/snippets/cache-rules.conf;
@@ -32,15 +41,24 @@ server {
root /usr/share/nginx/html/bio; root /usr/share/nginx/html/bio;
index index.html; index index.html;
# Map canonical .well-known paths to variant-specific files # Map canonical .well-known paths to variant-specific files (alias cannot use variables)
location = /.well-known/security.txt { location = /.well-known/security.txt {
alias $document_root/.well-known/bio.security.txt; alias /usr/share/nginx/html/bio/.well-known/bio.security.txt;
add_header Cache-Control "public, max-age=86400"; add_header Cache-Control "public, max-age=86400";
} }
location = /.well-known/appspecific/com.chrome.devtools.json { location = /.well-known/appspecific/com.chrome.devtools.json {
alias $document_root/.well-known/bio.com.chrome.devtools.json; alias /usr/share/nginx/html/bio/.well-known/bio.com.chrome.devtools.json;
add_header Cache-Control "public, max-age=86400"; add_header Cache-Control "public, max-age=86400";
} }
location = /robots.txt {
alias /usr/share/nginx/html/bio/robots-bio.txt;
add_header Cache-Control "public, max-age=86400";
}
location = /sitemap.xml {
alias /usr/share/nginx/html/bio/sitemap-bio.xml;
add_header Cache-Control "public, max-age=86400";
add_header Content-Type "application/xml; charset=utf-8";
}
include /etc/nginx/snippets/cache-rules.conf; include /etc/nginx/snippets/cache-rules.conf;

View File

@@ -38,11 +38,7 @@ location ~* \.(pdf|doc|docx)$ {
access_log off; access_log off;
} }
# robots.txt: short cache (1 day) # robots.txt and sitemap.xml: handled in default.conf with alias (variant-specific)
location = /robots.txt {
add_header Cache-Control "public, max-age=86400";
access_log off;
}
# favicon: long cache (30 days) # favicon: long cache (30 days)
location = /favicon.svg { location = /favicon.svg {

View File

@@ -5,12 +5,12 @@
"scripts": { "scripts": {
"dev:bio": "CONTENT_VARIANT=bio vite dev", "dev:bio": "CONTENT_VARIANT=bio vite dev",
"dev:dev": "CONTENT_VARIANT=dev vite dev", "dev:dev": "CONTENT_VARIANT=dev vite dev",
"build": "vite build && node scripts/externalize-inline-script.mjs", "build": "vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs",
"build:bio": "CONTENT_VARIANT=bio vite build && node scripts/externalize-inline-script.mjs", "build:bio": "CONTENT_VARIANT=bio vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs",
"build:dev": "CONTENT_VARIANT=dev vite build && node scripts/externalize-inline-script.mjs", "build:dev": "CONTENT_VARIANT=dev vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs",
"build:full": "vite build && pnpm run critical-css && node scripts/externalize-inline-script.mjs", "build:full": "vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs && pnpm run critical-css",
"build:full:bio": "CONTENT_VARIANT=bio vite build && pnpm run critical-css && node scripts/externalize-inline-script.mjs", "build:full:bio": "CONTENT_VARIANT=bio vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs && pnpm run critical-css",
"build:full:dev": "CONTENT_VARIANT=dev vite build && pnpm run critical-css && node scripts/externalize-inline-script.mjs", "build:full:dev": "CONTENT_VARIANT=dev vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs && pnpm run critical-css",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -22,6 +22,7 @@
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:report": "playwright show-report --port 9324",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:snapshots:dev": "CONTENT_VARIANT=dev pnpm run build && CONTENT_VARIANT=dev pnpm exec playwright test --update-snapshots", "test:e2e:snapshots:dev": "CONTENT_VARIANT=dev pnpm run build && CONTENT_VARIANT=dev pnpm exec playwright test --update-snapshots",
"test:e2e:snapshots:bio": "CONTENT_VARIANT=bio pnpm run build && CONTENT_VARIANT=bio pnpm exec playwright test --update-snapshots", "test:e2e:snapshots:bio": "CONTENT_VARIANT=bio pnpm run build && CONTENT_VARIANT=bio pnpm exec playwright test --update-snapshots",
@@ -39,6 +40,7 @@
"@typescript-eslint/parser": "^8.16.0", "@typescript-eslint/parser": "^8.16.0",
"@vitest/coverage-v8": "^2.1.0", "@vitest/coverage-v8": "^2.1.0",
"critical": "^7.0.0", "critical": "^7.0.0",
"esbuild": "^0.27.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.36.0",
@@ -63,7 +65,7 @@
"dependencies": { "dependencies": {
"@lucide/svelte": "^0.563.1" "@lucide/svelte": "^0.563.1"
}, },
"packageManager": "pnpm@10.28.2", "packageManager": "pnpm@10.29.1+sha512.48dae233635a645768a3028d19545cacc1688639eeb1f3734e42d6d6b971afbf22aa1ac9af52a173d9c3a20c15857cfa400f19994d79a2f626fcc73fccda9bbc",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mifi.dev/mifi-holdings/mifi-links.git" "url": "https://git.mifi.dev/mifi-holdings/mifi-links.git"

View File

@@ -22,5 +22,6 @@ export default defineConfig({
command: 'pnpm run preview', command: 'pnpm run preview',
url: 'http://localhost:4173', url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000,
}, },
}); });

352
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
critical: critical:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.2.1 version: 7.2.1
esbuild:
specifier: ^0.27.3
version: 0.27.3
eslint: eslint:
specifier: ^8.57.0 specifier: ^8.57.0
version: 8.57.1 version: 8.57.1
@@ -721,6 +724,15 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [aix] os: [aix]
'@esbuild/aix-ppc64@0.27.3':
resolution:
{
integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==,
}
engines: { node: '>=18' }
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5': '@esbuild/android-arm64@0.21.5':
resolution: resolution:
{ {
@@ -739,6 +751,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@esbuild/android-arm64@0.27.3':
resolution:
{
integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5': '@esbuild/android-arm@0.21.5':
resolution: resolution:
{ {
@@ -757,6 +778,15 @@ packages:
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@esbuild/android-arm@0.27.3':
resolution:
{
integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==,
}
engines: { node: '>=18' }
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5': '@esbuild/android-x64@0.21.5':
resolution: resolution:
{ {
@@ -775,6 +805,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [android] os: [android]
'@esbuild/android-x64@0.27.3':
resolution:
{
integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5': '@esbuild/darwin-arm64@0.21.5':
resolution: resolution:
{ {
@@ -793,6 +832,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@esbuild/darwin-arm64@0.27.3':
resolution:
{
integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5': '@esbuild/darwin-x64@0.21.5':
resolution: resolution:
{ {
@@ -811,6 +859,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution:
{
integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5': '@esbuild/freebsd-arm64@0.21.5':
resolution: resolution:
{ {
@@ -829,6 +886,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@esbuild/freebsd-arm64@0.27.3':
resolution:
{
integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5': '@esbuild/freebsd-x64@0.21.5':
resolution: resolution:
{ {
@@ -847,6 +913,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution:
{
integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5': '@esbuild/linux-arm64@0.21.5':
resolution: resolution:
{ {
@@ -865,6 +940,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@esbuild/linux-arm64@0.27.3':
resolution:
{
integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5': '@esbuild/linux-arm@0.21.5':
resolution: resolution:
{ {
@@ -883,6 +967,15 @@ packages:
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution:
{
integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==,
}
engines: { node: '>=18' }
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5': '@esbuild/linux-ia32@0.21.5':
resolution: resolution:
{ {
@@ -901,6 +994,15 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [linux] os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution:
{
integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==,
}
engines: { node: '>=18' }
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5': '@esbuild/linux-loong64@0.21.5':
resolution: resolution:
{ {
@@ -919,6 +1021,15 @@ packages:
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution:
{
integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==,
}
engines: { node: '>=18' }
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5': '@esbuild/linux-mips64el@0.21.5':
resolution: resolution:
{ {
@@ -937,6 +1048,15 @@ packages:
cpu: [mips64el] cpu: [mips64el]
os: [linux] os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution:
{
integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==,
}
engines: { node: '>=18' }
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5': '@esbuild/linux-ppc64@0.21.5':
resolution: resolution:
{ {
@@ -955,6 +1075,15 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution:
{
integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==,
}
engines: { node: '>=18' }
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5': '@esbuild/linux-riscv64@0.21.5':
resolution: resolution:
{ {
@@ -973,6 +1102,15 @@ packages:
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution:
{
integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==,
}
engines: { node: '>=18' }
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5': '@esbuild/linux-s390x@0.21.5':
resolution: resolution:
{ {
@@ -991,6 +1129,15 @@ packages:
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution:
{
integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==,
}
engines: { node: '>=18' }
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5': '@esbuild/linux-x64@0.21.5':
resolution: resolution:
{ {
@@ -1009,6 +1156,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution:
{
integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12': '@esbuild/netbsd-arm64@0.25.12':
resolution: resolution:
{ {
@@ -1018,6 +1174,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [netbsd] os: [netbsd]
'@esbuild/netbsd-arm64@0.27.3':
resolution:
{
integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.21.5': '@esbuild/netbsd-x64@0.21.5':
resolution: resolution:
{ {
@@ -1036,6 +1201,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [netbsd] os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution:
{
integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12': '@esbuild/openbsd-arm64@0.25.12':
resolution: resolution:
{ {
@@ -1045,6 +1219,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openbsd] os: [openbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution:
{
integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.21.5': '@esbuild/openbsd-x64@0.21.5':
resolution: resolution:
{ {
@@ -1063,6 +1246,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [openbsd] os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution:
{
integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12': '@esbuild/openharmony-arm64@0.25.12':
resolution: resolution:
{ {
@@ -1072,6 +1264,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@esbuild/openharmony-arm64@0.27.3':
resolution:
{
integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.21.5': '@esbuild/sunos-x64@0.21.5':
resolution: resolution:
{ {
@@ -1090,6 +1291,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [sunos] os: [sunos]
'@esbuild/sunos-x64@0.27.3':
resolution:
{
integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5': '@esbuild/win32-arm64@0.21.5':
resolution: resolution:
{ {
@@ -1108,6 +1318,15 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@esbuild/win32-arm64@0.27.3':
resolution:
{
integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==,
}
engines: { node: '>=18' }
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5': '@esbuild/win32-ia32@0.21.5':
resolution: resolution:
{ {
@@ -1126,6 +1345,15 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution:
{
integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==,
}
engines: { node: '>=18' }
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5': '@esbuild/win32-x64@0.21.5':
resolution: resolution:
{ {
@@ -1144,6 +1372,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution:
{
integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==,
}
engines: { node: '>=18' }
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.1': '@eslint-community/eslint-utils@4.9.1':
resolution: resolution:
{ {
@@ -2885,6 +3122,14 @@ packages:
engines: { node: '>=18' } engines: { node: '>=18' }
hasBin: true hasBin: true
esbuild@0.27.3:
resolution:
{
integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==,
}
engines: { node: '>=18' }
hasBin: true
escalade@3.2.0: escalade@3.2.0:
resolution: resolution:
{ {
@@ -6678,147 +6923,225 @@ snapshots:
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
optional: true optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.21.5': '@esbuild/android-arm64@0.21.5':
optional: true optional: true
'@esbuild/android-arm64@0.25.12': '@esbuild/android-arm64@0.25.12':
optional: true optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.21.5': '@esbuild/android-arm@0.21.5':
optional: true optional: true
'@esbuild/android-arm@0.25.12': '@esbuild/android-arm@0.25.12':
optional: true optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.21.5': '@esbuild/android-x64@0.21.5':
optional: true optional: true
'@esbuild/android-x64@0.25.12': '@esbuild/android-x64@0.25.12':
optional: true optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.21.5': '@esbuild/darwin-arm64@0.21.5':
optional: true optional: true
'@esbuild/darwin-arm64@0.25.12': '@esbuild/darwin-arm64@0.25.12':
optional: true optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.21.5': '@esbuild/darwin-x64@0.21.5':
optional: true optional: true
'@esbuild/darwin-x64@0.25.12': '@esbuild/darwin-x64@0.25.12':
optional: true optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.21.5': '@esbuild/freebsd-arm64@0.21.5':
optional: true optional: true
'@esbuild/freebsd-arm64@0.25.12': '@esbuild/freebsd-arm64@0.25.12':
optional: true optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.21.5': '@esbuild/freebsd-x64@0.21.5':
optional: true optional: true
'@esbuild/freebsd-x64@0.25.12': '@esbuild/freebsd-x64@0.25.12':
optional: true optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.21.5': '@esbuild/linux-arm64@0.21.5':
optional: true optional: true
'@esbuild/linux-arm64@0.25.12': '@esbuild/linux-arm64@0.25.12':
optional: true optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.21.5': '@esbuild/linux-arm@0.21.5':
optional: true optional: true
'@esbuild/linux-arm@0.25.12': '@esbuild/linux-arm@0.25.12':
optional: true optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.21.5': '@esbuild/linux-ia32@0.21.5':
optional: true optional: true
'@esbuild/linux-ia32@0.25.12': '@esbuild/linux-ia32@0.25.12':
optional: true optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.21.5': '@esbuild/linux-loong64@0.21.5':
optional: true optional: true
'@esbuild/linux-loong64@0.25.12': '@esbuild/linux-loong64@0.25.12':
optional: true optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.21.5': '@esbuild/linux-mips64el@0.21.5':
optional: true optional: true
'@esbuild/linux-mips64el@0.25.12': '@esbuild/linux-mips64el@0.25.12':
optional: true optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.21.5': '@esbuild/linux-ppc64@0.21.5':
optional: true optional: true
'@esbuild/linux-ppc64@0.25.12': '@esbuild/linux-ppc64@0.25.12':
optional: true optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.21.5': '@esbuild/linux-riscv64@0.21.5':
optional: true optional: true
'@esbuild/linux-riscv64@0.25.12': '@esbuild/linux-riscv64@0.25.12':
optional: true optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.21.5': '@esbuild/linux-s390x@0.21.5':
optional: true optional: true
'@esbuild/linux-s390x@0.25.12': '@esbuild/linux-s390x@0.25.12':
optional: true optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.21.5': '@esbuild/linux-x64@0.21.5':
optional: true optional: true
'@esbuild/linux-x64@0.25.12': '@esbuild/linux-x64@0.25.12':
optional: true optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.25.12': '@esbuild/netbsd-arm64@0.25.12':
optional: true optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.21.5': '@esbuild/netbsd-x64@0.21.5':
optional: true optional: true
'@esbuild/netbsd-x64@0.25.12': '@esbuild/netbsd-x64@0.25.12':
optional: true optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.25.12': '@esbuild/openbsd-arm64@0.25.12':
optional: true optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.21.5': '@esbuild/openbsd-x64@0.21.5':
optional: true optional: true
'@esbuild/openbsd-x64@0.25.12': '@esbuild/openbsd-x64@0.25.12':
optional: true optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.25.12': '@esbuild/openharmony-arm64@0.25.12':
optional: true optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.21.5': '@esbuild/sunos-x64@0.21.5':
optional: true optional: true
'@esbuild/sunos-x64@0.25.12': '@esbuild/sunos-x64@0.25.12':
optional: true optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.21.5': '@esbuild/win32-arm64@0.21.5':
optional: true optional: true
'@esbuild/win32-arm64@0.25.12': '@esbuild/win32-arm64@0.25.12':
optional: true optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.21.5': '@esbuild/win32-ia32@0.21.5':
optional: true optional: true
'@esbuild/win32-ia32@0.25.12': '@esbuild/win32-ia32@0.25.12':
optional: true optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.21.5': '@esbuild/win32-x64@0.21.5':
optional: true optional: true
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)':
dependencies: dependencies:
eslint: 8.57.1 eslint: 8.57.1
@@ -7904,6 +8227,35 @@ snapshots:
'@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12 '@esbuild/win32-x64': 0.25.12
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}

View File

@@ -6,23 +6,57 @@
* Usage: node scripts/critical-css.mjs [buildDir] * Usage: node scripts/critical-css.mjs [buildDir]
* buildDir: path to build output (default: "build"). Use from repo root. * buildDir: path to build output (default: "build"). Use from repo root.
*/ */
import { readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { cwd } from 'node:process'; import { cwd } from 'node:process';
const buildDir = join(cwd(), process.argv[2] || 'build'); const buildDir = join(cwd(), process.argv[2] || 'build');
const htmlPath = join(buildDir, 'index.html'); const htmlPath = join(buildDir, 'index.html');
// critical/penthouse use a nested puppeteer; point it at our installed Chrome // critical/penthouse use a nested puppeteer; point at an installed Chrome/Chromium.
try { // Only set PUPPETEER_EXECUTABLE_PATH if the path exists. Prefer Playwright's Chromium
const puppeteer = await import('puppeteer'); // on arm64 so we use the native binary (Puppeteer's default can be x86 on ARM hosts e.g. OrbStack).
const executablePath = puppeteer.default?.executablePath?.(); function getPlaywrightChromePath() {
if (executablePath) { const cacheBase =
process.env.PUPPETEER_EXECUTABLE_PATH = executablePath; process.env.PLAYWRIGHT_BROWSERS_PATH || join(homedir(), '.cache', 'ms-playwright');
if (!existsSync(cacheBase)) return null;
const dirs = readdirSync(cacheBase, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory() || !d.name.startsWith('chromium-')) continue;
const sub = join(cacheBase, d.name);
const rel =
platform() === 'win32'
? 'chrome-win\\chrome.exe'
: platform() === 'darwin'
? 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'
: 'chrome-linux/chrome';
const candidate = join(sub, rel);
if (existsSync(candidate)) return candidate;
} }
} catch { return null;
// no top-level puppeteer or no executable; rely on env or default
} }
async function resolveChromePath() {
const isArm64 = process.arch === 'arm64';
if (isArm64) {
const pw = getPlaywrightChromePath();
if (pw) return pw;
}
try {
const puppeteer = await import('puppeteer');
const path = puppeteer.default?.executablePath?.();
if (path && existsSync(path)) return path;
} catch {
// ignore
}
if (!isArm64) {
const pw = getPlaywrightChromePath();
if (pw) return pw;
}
return null;
}
const chromePath = await resolveChromePath();
if (chromePath) process.env.PUPPETEER_EXECUTABLE_PATH = chromePath;
try { try {
const { generate } = await import('critical'); const { generate } = await import('critical');
@@ -31,17 +65,30 @@ try {
base: buildDir, base: buildDir,
html, html,
inline: { strategy: 'default' }, // preload in head + link at end of body (no inline JS, CSP-safe) inline: { strategy: 'default' }, // preload in head + link at end of body (no inline JS, CSP-safe)
dimensions: [{ width: 1280, height: 720 }], dimensions: [
{ width: 375, height: 667 }, // mobile (iPhone SE)
{ width: 768, height: 1024 }, // tablet
{ width: 1280, height: 720 }, // desktop
],
penthouse: { timeout: 30000 }, penthouse: { timeout: 30000 },
}); });
writeFileSync(htmlPath, outHtml, 'utf-8'); // Ensure _app asset paths stay absolute (critical may rewrite; host-based routing needs /_app/...)
const normalized = outHtml.replace(/\.\/_app\//g, '/_app/');
writeFileSync(htmlPath, normalized, 'utf-8');
console.log(`Critical CSS inlined in ${htmlPath}`); console.log(`Critical CSS inlined in ${htmlPath}`);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
console.error('Critical CSS step failed:', msg); console.error('Critical CSS step failed:', msg);
if (msg.includes('Browser is not downloaded')) { if (
msg.includes('Browser is not downloaded') ||
msg.includes('did not find any executable') ||
msg.includes('Could not find Chrome')
) {
console.error('Install Chromium first: pnpm run critical-css:install'); console.error('Install Chromium first: pnpm run critical-css:install');
console.error('Or run "pnpm run build" without critical CSS.'); console.error(
'(Dev container: Playwright Chromium is also used if present in ~/.cache/ms-playwright.)',
);
console.error('Or run "pnpm run build" (without critical CSS) for a working build.');
} }
process.exit(1); process.exit(1);
} }

View File

@@ -1,6 +1,7 @@
/** /**
* Post-build: extract SvelteKit's inline bootstrap script to an external file * Post-build: extract SvelteKit's inline bootstrap script to an external file
* and replace it with <script src="..."> so CSP can use script-src 'self' without unsafe-inline. * and replace it with <script src="..."> so CSP can use script-src 'self' without unsafe-inline.
* Minifies the extracted JS.
* *
* Usage: node scripts/externalize-inline-script.mjs [buildDir] * Usage: node scripts/externalize-inline-script.mjs [buildDir]
* buildDir: path to build output (default: "build"). Use from repo root. * buildDir: path to build output (default: "build"). Use from repo root.
@@ -9,6 +10,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { join } from 'node:path'; import { join } from 'node:path';
import { cwd } from 'node:process'; import { cwd } from 'node:process';
import * as esbuild from 'esbuild';
const buildDir = join(cwd(), process.argv[2] || 'build'); const buildDir = join(cwd(), process.argv[2] || 'build');
const htmlPath = join(buildDir, 'index.html'); const htmlPath = join(buildDir, 'index.html');
@@ -66,28 +68,40 @@ function extractScriptContent(html, scriptStart) {
return null; return null;
} }
try { async function main() {
try {
let html = readFileSync(htmlPath, 'utf-8'); let html = readFileSync(htmlPath, 'utf-8');
const found = findSvelteKitInlineScript(html); const found = findSvelteKitInlineScript(html);
if (!found) { if (!found) {
console.log('No SvelteKit inline bootstrap script found in', htmlPath); console.log('No SvelteKit inline bootstrap script found in', htmlPath);
process.exit(0); process.exit(0);
} }
const content = found.content; let content = found.content;
// Bootstrap runs from /_app/immutable/bootstrap.xxx.js; imports like "./_app/immutable/entry/..."
// would resolve to /_app/immutable/_app/immutable/entry/... (duplicate). Use directory-relative paths.
content = content.replace(/\.\/_app\/immutable\//g, './');
// Minify
const minified = await esbuild.transform(content, { minify: true, loader: 'js' });
content = minified.code;
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8); const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
const filename = `bootstrap.${hash}.js`; const filename = `bootstrap.${hash}.js`;
const immutableDir = join(buildDir, '_app', 'immutable'); const immutableDir = join(buildDir, '_app', 'immutable');
mkdirSync(immutableDir, { recursive: true }); mkdirSync(immutableDir, { recursive: true });
const scriptPath = join(immutableDir, filename); const scriptPath = join(immutableDir, filename);
writeFileSync(scriptPath, content.trimStart(), 'utf-8'); writeFileSync(scriptPath, content, 'utf-8');
const scriptTag = `<script src="./_app/immutable/${filename}"></script>`; const scriptTag = `<script src="/_app/immutable/${filename}"></script>`;
html = html.slice(0, found.start) + scriptTag + html.slice(found.end); html = html.slice(0, found.start) + scriptTag + html.slice(found.end);
// Use absolute paths for _app assets so they resolve correctly (host-based routing, redirects)
html = html.replace(/\.\/_app\//g, '/_app/');
writeFileSync(htmlPath, html, 'utf-8'); writeFileSync(htmlPath, html, 'utf-8');
console.log('Externalized SvelteKit bootstrap to', scriptPath); console.log('Externalized SvelteKit bootstrap to', scriptPath);
} catch (err) { } catch (err) {
console.error( console.error(
'externalize-inline-script failed:', 'externalize-inline-script failed:',
err instanceof Error ? err.message : String(err), err instanceof Error ? err.message : String(err),
); );
process.exit(1); process.exit(1);
}
} }
main();

View File

@@ -0,0 +1,42 @@
/**
* Post-build: minify JS and CSS under build/assets (files copied from static/).
* Includes static/assets/js/*.js (e.g. ga-init.js, bootstrap.js) and assets/*.css.
* Usage: node scripts/minify-static-assets.mjs [buildDir]
*/
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { cwd } from 'node:process';
import * as esbuild from 'esbuild';
const buildDir = join(cwd(), process.argv[2] || 'build');
const assetsDir = join(buildDir, 'assets');
function* walk(dir, base = '') {
for (const name of readdirSync(dir, { withFileTypes: true })) {
const rel = join(base, name.name);
if (name.isDirectory()) yield* walk(join(dir, name.name), rel);
else yield rel;
}
}
async function minifyAll() {
if (!existsSync(assetsDir)) return;
for (const rel of walk(assetsDir, '')) {
const path = join(assetsDir, rel);
const ext = rel.slice(rel.lastIndexOf('.'));
if (ext === '.js') {
const code = readFileSync(path, 'utf-8');
const out = await esbuild.transform(code, { minify: true, loader: 'js' });
writeFileSync(path, out.code);
} else if (ext === '.css') {
const code = readFileSync(path, 'utf-8');
const out = await esbuild.transform(code, { minify: true, loader: 'css' });
writeFileSync(path, out.code);
}
}
}
minifyAll().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/assets/js/theme-store.js"></script>
<script src="/assets/js/bootstrap.js"></script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -34,6 +34,8 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-describedby={link.description ? descriptionId : undefined} aria-describedby={link.description ? descriptionId : undefined}
data-umami-event="contact panel item"
data-umami-event-label={link.label}
> >
{#if link.icon} {#if link.icon}
<LinkIcon href={link.href} icon={link.icon} label={link.label} /> <LinkIcon href={link.href} icon={link.icon} label={link.label} />
@@ -65,7 +67,7 @@
color: var(--color-secondary-muted); color: var(--color-secondary-muted);
display: block; display: block;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 0.5rem; margin-bottom: 1rem;
text-align: center; text-align: center;
} }
</style> </style>

View File

@@ -35,6 +35,8 @@
contactOpen = true; contactOpen = true;
shareOpen = false; shareOpen = false;
}} }}
data-umami-event="header button"
data-umami-event-label="contact"
> >
<IconContact size={20} /> <IconContact size={20} />
</button> </button>
@@ -47,6 +49,8 @@
shareOpen = true; shareOpen = true;
contactOpen = false; contactOpen = false;
}} }}
data-umami-event="header button"
data-umami-event-label="share"
> >
<IconShare size={20} /> <IconShare size={20} />
</button> </button>
@@ -98,6 +102,8 @@
contactOpen = true; contactOpen = true;
shareOpen = false; shareOpen = false;
}} }}
data-umami-event="hero card button"
data-umami-event-label="contact"
> >
<IconContact size={20} /> <IconContact size={20} />
<span>Contact</span> <span>Contact</span>
@@ -111,6 +117,8 @@
shareOpen = true; shareOpen = true;
contactOpen = false; contactOpen = false;
}} }}
data-umami-event="hero card button"
data-umami-event-label="share"
> >
<IconShare size={20} /> <IconShare size={20} />
<span>Share</span> <span>Share</span>

View File

@@ -5,7 +5,14 @@
let { href, icon, label, description }: ProcessedLink = $props(); let { href, icon, label, description }: ProcessedLink = $props();
</script> </script>
<a {href} rel="noopener noreferrer" target="_blank" class="link"> <a
{href}
rel="noopener noreferrer"
target="_blank"
class="link"
data-umami-event={`link click`}
data-umami-event-label={label}
>
<span class="icon" aria-hidden="true"> <span class="icon" aria-hidden="true">
<LinkIcon {href} {icon} {label} /> <LinkIcon {href} {icon} {label} />
</span> </span>

View File

@@ -33,7 +33,7 @@
<style> <style>
.link-section-heading { .link-section-heading {
color: var(--color-primary); color: var(--color-primary-muted);
font-family: var(--font-heading, var(--font-sans)); font-family: var(--font-heading, var(--font-sans));
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 500; font-weight: 500;

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
@@ -9,7 +10,7 @@
open = false, open = false,
title = '', title = '',
}: { }: {
children: import('svelte').Snippet; children: Snippet;
icon?: Component<{ size?: number }>; icon?: Component<{ size?: number }>;
onclose: () => void; onclose: () => void;
open: boolean; open: boolean;
@@ -41,6 +42,9 @@
aria-labelledby="panel-title" aria-labelledby="panel-title"
aria-modal="true" aria-modal="true"
onclose={handleDialogClose} onclose={handleDialogClose}
onclick={(e) => {
if (e.target === dialogEl) onclose();
}}
oncancel={(e) => { oncancel={(e) => {
e.preventDefault(); e.preventDefault();
onclose(); onclose();
@@ -60,6 +64,8 @@
aria-label="Close" aria-label="Close"
bind:this={closeBtnEl} bind:this={closeBtnEl}
onclick={onclose} onclick={onclose}
data-umami-event="panel button"
data-umami-event-label="close"
> >
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
</button> </button>
@@ -105,7 +111,7 @@
flex-direction: column; flex-direction: column;
display: flex; display: flex;
max-height: 70vh; max-height: 70vh;
padding-bottom: 2rem; padding-bottom: 1rem;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;

View File

@@ -50,14 +50,20 @@
{#if qrCodeImage} {#if qrCodeImage}
<div class="share-qr"> <div class="share-qr">
<img <img
src="/assets/images/{qrCodeImage}.png" src="/assets/images/{qrCodeImage}.svg"
alt="QR code for this page" alt="QR code for this page"
width="160" width="160"
height="160" height="160"
/> />
</div> </div>
{/if} {/if}
<button type="button" class="panel-btn" onclick={copyLink}> <button
type="button"
class="panel-btn"
onclick={copyLink}
data-umami-event="share panel item"
data-umami-event-label="copy"
>
<IconCopy size={20} /> <IconCopy size={20} />
{copied ? 'Copied!' : 'Copy link'} {copied ? 'Copied!' : 'Copy link'}
</button> </button>
@@ -67,12 +73,20 @@
onclick={onclose} onclick={onclose}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
data-umami-event="share panel item"
data-umami-event-label="email"
> >
<IconEmail size={20} /> <IconEmail size={20} />
Email link Email link
</a> </a>
{#if canShare} {#if canShare}
<button type="button" class="panel-btn" onclick={share}> <button
type="button"
class="panel-btn"
onclick={share}
data-umami-event="share panel item"
data-umami-event-label="device share"
>
<IconShare size={20} /> <IconShare size={20} />
Share… Share…
</button> </button>

View File

@@ -45,6 +45,8 @@
aria-label="Close theme menu" aria-label="Close theme menu"
tabindex="-1" tabindex="-1"
onclick={() => (expanded = false)} onclick={() => (expanded = false)}
data-umami-event="theme toggle button"
data-umami-event-label="close"
></button> ></button>
{/if} {/if}
@@ -64,6 +66,8 @@
aria-current={current === 'light' ? 'true' : undefined} aria-current={current === 'light' ? 'true' : undefined}
title="Light" title="Light"
onclick={() => handleClick('light')} onclick={() => handleClick('light')}
data-umami-event="theme toggle button"
data-umami-event-label="light"
> >
<Sun size={24} /> <Sun size={24} />
</button> </button>
@@ -75,6 +79,8 @@
aria-current={current === 'dark' ? 'true' : undefined} aria-current={current === 'dark' ? 'true' : undefined}
title="Dark" title="Dark"
onclick={() => handleClick('dark')} onclick={() => handleClick('dark')}
data-umami-event="theme toggle button"
data-umami-event-label="dark"
> >
<Moon size={24} /> <Moon size={24} />
</button> </button>
@@ -86,6 +92,8 @@
aria-current={current === 'auto' ? 'true' : undefined} aria-current={current === 'auto' ? 'true' : undefined}
title="Auto (system)" title="Auto (system)"
onclick={() => handleClick('auto')} onclick={() => handleClick('auto')}
data-umami-event="theme toggle button"
data-umami-event-label="auto"
> >
<SunMoon size={24} /> <SunMoon size={24} />
</button> </button>

View File

@@ -2,6 +2,8 @@
* App config: own-property hostnames for UTM attribution, variant hostnames, GA IDs. * App config: own-property hostnames for UTM attribution, variant hostnames, GA IDs.
*/ */
import { ContentVariant } from './data/constants';
export const OWN_PROPERTY_HOSTS = [ export const OWN_PROPERTY_HOSTS = [
'mifi.ventures', 'mifi.ventures',
'cal.mifi.ventures', 'cal.mifi.ventures',
@@ -9,20 +11,25 @@ export const OWN_PROPERTY_HOSTS = [
'mifi.bio', 'mifi.bio',
] as const; ] as const;
export const VARIANT_HOSTS: Record<'dev' | 'bio', string> = { export const VARIANT_HOSTS: Record<ContentVariant, string> = {
dev: 'mifi.dev', [ContentVariant.DEV]: 'mifi.dev',
bio: 'mifi.bio', [ContentVariant.BIO]: 'mifi.bio',
}; };
export const GA_MEASUREMENT_IDS: Record<'dev' | 'bio', string> = { export const GA_MEASUREMENT_IDS: Record<ContentVariant, string> = {
dev: 'G-P8V832WDM8', [ContentVariant.DEV]: 'G-P8V832WDM8',
bio: 'G-885B0KYWZ1', [ContentVariant.BIO]: 'G-885B0KYWZ1',
};
export const UMAMI_MEASUREMENT_IDS: Record<ContentVariant, string> = {
[ContentVariant.DEV]: 'ac7e751b-4ce3-49f2-80e0-f430b292b72a',
[ContentVariant.BIO]: 'cf44669d-10c1-4982-ad79-282aed4237e5',
}; };
/** theme-color meta values per variant (match tokens-{variant}.css --color-bg) */ /** theme-color meta values per variant (match tokens-{variant}.css --color-bg) */
export const THEME_COLORS: Record<'dev' | 'bio', { light: string; dark: string }> = { export const THEME_COLORS: Record<ContentVariant, { light: string; dark: string }> = {
dev: { light: '#f5f4f8', dark: '#131118' }, // hsl(260 20% 98%) / hsl(260 18% 8%) [ContentVariant.DEV]: { light: '#f5f4f8', dark: '#131118' }, // hsl(260 20% 98%) / hsl(260 18% 8%)
bio: { light: '#f4f6f9', dark: '#111318' }, // hsl(220 22% 98%) / hsl(220 18% 8%) [ContentVariant.BIO]: { light: '#f4f6f9', dark: '#111318' }, // hsl(220 22% 98%) / hsl(220 18% 8%)
}; };
export const UTM_MEDIUM = 'link'; export const UTM_MEDIUM = 'link';

View File

@@ -20,8 +20,7 @@
] ]
}, },
"linksHeading": "Professional Links and Profiles", "linksHeading": "Professional Links and Profiles",
"showContact": true, "qrCodeImage": "qr-mifi-dev"
"qrCodeImage": null
}, },
"bio": { "bio": {
"title": "mifi.bio — the homepage of the human Mike Fitzpatrick", "title": "mifi.bio — the homepage of the human Mike Fitzpatrick",
@@ -51,8 +50,7 @@
] ]
}, },
"linksHeading": "Links and Profiles", "linksHeading": "Links and Profiles",
"showContact": false, "qrCodeImage": "qr-mifi-bio"
"qrCodeImage": null
} }
}, },
"contactLinks": [ "contactLinks": [
@@ -124,6 +122,14 @@
"dev": 1 "dev": 1
}, },
"links": [ "links": [
{
"href": "https://mifi.dev/downloads/resume-2026lf.pdf",
"icon": "Resume",
"label": "Long-form CV",
"description": "My full resume, including all my work experience, education, and skills.",
"utmContent": "resume-lf",
"variants": ["dev", "bio"]
},
{ {
"href": "https://mifi.dev/downloads/resume-2026c.pdf", "href": "https://mifi.dev/downloads/resume-2026c.pdf",
"icon": "Resume", "icon": "Resume",

View File

@@ -29,11 +29,7 @@ export interface Site {
sameAs: string[]; sameAs: string[];
}; };
linksHeading?: string; linksHeading?: string;
/** If false, hide Contact button and panel for this variant. Default true. */
showContact?: boolean;
/** Contact panel links; if omitted, first section links are used. */
contactLinks?: ContactLink[]; contactLinks?: ContactLink[];
/** Optional QR code image path (e.g. /assets/images/qr-mifi-dev.png) for Share panel. */
qrCodeImage?: string | null; qrCodeImage?: string | null;
} }

View File

@@ -1,5 +1,10 @@
import contentData from '$lib/data/links.json'; import contentData from '$lib/data/links.json';
import { VARIANT_HOSTS, GA_MEASUREMENT_IDS, THEME_COLORS } from '$lib/config'; import {
VARIANT_HOSTS,
GA_MEASUREMENT_IDS,
THEME_COLORS,
UMAMI_MEASUREMENT_IDS,
} from '$lib/config';
import type { Site, ContentData, ProcessedLink } from '$lib/data/types'; import type { Site, ContentData, ProcessedLink } from '$lib/data/types';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { ContentVariant, HeroLayout } from '$lib/data/constants'; import { ContentVariant, HeroLayout } from '$lib/data/constants';
@@ -18,6 +23,7 @@ export type LayoutServerDataOut = {
}; };
variant: string; variant: string;
gaMeasurementId: string; gaMeasurementId: string;
umamiMeasurementId: string;
/** theme-color meta values for current variant */ /** theme-color meta values for current variant */
themeColorLight: string; themeColorLight: string;
themeColorDark: string; themeColorDark: string;
@@ -57,7 +63,6 @@ export const load: LayoutServerLoad<LayoutServerDataOut> = (): LayoutServerDataO
location: siteDef?.location, location: siteDef?.location,
person: siteDef?.person, person: siteDef?.person,
linksHeading: siteDef?.linksHeading, linksHeading: siteDef?.linksHeading,
showContact: siteDef?.showContact,
contactLinks: siteDef?.contactLinks, contactLinks: siteDef?.contactLinks,
qrCodeImage: siteDef?.qrCodeImage ?? undefined, qrCodeImage: siteDef?.qrCodeImage ?? undefined,
}; };
@@ -68,6 +73,7 @@ export const load: LayoutServerLoad<LayoutServerDataOut> = (): LayoutServerDataO
links: { sections }, links: { sections },
variant, variant,
gaMeasurementId: GA_MEASUREMENT_IDS[variant], gaMeasurementId: GA_MEASUREMENT_IDS[variant],
umamiMeasurementId: UMAMI_MEASUREMENT_IDS[variant],
themeColorLight: themeColors.light, themeColorLight: themeColors.light,
themeColorDark: themeColors.dark, themeColorDark: themeColors.dark,
}; };

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Footer from '$lib/components/Footer.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
@@ -36,6 +37,15 @@
</script> </script>
<svelte:head> <svelte:head>
<!-- Google tag (gtag.js): ID passed via data-ga-id for CSP (no inline script) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={data.gaMeasurementId}"></script>
<script defer src="/assets/js/ga-init.js" data-ga-id={data.gaMeasurementId}></script>
<script
defer
src="https://analytics.mifi.holdings/script.js"
data-website-id={data.umamiMeasurementId}
></script>
<link rel="stylesheet" href="/assets/tokens-{data.variant}.css" /> <link rel="stylesheet" href="/assets/tokens-{data.variant}.css" />
<link <link
@@ -88,3 +98,12 @@
<ThemeToggle /> <ThemeToggle />
</header> </header>
{@render children()} {@render children()}
<Footer />
<img
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
alt=""
width="1"
height="1"
role="presentation"
loading="eager"
/>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import ContactPanel from '$lib/components/ContactPanel.svelte'; import ContactPanel from '$lib/components/ContactPanel.svelte';
import Footer from '$lib/components/Footer.svelte';
import Hero from '$lib/components/Hero.svelte'; import Hero from '$lib/components/Hero.svelte';
import LinkGroup from '$lib/components/LinkGroup.svelte'; import LinkGroup from '$lib/components/LinkGroup.svelte';
import SharePanel from '$lib/components/SharePanel.svelte'; import SharePanel from '$lib/components/SharePanel.svelte';
@@ -67,7 +66,6 @@
/> />
{/each} {/each}
</div> </div>
<Footer />
<SharePanel <SharePanel
open={shareOpen} open={shareOpen}
url={shareUrl} url={shareUrl}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { load } from '../+layout.server'; import { load } from '../+layout.server';
import { UMAMI_MEASUREMENT_IDS } from '$lib/config';
import { ContentVariant } from '$lib/data/constants'; import { ContentVariant } from '$lib/data/constants';
import type { ContentData } from '$lib/data/types'; import type { ContentData } from '$lib/data/types';
@@ -95,7 +96,9 @@ describe('+layout.server', () => {
it('gaMeasurementId matches variant', () => { it('gaMeasurementId matches variant', () => {
process.env.CONTENT_VARIANT = ContentVariant.DEV; process.env.CONTENT_VARIANT = ContentVariant.DEV;
expect(load().gaMeasurementId).toMatch(/^G-/); expect(load().gaMeasurementId).toMatch(/^G-/);
expect(load().umamiMeasurementId).toBe(UMAMI_MEASUREMENT_IDS[ContentVariant.DEV]);
process.env.CONTENT_VARIANT = ContentVariant.BIO; process.env.CONTENT_VARIANT = ContentVariant.BIO;
expect(load().gaMeasurementId).toMatch(/^G-/); expect(load().gaMeasurementId).toMatch(/^G-/);
expect(load().umamiMeasurementId).toBe(UMAMI_MEASUREMENT_IDS[ContentVariant.BIO]);
}); });
}); });

View File

@@ -15,9 +15,12 @@ describe('+layout (LayoutData)', () => {
links: { sections: [] }, links: { sections: [] },
variant: 'dev', variant: 'dev',
gaMeasurementId: 'G-xxx', gaMeasurementId: 'G-xxx',
umamiMeasurementId: 'UM-xxx',
}; };
expect(mockData.site).toHaveProperty('title'); expect(mockData.site).toHaveProperty('title');
expect(mockData.site).toHaveProperty('url'); expect(mockData.site).toHaveProperty('url');
expect(mockData).toHaveProperty('umamiMeasurementId');
expect(mockData).toHaveProperty('gaMeasurementId');
expect(mockData).toHaveProperty('variant'); expect(mockData).toHaveProperty('variant');
expect(mockData.links).toHaveProperty('sections'); expect(mockData.links).toHaveProperty('sections');
}); });

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 MiB

18
static/assets/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,18 @@
(function () {
// Default Trusted Type policy so Svelte hydration can assign to innerHTML under CSP require-trusted-types-for 'script'.
if (typeof window.trustedTypes !== 'undefined' && window.trustedTypes.createPolicy) {
try {
window.trustedTypes.createPolicy('default', {
createHTML: function (input) {
return input;
},
});
} catch {
/* policy already exists */
}
}
var t = localStorage.getItem('mifi-theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
else document.documentElement.removeAttribute('data-theme');
})();

View File

@@ -0,0 +1,11 @@
(function () {
var script = document.currentScript;
var id = script && script.getAttribute('data-ga-id');
if (!id) return;
window.dataLayer = window.dataLayer || [];
function gtag() {
window.dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', id, { anonymize_ip: true });
})();

View File

@@ -1,5 +0,0 @@
(function () {
var t = localStorage.getItem('mifi-theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
else document.documentElement.removeAttribute('data-theme');
})();

View File

@@ -50,7 +50,7 @@
--color-border: hsl(220 14% 20%); --color-border: hsl(220 14% 20%);
--color-border-subtle: hsl(220 12% 16%); --color-border-subtle: hsl(220 12% 16%);
--color-primary: hsl(220 55% 62%); --color-primary: hsl(220 55% 62%);
--color-primary-muted: hsl(220 45% 55%); --color-primary-muted: hsl(220 45% 62%);
--color-secondary: hsl(330 50% 65%); --color-secondary: hsl(330 50% 65%);
--color-secondary-muted: hsl(330 42% 58%); --color-secondary-muted: hsl(330 42% 58%);
--color-accent: hsl(220 55% 62%); --color-accent: hsl(220 55% 62%);
@@ -93,7 +93,7 @@
--color-border: hsl(220 14% 20%); --color-border: hsl(220 14% 20%);
--color-border-subtle: hsl(220 12% 16%); --color-border-subtle: hsl(220 12% 16%);
--color-primary: hsl(220 55% 62%); --color-primary: hsl(220 55% 62%);
--color-primary-muted: hsl(220 45% 55%); --color-primary-muted: hsl(220 45% 62%);
--color-secondary: hsl(330 50% 65%); --color-secondary: hsl(330 50% 65%);
--color-secondary-muted: hsl(330 42% 58%); --color-secondary-muted: hsl(330 42% 58%);
--color-accent: hsl(220 55% 62%); --color-accent: hsl(220 55% 62%);

View File

@@ -42,7 +42,7 @@
--color-border: hsl(260 15% 22%); --color-border: hsl(260 15% 22%);
--color-border-subtle: hsl(260 12% 18%); --color-border-subtle: hsl(260 12% 18%);
--color-primary: hsl(262 70% 65%); --color-primary: hsl(262 70% 65%);
--color-primary-muted: hsl(262 50% 58%); --color-primary-muted: hsl(262 50% 65%);
--color-secondary: hsl(220 25% 68%); --color-secondary: hsl(220 25% 68%);
--color-secondary-muted: hsl(220 20% 58%); --color-secondary-muted: hsl(220 20% 58%);
--color-accent: hsl(262 70% 65%); --color-accent: hsl(262 70% 65%);
@@ -85,7 +85,7 @@
--color-border: hsl(260 15% 22%); --color-border: hsl(260 15% 22%);
--color-border-subtle: hsl(260 12% 18%); --color-border-subtle: hsl(260 12% 18%);
--color-primary: hsl(262 70% 65%); --color-primary: hsl(262 70% 65%);
--color-primary-muted: hsl(262 50% 58%); --color-primary-muted: hsl(262 50% 65%);
--color-secondary: hsl(220 25% 68%); --color-secondary: hsl(220 25% 68%);
--color-secondary-muted: hsl(220 20% 58%); --color-secondary-muted: hsl(220 20% 58%);
--color-accent: hsl(262 70% 65%); --color-accent: hsl(262 70% 65%);

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
static/robots-bio.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://mifi.bio/sitemap.xml

4
static/robots-dev.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://mifi.dev/sitemap.xml

9
static/sitemap-bio.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mifi.bio/</loc>
<lastmod>2026-02-06</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>

9
static/sitemap-dev.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mifi.dev/</loc>
<lastmod>2026-02-06</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@@ -1 +0,0 @@
hello

View File

@@ -3,4 +3,7 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
build: {
cssMinify: true,
},
}); });