Compare commits

..

17 Commits

Author SHA1 Message Date
9752501ee5 Pixel URI fix
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:50:23 -03:00
75f7f4b09c Tracking and Pixel
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:25:02 -03:00
21b28ba376 Umami analytics setup
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 11:02:50 -03:00
bbf1f0dde7 gzip that bitch
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 13:04:23 -03:00
7b63c0c922 WCAG fixes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 12:57:11 -03:00
93bbfee7f7 Accessibility fix
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 12:28:14 -03:00
c81cc54d91 Added sitemap.xml
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 11:21:10 -03:00
88f0e84537 robots.txt
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 11:07:30 -03:00
502bc1765f JSON-LD and silencing dev tools noise
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 11:03:05 -03:00
4a79266a27 Tweaks and updates
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-13 18:21:18 -03:00
27808cfd0e Tweaks, fixes, etc
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-13 18:12:52 -03:00
9a84e9003a More ignores
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-13 15:58:11 -03:00
56b2377cd1 Pipeline tweaks 2026-02-13 15:56:36 -03:00
97c6462254 King of typos today
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/build unknown status
ci/woodpecker/push/deploy unknown status
2026-02-13 15:51:19 -03:00
2545dd8cb0 Prettier!
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
2026-02-13 15:48:18 -03:00
348be469a0 Resolve CI Pipeline issue
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/build unknown status
ci/woodpecker/push/deploy unknown status
2026-02-13 15:46:34 -03:00
2452b3dec4 Update readme, fix docker-compose typo, add pipelines
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/build unknown status
ci/woodpecker/push/deploy unknown status
2026-02-13 15:33:02 -03:00
21 changed files with 3021 additions and 230 deletions

View File

@@ -0,0 +1,27 @@
{
"name": "mail-landing",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
"onCreateCommand": "npm install -g pnpm@10",
"postCreateCommand": "pnpm install",
"forwardPorts": [3000],
"portsAttributes": {
"3000": {
"label": "Preview",
"onAutoForward": "notify"
}
},
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"dbaeumer.vscode-eslint"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"remoteUser": "node"
}

View File

@@ -1,9 +1,16 @@
# Avoid sending secrets or dev tooling into the build context
# config/ and plugins/ are included (no secrets; PHP configs read from ENV at runtime)
.devcontainer
.pnpm-store
.vscode
.woodpecker
node_modules
scripts
.git
.prettierrc
.prettierignore
*.md
.env
.env.*
stylelint.config.js
eslint.config.js

3
.gitignore vendored
View File

@@ -1,9 +1,10 @@
.pnpm-store
.env
.env.*
!.env.example
node_modules
pnpm-lock.yaml
.eslintcache
.stylelintcache
dist

View File

@@ -1,2 +1,6 @@
.pnpm-store
node_modules
pnpm-lock.yaml
dist

View File

@@ -1,15 +1,15 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 4,
"proseWrap": "preserve"
}
}
]
"semi": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 4,
"proseWrap": "preserve"
}
}
]
}

159
.woodpecker/build.yml Normal file
View File

@@ -0,0 +1,159 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- ci
steps:
- name: Site build
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm build
- name: Docker image build
image: docker:latest
depends_on:
- Site build
environment:
REGISTRY_REPO: git.mifi.dev/mifi-holdings/landing
DOCKER_API_VERSION: '1.43'
DOCKER_BUILDKIT: '1'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Building Docker image (BuildKit) ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"'
- |
build() {
docker build \
--progress=plain \
--platform=linux/amd64 \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \
--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:
- Site build
- 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:
- Site build
- Docker image build
when:
- status: [failure]
- name: Push to registry
image: docker:latest
environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-holdings/landing
REGISTRY_USERNAME:
from_secret: gitea_registry_username
REGISTRY_PASSWORD:
from_secret: gitea_package_token
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Pushing to registry ==="
- 'echo "Registry: $REGISTRY_URL"'
- 'echo "Repository: $REGISTRY_REPO"'
- |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
-u "$REGISTRY_USERNAME" \
--password-stdin
- docker push $REGISTRY_REPO:${CI_COMMIT_SHA}
- docker push $REGISTRY_REPO:latest
- echo "✓ Images pushed successfully"
depends_on:
- Site 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]

