Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a16541e90d | |||
| 3f7bf3f1c8 | |||
| 4b9627d4ee | |||
| a476756a6c | |||
| 8d5501d927 | |||
| cd8ae32203 | |||
| 7110db8a4a | |||
| 7b36e14b65 | |||
| 32a760dd9a | |||
| 3d2f1dc8d4 | |||
| a957401a59 | |||
| 9cc5db5d28 | |||
| 16da17e533 | |||
| 8205215c17 | |||
| 36514286aa | |||
| cc271abedb | |||
| 8297002dc5 | |||
| 25ed57da53 | |||
| 90e410f49e | |||
| 14a3d32480 | |||
| f6c54d1074 | |||
| 1ffebc774f | |||
| 19b8becc30 |
@@ -0,0 +1,37 @@
|
|||||||
|
name: Auto Label PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
add-automerge-label:
|
||||||
|
runs-on: [macos, host]
|
||||||
|
steps:
|
||||||
|
- name: Add 'automerge' label to PR
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
GITEA_BASE_URL: https://gitea.jaybe.dev
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${CI_TOKEN:-}" ]; then
|
||||||
|
echo "CI_TOKEN not set; skipping labeling"; exit 0; fi
|
||||||
|
OWNER=$(echo "$REPO" | cut -d'/' -f1)
|
||||||
|
NAME=$(echo "$REPO" | cut -d'/' -f2)
|
||||||
|
echo "Resolving existing label id for 'automerge' in $OWNER/$NAME (no creation)"
|
||||||
|
curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true
|
||||||
|
# Extract all ids whose name == automerge, prefer the smallest id (oldest)
|
||||||
|
LID=$(tr '\n' ' ' < /tmp/labels.json | \
|
||||||
|
grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | \
|
||||||
|
sed -E 's/.*"id":([0-9]+).*/\1/' | sort -n | head -n1 || true)
|
||||||
|
if [ -z "${LID:-}" ]; then
|
||||||
|
echo "No existing 'automerge' label found; skipping labeling"; exit 0; fi
|
||||||
|
echo "Labeling PR #$PR_NUMBER with id $LID"
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
|
||||||
|
-d '{"labels":['"${LID}"']}' || true
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Auto PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'feat/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
open-pr:
|
||||||
|
runs-on: [macos, host]
|
||||||
|
steps:
|
||||||
|
- name: Create Pull Request via Gitea API
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
GITEA_BASE_URL: https://gitea.jaybe.dev
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
HEAD_BRANCH: ${{ github.ref_name }}
|
||||||
|
BASE_BRANCH: main
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${CI_TOKEN:-}" ]; then
|
||||||
|
echo "GITEA_TOKEN not set; skipping auto-pr"; exit 0; fi
|
||||||
|
OWNER=$(echo "$REPO" | cut -d'/' -f1)
|
||||||
|
NAME=$(echo "$REPO" | cut -d'/' -f2)
|
||||||
|
TITLE="auto: PR for $HEAD_BRANCH"
|
||||||
|
BODY="Automated PR created on push to $HEAD_BRANCH"
|
||||||
|
echo "Creating PR $HEAD_BRANCH -> $BASE_BRANCH for $OWNER/$NAME"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/pr_resp.json -w "%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls" \
|
||||||
|
-d "{\"head\":\"${HEAD_BRANCH}\",\"base\":\"${BASE_BRANCH}\",\"title\":\"${TITLE}\",\"body\":\"${BODY}\"}") || true
|
||||||
|
echo "API status: $HTTP_CODE"
|
||||||
|
if [ "$HTTP_CODE" = "201" ]; then
|
||||||
|
echo "PR created"; cat /tmp/pr_resp.json
|
||||||
|
PR_NUMBER=$(cat /tmp/pr_resp.json | sed -n 's/.*"number":\([0-9]*\).*/\1/p' | head -n1)
|
||||||
|
else
|
||||||
|
echo "PR not created (possibly exists). Response:"; cat /tmp/pr_resp.json || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Retry to resolve PR number by listing open PRs and matching head/base
|
||||||
|
i=0
|
||||||
|
while [ -z "${PR_NUMBER:-}" ] && [ $i -lt 10 ]; do
|
||||||
|
i=$((i+1))
|
||||||
|
echo "[retry $i] Resolving PR number for $HEAD_BRANCH -> $BASE_BRANCH"
|
||||||
|
curl -sS -o /tmp/pr_list.json -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls?state=open" || true
|
||||||
|
# Extract PR number matching head.ref==HEAD_BRANCH and base.ref==BASE_BRANCH
|
||||||
|
PR_NUMBER=$(awk -v hb="$HEAD_BRANCH" -v bb="$BASE_BRANCH" 'BEGIN{RS="},"; pr=""} {
|
||||||
|
if($0 ~ /\"head\":\{[^}]*\"ref\":\"" hb "\"/ && $0 ~ /\"base\":\{[^}]*\"ref\":\"" bb "\"/){
|
||||||
|
if (match($0, /\"number\":([0-9]+)/, m)) { print m[1]; exit }
|
||||||
|
}
|
||||||
|
}' /tmp/pr_list.json)
|
||||||
|
if [ -z "${PR_NUMBER:-}" ]; then sleep 2; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "${PR_NUMBER:-}" ]; then
|
||||||
|
echo "Resolving existing label id for 'automerge' in $OWNER/$NAME (no creation)"
|
||||||
|
curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true
|
||||||
|
LID=$(tr '\n' ' ' < /tmp/labels.json | \
|
||||||
|
grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | \
|
||||||
|
sed -E 's/.*"id":([0-9]+).*/\1/' | sort -n | head -n1 || true)
|
||||||
|
if [ -n "${LID:-}" ]; then
|
||||||
|
echo "Adding 'automerge' (id=$LID) label to PR #$PR_NUMBER"
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
|
||||||
|
-d '{"labels":['"${LID}"']}' || true
|
||||||
|
else
|
||||||
|
echo "No existing 'automerge' found; skip labeling"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "PR number could not be determined; skipping label add"
|
||||||
|
fi
|
||||||
@@ -42,3 +42,41 @@ jobs:
|
|||||||
path: playwright-report
|
path: playwright-report
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
- name: Auto-merge PR on green
|
||||||
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
GITEA_BASE_URL: https://gitea.jaybe.dev
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${CI_TOKEN:-}" ]; then
|
||||||
|
echo "CI_TOKEN not set; skipping auto-merge"; exit 0; fi
|
||||||
|
OWNER=$(echo "$REPO" | cut -d'/' -f1)
|
||||||
|
NAME=$(echo "$REPO" | cut -d'/' -f2)
|
||||||
|
echo "Checking labels for PR #$PR_NUMBER on $OWNER/$NAME"
|
||||||
|
LABELS_JSON=$(curl -sS -X GET \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}")
|
||||||
|
echo "labels payload (first 200 chars):"; echo "$LABELS_JSON" | cut -c1-200 || true
|
||||||
|
if ! echo "$LABELS_JSON" | grep -qi '"name":"automerge"'; then
|
||||||
|
echo "Label 'automerge' not present; skipping merge"; exit 0; fi
|
||||||
|
echo "Label 'automerge' present; proceeding to merge"
|
||||||
|
# ensure PR is open
|
||||||
|
PR_JSON=$(curl -sS -X GET \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
|
||||||
|
STATE=$(echo "$PR_JSON" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p')
|
||||||
|
echo "PR state: ${STATE:-unknown}"
|
||||||
|
if [ "${STATE:-}" != "open" ]; then
|
||||||
|
echo "PR state is not open; skipping merge"; exit 0; fi
|
||||||
|
echo "Merging PR #$PR_NUMBER for $OWNER/$NAME"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/merge_resp.json -w "%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/merge" \
|
||||||
|
-d '{"Do":"merge","MergeMessage":"auto-merge: CI green"}' || true)
|
||||||
|
echo "merge http: $HTTP_CODE"
|
||||||
|
echo "merge resp:"; cat /tmp/merge_resp.json || true
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe('Exporter a11y/responsive enhancements', () => {
|
|||||||
test('hero image alt derives from heading', () => {
|
test('hero image alt derives from heading', () => {
|
||||||
const out = exportPage(makePage())
|
const out = exportPage(makePage())
|
||||||
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
|
expect(out.html).toMatch(/<img[^>]*src="https:\/\/example\.com\/img\.png"[^>]*alt="Welcome"[^>]*\/>/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('responsive and a11y CSS tokens exist', () => {
|
test('responsive and a11y CSS tokens exist', () => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(overrides: Partial<Page> = {}): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'CTA Edge',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'cta', props: { text: ' ', href: '#' } },
|
||||||
|
{ type: 'features', props: { items: ['One'] } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 't', description: 'd' },
|
||||||
|
}
|
||||||
|
return pageSchema.parse({ ...base, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - CTA edge cases', () => {
|
||||||
|
it('drops CTA section when text is whitespace-only', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).not.toContain('<section class="cta"')
|
||||||
|
expect(out.html).not.toContain('<a class="btn-cta"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders CTA when text is valid after trimming', () => {
|
||||||
|
const out = exportPage(makePage({ sections: [ { type: 'cta', props: { text: ' Buy Now ', href: '#' } } ] as Page['sections'] }))
|
||||||
|
expect(out.html).toContain('<a class="btn-cta" href="#">Buy Now</a>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Dark Contrast Hover',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } },
|
||||||
|
{ type: 'cta', props: { text: 'Call', href: '#' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 't', description: 'd' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - dark mode hover/cta tokens', () => {
|
||||||
|
it('adds link hover underline and ensures btn-cta readable in dark', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const css = out.css
|
||||||
|
|
||||||
|
// underline on hover for links in dark for clearer affordance
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a:hover\{[^}]*text-decoration:underline/i)
|
||||||
|
|
||||||
|
// btn-cta has readable foreground/background in dark
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.btn-cta\{[^}]*background:[^;]+;[^}]*color:#fff/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Dark Contrast',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q', a: 'A' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 't', description: 'd' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - dark mode contrast tokens', () => {
|
||||||
|
it('includes dark mode tokens that improve contrast for links/buttons/faq text', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const css = out.css
|
||||||
|
|
||||||
|
// dark mode block exists
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
|
||||||
|
|
||||||
|
// link color token in dark
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a\{[^}]*color:#93c5fd/i)
|
||||||
|
|
||||||
|
// button keeps readable foreground/background in dark
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*button\{[^}]*background:[^;]+;[^}]*color:#fff/i)
|
||||||
|
|
||||||
|
// faq answer text brighter in dark
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.faq-a\{[^}]*color:#cbd5e1/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(overrides: Partial<Page> = {}): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'Hero Edge',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: ' ', imageUrl: 'https://img.example/h.png' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 't', description: 'd' },
|
||||||
|
}
|
||||||
|
return pageSchema.parse({ ...base, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - hero edge cases', () => {
|
||||||
|
it('omits subheading when whitespace-only', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
|
expect(out.html).not.toMatch(/<div class="text">[\s\S]*<p>\s*<\/p>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims subheading content', () => {
|
||||||
|
const out = exportPage(makePage({ sections: [ { type: 'hero', props: { heading: 'Welcome', subheading: ' Hello world ', imageUrl: 'https://img.example/h.png' } } ] as Page['sections'] }))
|
||||||
|
expect(out.html).toContain('<p>Hello world</p>')
|
||||||
|
})
|
||||||
|
})
|
||||||
+46
-15
@@ -5,7 +5,12 @@ export type Exported = { html: string; css: string; js: string }
|
|||||||
export type ExportOptions = { assetManager?: AssetManager }
|
export type ExportOptions = { assetManager?: AssetManager }
|
||||||
|
|
||||||
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||||
const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
const itemsRaw: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
||||||
|
const isBlank = (s: string | undefined | null) => !s || s.trim().length === 0
|
||||||
|
const items = itemsRaw
|
||||||
|
.map((it) => ({ q: (it.q || '').trim(), a: (it.a || '').trim() }))
|
||||||
|
// drop when both question and answer are blank
|
||||||
|
.filter((it) => !(isBlank(it.q) && isBlank(it.a)))
|
||||||
return `
|
return `
|
||||||
<section class="faq" aria-labelledby="faq-heading">
|
<section class="faq" aria-labelledby="faq-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -27,7 +32,9 @@ function renderFaq(section: { props: { items: Array<{ q: string; a: string }> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderFeatures(section: { props: { items: string[] } }) {
|
function renderFeatures(section: { props: { items: string[] } }) {
|
||||||
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : [])
|
||||||
|
.map((s) => String(s ?? '').trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
return `
|
return `
|
||||||
<section class="features" aria-labelledby="features-heading">
|
<section class="features" aria-labelledby="features-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -41,7 +48,9 @@ function renderFeatures(section: { props: { items: string[] } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBenefits(section: { props: { items: string[] } }) {
|
function renderBenefits(section: { props: { items: string[] } }) {
|
||||||
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : [])
|
||||||
|
.map((s) => String(s ?? '').trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
return `
|
return `
|
||||||
<section class="benefits" aria-labelledby="benefits-heading">
|
<section class="benefits" aria-labelledby="benefits-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -55,9 +64,12 @@ function renderBenefits(section: { props: { items: string[] } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
||||||
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
const items: Array<{ author: string; quote: string }> = (Array.isArray(section.props?.items)
|
||||||
? section.props.items
|
? section.props.items
|
||||||
: []
|
: [])
|
||||||
|
.map((it) => ({ author: (it.author || '').trim(), quote: (it.quote || '').trim() }))
|
||||||
|
// drop when both are blank
|
||||||
|
.filter((it) => !(it.author.length === 0 && it.quote.length === 0))
|
||||||
return `
|
return `
|
||||||
<section class="testimonials" aria-labelledby="testimonials-heading">
|
<section class="testimonials" aria-labelledby="testimonials-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -66,8 +78,8 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q
|
|||||||
.map(
|
.map(
|
||||||
(it) => `
|
(it) => `
|
||||||
<figure class="testimonial">
|
<figure class="testimonial">
|
||||||
<blockquote>“${escapeHtml(it.quote || '')}”</blockquote>
|
<blockquote>“${escapeHtml(it.quote)}”</blockquote>
|
||||||
<figcaption>— ${escapeHtml(it.author || '')}</figcaption>
|
${it.author.length > 0 ? `<figcaption>— ${escapeHtml(it.author)}</figcaption>` : ''}
|
||||||
</figure>`
|
</figure>`
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
@@ -77,7 +89,9 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCta(section: { props: { text: string; href?: string } }) {
|
function renderCta(section: { props: { text: string; href?: string } }) {
|
||||||
const { text, href } = section.props
|
const href = section.props.href
|
||||||
|
const text = (section.props.text || '').trim()
|
||||||
|
if (text.length === 0) return ''
|
||||||
return `
|
return `
|
||||||
<section class="cta" aria-labelledby="cta-heading">
|
<section class="cta" aria-labelledby="cta-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -96,7 +110,9 @@ const escapeHtml = (s: string) =>
|
|||||||
.replaceAll('"', '"')
|
.replaceAll('"', '"')
|
||||||
|
|
||||||
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
|
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
|
||||||
const { heading, subheading } = section.props
|
const { heading } = section.props
|
||||||
|
const subheadingRaw = section.props.subheading
|
||||||
|
const subheading = subheadingRaw ? subheadingRaw.trim() : ''
|
||||||
const am = opts?.assetManager
|
const am = opts?.assetManager
|
||||||
const imageUrlRaw: string = section.props.imageUrl
|
const imageUrlRaw: string = section.props.imageUrl
|
||||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||||
@@ -105,10 +121,10 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
|
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
|
||||||
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
|
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="lazy" decoding="async" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -237,6 +253,7 @@ function buildHead(page: Page) {
|
|||||||
return `
|
return `
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="canonical" href="/" />
|
||||||
<title>${escapeHtml(page.seo.title)}</title>
|
<title>${escapeHtml(page.seo.title)}</title>
|
||||||
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||||
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||||
@@ -261,17 +278,20 @@ function buildCss(page: Page) {
|
|||||||
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
|
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
|
||||||
.faq-list{ list-style:none; padding:0; margin:0 }
|
.faq-list{ list-style:none; padding:0; margin:0 }
|
||||||
.faq-item{ margin: .75rem 0 }
|
.faq-item{ margin: .75rem 0 }
|
||||||
.faq-q{ font-size: 1rem; font-weight:600; }
|
.faq-q{ font-size: 1rem; font-weight:600; overflow-wrap:anywhere }
|
||||||
.faq-a{ color:#334155 }
|
.faq-a{ color:#334155; overflow-wrap:anywhere }
|
||||||
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
|
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
|
||||||
.features{ background:#f8fafc }
|
.features{ background:#f8fafc }
|
||||||
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
|
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
|
||||||
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem }
|
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem; overflow-wrap:anywhere }
|
||||||
|
.benefits-list{ list-style:none; padding:0; margin:0 }
|
||||||
|
.benefit-item{ overflow-wrap:anywhere }
|
||||||
.testimonials{ background:#ffffff }
|
.testimonials{ background:#ffffff }
|
||||||
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
|
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
|
||||||
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
|
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937; overflow-wrap:anywhere }
|
||||||
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
||||||
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
||||||
|
@media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } a{ color:#93c5fd } a:hover{ text-decoration:underline } button{ background: var(--primary); color:#fff } .btn-cta{ background: var(--primary); color:#fff } .faq-a{ color:#cbd5e1 } }
|
||||||
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -282,6 +302,17 @@ function buildJs() {
|
|||||||
(function(){
|
(function(){
|
||||||
var ts = document.querySelector('input[name="_ts"]');
|
var ts = document.querySelector('input[name="_ts"]');
|
||||||
if(ts){ ts.value = String(Date.now()); }
|
if(ts){ ts.value = String(Date.now()); }
|
||||||
|
// Ensure skip-link moves focus into main for a11y
|
||||||
|
var skip = document.querySelector('a.skip-link');
|
||||||
|
var main = document.getElementById('main');
|
||||||
|
if (skip && main) {
|
||||||
|
skip.addEventListener('click', function(){
|
||||||
|
if (!main.hasAttribute('tabindex')) {
|
||||||
|
main.setAttribute('tabindex', '-1');
|
||||||
|
}
|
||||||
|
main.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(overrides: Partial<Page> = {}): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'Edge',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'features', props: { items: ['One', ' ', ' ', 'Two'] } },
|
||||||
|
{ type: 'benefits', props: { items: ['A', ' ', ' ', 'B'] } },
|
||||||
|
{ type: 'faq', props: { items: [
|
||||||
|
{ q: ' ', a: ' ' },
|
||||||
|
{ q: 'Valid Q', a: ' ' },
|
||||||
|
{ q: 'Another', a: 'Answer' },
|
||||||
|
] } },
|
||||||
|
{ type: 'testimonials', props: { items: [
|
||||||
|
{ author: ' ', quote: ' ' },
|
||||||
|
{ author: 'Jane', quote: ' Great product ' },
|
||||||
|
] } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 't', description: 'd' },
|
||||||
|
}
|
||||||
|
return pageSchema.parse({ ...base, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - sections edge cases', () => {
|
||||||
|
it('filters out whitespace-only items and keeps valid ones', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
|
||||||
|
// features: only One, Two
|
||||||
|
expect(html).toContain('<li class="feature-item">One</li>')
|
||||||
|
expect(html).toContain('<li class="feature-item">Two</li>')
|
||||||
|
expect(html).not.toMatch(/<li class="feature-item"><\/li>/)
|
||||||
|
|
||||||
|
// benefits: only A, B
|
||||||
|
expect(html).toContain('<li class="benefit-item">A</li>')
|
||||||
|
expect(html).toContain('<li class="benefit-item">B</li>')
|
||||||
|
|
||||||
|
// faq: drop fully empty, keep items even if answer empty when question is valid
|
||||||
|
expect(html).toContain('<h3 class="faq-q">Valid Q</h3>')
|
||||||
|
// testimonials: drop fully empty, keep trimmed quote
|
||||||
|
expect(html).toContain('<blockquote>“Great product”</blockquote>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds word-break tokens to avoid overflow for long words', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const css = out.css
|
||||||
|
expect(css).toMatch(/\.feature-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i)
|
||||||
|
expect(css).toMatch(/\.benefit-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i)
|
||||||
|
expect(css).toMatch(/\.testimonial blockquote\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(overrides: Partial<Page> = {}): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'Edge P3',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'faq', props: { items: [ { q: 'VeryVeryVeryVeryVeryLongWordWithoutSpaces', a: 'AnotherSuperLongUnbreakableWord' } ] } },
|
||||||
|
{ type: 'testimonials', props: { items: [
|
||||||
|
{ author: ' ', quote: 'Great!' },
|
||||||
|
{ author: 'Alice', quote: 'Nice' },
|
||||||
|
] } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 't', description: 'd' },
|
||||||
|
}
|
||||||
|
return pageSchema.parse({ ...base, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - phase3 edge cases', () => {
|
||||||
|
it('omits testimonial author line when author is blank', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
// First item author blank -> no figcaption for that blockquote
|
||||||
|
expect(html).toMatch(/<blockquote>“Great!\”<\/blockquote>[\s\S]*?<\/figure>/)
|
||||||
|
// Ensure the next item with author renders figcaption
|
||||||
|
expect(html).toMatch(/<figcaption>— Alice<\/figcaption>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds word-break/overflow-wrap for FAQ q/a to avoid overflow', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const css = out.css
|
||||||
|
expect(css).toMatch(/\.faq-q\{[\s\S]*(?:overflow-wrap:anywhere|word-break:break-word)/i)
|
||||||
|
expect(css).toMatch(/\.faq-a\{[\s\S]*(?:overflow-wrap:anywhere|word-break:break-word)/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'SEO/Perf',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/hero.png' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - small SEO/Perf improvements', () => {
|
||||||
|
it('includes canonical link and dark mode tokens; hero image lazy/async', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
const css = out.css
|
||||||
|
|
||||||
|
// canonical link present in head
|
||||||
|
expect(html).toMatch(/<link[^>]+rel="canonical"/)
|
||||||
|
|
||||||
|
// hero image has loading/decoding attributes
|
||||||
|
expect(html).toMatch(/<img[^>]*loading="lazy"/)
|
||||||
|
expect(html).toMatch(/<img[^>]*decoding="async"/)
|
||||||
|
|
||||||
|
// dark mode media query token exists
|
||||||
|
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
type WindowWithExport = Window & {
|
||||||
|
__exportPreview?: () => { html: string; css: string; js: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
// Verify that focus outline tokens from CSS are applied in a real browser context
|
||||||
|
// by focusing the submit button in the exported page and inspecting computed styles.
|
||||||
|
test('Exported page shows 3px solid outline color on focused button', async ({ page, context }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
// Minimal content to export
|
||||||
|
await page.getByTestId('card-hero').click()
|
||||||
|
|
||||||
|
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
|
||||||
|
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
|
||||||
|
expect(exported).not.toBeNull()
|
||||||
|
|
||||||
|
const preview = await context.newPage()
|
||||||
|
await preview.setContent(exported!.html)
|
||||||
|
await preview.addStyleTag({ content: exported!.css })
|
||||||
|
|
||||||
|
const submit = preview.locator('button[type="submit"]')
|
||||||
|
await expect(submit).toHaveCount(1)
|
||||||
|
await submit.focus()
|
||||||
|
|
||||||
|
// Read computed style in the page context
|
||||||
|
const style = await preview.evaluate(() => {
|
||||||
|
const el = document.querySelector('button[type="submit"]') as HTMLElement | null
|
||||||
|
if (!el) return null
|
||||||
|
const cs = window.getComputedStyle(el)
|
||||||
|
return { outlineWidth: cs.outlineWidth, outlineStyle: cs.outlineStyle, outlineColor: cs.outlineColor }
|
||||||
|
})
|
||||||
|
expect(style).not.toBeNull()
|
||||||
|
// Expect 3px solid and brand color (#2563eb => rgb(37, 99, 235))
|
||||||
|
expect(style!.outlineWidth).toBe('3px')
|
||||||
|
expect(style!.outlineStyle).toBe('solid')
|
||||||
|
expect(style!.outlineColor.replace(/\s+/g, '')).toMatch(/rgb\(37,99,235\)|#2563eb/i)
|
||||||
|
|
||||||
|
await preview.close()
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
type WindowWithExport = Window & {
|
||||||
|
__exportPreview?: () => { html: string; css: string; js: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
// Verify tab order after activating the skip-link: focus should move into <main>
|
||||||
|
// Then the first Tab should reach a focusable descendant (e.g., form submit button)
|
||||||
|
test('Skip-link Enter moves focus into main; next Tab reaches first focusable', async ({ page, context }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
await page.getByTestId('card-hero').click()
|
||||||
|
|
||||||
|
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
|
||||||
|
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
|
||||||
|
expect(exported).not.toBeNull()
|
||||||
|
|
||||||
|
const preview = await context.newPage()
|
||||||
|
await preview.setContent(exported!.html)
|
||||||
|
await preview.addStyleTag({ content: exported!.css })
|
||||||
|
|
||||||
|
const skip = preview.locator('a.skip-link')
|
||||||
|
await expect(skip).toHaveCount(1)
|
||||||
|
await skip.focus()
|
||||||
|
await expect(skip).toBeFocused()
|
||||||
|
|
||||||
|
// Activate skip-link
|
||||||
|
await preview.keyboard.press('Enter')
|
||||||
|
|
||||||
|
// Focus should be inside #main or on #main
|
||||||
|
const focusedInMain = await preview.evaluate(() => {
|
||||||
|
const main = document.getElementById('main')!
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (!active) return false
|
||||||
|
return active === main || main.contains(active)
|
||||||
|
})
|
||||||
|
expect(focusedInMain).toBeTruthy()
|
||||||
|
|
||||||
|
// Press Tab and expect a focusable inside main (submit button) to receive focus
|
||||||
|
await preview.keyboard.press('Tab')
|
||||||
|
const isSubmitFocused = await preview.evaluate(() => {
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
return !!active && active.tagName.toLowerCase() === 'button' && (active as HTMLButtonElement).type === 'submit'
|
||||||
|
})
|
||||||
|
expect(isSubmitFocused).toBeTruthy()
|
||||||
|
|
||||||
|
await preview.close()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user