Compare commits
25 Commits
22b21d254c
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
8baf017171
|
|||
|
d3f6747116
|
|||
|
f08784598d
|
|||
|
9201cb6f23
|
|||
|
f9223c2852
|
|||
|
ebc7ebc229
|
|||
|
3130661e65
|
|||
|
9bc51ff408
|
|||
|
c39fae5ec5
|
|||
|
88e355aa98
|
|||
|
ba2b3af650
|
|||
|
b66ab0602e
|
|||
|
840c6cdeba
|
|||
|
93e2618dcf
|
|||
|
9db2592cf4
|
|||
|
a52938f6cf
|
|||
|
d2995e4a08
|
|||
|
3ed0c30f3c
|
|||
|
886549f927
|
|||
|
b81be70cbe
|
|||
|
ab7b9fa70c
|
|||
|
460e3f9139
|
|||
|
de3ffc8eaa
|
|||
|
3f93580090
|
|||
|
2959360d65
|
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# CI: runs on every push. Lint, check, test, build (dev), e2e.
|
# CI: runs on every push. Install, lint, check, test, build (dev), e2e.
|
||||||
when:
|
when:
|
||||||
- event: pull_request
|
- event: pull_request
|
||||||
- event: push
|
- event: push
|
||||||
@@ -7,15 +7,194 @@ when:
|
|||||||
- event: manual
|
- event: manual
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
build:
|
- 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
|
||||||
- pnpm run lint
|
|
||||||
- pnpm run check
|
- name: lint
|
||||||
- pnpm run test:run
|
image: node:22-bookworm-slim
|
||||||
- pnpm run build
|
commands:
|
||||||
- pnpm exec playwright install chromium --with-deps
|
- corepack enable && corepack prepare pnpm@latest --activate
|
||||||
- pnpm run test:e2e
|
- pnpm run lint
|
||||||
|
depends_on:
|
||||||
|
- install
|
||||||
|
|
||||||
|
- 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
|
||||||
|
commands:
|
||||||
|
- corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
- pnpm run check
|
||||||
|
depends_on:
|
||||||
|
- lint
|
||||||
|
|
||||||
|
- 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
|
||||||
|
commands:
|
||||||
|
- corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
- pnpm run test:run
|
||||||
|
depends_on:
|
||||||
|
- check
|
||||||
|
|
||||||
|
- 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
|
||||||
|
commands:
|
||||||
|
- corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
- pnpm run build
|
||||||
|
depends_on:
|
||||||
|
- Unit Tests
|
||||||
|
|
||||||
|
- 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
|
||||||
|
commands:
|
||||||
|
- 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
|
||||||
|
- rm -rf /var/lib/apt/lists/*
|
||||||
|
- corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
- pnpm run critical-css:install
|
||||||
|
- pnpm run build:full
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
|
|
||||||
|
- 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
|
||||||
|
commands:
|
||||||
|
- corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
- pnpm exec playwright install chromium --with-deps
|
||||||
|
- pnpm run test:e2e
|
||||||
|
depends_on:
|
||||||
|
- 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]
|
||||||
|
|||||||
@@ -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"'
|
||||||
- |
|
- |
|
||||||
docker build \
|
build() {
|
||||||
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
|
docker build \
|
||||||
--tag $REGISTRY_REPO:latest \
|
--progress=plain \
|
||||||
--label "git.commit=${CI_COMMIT_SHA}" \
|
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
|
||||||
--label "git.branch=${CI_COMMIT_BRANCH}" \
|
--tag $REGISTRY_REPO:latest \
|
||||||
.
|
--label "git.commit=${CI_COMMIT_SHA}" \
|
||||||
- echo "✓ Docker image built successfully"
|
--label "git.branch=${CI_COMMIT_BRANCH}" \
|
||||||
|
.
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
|||||||
12
AGENTS.md
@@ -10,17 +10,18 @@ This repo is a **one-page static** Linktree-style site for mifi.dev. It is **not
|
|||||||
- **TypeScript** (always)
|
- **TypeScript** (always)
|
||||||
- **pnpm** (only); do not use npm or yarn
|
- **pnpm** (only); do not use npm or yarn
|
||||||
- **PostCSS** for CSS (nesting + preset-env)
|
- **PostCSS** for CSS (nesting + preset-env)
|
||||||
- **Critical CSS** via post-build script (`scripts/critical-css.mjs`); full build with critical CSS is `pnpm run build:full` (requires 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 SvelteKit’s 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
|
||||||
|
|
||||||
- Use **semantic HTML** and **JSON-LD** for SEO; target **WCAG 2.2 AAA** for accessibility.
|
- Use **semantic HTML** and **JSON-LD** for SEO; target **WCAG 2.2 AAA** for accessibility.
|
||||||
- **No unsafe-inline** scripts; all JS via `<script src="...">`.
|
- **No unsafe-inline** scripts; all JS via `<script src="...">`.
|
||||||
- **Dev container** uses the same Linux + Playwright Chromium as CI so e2e/visual-regression snapshots are comparable.
|
- **Dev container** uses the same Linux + Playwright Chromium as CI so e2e/visual-regression snapshots are comparable.
|
||||||
- **Docker:** Single image with both variants; **nginx** does host-based routing (mifi.dev / www.mifi.dev → `/html/dev`, mifi.bio / www.mifi.bio → `/html/bio`). Traefik labels for both hosts; network `marina-net`. Dockerfile builds both with `CONTENT_VARIANT=dev` and `CONTENT_VARIANT=bio`.
|
- **Docker:** Single image with both variants; **nginx** does host-based routing (mifi.dev / www.mifi.dev → `/html/dev`, mifi.bio / www.mifi.bio → `/html/bio`). Traefik labels for both hosts; network `marina-net`. Dockerfile builds both with `CONTENT_VARIANT=dev` and `CONTENT_VARIANT=bio`, each with critical CSS inlined.
|
||||||
- **CI:** Woodpecker; pipeline runs lint, unit tests, e2e/visual regression, build (pnpm).
|
- **CI:** Woodpecker; pipeline runs lint, unit tests, e2e/visual regression, build (pnpm), and build-full (critical CSS; installs Chromium).
|
||||||
|
|
||||||
## Key paths
|
## Key paths
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ This repo is a **one-page static** Linktree-style site for mifi.dev. It is **not
|
|||||||
- `src/lib/data/` – JSON content
|
- `src/lib/data/` – JSON content
|
||||||
- `src/app.html` – HTML shell
|
- `src/app.html` – HTML shell
|
||||||
- `scripts/critical-css.mjs` – post-build critical CSS
|
- `scripts/critical-css.mjs` – post-build critical CSS
|
||||||
|
- `scripts/externalize-inline-script.mjs` – post-build: extract bootstrap script for CSP
|
||||||
- `.devcontainer/` – dev container (Node, pnpm, Playwright Linux)
|
- `.devcontainer/` – dev container (Node, pnpm, Playwright Linux)
|
||||||
- `Dockerfile` – multi-stage build (both dev + bio variants), then nginx with host-based routing
|
- `Dockerfile` – multi-stage build (both dev + bio variants), then nginx with host-based routing
|
||||||
- `nginx/default.conf` – nginx server blocks for mifi.dev and mifi.bio
|
- `nginx/default.conf` – nginx server blocks for mifi.dev and mifi.bio
|
||||||
@@ -37,6 +39,6 @@ This repo is a **one-page static** Linktree-style site for mifi.dev. It is **not
|
|||||||
|
|
||||||
- Install: `pnpm install`
|
- Install: `pnpm install`
|
||||||
- Dev: `pnpm dev`
|
- Dev: `pnpm dev`
|
||||||
- Build: `pnpm build` (or `pnpm build:full` for critical CSS)
|
- Build: `pnpm build` (static SSR HTML + externalized script; or `pnpm build:full` for critical CSS; run `pnpm run critical-css:install` once first)
|
||||||
- Lint: `pnpm lint`
|
- Lint: `pnpm lint`
|
||||||
- Test: `pnpm test:run`, `pnpm test:e2e`
|
- Test: `pnpm test:run`, `pnpm test:e2e`
|
||||||
|
|||||||
30
Dockerfile
@@ -1,10 +1,24 @@
|
|||||||
# Multi-stage: build both variants (dev + bio), then nginx with host-based routing.
|
# Multi-stage: build both variants (dev + bio) with critical CSS, then nginx with host-based routing.
|
||||||
# No buildx; plain docker build.
|
# No buildx; plain docker build.
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
|
||||||
|
# Chromium deps for critical CSS (Puppeteer headless)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
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/*
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
@@ -14,13 +28,19 @@ WORKDIR /app
|
|||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Install Chromium for critical CSS (used by the "critical" package)
|
||||||
|
RUN pnpm run critical-css:install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build dev variant, move output, then build bio variant (same build/ dir).
|
# 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.
|
||||||
RUN set -e && \
|
RUN set -e && \
|
||||||
CONTENT_VARIANT=dev pnpm run build && \
|
CONTENT_VARIANT=dev pnpm run build && pnpm run critical-css && \
|
||||||
cp -r build /out/dev && \
|
cp -r build /out/dev && \
|
||||||
CONTENT_VARIANT=bio pnpm run build && \
|
CONTENT_VARIANT=bio pnpm run build && pnpm run critical-css && \
|
||||||
cp -r build /out/bio
|
cp -r build /out/bio
|
||||||
|
|
||||||
# Runtime: nginx serves /out/dev and /out/bio by Host header.
|
# Runtime: nginx serves /out/dev and /out/bio by Host header.
|
||||||
@@ -28,6 +48,8 @@ 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/
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
37
README.md
@@ -29,22 +29,23 @@ pnpm preview # preview build at http://localhost:4173
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Description |
|
| Script | Description |
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------ |
|
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| `pnpm dev:bio` | Start Vite dev server for mifi.bio |
|
| `pnpm dev:bio` | Start Vite dev server for mifi.bio |
|
||||||
| `pnpm dev:dev` | Start Vite dev server for mifi.dev |
|
| `pnpm dev:dev` | Start Vite dev server for mifi.dev |
|
||||||
| `pnpm build` | Build static site to `build/` |
|
| `pnpm build` | Build static site to `build/` |
|
||||||
| `pnpm build:full` | Build + inline critical CSS (requires Chromium: `pnpm exec puppeteer browsers install chromium`) |
|
| `pnpm build:full` | Build + inline critical CSS (run `pnpm run critical-css:install` once to install Chromium) |
|
||||||
| `pnpm preview` | Serve `build/` locally |
|
| `pnpm critical-css:install` | Install Chromium for critical CSS (required once before first `build:full`) |
|
||||||
| `pnpm check` | Run `svelte-kit sync` and `svelte-check` |
|
| `pnpm preview` | Serve `build/` locally |
|
||||||
| `pnpm lint` | ESLint + Stylelint |
|
| `pnpm check` | Run `svelte-kit sync` and `svelte-check` |
|
||||||
| `pnpm format` | Prettier (write) |
|
| `pnpm lint` | ESLint + Stylelint |
|
||||||
| `pnpm format:check` | Prettier (check only) |
|
| `pnpm format` | Prettier (write) |
|
||||||
| `pnpm test` | Vitest (watch) |
|
| `pnpm format:check` | Prettier (check only) |
|
||||||
| `pnpm test:run` | Vitest (single run) |
|
| `pnpm test` | Vitest (watch) |
|
||||||
| `pnpm test:coverage` | Vitest with coverage |
|
| `pnpm test:run` | Vitest (single run) |
|
||||||
| `pnpm test:e2e` | Playwright e2e (starts preview, then runs tests) |
|
| `pnpm test:coverage` | Vitest with coverage |
|
||||||
| `pnpm test:e2e:ui` | Playwright e2e in UI mode |
|
| `pnpm test:e2e` | Playwright e2e (starts preview, then runs tests) |
|
||||||
|
| `pnpm test:e2e:ui` | Playwright e2e in UI mode |
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ pnpm preview # preview build at http://localhost:4173
|
|||||||
- **Format:** Prettier
|
- **Format:** Prettier
|
||||||
- **Unit tests:** Vitest
|
- **Unit tests:** Vitest
|
||||||
- **E2E / visual regression:** Playwright (use same Linux build in dev container and CI)
|
- **E2E / visual regression:** Playwright (use same Linux build in dev container and CI)
|
||||||
- **Critical CSS:** Post-build step via `critical` (run `pnpm build:full` with Chromium installed)
|
- **Critical CSS:** Post-build step via `critical` (run `pnpm run critical-css:install` once, then `pnpm build:full`)
|
||||||
|
|
||||||
## Build and run with Docker
|
## Build and run with Docker
|
||||||
|
|
||||||
@@ -154,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'`. Svelte’s 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").
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ services:
|
|||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://127.0.0.1/']
|
||||||
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
@@ -14,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('homepage has title and main content', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await expect(page).toHaveTitle(/mifi\.dev/);
|
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('mifi.dev');
|
|
||||||
await expect(page.getByRole('main')).toBeVisible();
|
|
||||||
});
|
|
||||||
24
e2e/homepage.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('homepage has title and main content', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Title and hero (variant-agnostic)
|
||||||
|
await expect(page).toHaveTitle(/mifi\.(dev|bio)/);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('mifi');
|
||||||
|
|
||||||
|
// Key landmarks: header, main, footer
|
||||||
|
await expect(page.getByRole('banner')).toBeVisible();
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
await expect(page.getByRole('contentinfo')).toBeVisible();
|
||||||
|
|
||||||
|
// Skip link targets main content (a11y)
|
||||||
|
const skipLink = page.getByRole('link', { name: /skip to main content/i });
|
||||||
|
await expect(skipLink).toBeVisible();
|
||||||
|
await expect(skipLink).toHaveAttribute('href', '#main-content');
|
||||||
|
await expect(page.locator('main#main-content')).toBeVisible();
|
||||||
|
|
||||||
|
// At least one link (both variants have link sections)
|
||||||
|
const linkCount = await page.getByRole('link').count();
|
||||||
|
expect(linkCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
51
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,38 @@
|
|||||||
# Host-based routing: mifi.dev / www.mifi.dev → dev root, mifi.bio / www.mifi.bio → bio root
|
# Host-based routing: mifi.dev / www.mifi.dev → dev root, mifi.bio / www.mifi.bio → bio root
|
||||||
|
# Security headers are handled upstream by Traefik
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
server_name mifi.dev www.mifi.dev;
|
server_name mifi.dev www.mifi.dev;
|
||||||
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 (alias cannot use variables)
|
||||||
|
location = /.well-known/security.txt {
|
||||||
|
alias /usr/share/nginx/html/dev/.well-known/dev.security.txt;
|
||||||
|
add_header Cache-Control "public, max-age=86400";
|
||||||
|
}
|
||||||
|
location = /.well-known/appspecific/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";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error_page 404 /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@@ -14,7 +40,31 @@ server {
|
|||||||
server_name mifi.bio www.mifi.bio;
|
server_name mifi.bio www.mifi.bio;
|
||||||
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 (alias cannot use variables)
|
||||||
|
location = /.well-known/security.txt {
|
||||||
|
alias /usr/share/nginx/html/bio/.well-known/bio.security.txt;
|
||||||
|
add_header Cache-Control "public, max-age=86400";
|
||||||
|
}
|
||||||
|
location = /.well-known/appspecific/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";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error_page 404 /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
60
nginx/snippets/cache-rules.conf
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Cache and security rules for static site (included by both dev and bio server blocks)
|
||||||
|
# Security headers are handled upstream by Traefik
|
||||||
|
|
||||||
|
# HTML: no-cache (always revalidate)
|
||||||
|
location ~ \.html$ {
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSS and JavaScript: long cache with immutable (hashed filenames)
|
||||||
|
location ~* \.(css|js)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Images: long cache (30 days)
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SVG images: long cache (30 days)
|
||||||
|
location ~* \.svg$ {
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
add_header Content-Type image/svg+xml;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonts: long cache with immutable
|
||||||
|
location ~* \.(woff|woff2|ttf|otf|eot)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Documents: medium cache (30 days)
|
||||||
|
location ~* \.(pdf|doc|docx)$ {
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# robots.txt and sitemap.xml: handled in default.conf with alias (variant-specific)
|
||||||
|
|
||||||
|
# favicon: long cache (30 days)
|
||||||
|
location = /favicon.svg {
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
add_header Content-Type image/svg+xml;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# .well-known (security.txt, ACME, etc.)
|
||||||
|
location ^~ /.well-known/ {
|
||||||
|
add_header Cache-Control "public, max-age=86400";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny hidden files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
18
package.json
@@ -5,8 +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",
|
"build": "vite build && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs",
|
||||||
"build:full": "vite build && pnpm run critical-css",
|
"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/minify-static-assets.mjs && 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 && node scripts/minify-static-assets.mjs && node scripts/externalize-inline-script.mjs && pnpm run critical-css",
|
||||||
|
"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",
|
||||||
@@ -18,8 +22,13 @@
|
|||||||
"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",
|
||||||
"critical-css": "node scripts/critical-css.mjs"
|
"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",
|
||||||
|
"critical-css": "node scripts/critical-css.mjs",
|
||||||
|
"critical-css:install": "pnpm exec puppeteer browsers install chrome",
|
||||||
|
"externalize-inline-script": "node scripts/externalize-inline-script.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
@@ -31,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",
|
||||||
@@ -55,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"
|
||||||
|
|||||||
@@ -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
@@ -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: {}
|
||||||
|
|||||||
@@ -1,36 +1,94 @@
|
|||||||
/**
|
/**
|
||||||
* Post-build step: inline critical CSS in built HTML.
|
* Post-build step: inline critical CSS in built HTML.
|
||||||
* Reads build/index.html, extracts critical CSS, inlines in <head>,
|
* Reads <buildDir>/index.html, extracts critical CSS, inlines in <head>,
|
||||||
* and defers non-critical styles (preload + link at end of body).
|
* and defers non-critical styles (preload + link at end of body).
|
||||||
|
*
|
||||||
|
* Usage: node scripts/critical-css.mjs [buildDir]
|
||||||
|
* 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 { fileURLToPath } from 'node:url';
|
import { homedir, platform } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { cwd } from 'node:process';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const buildDir = join(cwd(), process.argv[2] || 'build');
|
||||||
const buildDir = join(__dirname, '..', 'build');
|
|
||||||
const htmlPath = join(buildDir, 'index.html');
|
const htmlPath = join(buildDir, 'index.html');
|
||||||
|
|
||||||
|
// critical/penthouse use a nested puppeteer; point at an installed Chrome/Chromium.
|
||||||
|
// Only set PUPPETEER_EXECUTABLE_PATH if the path exists. Prefer Playwright's Chromium
|
||||||
|
// on arm64 so we use the native binary (Puppeteer's default can be x86 on ARM hosts e.g. OrbStack).
|
||||||
|
function getPlaywrightChromePath() {
|
||||||
|
const cacheBase =
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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');
|
||||||
const html = readFileSync(htmlPath, 'utf-8');
|
const html = readFileSync(htmlPath, 'utf-8');
|
||||||
const { html: outHtml } = await generate({
|
const { html: outHtml } = await generate({
|
||||||
base: buildDir,
|
base: buildDir,
|
||||||
html,
|
html,
|
||||||
inline: true,
|
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/...)
|
||||||
console.log('Critical CSS inlined in build/index.html');
|
const normalized = outHtml.replace(/\.\/_app\//g, '/_app/');
|
||||||
|
writeFileSync(htmlPath, normalized, 'utf-8');
|
||||||
|
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(
|
console.error(
|
||||||
'Install Chromium for critical CSS: pnpm exec puppeteer browsers install chromium',
|
'(Dev container: Playwright Chromium is also used if present in ~/.cache/ms-playwright.)',
|
||||||
);
|
);
|
||||||
console.error('Or run "pnpm run build" without critical CSS.');
|
console.error('Or run "pnpm run build" (without critical CSS) for a working build.');
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
107
scripts/externalize-inline-script.mjs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* Minifies the extracted JS.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/externalize-inline-script.mjs [buildDir]
|
||||||
|
* buildDir: path to build output (default: "build"). Use from repo root.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { cwd } from 'node:process';
|
||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
|
const buildDir = join(cwd(), process.argv[2] || 'build');
|
||||||
|
const htmlPath = join(buildDir, 'index.html');
|
||||||
|
|
||||||
|
function findSvelteKitInlineScript(html) {
|
||||||
|
// Find <script> without src= whose content contains __sveltekit_ (the bootstrap)
|
||||||
|
let scriptStart = html.indexOf('<script>');
|
||||||
|
while (scriptStart !== -1) {
|
||||||
|
const result = extractScriptContent(html, scriptStart);
|
||||||
|
if (result && result.content.includes('__sveltekit_')) return result;
|
||||||
|
scriptStart = html.indexOf('<script>', scriptStart + 1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScriptContent(html, scriptStart) {
|
||||||
|
if (scriptStart === -1) return null;
|
||||||
|
const contentStart = scriptStart + '<script>'.length;
|
||||||
|
let pos = contentStart;
|
||||||
|
let inString = null;
|
||||||
|
let escape = false;
|
||||||
|
const endTag = '</script>';
|
||||||
|
while (pos < html.length) {
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) {
|
||||||
|
if (html[pos] === '\\') {
|
||||||
|
escape = true;
|
||||||
|
pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (html[pos] === inString) {
|
||||||
|
inString = null;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (html[pos] === '"' || html[pos] === "'") {
|
||||||
|
inString = html[pos];
|
||||||
|
pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (html.slice(pos, pos + endTag.length) === endTag) {
|
||||||
|
return {
|
||||||
|
start: scriptStart,
|
||||||
|
end: pos + endTag.length,
|
||||||
|
content: html.slice(contentStart, pos),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
let html = readFileSync(htmlPath, 'utf-8');
|
||||||
|
const found = findSvelteKitInlineScript(html);
|
||||||
|
if (!found) {
|
||||||
|
console.log('No SvelteKit inline bootstrap script found in', htmlPath);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
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 filename = `bootstrap.${hash}.js`;
|
||||||
|
const immutableDir = join(buildDir, '_app', 'immutable');
|
||||||
|
mkdirSync(immutableDir, { recursive: true });
|
||||||
|
const scriptPath = join(immutableDir, filename);
|
||||||
|
writeFileSync(scriptPath, content, 'utf-8');
|
||||||
|
const scriptTag = `<script src="/_app/immutable/${filename}"></script>`;
|
||||||
|
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');
|
||||||
|
console.log('Externalized SvelteKit bootstrap to', scriptPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'externalize-inline-script failed:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
42
scripts/minify-static-assets.mjs
Normal 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);
|
||||||
|
});
|
||||||
13
src/app.html
@@ -3,12 +3,19 @@
|
|||||||
<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" />
|
||||||
<link rel="preload" href="/assets/js/theme-store.js" as="script" />
|
|
||||||
<title>mifi.dev</title>
|
<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">
|
||||||
<script src="/assets/js/theme-store.js"></script>
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
<img
|
||||||
|
src="https://analytics.mifi.holdings/pixel.gif"
|
||||||
|
alt=""
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
role="presentation"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
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 ${link.label} link`}
|
||||||
>
|
>
|
||||||
{#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 +66,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>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
contactOpen = true;
|
contactOpen = true;
|
||||||
shareOpen = false;
|
shareOpen = false;
|
||||||
}}
|
}}
|
||||||
|
data-umami-event="header contact button"
|
||||||
>
|
>
|
||||||
<IconContact size={20} />
|
<IconContact size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
shareOpen = true;
|
shareOpen = true;
|
||||||
contactOpen = false;
|
contactOpen = false;
|
||||||
}}
|
}}
|
||||||
|
data-umami-event="header share button"
|
||||||
>
|
>
|
||||||
<IconShare size={20} />
|
<IconShare size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -98,6 +100,7 @@
|
|||||||
contactOpen = true;
|
contactOpen = true;
|
||||||
shareOpen = false;
|
shareOpen = false;
|
||||||
}}
|
}}
|
||||||
|
data-umami-event="hero contact button"
|
||||||
>
|
>
|
||||||
<IconContact size={20} />
|
<IconContact size={20} />
|
||||||
<span>Contact</span>
|
<span>Contact</span>
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
shareOpen = true;
|
shareOpen = true;
|
||||||
contactOpen = false;
|
contactOpen = false;
|
||||||
}}
|
}}
|
||||||
|
data-umami-event="hero share button"
|
||||||
>
|
>
|
||||||
<IconShare size={20} />
|
<IconShare size={20} />
|
||||||
<span>Share</span>
|
<span>Share</span>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
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={`${label} link`}>
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<LinkIcon {href} {icon} {label} />
|
<LinkIcon {href} {icon} {label} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -105,7 +109,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%;
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,19 @@
|
|||||||
{#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 copy"
|
||||||
|
>
|
||||||
<IconCopy size={20} />
|
<IconCopy size={20} />
|
||||||
{copied ? 'Copied!' : 'Copy link'}
|
{copied ? 'Copied!' : 'Copy link'}
|
||||||
</button>
|
</button>
|
||||||
@@ -67,12 +72,18 @@
|
|||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
data-umami-event="share panel email link"
|
||||||
>
|
>
|
||||||
<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 device share"
|
||||||
|
>
|
||||||
<IconShare size={20} />
|
<IconShare size={20} />
|
||||||
Share…
|
Share…
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { Moon, SunMoon, Sun } from '@lucide/svelte';
|
import { Moon, SunMoon, Sun } from '@lucide/svelte';
|
||||||
import { getStoredTheme, setTheme } from '$lib/theme';
|
import { getStoredTheme, setTheme } from '$lib/theme';
|
||||||
import type { ThemeMode } from '$lib/theme';
|
import type { ThemeMode } from '$lib/theme';
|
||||||
@@ -18,7 +17,8 @@
|
|||||||
|
|
||||||
const themeOffset = $derived(OFFSETS[current]);
|
const themeOffset = $derived(OFFSETS[current]);
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
current = getStoredTheme();
|
current = getStoredTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
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 close"
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
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 light"
|
||||||
>
|
>
|
||||||
<Sun size={24} />
|
<Sun size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
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 dark"
|
||||||
>
|
>
|
||||||
<Moon size={24} />
|
<Moon size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -86,6 +89,7 @@
|
|||||||
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 auto"
|
||||||
>
|
>
|
||||||
<SunMoon size={24} />
|
<SunMoon size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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,14 +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) */
|
||||||
|
export const THEME_COLORS: Record<ContentVariant, { light: string; dark: string }> = {
|
||||||
|
[ContentVariant.DEV]: { light: '#f5f4f8', dark: '#131118' }, // hsl(260 20% 98%) / hsl(260 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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,6 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Plus Jakarta Sans';
|
|
||||||
src: url('/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2') format('woff2');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Plus Jakarta Sans';
|
font-family: 'Plus Jakarta Sans';
|
||||||
src: url('/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2');
|
src: url('/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2');
|
||||||
@@ -49,19 +41,3 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: Inter;
|
|
||||||
src: url('/assets/fonts/inter-v20-latin-500.woff2') format('woff2');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: Inter;
|
|
||||||
src: url('/assets/fonts/inter-v20-latin-600.woff2') format('woff2');
|
|
||||||
font-weight: 600;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import contentData from '$lib/data/links.json';
|
import contentData from '$lib/data/links.json';
|
||||||
import { VARIANT_HOSTS, GA_MEASUREMENT_IDS } 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,10 @@ export type LayoutServerDataOut = {
|
|||||||
};
|
};
|
||||||
variant: string;
|
variant: string;
|
||||||
gaMeasurementId: string;
|
gaMeasurementId: string;
|
||||||
|
umamiMeasurementId: string;
|
||||||
|
/** theme-color meta values for current variant */
|
||||||
|
themeColorLight: string;
|
||||||
|
themeColorDark: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const load: LayoutServerLoad<LayoutServerDataOut> = (): LayoutServerDataOut => {
|
export const load: LayoutServerLoad<LayoutServerDataOut> = (): LayoutServerDataOut => {
|
||||||
@@ -54,15 +63,18 @@ 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,
|
||||||
};
|
};
|
||||||
|
const themeColors = THEME_COLORS[variant];
|
||||||
return {
|
return {
|
||||||
site,
|
site,
|
||||||
contactLinks,
|
contactLinks,
|
||||||
links: { sections },
|
links: { sections },
|
||||||
variant,
|
variant,
|
||||||
gaMeasurementId: GA_MEASUREMENT_IDS[variant],
|
gaMeasurementId: GA_MEASUREMENT_IDS[variant],
|
||||||
|
umamiMeasurementId: UMAMI_MEASUREMENT_IDS[variant],
|
||||||
|
themeColorLight: themeColors.light,
|
||||||
|
themeColorDark: themeColors.dark,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,37 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
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';
|
||||||
|
|
||||||
export let data: LayoutData;
|
import '../app.css';
|
||||||
|
|
||||||
const jsonLd = {
|
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||||
|
|
||||||
|
const jsonLd = $derived({
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebSite' as const,
|
'@type': 'WebSite' as const,
|
||||||
name: data.site.title,
|
name: data.site.title,
|
||||||
url: data.site.url,
|
url: data.site.url,
|
||||||
description: data.site.metaDescription,
|
description: data.site.metaDescription,
|
||||||
};
|
});
|
||||||
|
|
||||||
$: personLd = data.site.person
|
const personLd = $derived(
|
||||||
? {
|
data.site.person
|
||||||
'@context': 'https://schema.org' as const,
|
? {
|
||||||
'@type': 'Person' as const,
|
'@context': 'https://schema.org' as const,
|
||||||
name: data.site.person.name,
|
'@type': 'Person' as const,
|
||||||
url: data.site.url,
|
name: data.site.person.name,
|
||||||
sameAs: data.site.person.sameAs,
|
url: data.site.url,
|
||||||
}
|
sameAs: data.site.person.sameAs,
|
||||||
: null;
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
// Inject as HTML to avoid Prettier parsing ld+json script body as JS (Babel syntax error)
|
// Inject as HTML to avoid Prettier parsing ld+json script body as JS (Babel syntax error)
|
||||||
const ldJsonTag = (payload: string) =>
|
const ldJsonTag = (payload: string) =>
|
||||||
'<' + 'script type="application/ld+json">' + payload + '<' + '/script>';
|
'<' + 'script type="application/ld+json">' + payload + '<' + '/script>';
|
||||||
$: jsonLdHtml = ldJsonTag(JSON.stringify(jsonLd));
|
const jsonLdHtml = $derived(ldJsonTag(JSON.stringify(jsonLd)));
|
||||||
$: personLdHtml = personLd != null ? ldJsonTag(JSON.stringify(personLd)) : '';
|
const personLdHtml = $derived(personLd != null ? ldJsonTag(JSON.stringify(personLd)) : '');
|
||||||
</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>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/assets/tokens-{data.variant}.css" />
|
<link rel="stylesheet" href="/assets/tokens-{data.variant}.css" />
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/assets/fonts/fraunces-variable-opsz-wght.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/assets/fonts/inter-v20-latin-regular.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content={data.themeColorLight} media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content={data.themeColorDark} media="(prefers-color-scheme: dark)" />
|
||||||
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- safe: our own JSON.stringify, no user input -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- safe: our own JSON.stringify, no user input -->
|
||||||
{@html jsonLdHtml}
|
{@html jsonLdHtml}
|
||||||
{#if personLdHtml}
|
{#if personLdHtml}
|
||||||
@@ -44,4 +92,5 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
<slot />
|
{@render children()}
|
||||||
|
<Footer />
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
export const ssr = true;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -22,6 +21,36 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.site.title}</title>
|
<title>{data.site.title}</title>
|
||||||
<meta name="description" content={data.site.metaDescription} />
|
<meta name="description" content={data.site.metaDescription} />
|
||||||
|
<link rel="canonical" href={data.site.url} />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
{#if data.site.person?.name}
|
||||||
|
<meta name="author" content={data.site.person.name} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={data.site.title} />
|
||||||
|
<meta property="og:description" content={data.site.metaDescription} />
|
||||||
|
<meta property="og:url" content={data.site.url} />
|
||||||
|
<meta property="og:site_name" content={data.site.title} />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta
|
||||||
|
property="og:image"
|
||||||
|
content="{data.site.url}/assets/images/mifi-{data.variant}-og-image.webp"
|
||||||
|
/>
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={data.site.title} />
|
||||||
|
<meta name="twitter:description" content={data.site.metaDescription} />
|
||||||
|
<meta
|
||||||
|
name="twitter:image"
|
||||||
|
content="{data.site.url}/assets/images/mifi-{data.variant}-twitter-image.webp"
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image:width" content="1200" />
|
||||||
|
<meta name="twitter:image:height" content="1200" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
@@ -37,7 +66,6 @@
|
|||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
|
||||||
<SharePanel
|
<SharePanel
|
||||||
open={shareOpen}
|
open={shareOpen}
|
||||||
url={shareUrl}
|
url={shareUrl}
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
12
static/.well-known/bio.com.chrome.devtools.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "mifi.bio",
|
||||||
|
"url": "https://mifi.bio",
|
||||||
|
"description": "mifi.bio",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "https://mifi.dev/assets/images/apple-touch-icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
static/.well-known/bio.security.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Canonical URL for this file (recommended for validation)
|
||||||
|
Canonical: https://mifi.bio/.well-known/security.txt
|
||||||
|
|
||||||
|
# Contact for reporting security vulnerabilities (required)
|
||||||
|
Contact: mailto:security@mifi.holdings
|
||||||
|
|
||||||
|
# Optional: link to your vulnerability disclosure policy when you have one
|
||||||
|
# Policy: https://mifi.bio/security
|
||||||
|
|
||||||
|
# Date after which this file is considered stale (required; RFC 3339; max 1 year recommended)
|
||||||
|
Expires: 2027-02-01T00:00:00.000Z
|
||||||
12
static/.well-known/dev.com.chrome.devtools.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "mifi.dev",
|
||||||
|
"url": "https://mifi.dev",
|
||||||
|
"description": "mifi.dev",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "https://mifi.dev/assets/images/apple-touch-icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
static/.well-known/dev.security.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Canonical URL for this file (recommended for validation)
|
||||||
|
Canonical: https://mifi.dev/.well-known/security.txt
|
||||||
|
|
||||||
|
# Contact for reporting security vulnerabilities (required)
|
||||||
|
Contact: mailto:security@mifi.holdings
|
||||||
|
|
||||||
|
# Optional: link to your vulnerability disclosure policy when you have one
|
||||||
|
# Policy: https://mifi.dev/security
|
||||||
|
|
||||||
|
# Date after which this file is considered stale (required; RFC 3339; max 1 year recommended)
|
||||||
|
Expires: 2027-02-01T00:00:00.000Z
|
||||||
BIN
static/assets/images/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
static/assets/images/mifi-bio og-image.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
static/assets/images/mifi-bio-og-image.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
static/assets/images/mifi-bio-twitter-image.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
static/assets/images/mifi-bio-twitter-image.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
static/assets/images/mifi-dev-og-image.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
static/assets/images/mifi-dev-og-image.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
static/assets/images/mifi-dev-twitter-image.png
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
static/assets/images/mifi-dev-twitter-image.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
2
static/assets/images/qr-mifi-bio.svg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
2
static/assets/images/qr-mifi-dev.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
18
static/assets/js/bootstrap.js
vendored
Normal 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');
|
||||||
|
})();
|
||||||
11
static/assets/js/ga-init.js
Normal 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 });
|
||||||
|
})();
|
||||||
@@ -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');
|
|
||||||
})();
|
|
||||||
@@ -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%);
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
|||||||
BIN
static/downloads/resume-2026c.pdf
Normal file
BIN
static/downloads/resume-2026lf.pdf
Normal file
BIN
static/downloads/resume-2026p.pdf
Normal file
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 2134 2134" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M2133.333,400l0,1333.333c0,220.766 -179.234,400 -400,400l-1333.333,0c-220.766,0 -400,-179.234 -400,-400l0,-1333.333c0,-220.766 179.234,-400 400,-400l1333.333,0c220.766,0 400,179.234 400,400Z" style="fill:#0b0b0f;"/><g><path d="M178.684,1755.333l0,-916.655l208.714,0l0,211.244l-24.034,-34.153c16.304,-66.339 49.965,-115.812 100.984,-148.419c51.019,-32.607 110.822,-48.911 179.41,-48.911c74.772,0 140.899,19.466 198.384,58.398c57.484,38.932 94.659,90.724 111.525,155.376l-63.247,5.481c28.391,-73.928 70.625,-128.953 126.704,-165.074c56.079,-36.121 120.801,-54.181 194.167,-54.181c64.933,0 122.98,14.617 174.139,43.851c51.16,29.234 91.567,69.852 121.223,121.855c29.656,52.003 44.483,112.157 44.483,180.464l0,590.724l-221.363,0l0,-538.018c0,-40.759 -7.309,-75.615 -21.926,-104.568c-14.617,-28.953 -34.996,-51.511 -61.138,-67.674c-26.142,-16.163 -57.344,-24.245 -93.605,-24.245c-35.137,0 -66.128,8.082 -92.973,24.245c-26.845,16.163 -47.646,38.791 -62.403,67.885c-14.758,29.093 -22.136,63.879 -22.136,104.357l0,538.018l-221.363,0l0,-538.018c0,-40.759 -7.309,-75.615 -21.926,-104.568c-14.617,-28.953 -34.996,-51.511 -61.138,-67.674c-26.142,-16.163 -57.344,-24.245 -93.605,-24.245c-35.418,0 -66.479,8.082 -93.183,24.245c-26.704,16.163 -47.435,38.791 -62.193,67.885c-14.758,29.093 -22.136,63.879 -22.136,104.357l0,538.018l-221.363,0Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1733.286,1755.333l0,-916.655l221.363,0l0,916.655l-221.363,0Zm0,-1020.379l0,-236.121l221.363,0l0,236.121l-221.363,0Z" style="fill:#fff;fill-rule:nonzero;"/></g></svg>
|
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<style>
|
||||||
|
.block { fill: #0b0b0f; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.block { fill: #f2f2f2; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path class="block" d="M512,96l0,320c0,52.984 -43.016,96 -96,96l-320,0c-52.984,0 -96,-43.016 -96,-96l0,-320c0,-52.984 43.016,-96 96,-96l320,0c52.984,0 96,43.016 96,96Zm-96.011,80.389l53.127,0l0,-56.669l-53.127,0l0,56.669Zm-193.658,55.292c-4.819,-8.296 -11.558,-15.376 -20.217,-21.24c-13.796,-9.344 -29.667,-14.015 -47.612,-14.015c-16.461,0 -30.814,3.913 -43.058,11.739c-7.882,5.038 -14.038,11.753 -18.468,20.146l0,-27.027l-50.091,0l0,219.997l53.127,0l0,-129.124c0,-9.715 1.771,-18.063 5.313,-25.046c3.542,-6.982 8.517,-12.413 14.926,-16.292c6.409,-3.879 13.864,-5.819 22.364,-5.819c8.703,0 16.191,1.94 22.465,5.819c6.274,3.879 11.165,9.293 14.673,16.242c3.508,6.949 5.262,15.314 5.262,25.096l0,129.124l53.127,0l0,-129.124c0,-9.715 1.771,-18.063 5.313,-25.046c3.542,-6.982 8.534,-12.413 14.977,-16.292c6.443,-3.879 13.881,-5.819 22.313,-5.819c8.703,0 16.191,1.94 22.465,5.819c6.274,3.879 11.165,9.293 14.673,16.242c3.508,6.949 5.262,15.314 5.262,25.096l0,129.124l53.127,0l0,-141.774c0,-16.394 -3.559,-30.831 -10.676,-43.311c-7.117,-12.481 -16.815,-22.229 -29.093,-29.245c-12.278,-7.016 -26.209,-10.524 -41.793,-10.524c-17.608,0 -33.141,4.335 -46.6,13.004c-8.624,5.555 -15.884,12.972 -21.779,22.252Zm193.658,189.599l53.127,0l0,-219.997l-53.127,0l0,219.997Z" />
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
4
static/robots-bio.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://mifi.bio/sitemap.xml
|
||||||
4
static/robots-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://mifi.dev/sitemap.xml
|
||||||
9
static/sitemap-bio.xml
Normal 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
@@ -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>
|
||||||
@@ -12,5 +12,15 @@
|
|||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||||
"exclude": ["node_modules", "dist", "build", "coverage", "playwright-report", "test-results", ".svelte-kit", "src/**/*.test.ts", "src/**/*.spec.ts"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"coverage",
|
||||||
|
"playwright-report",
|
||||||
|
"test-results",
|
||||||
|
".svelte-kit",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ import { defineConfig } from 'vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
|
build: {
|
||||||
|
cssMinify: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||