118
.woodpecker/ci.yml Normal file
View File

@@ -0,0 +1,118 @@
# CI: lint and format check. Runs on every PR and every push to main.
when:
- event: pull_request
- branch: main
event: push
steps:
- name: install
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- name: Prettier Format Check
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm format:check
depends_on:
- install
- name: Send Prettier Format 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] Prettier Format 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:
- Prettier Format Check
when:
- status: [failure]
- name: Lint Check
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm lint
depends_on:
- Prettier Format Check
- name: Send Lint Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Lint failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Lint Check
when:
- status: [failure]
- name: Build
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm build
depends_on:
- Lint Check
- 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: 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
- Prettier Format Check
- Lint Check
- Build
when:
- status: [success]

70
.woodpecker/deploy.yml Normal file
View File

@@ -0,0 +1,70 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
skip_clone: true
# Use writable workspace when clone is skipped (no root clone step to create /woodpecker/src)
workspace:
base: /tmp
path: deploy
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- build
steps:
- name: Trigger Portainer stack redeploy
image: curlimages/curl:latest
environment:
PORTAINER_WEBHOOK_URL:
from_secret: portainer_webhook_url
commands:
- set -e
- echo "=== Triggering Portainer stack redeploy ==="
- |
resp=$(curl -s -w "\n%{http_code}" -X POST "$PORTAINER_WEBHOOK_URL")
body=$(echo "$resp" | head -n -1)
code=$(echo "$resp" | tail -n 1)
if [ "$code" != "200" ] && [ "$code" != "204" ]; then
echo "Webhook failed (HTTP $code): $body"
exit 1
fi
echo "✓ Portainer redeploy triggered (HTTP $code)"
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

View File

