Compare commits
15 Commits
v1.2.9
...
f0522ff3b7
| Author | SHA1 | Date | |
|---|---|---|---|
| f0522ff3b7 | |||
| d6c799078c | |||
| d11dae5f85 | |||
| dd7e800ec4 | |||
| 046ad4475e | |||
| b29e08e954 | |||
| 36d193f8ec | |||
| b8f04d3595 | |||
| 5f7dd838ac | |||
| 8c9f51b74a | |||
| cef86717d9 | |||
| a97a00b7fd | |||
| f696e55600 | |||
| 36455ef479 | |||
| a5384134e7 |
@@ -8,3 +8,5 @@ node_modules
|
|||||||
docs
|
docs
|
||||||
reference
|
reference
|
||||||
public/datasheets/*.pdf
|
public/datasheets/*.pdf
|
||||||
|
.pnpm-store
|
||||||
|
.gitea
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -184,6 +187,7 @@ jobs:
|
|||||||
if: github.event.inputs.skip_checks != 'true'
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
run: |
|
run: |
|
||||||
pnpm lint
|
pnpm lint
|
||||||
|
pnpm check:spell
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|
||||||
@@ -219,8 +223,6 @@ jobs:
|
|||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2
|
|
||||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2,mode=max
|
|
||||||
secrets: |
|
secrets: |
|
||||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||||
|
|
||||||
@@ -469,8 +471,14 @@ jobs:
|
|||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Setup APT Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
run: |
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y gnupg wget ca-certificates
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
@@ -510,19 +518,258 @@ jobs:
|
|||||||
PAGESPEED_LIMIT: 8
|
PAGESPEED_LIMIT: 8
|
||||||
run: pnpm run pagespeed:test
|
run: pnpm run pagespeed:test
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 7: WCAG Audit
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
wcag:
|
||||||
|
name: ♿ WCAG
|
||||||
|
needs: [prepare, deploy, smoke_test]
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
- name: Setup Puppeteer cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/puppeteer
|
||||||
|
key: ${{ runner.os }}-puppeteer-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-puppeteer-
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Setup APT Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
echo "🎯 Debian detected - installing native chromium"
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
|
# Fetch PPA key
|
||||||
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
|
|
||||||
|
# Add PPA repository
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
|
||||||
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --allow-downgrades chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Standardize binary paths
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
- name: ♿ Run WCAG Audit
|
- name: ♿ Run WCAG Audit
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
CHROME_PATH: /usr/bin/chromium
|
||||||
PAGESPEED_LIMIT: 8
|
PAGESPEED_LIMIT: 8
|
||||||
run: pnpm run check:wcag
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 7: Notifications
|
# JOB 8: Visual Regression Testing
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
visual_regression:
|
||||||
|
name: 📸 Visual Diff
|
||||||
|
needs: [prepare, deploy, smoke_test]
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
- name: Setup Puppeteer cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/puppeteer
|
||||||
|
key: ${{ runner.os }}-puppeteer-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-puppeteer-
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Setup APT Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
echo "🎯 Debian detected - installing native chromium"
|
||||||
|
apt-get install -y chromium
|
||||||
|
else
|
||||||
|
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
|
||||||
|
# Fetch PPA key
|
||||||
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
|
|
||||||
|
# Add PPA repository
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
|
||||||
|
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||||
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --allow-downgrades chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Standardize binary paths
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
- name: 📸 Run BackstopJS Test
|
||||||
|
env:
|
||||||
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
CHROME_PATH: /usr/bin/chromium
|
||||||
|
run: pnpm backstop:ci
|
||||||
|
- name: 📤 Upload Report on Failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backstop-report
|
||||||
|
path: backstop_data/html_report/
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 9: Quality Assertions
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
quality_assertions:
|
||||||
|
name: 🛡️ Quality Gates
|
||||||
|
needs: [prepare, deploy, smoke_test]
|
||||||
|
if: success() && needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🌐 HTML DOM Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:html
|
||||||
|
- name: 🔒 Security Headers Scan
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:security
|
||||||
|
- name: 🔗 Lychee Deep Link Crawl
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:links
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 10: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy, smoke_test, lighthouse]
|
needs: [prepare, deploy, smoke_test, lighthouse, wcag, visual_regression, quality_assertions]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -16,4 +16,11 @@ directus/uploads
|
|||||||
.next-docker
|
.next-docker
|
||||||
|
|
||||||
# Pa11y CI
|
# Pa11y CI
|
||||||
.pa11yci/
|
.pa11yci/
|
||||||
|
|
||||||
|
# BackstopJS
|
||||||
|
backstop_data/html_report/
|
||||||
|
backstop_data/ci_report/
|
||||||
|
backstop_data/bitmaps_test/
|
||||||
|
|
||||||
|
.htmlvalidate-tmp
|
||||||
22
.htmlvalidate.json
Normal file
22
.htmlvalidate.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": ["html-validate:recommended", "html-validate:document"],
|
||||||
|
"rules": {
|
||||||
|
"require-sri": "off",
|
||||||
|
"meta-refresh": "off",
|
||||||
|
"heading-level": "warn",
|
||||||
|
"no-trailing-whitespace": "off",
|
||||||
|
"wcag/h37": "warn",
|
||||||
|
"no-inline-style": "off",
|
||||||
|
"svg-focusable": "off",
|
||||||
|
"attribute-boolean-style": "off",
|
||||||
|
"attr-case": "off",
|
||||||
|
"void-style": "off",
|
||||||
|
"no-implicit-button-type": "off",
|
||||||
|
"unique-landmark": "off",
|
||||||
|
"long-title": "off",
|
||||||
|
"valid-id": "off",
|
||||||
|
"element-required-attributes": "off",
|
||||||
|
"attribute-empty-style": "off",
|
||||||
|
"element-permitted-content": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{featuredPost.frontmatter.title}
|
{featuredPost.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl">
|
||||||
{featuredPost.frontmatter.excerpt}
|
{featuredPost.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -197,10 +197,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
|
<p className="text-text-secondary text-sm md:text-lg line-clamp-3 md:line-clamp-4 mb-4 md:mb-8 leading-relaxed">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
||||||
|
|||||||
70
backstop.config.cjs
Normal file
70
backstop.config.cjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/* global process, module */
|
||||||
|
const BASE_URL = process.env.TEST_URL || 'http://localhost:3000';
|
||||||
|
const REFERENCE_URL = process.env.REFERENCE_URL || 'https://klz-cables.com';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'klz-cables',
|
||||||
|
viewports: [
|
||||||
|
{
|
||||||
|
label: 'phone',
|
||||||
|
width: 375,
|
||||||
|
height: 667,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'tablet',
|
||||||
|
width: 768,
|
||||||
|
height: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'desktop',
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onBeforeScript: 'puppet/onBefore.cjs',
|
||||||
|
onReadyScript: 'puppet/onReady.cjs',
|
||||||
|
scenarios: [
|
||||||
|
{
|
||||||
|
label: 'Homepage',
|
||||||
|
url: `${BASE_URL}/`,
|
||||||
|
referenceUrl: `${REFERENCE_URL}/`,
|
||||||
|
readyEvent: '',
|
||||||
|
readySelector: '',
|
||||||
|
delay: 500,
|
||||||
|
hideSelectors: [],
|
||||||
|
removeSelectors: [],
|
||||||
|
hoverSelector: '',
|
||||||
|
clickSelector: '',
|
||||||
|
postInteractionWait: 0,
|
||||||
|
selectors: [],
|
||||||
|
selectorExpansion: true,
|
||||||
|
expect: 0,
|
||||||
|
misMatchThreshold: 0.1,
|
||||||
|
requireSameDimensions: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '404 Error Page',
|
||||||
|
url: `${BASE_URL}/this-page-does-not-exist`,
|
||||||
|
referenceUrl: `${REFERENCE_URL}/this-page-does-not-exist`,
|
||||||
|
delay: 500,
|
||||||
|
misMatchThreshold: 0.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
bitmaps_reference: 'backstop_data/bitmaps_reference',
|
||||||
|
bitmaps_test: 'backstop_data/bitmaps_test',
|
||||||
|
engine_scripts: 'backstop_data/engine_scripts',
|
||||||
|
html_report: 'backstop_data/html_report',
|
||||||
|
ci_report: 'backstop_data/ci_report',
|
||||||
|
},
|
||||||
|
report: process.env.CI ? ['CI', 'json'] : ['browser'],
|
||||||
|
engine: 'puppeteer',
|
||||||
|
engineOptions: {
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
|
executablePath: process.env.CHROME_PATH || undefined, // Use explicit Chrome in CI
|
||||||
|
},
|
||||||
|
asyncCaptureLimit: 5,
|
||||||
|
asyncCompareLimit: 50,
|
||||||
|
debug: false,
|
||||||
|
debugWindow: false,
|
||||||
|
};
|
||||||
26
backstop_data/engine_scripts/puppet/onBefore.cjs
Normal file
26
backstop_data/engine_scripts/puppet/onBefore.cjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = async (page, scenario, vp, isReference, browserContext) => {
|
||||||
|
console.log('onBefore: Setting up Gatekeeper Auth Cookie...');
|
||||||
|
|
||||||
|
// BackstopJS might be hitting localhost, testing, or staging URLs
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
// Extract domain from the scenario URL
|
||||||
|
let targetDomain = 'localhost';
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(scenario.url);
|
||||||
|
targetDomain = urlObj.hostname;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the ForwardAuth session cookie
|
||||||
|
await page.setCookie({
|
||||||
|
name: 'klz_gatekeeper_session',
|
||||||
|
value: gatekeeperPassword,
|
||||||
|
domain: targetDomain, // Puppeteer requires exact or matching domain for cookies
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: targetDomain !== 'localhost' && targetDomain !== 'host.docker.internal',
|
||||||
|
});
|
||||||
|
};
|
||||||
20
backstop_data/engine_scripts/puppet/onReady.cjs
Normal file
20
backstop_data/engine_scripts/puppet/onReady.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = async (page, scenario, vp) => {
|
||||||
|
console.log('SCENARIO > ' + scenario.label);
|
||||||
|
|
||||||
|
// Disable CSS animations instantly to avoid flaky screenshots
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = `
|
||||||
|
* {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
caret-color: transparent !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example: Wait for fonts to load
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
};
|
||||||
@@ -148,7 +148,6 @@ export default function ContactForm() {
|
|||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
onFocus={() => handleFocus('contact-name')}
|
onFocus={() => handleFocus('contact-name')}
|
||||||
aria-label={t('form.name')}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +162,6 @@ export default function ContactForm() {
|
|||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.emailPlaceholder')}
|
placeholder={t('form.emailPlaceholder')}
|
||||||
onFocus={() => handleFocus('contact-email')}
|
onFocus={() => handleFocus('contact-email')}
|
||||||
aria-label={t('form.email')}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +174,6 @@ export default function ContactForm() {
|
|||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
onFocus={() => handleFocus('contact-message')}
|
onFocus={() => handleFocus('contact-message')}
|
||||||
aria-label={t('form.message')}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function Footer() {
|
|||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
|
<h2 className="sr-only">Footer Navigation</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|||||||
@@ -172,7 +172,6 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onFocus={() => handleFocus('quote-email')}
|
onFocus={() => handleFocus('quote-email')}
|
||||||
placeholder={t('email')}
|
placeholder={t('email')}
|
||||||
aria-label={t('email')}
|
|
||||||
className="h-9 text-xs !mt-0"
|
className="h-9 text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +185,6 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
onChange={(e) => setRequest(e.target.value)}
|
onChange={(e) => setRequest(e.target.value)}
|
||||||
onFocus={() => handleFocus('quote-request')}
|
onFocus={() => handleFocus('quote-request')}
|
||||||
placeholder={t('message')}
|
placeholder={t('message')}
|
||||||
aria-label={t('message')}
|
|
||||||
className="text-xs !mt-0"
|
className="text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import PowerCTA from '@/components/blog/PowerCTA';
|
|||||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||||
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
||||||
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
||||||
|
import { generateHeadingId, getTextContent } from '@/lib/blog';
|
||||||
|
|
||||||
export const mdxComponents = {
|
export const mdxComponents = {
|
||||||
VisualLinkPreview,
|
VisualLinkPreview,
|
||||||
@@ -36,17 +37,28 @@ export const mdxComponents = {
|
|||||||
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
|
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">(PDF)</span>
|
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">
|
||||||
|
(PDF)
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (href?.startsWith('/')) {
|
if (href?.startsWith('/')) {
|
||||||
return (
|
return (
|
||||||
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
|
<Link
|
||||||
|
href={href}
|
||||||
|
{...props}
|
||||||
|
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -61,18 +73,19 @@ export const mdxComponents = {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
img: (props: any) => (
|
img: (props: any) => <AnimatedImage src={props.src} alt={props.alt} />,
|
||||||
<AnimatedImage src={props.src} alt={props.alt} />
|
|
||||||
),
|
|
||||||
h2: ({ children, ...props }: any) => {
|
h2: ({ children, ...props }: any) => {
|
||||||
const id = typeof children === 'string'
|
const id = props.id || generateHeadingId(getTextContent(children));
|
||||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
|
||||||
: props.id;
|
|
||||||
return (
|
return (
|
||||||
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
||||||
{children}
|
{children}
|
||||||
@@ -80,9 +93,7 @@ export const mdxComponents = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
h3: ({ children, ...props }: any) => {
|
h3: ({ children, ...props }: any) => {
|
||||||
const id = typeof children === 'string'
|
const id = props.id || generateHeadingId(getTextContent(children));
|
||||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
|
||||||
: props.id;
|
|
||||||
return (
|
return (
|
||||||
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
||||||
{children}
|
{children}
|
||||||
@@ -90,9 +101,9 @@ export const mdxComponents = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
p: ({ children, ...props }: any) => (
|
p: ({ children, ...props }: any) => (
|
||||||
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
<div {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</div>
|
||||||
),
|
),
|
||||||
ul: ({ children, ...props }: any) => (
|
ul: ({ children, ...props }: any) => (
|
||||||
<ul {...props} className="my-8 space-y-3">
|
<ul {...props} className="my-8 space-y-3">
|
||||||
@@ -108,17 +119,22 @@ export const mdxComponents = {
|
|||||||
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
||||||
<span className="text-primary mt-1.5 flex-shrink-0">
|
<span className="text-primary mt-1.5 flex-shrink-0">
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">{children}</span>
|
<span className="flex-1">{children}</span>
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
blockquote: ({ children, ...props }: any) => (
|
blockquote: ({ children, ...props }: any) => (
|
||||||
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
|
<blockquote
|
||||||
<div className="text-lg text-text-primary italic">
|
{...props}
|
||||||
{children}
|
className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg"
|
||||||
</div>
|
>
|
||||||
|
<div className="text-lg text-text-primary italic">{children}</div>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
strong: ({ children, ...props }: any) => (
|
strong: ({ children, ...props }: any) => (
|
||||||
@@ -144,7 +160,10 @@ export const mdxComponents = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ children, ...props }: any) => (
|
thead: ({ children, ...props }: any) => (
|
||||||
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
|
<thead
|
||||||
|
{...props}
|
||||||
|
className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</thead>
|
</thead>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function ProductCategories() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||||
<h2 className="sr-only">{t('title')}</h2>
|
{t('title') && <h2 className="sr-only">{t('title')}</h2>}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
97
cspell.json
Normal file
97
cspell.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2",
|
||||||
|
"language": "en,de",
|
||||||
|
"dictionaries": ["de-de", "html", "css", "typescript", "npm"],
|
||||||
|
"words": [
|
||||||
|
"Datasheet",
|
||||||
|
"datasheets",
|
||||||
|
"Bodemer",
|
||||||
|
"Mintel",
|
||||||
|
"Umami",
|
||||||
|
"Energiezukunft",
|
||||||
|
"Energiewende",
|
||||||
|
"Solarparks",
|
||||||
|
"Energiekabel",
|
||||||
|
"Kabelinfrastruktur",
|
||||||
|
"Großprojekte",
|
||||||
|
"Zertifizierte",
|
||||||
|
"Erstberatung",
|
||||||
|
"Vertriebs",
|
||||||
|
"Windparkbau",
|
||||||
|
"Kabelherausforderungen",
|
||||||
|
"Energieprojekt",
|
||||||
|
"mittelspannungskabel",
|
||||||
|
"niederspannungskabel",
|
||||||
|
"hochspannungskabel",
|
||||||
|
"solarkabel",
|
||||||
|
"extralight",
|
||||||
|
"medv",
|
||||||
|
"Crect",
|
||||||
|
"Csvg",
|
||||||
|
"mintel",
|
||||||
|
"Zurück",
|
||||||
|
"Übersicht",
|
||||||
|
"Raiffeisenstraße",
|
||||||
|
"Remshalden",
|
||||||
|
"Experte",
|
||||||
|
"hochwertige",
|
||||||
|
"Stromkabel",
|
||||||
|
"Mittelspannungslösungen",
|
||||||
|
"Zuverlässige",
|
||||||
|
"Infrastruktur",
|
||||||
|
"eine",
|
||||||
|
"grüne",
|
||||||
|
"Weiterer",
|
||||||
|
"Artikel",
|
||||||
|
"Vorheriger",
|
||||||
|
"Beitrag",
|
||||||
|
"Nächster",
|
||||||
|
"Lösungen",
|
||||||
|
"Bereit",
|
||||||
|
"Planung",
|
||||||
|
"Lieferung",
|
||||||
|
"hochwertiger",
|
||||||
|
"erwecken",
|
||||||
|
"Ihre",
|
||||||
|
"Projekte",
|
||||||
|
"Leben",
|
||||||
|
"Strategischer",
|
||||||
|
"schnelle",
|
||||||
|
"Nachhaltige",
|
||||||
|
"Expertenberatung",
|
||||||
|
"Qualität",
|
||||||
|
"nach",
|
||||||
|
"Projekt",
|
||||||
|
"anfragen",
|
||||||
|
"Kostenlose",
|
||||||
|
"Vorhaben",
|
||||||
|
"kopiert",
|
||||||
|
"Teilen",
|
||||||
|
"Inhalt",
|
||||||
|
"produkte",
|
||||||
|
"Fokus",
|
||||||
|
"drei",
|
||||||
|
"typische",
|
||||||
|
"fokus",
|
||||||
|
"Warum",
|
||||||
|
"ideale",
|
||||||
|
"Kabel",
|
||||||
|
"Deutsch",
|
||||||
|
"Spannung",
|
||||||
|
"unbekannt"
|
||||||
|
],
|
||||||
|
"ignorePaths": [
|
||||||
|
"node_modules",
|
||||||
|
".next",
|
||||||
|
"public",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"*.svg",
|
||||||
|
"*.mp4",
|
||||||
|
"directus",
|
||||||
|
"backstop_data",
|
||||||
|
".gitea",
|
||||||
|
"out",
|
||||||
|
"coverage",
|
||||||
|
"*.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ category: Kabel Technologie
|
|||||||
Kabeltrommeln spielen eine essenzielle Rolle in der Windkraftbranche – sie ermöglichen den sicheren Transport und die Lagerung von Stromkabeln. Doch was geschieht mit ihnen, wenn die Kabel verlegt sind? Jährlich fallen unzählige Trommeln an, die entweder entsorgt oder einer sinnvollen Wiederverwendung zugeführt werden müssen.
|
Kabeltrommeln spielen eine essenzielle Rolle in der Windkraftbranche – sie ermöglichen den sicheren Transport und die Lagerung von Stromkabeln. Doch was geschieht mit ihnen, wenn die Kabel verlegt sind? Jährlich fallen unzählige Trommeln an, die entweder entsorgt oder einer sinnvollen Wiederverwendung zugeführt werden müssen.
|
||||||
Ohne ein durchdachtes Recyclingkonzept würden enorme Mengen an Holz, Stahl und Kunststoff ungenutzt bleiben. Dabei gibt es längst effiziente Lösungen, um Kabeltrommeln in den Rohstoffkreislauf zurückzuführen und die Umweltbelastung zu minimieren.
|
Ohne ein durchdachtes Recyclingkonzept würden enorme Mengen an Holz, Stahl und Kunststoff ungenutzt bleiben. Dabei gibt es längst effiziente Lösungen, um Kabeltrommeln in den Rohstoffkreislauf zurückzuführen und die Umweltbelastung zu minimieren.
|
||||||
<hr />
|
<hr />
|
||||||
##
|
|
||||||
### Materialien und ihre Wiederverwertung
|
### Materialien und ihre Wiederverwertung
|
||||||
Kabeltrommeln bestehen aus unterschiedlichen Materialien, die jeweils verschiedene Recyclingmöglichkeiten bieten. Eine gezielte Rückführung hängt davon ab, ob das Material wiederverwertet oder weiterverarbeitet werden kann.
|
Kabeltrommeln bestehen aus unterschiedlichen Materialien, die jeweils verschiedene Recyclingmöglichkeiten bieten. Eine gezielte Rückführung hängt davon ab, ob das Material wiederverwertet oder weiterverarbeitet werden kann.
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ Ein Pluspunkt des H1Z2Z2-K ist seine Eignung zur direkten Erdverlegung – ohne
|
|||||||
|
|
||||||
**Wichtig:** Für Projekte ab mehreren hundert Metern lohnt sich eine Spannungsfallberechnung – 6mm² ist nicht immer automatisch die optimale Wahl.
|
**Wichtig:** Für Projekte ab mehreren hundert Metern lohnt sich eine Spannungsfallberechnung – 6mm² ist nicht immer automatisch die optimale Wahl.
|
||||||
<hr />
|
<hr />
|
||||||
##
|
|
||||||
## FAQ: Die häufigsten Fragen rund um H1Z2Z2-K Solarkabel
|
## FAQ: Die häufigsten Fragen rund um H1Z2Z2-K Solarkabel
|
||||||
**Was bedeutet H1Z2Z2-K?**<br />Die Bezeichnung steht für einen Kabeltyp mit bestimmten Isoliermaterialien und Eigenschaften laut EN 50618, geeignet für DC-Strom bis 1500 V.
|
**Was bedeutet H1Z2Z2-K?**<br />Die Bezeichnung steht für einen Kabeltyp mit bestimmten Isoliermaterialien und Eigenschaften laut EN 50618, geeignet für DC-Strom bis 1500 V.
|
||||||
**Ist das Kabel für Erdverlegung zugelassen?**<br />Ja, inklusive direkter Erdverlegung ohne zusätzliche Schutzrohre.
|
**Ist das Kabel für Erdverlegung zugelassen?**<br />Ja, inklusive direkter Erdverlegung ohne zusätzliche Schutzrohre.
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ One major advantage of the H1Z2Z2-K is its suitability for direct burial – wit
|
|||||||
|
|
||||||
**Important:** For projects spanning several hundred meters, a voltage drop calculation is worthwhile – 6mm² isn’t always the best fit by default.
|
**Important:** For projects spanning several hundred meters, a voltage drop calculation is worthwhile – 6mm² isn’t always the best fit by default.
|
||||||
<hr />
|
<hr />
|
||||||
##
|
|
||||||
## FAQ: The most frequently asked questions about H1Z2Z2-K solar cables
|
## FAQ: The most frequently asked questions about H1Z2Z2-K solar cables
|
||||||
**What does H1Z2Z2-K mean?**<br />This designation refers to a cable type with specific insulation materials and properties according to EN 50618, suitable for DC voltage up to 1500 V.
|
**What does H1Z2Z2-K mean?**<br />This designation refers to a cable type with specific insulation materials and properties according to EN 50618, suitable for DC voltage up to 1500 V.
|
||||||
**Is the cable approved for underground installation?**<br />Yes, including direct burial without additional protective conduits.
|
**Is the cable approved for underground installation?**<br />Yes, including direct burial without additional protective conduits.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default [
|
|||||||
"**/.git/**",
|
"**/.git/**",
|
||||||
"*.js",
|
"*.js",
|
||||||
"*.mjs",
|
"*.mjs",
|
||||||
|
"*.cjs",
|
||||||
"scripts/**",
|
"scripts/**",
|
||||||
"tests/**",
|
"tests/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|||||||
86
lib/blog.ts
86
lib/blog.ts
@@ -4,6 +4,31 @@ import matter from 'gray-matter';
|
|||||||
import { mapSlugToFileSlug } from './slugs';
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
import { config } from '@/lib/config';
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export function extractExcerpt(content: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
// Remove frontmatter if present (though matter() usually strips it out)
|
||||||
|
let text = content.replace(/^---[\s\S]*?---/, '');
|
||||||
|
// Remove MDX component imports and usages
|
||||||
|
text = text.replace(/<[^>]+>/g, '');
|
||||||
|
text = text.replace(/^[ \t]*import\s+.*$/gm, '');
|
||||||
|
text = text.replace(/^[ \t]*export\s+.*$/gm, '');
|
||||||
|
// Remove markdown headings
|
||||||
|
text = text.replace(/^#+.*$/gm, '');
|
||||||
|
// Extract first paragraph or combined lines
|
||||||
|
const paragraphs = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.filter((p) => p.trim() && !p.trim().startsWith('---') && !p.trim().startsWith('#'));
|
||||||
|
if (paragraphs.length === 0) return '';
|
||||||
|
|
||||||
|
const excerpt = paragraphs[0]
|
||||||
|
.replace(/[*_`]/g, '') // remove markdown bold/italic/code
|
||||||
|
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // replace links with their text
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return excerpt.length > 200 ? excerpt.slice(0, 197) + '...' : excerpt;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostFrontmatter {
|
export interface PostFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -46,7 +71,10 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
|
|
||||||
const postInfo = {
|
const postInfo = {
|
||||||
slug: fileSlug,
|
slug: fileSlug,
|
||||||
frontmatter: data as PostFrontmatter,
|
frontmatter: {
|
||||||
|
...data,
|
||||||
|
excerpt: data.excerpt || extractExcerpt(content),
|
||||||
|
} as PostFrontmatter,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,7 +98,10 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
|||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
return {
|
return {
|
||||||
slug: file.replace(/\.mdx$/, ''),
|
slug: file.replace(/\.mdx$/, ''),
|
||||||
frontmatter: data as PostFrontmatter,
|
frontmatter: {
|
||||||
|
...data,
|
||||||
|
excerpt: data.excerpt || extractExcerpt(content),
|
||||||
|
} as PostFrontmatter,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -95,7 +126,10 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
|
|||||||
const { data } = matter(fileContent);
|
const { data } = matter(fileContent);
|
||||||
return {
|
return {
|
||||||
slug: file.replace(/\.mdx$/, ''),
|
slug: file.replace(/\.mdx$/, ''),
|
||||||
frontmatter: data as PostFrontmatter,
|
frontmatter: {
|
||||||
|
...data,
|
||||||
|
excerpt: data.excerpt || extractExcerpt(fileContent.replace(/^---[\s\S]*?---/, '')),
|
||||||
|
} as PostFrontmatter,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(isPostVisible)
|
.filter(isPostVisible)
|
||||||
@@ -109,7 +143,12 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
|
|||||||
export async function getAdjacentPosts(
|
export async function getAdjacentPosts(
|
||||||
slug: string,
|
slug: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<{ prev: PostMdx | null; next: PostMdx | null; isPrevRandom?: boolean; isNextRandom?: boolean }> {
|
): Promise<{
|
||||||
|
prev: PostMdx | null;
|
||||||
|
next: PostMdx | null;
|
||||||
|
isPrevRandom?: boolean;
|
||||||
|
isNextRandom?: boolean;
|
||||||
|
}> {
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||||
|
|
||||||
@@ -127,7 +166,7 @@ export async function getAdjacentPosts(
|
|||||||
let isPrevRandom = false;
|
let isPrevRandom = false;
|
||||||
|
|
||||||
const getRandomPost = (excludeSlugs: string[]) => {
|
const getRandomPost = (excludeSlugs: string[]) => {
|
||||||
const available = posts.filter(p => !excludeSlugs.includes(p.slug));
|
const available = posts.filter((p) => !excludeSlugs.includes(p.slug));
|
||||||
if (available.length === 0) return null;
|
if (available.length === 0) return null;
|
||||||
return available[Math.floor(Math.random() * available.length)];
|
return available[Math.floor(Math.random() * available.length)];
|
||||||
};
|
};
|
||||||
@@ -154,17 +193,42 @@ export function getReadingTime(content: string): number {
|
|||||||
return Math.ceil(minutes);
|
return Math.ceil(minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateHeadingId(text: string): string {
|
||||||
|
let id = text.toLowerCase();
|
||||||
|
id = id.replace(/ä/g, 'ae');
|
||||||
|
id = id.replace(/ö/g, 'oe');
|
||||||
|
id = id.replace(/ü/g, 'ue');
|
||||||
|
id = id.replace(/ß/g, 'ss');
|
||||||
|
|
||||||
|
id = id.replace(/[*_`]/g, '');
|
||||||
|
id = id.replace(/[^\w\s-]/g, '');
|
||||||
|
id = id
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
return id || 'heading';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextContent(node: any): string {
|
||||||
|
if (typeof node === 'string') return node;
|
||||||
|
if (typeof node === 'number') return node.toString();
|
||||||
|
if (Array.isArray(node)) return node.map(getTextContent).join('');
|
||||||
|
if (node && typeof node === 'object' && node.props && node.props.children) {
|
||||||
|
return getTextContent(node.props.children);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
export function getHeadings(content: string): { id: string; text: string; level: number }[] {
|
export function getHeadings(content: string): { id: string; text: string; level: number }[] {
|
||||||
const headingLines = content.split('\n').filter((line) => line.match(/^#{2,3}\s/));
|
const headingLines = content.split('\n').filter((line) => line.match(/^#{2,3}\s/));
|
||||||
|
|
||||||
return headingLines.map((line) => {
|
return headingLines.map((line) => {
|
||||||
const level = line.match(/^#+/)?.[0].length || 0;
|
const level = line.match(/^#+/)?.[0].length || 0;
|
||||||
const text = line.replace(/^#+\s/, '').trim();
|
const rawText = line.replace(/^#+\s/, '').trim();
|
||||||
const id = text
|
const cleanText = rawText.replace(/[*_`]/g, '');
|
||||||
.toLowerCase()
|
const id = generateHeadingId(cleanText);
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
return { id, text, level };
|
return { id, text: cleanText, level };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ const nextConfig = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
source: '/de/produkte',
|
||||||
|
destination: '/de/products',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/cms/:path*',
|
source: '/cms/:path*',
|
||||||
destination: `${directusUrl}/:path*`,
|
destination: `${directusUrl}/:path*`,
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -46,6 +46,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.4.0",
|
"@commitlint/cli": "^20.4.0",
|
||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "1.8.3",
|
"@mintel/eslint-config": "1.8.3",
|
||||||
"@mintel/tsconfig": "1.8.3",
|
"@mintel/tsconfig": "1.8.3",
|
||||||
@@ -66,10 +67,13 @@
|
|||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
"backstopjs": "^6.3.25",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"critters": "^0.0.25",
|
"critters": "^0.0.25",
|
||||||
|
"cspell": "^9.6.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
|
"html-validate": "^10.8.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
@@ -99,6 +103,14 @@
|
|||||||
"check:mdx": "node scripts/validate-mdx.mjs",
|
"check:mdx": "node scripts/validate-mdx.mjs",
|
||||||
"check:a11y": "pa11y-ci",
|
"check:a11y": "pa11y-ci",
|
||||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||||
|
"check:html": "tsx ./scripts/check-html.ts",
|
||||||
|
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
||||||
|
"check:security": "tsx ./scripts/check-security.ts",
|
||||||
|
"check:links": "bash ./scripts/check-links.sh",
|
||||||
|
"backstop:reference": "backstop reference --config=backstop.config.cjs --docker",
|
||||||
|
"backstop:test": "backstop test --config=backstop.config.cjs --docker",
|
||||||
|
"backstop:approve": "backstop approve --config=backstop.config.cjs --docker",
|
||||||
|
"backstop:ci": "backstop test --config=backstop.config.cjs",
|
||||||
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
|
|||||||
1183
pnpm-lock.yaml
generated
1183
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
87
scripts/check-html.ts
Normal file
87
scripts/check-html.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 0; // 0 means no limit
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
|
||||||
|
console.log(`📊 Limit: ${limit ? limit : 'None (Full Sitemap)'} pages\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
|
||||||
|
const response = await axios.get(sitemapUrl, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
validateStatus: (status) => status < 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
|
let urls = $('url loc')
|
||||||
|
.map((i, el) => $(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^\/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith('http'))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
console.error('❌ No URLs found in sitemap. Is the site up?');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit && urls.length > limit) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||||
|
);
|
||||||
|
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
|
||||||
|
const others = urls.filter((u) => !home.includes(u));
|
||||||
|
urls = [...home, ...others.slice(0, limit - home.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
|
||||||
|
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
console.log(`📥 Fetching HTML for ${urls.length} pages...`);
|
||||||
|
for (let i = 0; i < urls.length; i++) {
|
||||||
|
const u = urls[i];
|
||||||
|
try {
|
||||||
|
const res = await axios.get(u, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
});
|
||||||
|
const filename = `page-${i}.html`;
|
||||||
|
fs.writeFileSync(path.join(outputDir, filename), res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n💻 Executing html-validate...`);
|
||||||
|
try {
|
||||||
|
execSync(`npx html-validate .htmlvalidate-tmp/*.html`, { stdio: 'inherit' });
|
||||||
|
console.log(`✅ HTML Validation passed perfectly!`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`❌ HTML Validation found issues.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`\n❌ Error during HTML Validation:`, error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
|
||||||
|
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
26
scripts/check-links.sh
Normal file
26
scripts/check-links.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Auto-provision Lychee Rust Binary if missing
|
||||||
|
if [ ! -f ./lychee ]; then
|
||||||
|
echo "📥 Downloading Lychee Link Checker (v0.15.1)..."
|
||||||
|
curl -sSLo lychee.tar.gz https://github.com/lycheeverse/lychee/releases/download/v0.15.1/lychee-v0.15.1-x86_64-unknown-linux-gnu.tar.gz
|
||||||
|
tar -xzf lychee.tar.gz lychee
|
||||||
|
rm lychee.tar.gz
|
||||||
|
chmod +x ./lychee
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starting Deep Link Assessment (Lychee)..."
|
||||||
|
|
||||||
|
# Exclude localhost, mintel.me (internal infrastructure), and common placeholder domains
|
||||||
|
# Scan markdown files and component files for hardcoded dead links
|
||||||
|
./lychee \
|
||||||
|
--exclude "localhost" \
|
||||||
|
--exclude "127.0.0.1" \
|
||||||
|
--exclude "mintel\.me" \
|
||||||
|
--exclude "example\.com" \
|
||||||
|
--exclude-mail \
|
||||||
|
--accept 200,204,401,403 \
|
||||||
|
"./content/**/*.mdx" "./content/**/*.md" "./app/**/*.tsx" "./components/**/*.tsx"
|
||||||
|
|
||||||
|
echo "✅ All project source links are alive and healthy!"
|
||||||
54
scripts/check-security.ts
Normal file
54
scripts/check-security.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
const requiredHeaders = [
|
||||||
|
'strict-transport-security',
|
||||||
|
'x-frame-options',
|
||||||
|
'x-content-type-options',
|
||||||
|
'referrer-policy',
|
||||||
|
'content-security-policy',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🛡️ Starting Security Headers Scan for: ${targetUrl}\n`);
|
||||||
|
try {
|
||||||
|
const response = await axios.head(targetUrl, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = response.headers;
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
const results = requiredHeaders.map((header) => {
|
||||||
|
const present = !!headers[header];
|
||||||
|
if (!present) allPassed = false;
|
||||||
|
return {
|
||||||
|
Header: header,
|
||||||
|
Status: present ? '✅ Present' : '❌ Missing',
|
||||||
|
Value: present
|
||||||
|
? headers[header].length > 50
|
||||||
|
? headers[header].substring(0, 47) + '...'
|
||||||
|
: headers[header]
|
||||||
|
: 'N/A',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.table(results);
|
||||||
|
|
||||||
|
if (allPassed) {
|
||||||
|
console.log(`\n✅ All required security headers are correctly configured!\n`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(`\n❌ Missing critical security headers. Please update next.config.mjs!\n`);
|
||||||
|
process.exit(process.env.CI ? 1 : 0); // Don't crash local dev hard if missing, but crash CI
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ Failed to scan headers: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -84,6 +84,7 @@ async function main() {
|
|||||||
ignore: [...(baseConfig.defaults?.ignore || []), 'color-contrast'],
|
ignore: [...(baseConfig.defaults?.ignore || []), 'color-contrast'],
|
||||||
chromeLaunchConfig: {
|
chromeLaunchConfig: {
|
||||||
...baseConfig.defaults?.chromeLaunchConfig,
|
...baseConfig.defaults?.chromeLaunchConfig,
|
||||||
|
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
|
||||||
args: [
|
args: [
|
||||||
...(baseConfig.defaults?.chromeLaunchConfig?.args || []),
|
...(baseConfig.defaults?.chromeLaunchConfig?.args || []),
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
|
|||||||
Reference in New Issue
Block a user