Files
opencode-skill/skills/website-creator/SPECIFICATION.md
2026-03-08 23:03:19 +07:00

935 lines
23 KiB
Markdown

# Website Creator Skill - Technical Specification
**Version:** 2.0
**Last Updated:** 2026-03-08
**Framework:** Astro 5.x
**Compliance:** Thailand PDPA
---
## 🎯 Overview
This specification defines the complete structure and implementation for the `website-creator` skill, which generates PDPA-compliant Astro websites with:
- Bilingual support (Thai/English)
- Umami Analytics integration
- Cookie consent management
- Consent logging database
- Easypanel deployment
---
## 📁 Standard Folder Structure
```
{website-name}/
├── public/
│ ├── favicon.ico
│ ├── favicon.svg
│ ├── images/
│ │ └── logo.svg
│ └── robots.txt
├── src/
│ ├── components/
│ │ ├── common/
│ │ │ ├── Header.astro
│ │ │ ├── Footer.astro
│ │ │ └── LanguageSwitcher.astro
│ │ ├── consent/
│ │ │ ├── CookieBanner.astro
│ │ │ └── ConsentPreferences.astro
│ │ └── ui/
│ │ ├── Button.astro
│ │ ├── Card.astro
│ │ └── Section.astro
│ │
│ ├── layouts/
│ │ └── BaseLayout.astro
│ │
│ ├── pages/
│ │ ├── index.astro # Home (redirects to default locale)
│ │ ├── th/
│ │ │ ├── index.astro
│ │ │ ├── about.astro
│ │ │ ├── contact.astro
│ │ │ ├── privacy-policy.astro
│ │ │ ├── terms-and-conditions.astro
│ │ │ └── blog/
│ │ │ ├── index.astro
│ │ │ └── [slug].astro
│ │ ├── en/
│ │ │ ├── index.astro
│ │ │ ├── about.astro
│ │ │ ├── contact.astro
│ │ │ ├── privacy-policy.astro
│ │ │ ├── terms-and-conditions.astro
│ │ │ └── blog/
│ │ │ ├── index.astro
│ │ │ └── [slug].astro
│ │ └── admin/
│ │ └── consent-logs.astro # Password-protected admin
│ │
│ ├── pages/api/
│ │ └── consent/
│ │ ├── POST.ts # Log consent
│ │ ├── GET.ts # Get consent logs (admin)
│ │ └── [sessionId]/DELETE.ts # Delete consent (right to be forgotten)
│ │
│ ├── styles/
│ │ └── global.css
│ │
│ ├── content/
│ │ ├── blog/
│ │ │ ├── (th)/
│ │ │ │ └── *.md
│ │ │ └── (en)/
│ │ │ └── *.md
│ │ └── config.ts
│ │
│ ├── lib/
│ │ ├── i18n.ts # i18n utilities
│ │ ├── consent.ts # Consent utilities
│ │ └── utils.ts
│ │
│ └── middleware.ts # i18n middleware
├── db/
│ ├── config.ts # Astro DB schema
│ └── seed.ts # Development seed data
├── Dockerfile
├── docker-compose.yml
├── package.json
├── astro.config.mjs
├── tailwind.config.mjs
├── tsconfig.json
├── .env.example
├── .gitignore
├── README.md
├── DEPLOYMENT.md
├── CONTENT-GUIDE.md
└── CHECKLIST.md
```
---
## 🔧 Configuration Files
### astro.config.mjs
```javascript
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import db from '@astrojs/db';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
output: 'hybrid', // Static + server endpoints for API
i18n: {
locales: ['en', 'th'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false, // /about for EN, /th/about for TH
fallbackType: 'rewrite',
},
fallback: {
th: 'en', // Fallback Thai → English
},
},
integrations: [
tailwindcss(),
db(),
sitemap({
i18n: {
defaultLocale: 'en',
},
}),
],
});
```
### db/config.ts (Consent Logging Schema)
```typescript
import { defineDb, defineTable, column } from 'astro:db';
const ConsentLog = defineTable({
columns: {
id: column.number({ primaryKey: true }),
sessionId: column.text({ unique: true }),
timestamp: column.date(),
locale: column.text(), // 'th' | 'en'
essential: column.boolean(),
analytics: column.boolean(),
marketing: column.boolean(),
policyVersion: column.text(),
ipHash: column.text(),
userAgent: column.text(),
},
});
export default defineDb({
tables: { ConsentLog },
});
```
### package.json (Dependencies)
```json
{
"dependencies": {
"astro": "^5.17.1",
"@astrojs/db": "^0.14.0",
"@astrojs/sitemap": "^3.2.0",
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"astro-consent": "^1.0.0",
"drizzle-orm": "^0.38.0",
"@libsql/client": "^0.14.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build --remote",
"preview": "astro preview",
"db:push": "astro db push --remote",
"db:seed": "astro db seed"
}
}
```
---
## 🌐 i18n Implementation
### src/middleware.ts
```typescript
import { defineMiddleware, sequence } from "astro:middleware";
import { middleware } from "astro:i18n";
// Custom middleware (optional - for additional logic)
export const customMiddleware = defineMiddleware(async (ctx, next) => {
const response = await next();
return response;
});
export const onRequest = sequence(
customMiddleware,
middleware({
redirectToDefaultLocale: true,
prefixDefaultLocale: false,
})
);
```
### src/lib/i18n.ts
```typescript
export const languages = {
en: {
name: 'English',
locale: 'en',
},
th: {
name: 'ไทย',
locale: 'th',
},
};
export const defaultLocale = 'en';
export function getLanguageFromLocale(locale: string) {
return languages[locale as keyof typeof languages] || languages.en;
}
```
### src/components/common/LanguageSwitcher.astro
```astro
---
import { getRelativeLocaleUrl } from 'astro:i18n';
import { languages } from '../../lib/i18n';
interface Props {
currentLocale: string;
}
const { currentLocale } = Astro.props;
const currentPath = Astro.url.pathname;
---
<div class="language-switcher">
{Object.values(languages).map((lang) => (
<a
href={getRelativeLocaleUrl(lang.locale, currentPath)}
class:list={['lang-link', lang.locale === currentLocale && 'active']}
lang={lang.locale}
>
{lang.name}
</a>
))}
</div>
<style>
.language-switcher {
display: flex;
gap: 1rem;
}
.lang-link {
opacity: 0.6;
transition: opacity 0.2s;
}
.lang-link.active {
opacity: 1;
font-weight: bold;
}
</style>
```
---
## 🍪 Cookie Consent Implementation
### src/components/consent/CookieBanner.astro
```astro
---
const siteName = "Website Name";
const policyUrl = "/privacy-policy";
---
<div
id="cookie-consent-banner"
class="fixed bottom-0 left-0 right-0 bg-white shadow-lg p-6 z-50 hidden"
data-component="cookie-banner"
>
<div class="container mx-auto max-w-4xl">
<h2 class="text-xl font-bold mb-4">🍪 Cookie Consent</h2>
<p class="mb-6">
We use cookies to improve your experience. By clicking "Accept All",
you consent to our use of cookies.
<a href={policyUrl} class="text-blue-600 underline">Learn more</a>
</p>
<div class="flex gap-4 flex-wrap">
<button
id="consent-reject"
class="px-6 py-3 bg-gray-200 hover:bg-gray-300 rounded"
>
Reject Non-Essential
</button>
<button
id="consent-accept"
class="px-6 py-3 bg-blue-600 text-white hover:bg-blue-700 rounded"
>
Accept All
</button>
<button
id="consent-customize"
class="px-6 py-3 border border-blue-600 text-blue-600 hover:bg-blue-50 rounded"
>
Customize
</button>
</div>
</div>
</div>
<script>
// Cookie consent logic with astro-consent integration
function initCookieBanner() {
const banner = document.getElementById('cookie-consent-banner');
const acceptBtn = document.getElementById('consent-accept');
const rejectBtn = document.getElementById('consent-reject');
const customizeBtn = document.getElementById('consent-customize');
// Check if consent already given
const existingConsent = localStorage.getItem('consent-preferences');
if (!existingConsent) {
banner?.classList.remove('hidden');
}
acceptBtn?.addEventListener('click', () => {
handleConsent({ essential: true, analytics: true, marketing: true });
banner?.classList.add('hidden');
});
rejectBtn?.addEventListener('click', () => {
handleConsent({ essential: true, analytics: false, marketing: false });
banner?.classList.add('hidden');
});
customizeBtn?.addEventListener('click', () => {
// Open preferences modal
const event = new CustomEvent('open-consent-preferences');
window.dispatchEvent(event);
});
async function handleConsent(consent: any) {
// Store in localStorage
localStorage.setItem('consent-preferences', JSON.stringify({
timestamp: new Date().toISOString(),
...consent
}));
// Log to database
const sessionId = crypto.randomUUID();
await fetch('/api/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
locale: document.documentElement.lang,
...consent,
policyVersion: '1.0.0',
}),
});
// Initialize analytics if consented
if (consent.analytics) {
initializeAnalytics();
}
}
function initializeAnalytics() {
// Load Umami tracking script
const script = document.createElement('script');
script.defer = true;
script.src = 'https://analytics.example.com/script.js';
script.setAttribute('data-website-id', import.meta.env.UMAMI_WEBSITE_ID);
document.head.appendChild(script);
}
}
initCookieBanner();
</script>
```
### src/pages/api/consent/POST.ts
```typescript
import type { APIRoute } from 'astro';
import { db, ConsentLog } from 'astro:db';
import { createHash } from 'crypto';
export const POST: APIRoute = async ({ request }) => {
try {
const data = await request.json();
// Validate required fields
const { sessionId, locale, essential, analytics, marketing, policyVersion } = data;
if (!sessionId || !locale) {
return new Response(
JSON.stringify({ error: 'Missing required fields' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Hash IP address for privacy
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const ipHash = createHash('sha256').update(ip).digest('hex').substring(0, 16);
// Insert consent record
await db.insert(ConsentLog).values({
sessionId,
timestamp: new Date(),
locale,
essential: essential || false,
analytics: analytics || false,
marketing: marketing || false,
policyVersion,
ipHash,
userAgent: request.headers.get('user-agent') || '',
});
return new Response(
JSON.stringify({ success: true, sessionId }),
{
status: 201,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('Consent logging error:', error);
return new Response(
JSON.stringify({ error: 'Failed to log consent' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
```
### src/pages/api/consent/[sessionId]/DELETE.ts
```typescript
import type { APIRoute } from 'astro';
import { db, ConsentLog, eq } from 'astro:db';
export const DELETE: APIRoute = async ({ params }) => {
try {
const { sessionId } = params;
if (!sessionId) {
return new Response(
JSON.stringify({ error: 'Session ID required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Delete consent record (right to be forgotten)
const result = await db.delete(ConsentLog).where(
eq(ConsentLog.sessionId, sessionId)
);
return new Response(
JSON.stringify({
success: true,
deleted: result.changes > 0
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('Consent deletion error:', error);
return new Response(
JSON.stringify({ error: 'Failed to delete consent' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
```
### src/pages/admin/consent-logs.astro
```astro
---
// Password-protected admin page for viewing consent logs
import { db, ConsentLog, desc } from 'astro:db';
// Simple password protection (in production, use proper auth)
const ADMIN_PASSWORD = Astro.env.ADMIN_PASSWORD || 'changeme';
let logs = [];
let isAuthenticated = false;
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const password = formData.get('password');
if (password === ADMIN_PASSWORD) {
isAuthenticated = true;
logs = await db.select().from(ConsentLog).orderBy(desc(ConsentLog.timestamp)).limit(100);
}
}
---
<html>
<head>
<title>Consent Logs Admin</title>
</head>
<body>
<div class="container mx-auto p-8">
<h1 class="text-3xl font-bold mb-8">Consent Logs</h1>
{!isAuthenticated ? (
<form method="POST" class="max-w-md">
<label class="block mb-4">
<span class="block text-sm font-medium mb-2">Admin Password</span>
<input
type="password"
name="password"
class="w-full px-4 py-2 border rounded"
required
/>
</label>
<button
type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Login
</button>
</form>
) : (
<div>
<div class="mb-4">
<a href="/admin/consent-logs" class="text-blue-600 underline">Refresh</a>
</div>
<table class="w-full border">
<thead>
<tr class="bg-gray-100">
<th class="p-3 text-left">Date</th>
<th class="p-3 text-left">Locale</th>
<th class="p-3 text-left">Session ID</th>
<th class="p-3 text-left">Essential</th>
<th class="p-3 text-left">Analytics</th>
<th class="p-3 text-left">Marketing</th>
<th class="p-3 text-left">Policy Ver</th>
<th class="p-3 text-left">IP Hash</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr class="border-t">
<td class="p-3">{new Date(log.timestamp).toLocaleString()}</td>
<td class="p-3">{log.locale}</td>
<td class="p-3 font-mono text-sm">{log.sessionId}</td>
<td class="p-3">{log.essential ? '✅' : '❌'}</td>
<td class="p-3">{log.analytics ? '✅' : '❌'}</td>
<td class="p-3">{log.marketing ? '✅' : '❌'}</td>
<td class="p-3">{log.policyVersion}</td>
<td class="p-3 font-mono text-sm">{log.ipHash}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</body>
</html>
```
---
## 📊 Umami Analytics Integration
### Conditional Loading (Based on Consent)
```astro
---
// In BaseLayout.astro
const umamiWebsiteId = Astro.env.UMAMI_WEBSITE_ID;
const umamiDomain = Astro.env.UMAMI_DOMAIN || 'analytics.example.com';
---
<head>
<!-- Other head content -->
<!-- Umami Analytics - Loaded conditionally -->
<script is:inline>
// Check consent before loading
const consent = JSON.parse(localStorage.getItem('consent-preferences') || '{}');
if (consent.analytics) {
const script = document.createElement('script');
script.defer = true;
script.src = 'https://{umamiDomain}/script.js';
script.setAttribute('data-website-id', '{umamiWebsiteId}');
document.head.appendChild(script);
}
</script>
</head>
```
---
## 📄 PDPA-Compliant Privacy Policy
### Structure (Both TH/EN)
```markdown
# Privacy Policy
## 1. Data Controller Information
- Company name, address, contact
- DPO contact (if applicable)
## 2. Types of Data Collected
- Personal data categories
- Collection methods
## 3. Purpose of Data Processing
- Legal basis (consent, legitimate interest, etc.)
- Specific purposes
## 4. Data Retention Period
- How long we keep data
- Deletion criteria
## 5. Data Sharing & Disclosure
- Third parties
- Cross-border transfers
## 6. Cookies & Tracking
- Types of cookies used
- Consent mechanism
## 7. Your Rights (PDPA)
- Right to access
- Right to rectification
- Right to erasure (deletion)
- Right to restrict processing
- Right to data portability
- Right to object
- Right to withdraw consent
## 8. Data Security
- Security measures
- Breach notification
## 9. Contact & Complaints
- How to contact us
- PDPC complaint process
## 10. Policy Updates
- Last updated date
- Version number
```
**Note:** Full template text will be in Thai and English with all PDPA-mandated disclosures.
---
## 🐳 Docker Configuration
### Dockerfile
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/db ./db
# Install SQLite runtime dependencies
RUN apk add --no-cache sqlite-libs
EXPOSE 80
# Set environment variables
ENV NODE_ENV=production
ENV ASTRO_DB_REMOTE_URL=file:/app/data/consent.db
ENV ASTRO_DB_APP_TOKEN=
CMD ["sh", "-c", "mkdir -p /app/data && npx astro preview --host 0.0.0.0 --port 80"]
```
### docker-compose.yml
```yaml
version: '3.8'
services:
website:
build: .
ports:
- "80:80"
environment:
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- UMAMI_DOMAIN=${UMAMI_DOMAIN}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ASTRO_DB_REMOTE_URL=file:/app/data/consent.db
volumes:
- consent-data:/app/data
restart: unless-stopped
volumes:
consent-data:
```
---
## 🎨 Design System
### Typography (from existing SKILL.md)
```css
/* Global styles */
html {
font-size: 18px; /* Base size */
}
@media (min-width: 1280px) {
html { font-size: 20px; }
}
@media (min-width: 1536px) {
html { font-size: 22px; }
}
@media (min-width: 1920px) {
html { font-size: 24px; }
}
```
### Color Scheme
```css
:root {
/* Default colors - customizable per website */
--color-primary: #2563eb;
--color-secondary: #1e40af;
--color-accent: #f59e0b;
/* Neutral */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
}
```
---
## 📝 Content Collections
### src/content/config.ts
```typescript
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
updatedDate: z.date().optional(),
heroImage: image().optional(),
locale: z.enum(['en', 'th']),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
}),
});
export const collections = {
blog: blogCollection,
};
```
---
## 🗂️ Environment Variables
### .env.example
```bash
# Umami Analytics
UMAMI_WEBSITE_ID=your-website-id-here
UMAMI_DOMAIN=analytics.example.com
# Admin
ADMIN_PASSWORD=change-this-secure-password
# Database (for production)
ASTRO_DB_REMOTE_URL=libsql://your-db.turso.io
ASTRO_DB_APP_TOKEN=your-turso-token
# Site Configuration
SITE_URL=https://example.com
SITE_NAME="Example Website"
```
---
## 🚀 Generation Workflow
### Python Script CLI
```bash
python3 create_astro_website.py \
--name "Deal Plus Tech" \
--type "corporate" \
--languages "th,en" \
--primary-color "#2563eb" \
--secondary-color "#1e40af" \
--features "blog,products,contact" \
--umami-id "xxx-xxx-xxx" \
--output "./dealplustech-website"
```
### Script Responsibilities
1. **Validate input** (name, languages, features)
2. **Create folder structure** (copy templates)
3. **Generate configs** (astro.config.mjs, package.json)
4. **Create i18n pages** (TH/EN versions)
5. **Generate legal pages** (Privacy Policy, Terms)
6. **Setup database** (db/config.ts, seed.ts)
7. **Create components** (Header, Footer, Consent)
8. **Add Docker files** (Dockerfile, docker-compose.yml)
9. **Generate documentation** (README, DEPLOYMENT, etc.)
10. **Initialize Git repo** (optional)
---
## ✅ Quality Assurance
### Pre-deployment Checklist
- [ ] All pages render without errors
- [ ] i18n routing works (TH/EN switch)
- [ ] Cookie banner appears on first visit
- [ ] Consent is logged to database
- [ ] Umami loads only with consent
- [ ] Admin page accessible with password
- [ ] Data deletion works (right to be forgotten)
- [ ] Docker build succeeds
- [ ] All TypeScript types correct
- [ ] Lighthouse score > 90
### PDPA Compliance Checklist
- [ ] Privacy Policy contains all 12+ disclosures
- [ ] Cookie consent is opt-in (not pre-ticked)
- [ ] Granular consent choices (essential/analytics/marketing)
- [ ] Consent withdrawal as easy as acceptance
- [ ] Consent logs stored with timestamp
- [ ] Data deletion mechanism exists
- [ ] Policy version tracking implemented
- [ ] Thai language available (or bilingual)
---
## 🔄 Refactoring Existing Websites
### Migration Script
```bash
python3 refactor_existing_website.py \
--input "./dealplustech-astro" \
--output "./dealplustech-astro-refactored" \
--add-features "i18n,consent,umami" \
--languages "th,en"
```
### Migration Steps
1. **Backup existing content** (blog posts, products)
2. **Create new structure** (standardized folders)
3. **Migrate content** (copy to new locations)
4. **Add i18n routing** (split TH/EN)
5. **Integrate consent** (add components, API)
6. **Add Umami** (conditional loading)
7. **Update Dockerfile** (for Astro DB)
8. **Test thoroughly** (all features)
---
## 📊 Success Metrics
- **Consistency:** Every website has identical structure
- **Compliance:** 100% PDPA compliant
- **Maintainability:** Easy to update all websites simultaneously
- **Performance:** Lighthouse score > 90
- **Developer Experience:** Generate new website in < 5 minutes
---
**END OF SPECIFICATION**