@@ -1,6 +1,6 @@
# mifi.holdings — Landing Page
Static landing site for **mifi.holdings** (and www). Plain HTML/CSS, no framework; served by **nginx** in Docker behind **Traefik**, with CI/CD via **Woodpecker** and deployment via **Portainer**.
Static landing site for **mifi.holdings** (and www). Plain HTML/CSS/JS source; a **build step** produces minified assets and inlines critical CSS. Served by **nginx** in Docker behind **Traefik**, with CI/CD via **Woodpecker** and deployment via **Portainer**.
---
@@ -14,6 +14,7 @@ Static landing site for **mifi.holdings** (and www). Plain HTML/CSS, no framewor
| **CI/CD** | Woodpecker (ci → build → deploy) |
| **Registry** | `git.mifi.dev/mifi-holdings/landing` |
| **Package manager** | pnpm |
| **Build output** | `dist/` (gitignored) |
---
@@ -28,11 +29,11 @@ Static landing site for **mifi.holdings** (and www). Plain HTML/CSS, no framewor
│ Docker container │ mifi-holdings-landing
│ nginx:alpine │ port 80
│ /usr/share/ │
│ nginx/html ← │ static files from image
│ nginx/html ← │ built output from dist/
└─────────────────┘
```
- **Build**: Docker image = `nginx:alpine` + project `nginx/conf.d/` + `src/` copied into `/usr/share/nginx/html`.
- **Build**: `pnpm build` copies `src/``dist/`, minifies JS/CSS, inlines critical CSS (Critters). Docker image = `nginx:alpine` + `nginx/conf.d/` + **`dist/`** into `/usr/share/nginx/html`.
- **Run**: Single service on external network `marina-net`; Traefik routes `mifi.holdings` and `www.mifi.holdings` to this container (HTTPS, `security-prison@file` middleware).
---
@@ -41,52 +42,70 @@ Static landing site for **mifi.holdings** (and www). Plain HTML/CSS, no framewor
```
.
├── src/ # Static site (served as-is)
├── src/ # Source (HTML, CSS, JS)
│ ├── index.html
│ └── css/
── style.css
│ └── assets/
── css/style.css
│ ├── js/ga-init.js
│ └── images/ # favicon, etc.
├── scripts/
│ └── build.js # Build: copy, minify, inline critical CSS
├── dist/ # Build output (gitignored)
├── nginx/
│ └── conf.d/
│ └── default.conf # nginx server config (cache rules, SPA fallback)
├── .woodpecker/
│ ├── ci.yml # Lint + format check (PR + push to main)
│ ├── build.yml # Build image, push to registry (main)
│ ├── ci.yml # Lint, format, build check (PR + push to main)
│ ├── build.yml # Site build → Docker image push (main)
│ └── deploy.yml # Trigger Portainer redeploy (main)
├── docker-compose.yml # Service + Traefik labels for production
├── Dockerfile # nginx + config + src
├── package.json # pnpm scripts: format, lint, docker build/push
├── Dockerfile # nginx + config + dist (not src)
├── package.json # pnpm scripts: build, format, lint, docker
└── README.md
```
---
## Build (pnpm build)
- **Output**: `dist/` (gitignored). Do not edit `dist/`; it is generated.
- **Steps** (see `scripts/build.js`):
1. Clean and copy `src/``dist/`.
2. Minify all `.js` (terser) and `.css` (clean-css).
3. Inline critical CSS with **Critters** (lazy-loads the rest; no browser required, so it works in CI).
- **When**: Run before `docker build`. CI and the build pipeline both run `pnpm build` before packaging.
---
## Tech stack
- **Frontend**: Static HTML + CSS only (no JS build step).
- **Frontend**: Static HTML + CSS + JS in `src/`; production build minifies and inlines critical CSS.
- **Server**: nginx (Alpine) in Docker.
- **Tooling**: **pnpm** (format with Prettier, lint with yamllint for `.woodpecker/*.yml` and `docker-compose.yml`).
- **Deployment**: Docker image → Gitea registry → Portainer stack redeploy.
- **Tooling**: **pnpm**; Prettier (format); ESLint (JS), Stylelint (CSS), yamllint (YAML); **terser**, **clean-css**, **beasties** (build).
- **Deployment**: Docker image (from `dist/`) → Gitea registry → Portainer stack redeploy.
---
## Local development
- **Dependencies**: `pnpm install` (only devDependencies: Prettier, yaml-lint).
- **Dependencies**: `pnpm install`.
- **Format**: `pnpm format` / `pnpm format:check`.
- **Lint**: `pnpm lint` (yamllint on Woodpecker and docker-compose YAML).
- **Lint**: `pnpm lint` (ESLint for `src/**/*.js`, Stylelint for `src/**/*.css`, yamllint for Woodpecker + docker-compose). Use `pnpm lint:fix` to auto-fix where supported.
- **Build**: `pnpm build` → produces `dist/`. Required before building the Docker image.
- **Run locally (Docker)**:
- Build: `pnpm docker:build` (or `docker build --platform linux/amd64 -t git.mifi.dev/mifi-holdings/landing:latest .`).
- Run: use `docker-compose up` **only** on a host that has `marina-net` and Traefik; otherwise run the image with a port map and open `http://localhost:<port>`.
- Build site: `pnpm build`.
- Image: `pnpm docker:build` (or `docker build --platform linux/amd64 -t git.mifi.dev/mifi-holdings/landing:latest .`).
- Run: use `docker-compose up` **only** on a host that has `marina-net` and Traefik; otherwise run the image with a port map and open `http://localhost:<port>`.
There is no dev server in this repo; edit `src/` and refresh the browser or rebuild the image to test.
No dev server; edit `src/` and run `pnpm build` (and optionally `docker run` or a local static server on `dist/`) to test.
---
## Docker
- **Dockerfile**: Copies `nginx/conf.d/` and `src/` into an `nginx:alpine` image. No multi-stage build; the image is the final runtime.
- **Dockerfile**: Copies `nginx/conf.d/` and **`dist/`** (not `src/`) into an `nginx:alpine` image. Run `pnpm build` first so `dist/` exists.
- **Image**: Tagged as `git.mifi.dev/mifi-holdings/landing:latest` (and `:<commit-sha>` in CI).
- **Local build/push**: `pnpm docker:build`, `pnpm docker:push` (requires login to `git.mifi.dev`).
- **Local build/push**: `pnpm build``pnpm docker:build` `pnpm docker:push` (requires login to `git.mifi.dev`).
---
@@ -95,16 +114,16 @@ There is no dev server in this repo; edit `src/` and refresh the browser or rebu
Three pipelines:
1. **ci** (`.woodpecker/ci.yml`)
- **When**: Every PR and every push to `main`.
- **Steps**: Install deps → Prettier format check → yamllint (Woodpecker + docker-compose). Mattermost notifications on success/failure.
- **When**: Every PR and every push to `main`.
- **Steps**: Install deps → Prettier format check → lint (ESLint, Stylelint, yamllint) → **Build** (`pnpm build`). Mattermost notifications on success/failure.
2. **build** (`.woodpecker/build.yml`)
- **When**: Push/tag/manual on `main` (and deployment to production); **depends_on: ci**.
- **Steps**: Build Docker image (linux/amd64, up to 3 retries) → push `:latest` and `:<CI_COMMIT_SHA>` to `git.mifi.dev/mifi-holdings/landing`. Mattermost notifications.
- **When**: Push/tag/manual on `main` (and deployment to production); **depends_on: ci**.
- **Steps**: **Site build** (`pnpm install`, `pnpm build`) → Docker image build (linux/amd64, up to 3 retries) → push `:latest` and `:<CI_COMMIT_SHA>` to `git.mifi.dev/mifi-holdings/landing`. Mattermost notifications.
3. **deploy** (`.woodpecker/deploy.yml`)
- **When**: Same as build (main / production deploy); **depends_on: ci**.
- **Steps**: Call Portainer webhook to redeploy the stack. Mattermost notifications.
- **When**: Same as build (main / production deploy); **depends_on: ci**.
- **Steps**: Call Portainer webhook to redeploy the stack. Mattermost notifications.
Secrets used: `gitea_registry_username`, `gitea_package_token`, `portainer_webhook_url`, `mattermost_*` (bot token, channel IDs, API URL).
@@ -115,11 +134,11 @@ Secrets used: `gitea_registry_username`, `gitea_package_token`, `portainer_webho
- **Orchestration**: Stack defined in `docker-compose.yml`, deployed via **Portainer** (webhook triggered by Woodpecker deploy pipeline).
- **Network**: Container joins external network `marina-net` (shared with Traefik).
- **Traefik**:
- Hosts: `mifi.holdings`, `www.mifi.holdings`.
- Entrypoint: `websecure` (HTTPS).
- TLS: Let's Encrypt (`tls.certresolver=letsencrypt`).
- Middleware: `security-prison@file`.
- Backend: this service, port 80.
- Hosts: `mifi.holdings`, `www.mifi.holdings`.
- Entrypoint: `websecure` (HTTPS).
- TLS: Let's Encrypt (`tls.certresolver=letsencrypt`).
- Middleware: `security-prison@file`.
- Backend: this service, port 80.
- **Healthcheck**: `wget --spider -q http://localhost/` every 20s (timeout 3s, 3 retries).
To deploy manually: pull the latest image and redeploy the stack in Portainer, or trigger the Portainer webhook (e.g. same URL as in `portainer_webhook_url`).
@@ -128,7 +147,7 @@ To deploy manually: pull the latest image and redeploy the stack in Portainer, o
## nginx behavior
- **Root**: `/usr/share/nginx/html` (contents of `src/`).
- **Root**: `/usr/share/nginx/html` (contents of **`dist/`** after build).
- **HTML**: `Cache-Control: public, no-cache` so updates are visible quickly.
- **JS/CSS**: Long cache, `immutable` for hashed/versioned assets.
- **Images/fonts**: Cached (30d / 1y).
@@ -138,6 +157,6 @@ To deploy manually: pull the latest image and redeploy the stack in Portainer, o
## Summary
- **What it is**: Static landing for mifi.holdings.
- **How it runs**: nginx in Docker, fronted by Traefik on `marina-net`.
- **How its updated**: Push to `main` → Woodpecker runs ci, build (image + push), deploy (Portainer webhook); Mattermost reports status.
- **What it is**: Static landing for mifi.holdings; source in `src/`, built output in `dist/`.
- **How it runs**: nginx in Docker serving `dist/`, fronted by Traefik on `marina-net`.
- **How its updated**: Push to `main` → Woodpecker runs ci (lint, format, **build**), then build pipeline (**site build** → Docker image push), then deploy (Portainer webhook); Mattermost reports status.

