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

23 KiB

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

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',
      },
    }),
  ],
});
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)

{
  "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

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

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

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

src/components/consent/CookieBanner.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

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

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

---
// 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

---
// 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)

# 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

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

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)

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

: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

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

# 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

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

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