# 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**