View File

@@ -2,6 +2,7 @@ services:
mifi-holdings-landing:
image: git.mifi.dev/mifi-holdings/landing:latest
container_name: mifi-holdings-landing
restart: unless-stopped
networks:
- marina-net
labels:
@@ -9,7 +10,7 @@ services:
- 'traefik.docker.network=marina-net'
- 'traefik.http.routers.holdings-landing.rule=Host(`mifi.holdings`) || Host(`www.mifi.holdings`)'
- 'traefik.http.routers.holdings-landing.entrypoints=websecure'
- 'traefik.http.routers.holdings-landing.middlewares=ecurity-supermax-with-analytics@@file,redirect-www-to-non-www@file'
- 'traefik.http.routers.holdings-landing.middlewares=gzip@file,security-supermax-with-analytics@file,redirect-www-to-non-www@file'
- 'traefik.http.routers.holdings-landing.tls=true'
- 'traefik.http.routers.holdings-landing.tls.certresolver=letsencrypt'
- 'traefik.http.services.holdings-landing.loadbalancer.server.port=80'

View File

@@ -1,20 +1,20 @@
import prettierConfig from 'eslint-config-prettier/flat'
export default [
{
files: ['src/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: {
window: 'readonly',
document: 'readonly',
dataLayer: 'writable',
},
{
files: ['src/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: {
window: 'readonly',
document: 'readonly',
dataLayer: 'writable'
}
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }]
}
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
prettierConfig,
prettierConfig
]

View File

@@ -1,36 +1,39 @@
{
"type": "module",
"name": "mifi-holdings-landing",
"version": "1.0.0",
"packageManager": "pnpm@10.29.3",
"scripts": {
"build": "node scripts/build.js",
"docker:build": "docker build --platform linux/amd64 -t git.mifi.dev/mifi-holdings/landing:latest .",
"docker:push": "docker push git.mifi.dev/mifi-holdings/landing:latest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "pnpm run lint:yaml && pnpm run lint:js && pnpm run lint:css",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:js": "eslint src/",
"lint:yaml": "yamllint .woodpecker/ci.yml .woodpecker/build.yml .woodpecker/deploy.yml docker-compose.yml",
"lint:fix": "pnpm run lint:fix:js && pnpm run lint:fix:css && pnpm run lint:fix:yaml",
"lint:fix:js": "eslint src/ --fix",
"lint:fix:css": "stylelint \"src/**/*.css\" --fix",
"lint:fix:yaml": "yamllint .woodpecker/ci.yml .woodpecker/build.yml .woodpecker/deploy.yml docker-compose.yml --fix"
},
"devDependencies": {
"clean-css": "^5.3.3",
"critters": "^0.0.25",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.4.2",
"stylelint": "^17.3.0",
"stylelint-config-standard": "^40.0.0",
"terser": "^5.46.0",
"yaml-lint": "^1.7.0"
},
"repository": {
"type": "git",
"url": "https://github.com/mifi-holdings/landing.git"
}
"name": "mifi-holdings-landing",
"type": "module",
"version": "1.0.0",
"packageManager": "pnpm@10.29.3",
"scripts": {
"build": "node scripts/build.js",
"docker:build": "docker build --platform linux/amd64 -t git.mifi.dev/mifi-holdings/landing:latest .",
"docker:push": "docker push git.mifi.dev/mifi-holdings/landing:latest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "pnpm run lint:yaml && pnpm run lint:js && pnpm run lint:css",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:js": "eslint src/",
"lint:yaml": "yamllint .woodpecker/ci.yml .woodpecker/build.yml .woodpecker/deploy.yml docker-compose.yml",
"lint:fix": "pnpm run lint:fix:js && pnpm run lint:fix:css && pnpm run lint:fix:yaml",
"lint:fix:js": "eslint src/ --fix",
"lint:fix:css": "stylelint \"src/**/*.css\" --fix",
"lint:fix:yaml": "yamllint .woodpecker/ci.yml .woodpecker/build.yml .woodpecker/deploy.yml docker-compose.yml --fix",
"preview": "serve src -l 3000",
"preview:prod": "pnpm build && serve dist -l 3000"
},
"devDependencies": {
"clean-css": "^5.3.3",
"beasties": "^0.4.1",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.4.2",
"serve": "^14.2.4",
"stylelint": "^17.3.0",
"stylelint-config-standard": "^40.0.0",
"terser": "^5.46.0",
"yaml-lint": "^1.7.0"
},
"repository": {
"type": "git",
"url": "https://github.com/mifi-holdings/landing.git"
}
}

2283
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,18 @@
/**
* Build script: copy src → dist, minify JS/CSS, inline critical CSS (Critters).
* Build script: copy src → dist, minify JS/CSS, inline critical CSS (Beasties).
* Run with: pnpm build
*/
import { rmSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from 'fs'
import {
rmSync,
mkdirSync,
readFileSync,
writeFileSync,
cpSync,
readdirSync
} from 'fs'
import { join, dirname, extname } from 'path'
import { fileURLToPath } from 'url'
import Critters from 'critters'
import Beasties from 'beasties'
import { minify as minifyJs } from 'terser'
import CleanCSS from 'clean-css'
@@ -15,55 +22,57 @@ const srcDir = join(root, 'src')
const distDir = join(root, 'dist')
function getFiles(dir, files = []) {
const entries = readdirSync(dir, { withFileTypes: true })
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory()) getFiles(full, files)
else files.push(full)
}
return files
const entries = readdirSync(dir, { withFileTypes: true })
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory()) getFiles(full, files)
else files.push(full)
}
return files
}
async function main() {
// 1. Clean and copy src → dist
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
cpSync(srcDir, distDir, { recursive: true })
// 1. Clean and copy src → dist
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
cpSync(srcDir, distDir, { recursive: true })
const distFiles = getFiles(distDir)
const distFiles = getFiles(distDir)
// 2. Minify JS
const jsFiles = distFiles.filter((f) => extname(f) === '.js')
for (const f of jsFiles) {
const code = readFileSync(f, 'utf8')
const result = await minifyJs(code, { format: { comments: false } })
if (result.code) writeFileSync(f, result.code)
}
// 2. Minify JS
const jsFiles = distFiles.filter((f) => extname(f) === '.js')
for (const f of jsFiles) {
const code = readFileSync(f, 'utf8')
const result = await minifyJs(code, { format: { comments: false } })
if (result.code) writeFileSync(f, result.code)
}
// 3. Minify CSS
const cleanCss = new CleanCSS({ level: 2 })
const cssFiles = distFiles.filter((f) => extname(f) === '.css')
for (const f of cssFiles) {
const code = readFileSync(f, 'utf8')
const result = cleanCss.minify(code)
if (!result.errors.length) writeFileSync(f, result.styles)
}
// 3. Minify CSS
const cleanCss = new CleanCSS({ level: 2 })
const cssFiles = distFiles.filter((f) => extname(f) === '.css')
for (const f of cssFiles) {
const code = readFileSync(f, 'utf8')
const result = cleanCss.minify(code)
if (!result.errors.length) writeFileSync(f, result.styles)
}
// 4. Inline critical CSS with Critters (no browser; works in CI)
const critters = new Critters({
path: distDir,
preload: 'default',
logLevel: 'warn',
})
const indexPath = join(distDir, 'index.html')
const html = readFileSync(indexPath, 'utf8')
const inlined = await critters.process(html)
writeFileSync(indexPath, inlined)
// 4. Inline critical CSS with Beasties for all HTML files (no browser; works in CI)
const htmlFiles = distFiles.filter((f) => extname(f) === '.html')
const beasties = new Beasties({
path: distDir,
preload: 'default',
logLevel: 'warn'
})
for (const htmlFile of htmlFiles) {
const html = readFileSync(htmlFile, 'utf8')
const inlined = await beasties.process(html)
writeFileSync(htmlFile, inlined)
}
console.log('Build complete: dist/')
console.log('Build complete: dist/')
}
main().catch((err) => {
console.error(err)
process.exit(1)
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1 @@
{}

View File

@@ -1,70 +1,90 @@
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 100;
padding: 0.75rem 1.25rem;
background: #70ffd7;
color: #1b1e22;
font-weight: 700;
text-decoration: none;
border-radius: 0 0 0.25rem;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
outline: 2px solid #70ffd7;
outline-offset: 2px;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
background: #121212;
color: #f4f4f4;
font-family: Inter, Arial, sans-serif;
height: 100%;
margin: 0;
padding: 0;
background: #121212;
color: #f4f4f4;
font-family: Inter, Arial, sans-serif;
}
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
text-align: center;
background: rgb(34 39 44 / 92%);
padding: 3rem 2rem;
border-radius: 1.5rem;
box-shadow: 0 2px 24px 0 rgb(0 0 0 / 13%);
max-width: 400px;
margin: 1rem;
text-align: center;
background: rgb(34 39 44 / 92%);
padding: 3rem 2rem;
border-radius: 1.5rem;
box-shadow: 0 2px 24px 0 rgb(0 0 0 / 13%);
max-width: 400px;
margin: 1rem;
}
.emoji {
font-size: 2.8rem;
margin-bottom: 1rem;
font-size: 2.8rem;
margin-bottom: 1rem;
}
h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.5rem;
letter-spacing: -1px;
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.5rem;
letter-spacing: -1px;
}
p {
color: #babed8;
margin-bottom: 1.7rem;
font-size: 1.14rem;
line-height: 1.55;
color: #babed8;
margin-bottom: 1.7rem;
font-size: 1.14rem;
line-height: 1.55;
}
.scram {
display: inline-block;
padding: 0.7rem 1.4rem;
background: #70ffd7;
color: #1b1e22;
border-radius: 999px;
font-weight: 700;
font-size: 1.1rem;
text-decoration: none;
transition: background 0.2s;
margin-top: 0.5rem;
box-shadow: 0 1px 4px 0 rgb(112 255 215 / 10%);
display: inline-block;
padding: 0.7rem 1.4rem;
background: #70ffd7;
color: #1b1e22;
border-radius: 999px;
font-weight: 700;
font-size: 1.1rem;
text-decoration: none;
transition: background 0.2s;
margin-top: 0.5rem;
box-shadow: 0 1px 4px 0 rgb(112 255 215 / 10%);
}
.scram:hover {
background: #50bf9c;
color: #fff;
background: #50bf9c;
color: #fff;
}
@media (width <= 430px) {
.container {
padding: 2rem 0.6rem;
}
.container {
padding: 2rem 0.6rem;
}
}

View File

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

View File

@@ -1,37 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-4000VNMXLK"></script>
<script defer src="/assets/js/ga-init.js" data-ga-id="G-4000VNMXLK"></script>
<head>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-4000VNMXLK"
></script>
<script
defer
src="/assets/js/ga-init.js"
data-ga-id="G-4000VNMXLK"
></script>
<script
defer
src="https://analytics.mifi.holdings/script.js"
data-website-id="ce2a7f8a-95e9-4bc5-93cb-2e6075d836b8"
></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>mifi.holdings</title>
<meta name="description" content="This is just a landing page so something exists at the root domain of all the digital holdings of mifi." />
<link rel="canonical" href="https://mifi.holdings" />
<meta name="robots" content="index, follow" />
<meta name="author" content="mifi" />
<title>mifi.holdings</title>
<meta
name="description"
content="This is just a landing page so something exists at the root domain of all the digital holdings of mifi."
/>
<link rel="canonical" href="https://mifi.holdings" />
<meta name="robots" content="index, follow" />
<meta name="author" content="mifi" />
<link rel="stylesheet" href="assets/css/style.css" />
<link rel="stylesheet" href="assets/css/style.css" />
<link rel="icon" type="image/svg+xml" href="/assets/images/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png" />
</head>
<body>
<div class="container">
<div class="emoji">🛸</div>
<h1>Nothing to See Here</h1>
<p>
You&apos;ve stumbled onto <b>mifi.holdings</b> — the legendary vault of
digital oddities, curios, and coffee-fueled experiments belonging to a
possibly-human, definitely-mysterious entity named
<b>mifi</b>.<br /><br />
There&apos;s nothing here for you.<br />
Go on. Shoo. Scram. Or just keep wondering.
</p>
</div>
</body>
<link
rel="icon"
type="image/svg+xml"
href="/assets/images/favicon.svg"
/>
<link
rel="icon"
type="image/x-icon"
href="/assets/images/favicon.ico"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/assets/images/apple-touch-icon.png"
/>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "mifi Holdings",
"url": "https://mifi.holdings",
"description": "This is just a landing page so something exists at the root domain of all the digital holdings of mifi.",
"publisher": {
"@type": "Organization",
"name": "mifi Ventures",
"url": "https://mifi.ventures",
"email": "postmaster@mifi.holdings"
}
}
</script>
</head>
<body>
<a
href="#main-content"
class="skip-link"
data-umami-event="skip to main content"
>Skip to main content</a
>
<main id="main-content" class="container" tabindex="-1">
<div class="emoji">🛸</div>
<h1>Nothing to See Here</h1>
<p>
You&apos;ve stumbled onto <b>mifi.holdings</b> — the legendary
vault of digital oddities, curios, and coffee-fueled experiments
belonging to a possibly-human, definitely-mysterious entity
named <b>mifi</b>.<br /><br />
There&apos;s nothing here for you.<br />
Go on. Shoo. Scram. Or just keep wondering.
</p>
</main>
<img
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
alt=""
width="1"
height="1"
role="presentation"
loading="eager"
/>
</body>
</html>

4
src/robots.txt Normal file
View File

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

8
src/sitemap.xml Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mifi.holdings/</loc>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@@ -1,8 +1,8 @@
export default {
extends: ['stylelint-config-standard'],
overrides: [
{
files: ['src/**/*.css'],
},
],
extends: ['stylelint-config-standard'],
overrides: [
{
files: ['src/**/*.css']
}
]
}