weweb-dynamic-metadata 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Melina Reisinger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,412 @@
1
+ # weweb-dynamic-metadata
2
+ ⭐ Build-time SEO metadata generator for WeWeb static exports
3
+
4
+ [![npm version](https://img.shields.io/npm/v/weweb-dynamic-metadata)](https://www.npmjs.com/package/weweb-dynamic-metadata)
5
+ [![npm downloads](https://img.shields.io/npm/dm/weweb-dynamic-metadata)](https://www.npmjs.com/package/weweb-dynamic-metadata)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![GitHub stars](https://img.shields.io/github/stars/Mel000000/weweb-dynamic-metadata?style=social)](https://github.com/Mel000000/weweb-dynamic-metadata)
8
+
9
+ A build-time tool that generates unique SEO metadata for each dynamic page in your WeWeb project. Zero runtime overhead, perfect SEO, and completely free.
10
+
11
+ ---
12
+
13
+ ## Table of Contents
14
+
15
+ - [Overview](#overview)
16
+ - [Why This Package Matters](#why-this-package-matters)
17
+ - [Who This Is For](#who-this-is-for)
18
+ - [Why This Project Exists](#why-this-project-exists)
19
+ - [Usage Example](#usage-example)
20
+ - [Architecture](#architecture)
21
+ - [Features](#features)
22
+ - [Quick Start](#quick-start)
23
+ - [Prerequisites Checklist](#prerequisites-checklist)
24
+ - [Setup](#setup)
25
+ - [1. Configure Supabase](#1-configure-supabase)
26
+ - [2. Create Config File](#2-create-config-file)
27
+ - [3. Run the Generator](#3-run-the-generator)
28
+ - [How It Works](#how-it-works)
29
+ - [1. Reads Your Config](#1-reads-your-config)
30
+ - [2. Discovers Content IDs](#2-discovers-content-ids)
31
+ - [3. Fetches Metadata](#3-fetches-metadata)
32
+ - [4. Generates Central Metadata](#4-generates-central-metadata)
33
+ - [5. Injects Script into Template](#5-injects-script-into-template)
34
+ - [6. Creates Reference Files](#6-creates-reference-files)
35
+ - [Project Folder Transformation](#project-folder-transformation)
36
+ - [Output Summary](#output-summary)
37
+ - [Programmatic Usage](#programmatic-usage)
38
+ - [Why Not Cloudflare Workers?](#why-not-cloudflare-workers)
39
+ - [Troubleshooting](#troubleshooting)
40
+ - [License](#license)
41
+ ---
42
+
43
+ ## Overview
44
+
45
+ WeWeb exports static HTML files where all dynamic routes (like `/article/1`, `/article/2`) share the **exact same HTML template** with identical metadata. This is terrible for SEO - every article page looks identical to search engines.
46
+
47
+ This package solves that by generating a **central metadata file** and **tiny reference HTML files** that point to your main template. The result? Each article page gets its own unique metadata while maintaining a single source of truth.
48
+
49
+ ### Repository Structure
50
+ - **📦 This Package**: The npm package that does the metadata generation
51
+ - **📦 Your WeWeb Project**: Where you install and run it
52
+
53
+ ### Key challenges addressed:
54
+ - Complete generation in ~1 second for 100 articles
55
+ - Zero runtime overhead - all metadata pre-generated
56
+ - Tiny footprint - only ~500 bytes per article
57
+
58
+ ---
59
+
60
+ ## Why This Package Matters
61
+
62
+ WeWeb currently has **no built-in solution** for dynamic SEO metadata. Every dynamic page shares the same HTML template, making SEO optimization impossible.
63
+
64
+ This package provides a **production-tested solution** that:
65
+ - Gives each page unique metadata
66
+ - Requires no serverless functions
67
+ - Works with any hosting platform
68
+ - Preserves all WeWeb dynamic functionality
69
+
70
+ ---
71
+
72
+ ## Who This Is For
73
+
74
+ This package is ideal for:
75
+
76
+ - WeWeb users needing SEO for dynamic pages
77
+ - Developers deploying WeWeb to any static host
78
+ - Projects requiring unique metadata per page
79
+ - Teams wanting zero-latency SEO solution
80
+
81
+ This package may NOT be ideal if:
82
+
83
+ - Your content changes every minute (use server-side instead)
84
+ - You can't run build scripts in your deployment
85
+ - You need real-time metadata updates
86
+
87
+ ---
88
+
89
+ ## Why This Project Exists
90
+
91
+ WeWeb's dynamic pages share HTML templates, making unique metadata impossible. Official solutions require Cloudflare Workers (cost, complexity, latency).
92
+
93
+ This project provides a **simpler, cheaper, faster alternative**:
94
+
95
+ - **Zero runtime costs** - Everything runs at build time
96
+ - **Perfect SEO** - Instant HTML for crawlers
97
+ - **Dead simple** - Just a config file and one command
98
+ - **Works everywhere** - Any static hosting works
99
+
100
+ ---
101
+
102
+ ## Usage Example
103
+
104
+ 1. Export your WeWeb project (creates `dist/` folder)
105
+ 2. Create `weweb.config.js` in your project root
106
+ 3. Run `npx weweb-dynamic-metadata`
107
+ 4. Deploy anywhere - each article now has unique metadata!
108
+
109
+ ```bash
110
+ # One-time setup
111
+ npm install --save-dev weweb-dynamic-metadata
112
+
113
+ # Generate metadata (run after each WeWeb export)
114
+ npx weweb-dynamic-metadata
115
+
116
+ # That's it! Your articles now have unique SEO metadata
117
+ ```
118
+
119
+ ## Features
120
+
121
+ - 🚀 **Zero Runtime Overhead**: All metadata pre-generated at build time
122
+ - 📦 **Tiny Footprint**: Only ~500 bytes per article (reference files)
123
+ - 🎯 **Perfect SEO**: Each page gets unique titles, descriptions, and Open Graph tags
124
+ - 🔧 **Simple Setup**: Just add a config file and run
125
+ - 💸 **Completely Free**: No Cloudflare Workers, no serverless costs
126
+ - 🌍 **Works Everywhere**: Deploy to any static hosting (Netlify, Vercel, GitHub Pages, S3, etc.)
127
+ - ⚡ **Fast**: Generates 1000 articles in ~3 seconds
128
+ - 🔗 **Commit Traceability**: Optional git-info.js injection for deploy visibility
129
+
130
+ ## Quick Start
131
+ ```bash
132
+ # 1. Install the package
133
+ npm install --save-dev weweb-dynamic-metadata
134
+
135
+ # 2. Create weweb.config.js in your project root
136
+ # (see Setup section below)
137
+
138
+ # 3. Run it!
139
+ npx weweb-dynamic-metadata
140
+
141
+ # Done! Your articles now have unique metadata
142
+ ```
143
+ ## Getting Started
144
+
145
+ ### Prerequisites Checklist
146
+ Before using this package, ensure you have:
147
+ - A WeWeb project exported to static files (has ``article/_param/index.html``)
148
+ - Node.js 18 or higher installed
149
+ - A Supabase project with your content
150
+ - Your Supabase URL and anon key ready
151
+ - Your WeWeb build folder (usually ``dist/`` or project root)
152
+
153
+ ### Setup
154
+ #### 1. Configure Supabase
155
+ Create a view in your Supabase database for optimal performance:
156
+ ```sql
157
+ -- Create a view for article metadata
158
+ CREATE VIEW article_metadata AS
159
+ SELECT
160
+ id,
161
+ title,
162
+ LEFT(content, 160) AS excerpt, -- First 160 chars for descriptions
163
+ image_url
164
+ FROM articles;
165
+
166
+ -- Enable public read access
167
+ ALTER VIEW article_metadata ENABLE ROW LEVEL SECURITY;
168
+
169
+ CREATE POLICY "Allow public read access"
170
+ ON article_metadata
171
+ FOR SELECT
172
+ TO anon
173
+ USING (true);
174
+ ```
175
+ #### 2. Create Config File
176
+ Create ``weweb.config.js`` in your project root:
177
+ ```javascript
178
+ export default {
179
+ // Your Supabase configuration
180
+ supabase: {
181
+ url: process.env.SUPABASE_URL,
182
+ anonKey: process.env.SUPABASE_ANON_KEY
183
+ },
184
+
185
+ // Optional: Specify your build folder (defaults to ./dist)
186
+ outputDir: "./dist",
187
+
188
+ // Define your dynamic routes
189
+ pages: [
190
+ {
191
+ route: "/article/:id",
192
+ table: "articles", // Your Supabase table name
193
+ metadata: {
194
+ title: "title", // Database field for title
195
+ content: "excerpt", // Database field for description
196
+ image: "featured_image" // Database field for image
197
+ }
198
+ }
199
+ ]
200
+ };
201
+ ```
202
+ #### 3. Run the Generator
203
+ ```bash
204
+ # One-time generation
205
+ npx weweb-dynamic-metadata
206
+
207
+ # Add to your package.json scripts
208
+ {
209
+ "scripts": {
210
+ "build:metadata": "weweb-dynamic-metadata",
211
+ "build": "weweb export && weweb-dynamic-metadata"
212
+ }
213
+ }
214
+ ```
215
+
216
+ ## How It Works
217
+
218
+ ### 1. Reads Your Config
219
+ The package reads ``weweb.config.js`` from your project root to understand your Supabase connection and dynamic routes.
220
+
221
+ ### 2. Discovers Content IDs
222
+ Fetches all IDs from your Supabase table to know which articles need metadata.
223
+
224
+ ### 3. Fetches Metadata
225
+ For each ID, retrieves the metadata fields you specified (title, content, image, etc.).
226
+
227
+ ### 4. Generates Central Metadata
228
+ Creates a central JavaScript file with all metadata:
229
+ ```javascript
230
+ window.METADATA = {
231
+ "1": { title: "Article 1", content: "...", image: "..." },
232
+ "2": { title: "Article 2", content: "...", image: "..." },
233
+ // ... one entry per article
234
+ };
235
+ ```
236
+
237
+ ### 5. Injects Script into Template
238
+ Adds the metadata injector script to your ``your-page-name/_param/index.html`` template.
239
+
240
+ ### 6. Creates Reference Files
241
+ Generates tiny HTML files for each article that load the template and pass the article ID:
242
+ ```html
243
+ <!-- article/1/index.html - only ~500 bytes! -->
244
+ <script>
245
+ window.CURRENT_ARTICLE_ID = "1";
246
+ window.location.replace('../_param/index.html#1');
247
+ </script>
248
+ ```
249
+
250
+ ## Architecture
251
+
252
+ ```mermaid
253
+ flowchart TD
254
+ %% Styles
255
+ classDef setup fill:#2da44e,stroke:#1a7f37,color:#ffffff
256
+ classDef core fill:#0969da,stroke:#0550ae,color:#ffffff
257
+ classDef output fill:#8250df,stroke:#6639ba,color:#ffffff
258
+ classDef runtime fill:#f66a0a,stroke:#bf4e00,color:#ffffff
259
+ classDef note fill:#fff,stroke:#6e7781,color:#24292f,stroke-width:1px,stroke-dasharray:3 3
260
+
261
+ subgraph Setup ["🟢 User Setup (One Time)"]
262
+ A["User exports WeWeb project<br/>creates static files"] --> B
263
+ B["User creates weweb.config.js<br/>in project root"] --> C
264
+ C["Configure:<br/>• Supabase URL & anonKey<br/>• Dynamic routes (/article/:id)<br/>• Table & metadata fields<br/>• Optional: outputDir"] --> D
265
+ D["Save config file"]
266
+ end
267
+ class A,B,C,D setup
268
+
269
+ subgraph Build ["🔵 Build Time - Metadata Generation"]
270
+ direction TB
271
+ E["Run: npx weweb-dynamic-metadata"] --> F
272
+ F["Read weweb.config.js"] --> G
273
+ G["Validate configuration"] --> I["For each dynamic route:<br/>e.g., /article/:id"]
274
+
275
+ I --> J["Discover content IDs<br/>GET /rest/v1/table?select=id"]
276
+ J --> K["IDs: [1, 2, 3, ...]"]
277
+
278
+ K --> L["Fetch metadata for each ID<br/>GET /rest/v1/table?id=eq.{id}"]
279
+ L --> M["Build central metadata object<br/>{1: {...}, 2: {...}, ...}"]
280
+
281
+ M --> N["Generate article/metadata.js<br/>window.METADATA = {...}"]
282
+
283
+ N --> O["Locate WeWeb template<br/>article/_param/index.html"]
284
+ O --> P["Inject metadata script into template"]
285
+
286
+ P --> Q["For each ID, create reference file:<br/>article/1/index.html<br/>article/2/index.html<br/>..."]
287
+
288
+ Q --> R["Copy metadata.js to _param/<br/>for backward compatibility"]
289
+ end
290
+ class E,F,G,I,J,K,L,M,N,O,P,Q,R core
291
+
292
+ subgraph Output ["🟣 Generated Output"]
293
+ S["📁 article/<br/>├── metadata.js<br/>├── _param/<br/>│ ├── index.html (modified)<br/>│ └── metadata.js<br/>├── 1/<br/>│ ├── index.html (reference)<br/>│ └── metadata.js<br/>├── 2/<br/>│ ├── index.html (reference)<br/>│ └── metadata.js<br/>└── ..."]
294
+ end
295
+ class S output
296
+
297
+ subgraph Runtime ["🟠 Runtime - Browser"]
298
+ T["User visits /article/2"] --> U
299
+ U["Browser loads article/2/index.html<br/>(tiny reference file)"] --> V
300
+ V["JavaScript sets window.CURRENT_ARTICLE_ID=2<br/>and loads _param/index.html"] --> W
301
+ W["Main template loads with ID=2"] --> X
302
+ X["Metadata injector reads window.METADATA[2]<br/>and updates page metadata"] --> Y
303
+ Y["Page displays with correct title,<br/>description, Open Graph tags"]
304
+ end
305
+ class T,U,V,W,X,Y runtime
306
+
307
+ %% Connections
308
+ D ==> E
309
+ R ==> S
310
+ S ==> T
311
+
312
+ %% Legend
313
+ L1["🟢 User Setup: One-time configuration"]:::note
314
+ L2["🔵 Build Time: Generates metadata + reference files"]:::note
315
+ L3["🟣 Output: Generated files ready for deployment"]:::note
316
+ L4["🟠 Runtime: Browser loads and applies metadata"]:::note
317
+ L5["⚙️ weweb.config.js: Central configuration file"]:::note
318
+
319
+ L1 ~~~ L2 ~~~ L3 ~~~ L4 ~~~ L5
320
+ ```
321
+ ## Project Folder Transformation
322
+
323
+ ### Before: WeWeb Export (No Metadata)
324
+ ```text
325
+ dist/ (or your build folder)
326
+ ├── your-page-name/
327
+ │ └── _param/
328
+ │ └── index.html # Same for ALL articles!
329
+ ├── assets/
330
+ ├── index.html
331
+ └── ... # Sitemap
332
+ ```
333
+
334
+ **The Problem**: Every article at `/your-page-name/1`, `/your-page-name/2`, etc. serves the EXACT same HTML file with identical metadata.
335
+
336
+ ---
337
+ ### After: Package Runs
338
+ ```
339
+ dist/
340
+ ├── your-page-name/
341
+ │ ├── metadata.js # Central metadata for all articles
342
+ │ ├── _param/
343
+ │ │ ├── index.html # Original template (script injected)
344
+ │ │ └── metadata.js # Copy for compatibility
345
+ │ ├── 1/
346
+ │ │ ├── index.html # Tiny reference file (~500 bytes)
347
+ │ │ └── metadata.js # Points to central metadata
348
+ │ ├── 2/
349
+ │ │ ├── index.html # Tiny reference file
350
+ │ │ └── metadata.js # Points to central metadata
351
+ │ └── ...
352
+ ├── assets/
353
+ └── index.html
354
+
355
+ ```
356
+ **The Solution**: Each article now has its own unique metadata while sharing the same template!
357
+
358
+ ## Output Summary
359
+
360
+ After running, you'll get a JSON summary:
361
+ ```json
362
+ {
363
+ "timestamp": "2024-03-14T20:49:28.825Z",
364
+ "pages": [
365
+ {
366
+ "route": "/article/:id",
367
+ "total": 150,
368
+ "succeeded": 150,
369
+ "failed": 0,
370
+ "metadataEntries": 150,
371
+ "referencesCreated": 150
372
+ }
373
+ ],
374
+ "totalMetadataEntries": 150,
375
+ "outputDirectories": ["dist/article"],
376
+ "duration": "2.34"
377
+ }
378
+ ```
379
+ ## Programmatic Usage
380
+
381
+ ```javascript
382
+ import { processFiles } from 'weweb-dynamic-metadata';
383
+
384
+ const result = await processFiles();
385
+ console.log(`Generated ${result.totalMetadataEntries} metadata entries`);
386
+ console.log(`Took ${result.duration} seconds`);
387
+ ```
388
+ ## Why Not Cloudflare Workers?
389
+
390
+ | Approach | This Package | Cloudflare Worker |
391
+ |----------|--------------|-------------------|
392
+ | **Runtime** | None (pre-generated) | Each request |
393
+ | **Latency** | 0ms | +100-300ms |
394
+ | **Cost** | Free | Pay per request |
395
+ | **SEO** | Perfect - instant HTML | Crawlers might timeout |
396
+ | **Complexity** | Simple config | Worker deployment |
397
+ | **Scaling** | Infinite (static files) | Worker limits |
398
+ | **Cold starts** | None | Possible |
399
+
400
+ ## Troubleshooting
401
+
402
+ | Issue | Likely Cause | Solution |
403
+ |-------|--------------|----------|
404
+ | `getOutputDir: Could not find build folder` | No WeWeb export found | Run `weweb export` first or set `outputDir` in config |
405
+ | `Config error: Invalid or unexpected token` | BOM characters in config | Recreate file without BOM (use VSCode "Save with Encoding → UTF-8") |
406
+ | No metadata generated | Supabase connection issue | Check your Supabase URL and anon key |
407
+ | `Failed to fetch ID` | Table or field names wrong | Verify table and field names in config |
408
+ | Reference files not created | Permission issues | Check write permissions in build folder |
409
+
410
+ ## License
411
+
412
+ This project is licensed under the MIT License. See the LICENSE file for details.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "weweb-dynamic-metadata",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Generate dynamic metadata for WeWeb static exports",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "weweb-metadata": "src/index.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node test/run.js"
17
+ },
18
+ "dependencies": {
19
+ "fs-extra": "^11.3.4",
20
+ "dotenv": "^17.3.1"
21
+ },
22
+ "keywords": [
23
+ "weweb",
24
+ "metadata",
25
+ "seo",
26
+ "static-site"
27
+ ],
28
+ "author": "Melina Reisinger",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Mel000000/weweb-dynamic-metadata.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/Mel000000/weweb-dynamic-metadata/issues"
36
+ },
37
+ "homepage": "https://github.com/Mel000000/weweb-dynamic-metadata#readme"
38
+ }
@@ -0,0 +1,264 @@
1
+ import { fileURLToPath } from 'url';
2
+ import { getConfigPath, getOutputDir, getRoutePaths } from '../utils/paths.js';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import { injectScriptInTemplate } from "../utils/template-injector.js";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ // ========== CONFIG LOADING ==========
10
+ async function readConfig() {
11
+ try {
12
+ const configPath = getConfigPath();
13
+ const configUrl = new URL(`file://${configPath.replace(/\\/g, '/')}`);
14
+ const configModule = await import(configUrl.href);
15
+ const config = configModule.default || configModule;
16
+
17
+ const { supabase, pages, outputDir } = config;
18
+
19
+ return {
20
+ supabase,
21
+ outputDir, // Pass this through
22
+ pages: pages.map(page => ({
23
+ route: page.route,
24
+ table: page.table,
25
+ metadataEndpoint: `${supabase.url}/rest/v1/${page.table}?id=eq.{id}&select=${Object.values(page.metadata).join(',')}`,
26
+ headers: {
27
+ 'apikey': supabase.anonKey,
28
+ 'Authorization': `Bearer ${supabase.anonKey}`
29
+ },
30
+ metadataFields: Object.values(page.metadata)
31
+ }))
32
+ };
33
+ } catch (error) {
34
+ throw new Error(`Config error: ${error.message}`);
35
+ }
36
+ }
37
+
38
+ // ========== ID DISCOVERY ==========
39
+ async function discoverIds(page) {
40
+ try {
41
+ const url = page.metadataEndpoint.split("?id")[0];
42
+ const res = await fetch(url, { headers: page.headers });
43
+ const data = await res.json();
44
+ return data.map(item => item.id).filter(Boolean);
45
+ } catch (error) {
46
+ throw new Error(`ID discovery failed: ${error.message}`);
47
+ }
48
+ }
49
+
50
+ // ========== METADATA FETCHING ==========
51
+ async function fetchMetadata(page, id) {
52
+ try {
53
+ const url = page.metadataEndpoint.replace('{id}', id || "1");
54
+ const res = await fetch(url, { headers: page.headers });
55
+ const data = await res.json();
56
+ return Array.isArray(data) ? data[0] : data;
57
+ } catch (error) {
58
+ throw new Error(`Failed to fetch ID ${id}: ${error.message}`);
59
+ }
60
+ }
61
+
62
+ // ========== HELPER FUNCTIONS ==========
63
+ function generateMetadataJs(metadataObject) {
64
+ return `// Metadata generated on ${new Date().toISOString()}
65
+ window.METADATA = ${JSON.stringify(metadataObject, null, 2)};`;
66
+ }
67
+
68
+ async function updateTemplateScriptSrc(templatePath, newSrc) {
69
+ try {
70
+ let content = await fs.readFile(templatePath, 'utf-8');
71
+ content = content.replace(
72
+ /<script src="\/article\/metadata\.js"><\/script>/,
73
+ `<script src="${newSrc}"></script>`
74
+ );
75
+ await fs.writeFile(templatePath, content, 'utf-8');
76
+ } catch (error) {
77
+ // Silently fail - this is non-critical
78
+ }
79
+ }
80
+
81
+ async function ensureTemplateExists(templatePath) {
82
+ if (await fs.pathExists(templatePath)) return;
83
+
84
+ const minimalTemplate = `<!DOCTYPE html>
85
+ <html lang="en">
86
+ <head>
87
+ <meta charset="utf-8">
88
+ <meta name="viewport" content="width=device-width, initial-scale=1">
89
+ <base href="/">
90
+ <title></title>
91
+ </head>
92
+ <body>
93
+ <div id="app"></div>
94
+ <script type="module" src="/assets/main.js"></script>
95
+ </body>
96
+ </html>`;
97
+ await fs.writeFile(templatePath, minimalTemplate, 'utf-8');
98
+ }
99
+
100
+ // ========== FIXED: Original working reference HTML ==========
101
+ function generateReferenceHtml(id, title, relativeTemplatePath) {
102
+ return `<!DOCTYPE html>
103
+ <html>
104
+ <head>
105
+ <meta charset="utf-8">
106
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
107
+ <meta name="viewport" content="width=device-width, initial-scale=1">
108
+ <title>${title || 'Loading...'}</title>
109
+ <!-- Load the template dynamically -->
110
+ <script>
111
+ (function() {
112
+ // Store the ID in a global variable for the template to use
113
+ window.CURRENT_ARTICLE_ID = ${JSON.stringify(String(id))};
114
+
115
+ // Fetch the template and replace the current document
116
+ fetch('${relativeTemplatePath}')
117
+ .then(response => response.text())
118
+ .then(html => {
119
+ // Write the template HTML
120
+ document.open();
121
+ document.write(html);
122
+ document.close();
123
+ })
124
+ .catch(error => {
125
+ console.error('Failed to load template:', error);
126
+ document.body.innerHTML = '<h1>Error loading article</h1><p>Please refresh the page</p>';
127
+ });
128
+ })();
129
+ </script>
130
+
131
+ <!-- Fallback for noscript -->
132
+ <noscript>
133
+ <meta http-equiv="refresh" content="0; url=${relativeTemplatePath}?id=${id}">
134
+ </noscript>
135
+ </head>
136
+ <body>
137
+ <p>Loading article ${id}... <a href="${relativeTemplatePath}?id=${id}">Click here if not redirected</a></p>
138
+ </body>
139
+ </html>`;
140
+ }
141
+
142
+ // ========== MAIN PROCESSOR ==========
143
+ export async function processFiles() {
144
+ const startTime = Date.now();
145
+
146
+ try {
147
+ const config = await readConfig();
148
+ const summary = {
149
+ timestamp: new Date().toISOString(),
150
+ pages: [],
151
+ totalMetadataEntries: 0,
152
+ outputDirectories: [],
153
+ duration: 0
154
+ };
155
+
156
+ for (let i = 0; i < config.pages.length; i++) {
157
+ const page = config.pages[i];
158
+
159
+ // Discover IDs
160
+ const ids = await discoverIds(page);
161
+
162
+ // Setup paths
163
+ const routeName = page.route.split('/')[1];
164
+ const baseDir = config.outputDir || getOutputDir();
165
+
166
+ const paramDir = path.join(baseDir, routeName, '_param');
167
+ const articleRootDir = path.join(baseDir, routeName);
168
+ const templatePath = path.join(paramDir, 'index.html');
169
+
170
+ await fs.ensureDir(paramDir);
171
+ await fs.ensureDir(articleRootDir);
172
+
173
+ // Ensure template exists and inject script
174
+ await ensureTemplateExists(templatePath);
175
+ await injectScriptInTemplate(templatePath);
176
+
177
+ // Fetch all metadata
178
+ const metadataMap = new Map();
179
+ let successCount = 0;
180
+ let failCount = 0;
181
+
182
+ for (const id of ids) {
183
+ try {
184
+ const metadata = await fetchMetadata(page, id);
185
+ metadataMap.set(String(id), metadata);
186
+ successCount++;
187
+ } catch (error) {
188
+ failCount++;
189
+ }
190
+ }
191
+
192
+ // Write metadata.js
193
+ const metadataObj = Object.fromEntries(metadataMap);
194
+ const metadataJsPath = path.join(articleRootDir, 'metadata.js');
195
+ await fs.writeFile(metadataJsPath, generateMetadataJs(metadataObj));
196
+
197
+ // Copy to _param for compatibility
198
+ await fs.copyFile(metadataJsPath, path.join(paramDir, 'metadata.js'));
199
+
200
+ // Update template script src
201
+ await updateTemplateScriptSrc(templatePath, '/article/metadata.js');
202
+
203
+ // Create reference files using the ORIGINAL working logic
204
+ const relativeTemplatePath = '../_param/index.html';
205
+ let referencesCreated = 0;
206
+
207
+ for (const [id, metadata] of metadataMap.entries()) {
208
+ try {
209
+ const articleDir = path.join(baseDir, routeName, id);
210
+ await fs.ensureDir(articleDir);
211
+
212
+ await fs.writeFile(
213
+ path.join(articleDir, 'index.html'),
214
+ generateReferenceHtml(id, metadata.title, relativeTemplatePath)
215
+ );
216
+
217
+ // Create a small metadata.js reference
218
+ const articleMetadataJsPath = path.join(articleDir, 'metadata.js');
219
+ const metadataReference = `// Reference to central metadata file
220
+ // This file points to the main metadata.js
221
+ // The actual metadata is loaded from /article/metadata.js`;
222
+
223
+ await fs.writeFile(articleMetadataJsPath, metadataReference, 'utf-8');
224
+
225
+ referencesCreated++;
226
+ } catch (error) {
227
+ // Silently continue
228
+ }
229
+ }
230
+
231
+ summary.pages.push({
232
+ route: page.route,
233
+ total: ids.length,
234
+ succeeded: successCount,
235
+ failed: failCount,
236
+ metadataEntries: metadataMap.size,
237
+ referencesCreated
238
+ });
239
+
240
+ summary.totalMetadataEntries += metadataMap.size;
241
+ summary.outputDirectories.push(articleRootDir);
242
+ }
243
+
244
+ summary.duration = ((Date.now() - startTime) / 1000).toFixed(2);
245
+
246
+ return summary;
247
+
248
+ } catch (error) {
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ // Run if called directly
254
+ if (import.meta.url === `file://${process.argv[1]}`) {
255
+ processFiles()
256
+ .then(summary => {
257
+ console.log(JSON.stringify(summary, null, 2));
258
+ process.exit(0);
259
+ })
260
+ .catch(error => {
261
+ console.error(JSON.stringify({ error: error.message }));
262
+ process.exit(1);
263
+ });
264
+ }
package/src/index.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { processFiles } from "./core/file-processor.js";
3
+ import dotenv from 'dotenv';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ // Load environment variables from project root
10
+ dotenv.config({ path: path.join(__dirname, '..', '.env') });
11
+
12
+ // Simple console colors
13
+ const colors = {
14
+ reset: '\x1b[0m',
15
+ green: '\x1b[32m',
16
+ red: '\x1b[31m',
17
+ yellow: '\x1b[33m',
18
+ cyan: '\x1b[36m',
19
+ magenta: '\x1b[35m',
20
+ blue: '\x1b[34m'
21
+ };
22
+
23
+ // Helper to safely use colors
24
+ function colorize(text, color) {
25
+ return colors[color] ? `${colors[color]}${text}${colors.reset}` : text;
26
+ }
27
+
28
+ console.log(`${colorize('🚀 WeWeb Dynamic Metadata Generator', 'cyan')}\n`);
29
+
30
+ export { processFiles };
31
+ // Handle the promise properly
32
+ if (import.meta.url === `file://${process.argv[1]}`) {
33
+ processFiles()
34
+ .then(result => {
35
+ console.log(`\n${colorize('╔' + '═'.repeat(48) + '╗', 'magenta')}`);
36
+ console.log(`${colorize('║', 'magenta')}${colorize('🎉 GENERATION COMPLETE'.padEnd(48), 'green')}${colorize('║', 'magenta')}`);
37
+ console.log(`${colorize('╟' + '─'.repeat(48) + '╢', 'magenta')}`);
38
+ console.log(`${colorize('║', 'magenta')} ⏱️ Duration: ${result.duration}s`.padEnd(50) + `${colorize('║', 'magenta')}`);
39
+ console.log(`${colorize('║', 'magenta')} 📊 Total entries: ${result.totalMetadataEntries}`.padEnd(50) + `${colorize('║', 'magenta')}`);
40
+ console.log(`${colorize('║', 'magenta')} 📁 Output: ${path.basename(result.outputDirectories[0])}`.padEnd(50) + `${colorize('║', 'magenta')}`);
41
+ console.log(`${colorize('╚' + '═'.repeat(48) + '╝', 'magenta')}\n`);
42
+
43
+ process.exit(0);
44
+ })
45
+ .catch(error => {
46
+ console.error(`\n${colorize('❌ Generation failed:', 'red')}`, error.message);
47
+ console.error(error.stack);
48
+ process.exit(1);
49
+ });
50
+ }
@@ -0,0 +1,191 @@
1
+ // src/templates/metadata-injector.js
2
+ export const METADATA_INJECTOR_SCRIPT = `
3
+ <!-- METADATA INJECTOR -->
4
+ <script src="/article/metadata.js"></script>
5
+
6
+ <script>
7
+ (function() {
8
+ 'use strict';
9
+
10
+ // Prevent duplicate execution
11
+ if (window.__METADATA_INJECTOR_LOADED) {
12
+ console.log('🔄 Metadata injector already loaded, skipping...');
13
+ return;
14
+ }
15
+ window.__METADATA_INJECTOR_LOADED = true;
16
+
17
+ console.log('🚀 Metadata injector starting...');
18
+
19
+ // Configuration
20
+ const CONFIG = {
21
+ DEBUG: true,
22
+ META_TIMEOUT: 2000
23
+ };
24
+
25
+ // State
26
+ let currentMetadata = null;
27
+ let appliedId = null;
28
+
29
+ // Debug logging
30
+ function debugLog(...args) {
31
+ if (CONFIG.DEBUG) console.log('[Metadata]', ...args);
32
+ }
33
+
34
+ // Get ID from URL
35
+ function getArticleId() {
36
+ const path = window.location.pathname;
37
+ const parts = path.split('/').filter(p => p.length);
38
+
39
+ // Case 1: /article/2
40
+ if (parts[0] === 'article' && parts[1] && parts[1] !== '_param') {
41
+ return parts[1];
42
+ }
43
+
44
+ // Case 2: /article/_param/?id=2
45
+ if (parts[0] === 'article' && parts[1] === '_param') {
46
+ return new URLSearchParams(window.location.search).get('id');
47
+ }
48
+
49
+ // Case 3: Reference mode (ID stored in global)
50
+ return window.__REFERENCE_ARTICLE_ID;
51
+ }
52
+
53
+ // Update meta tags
54
+ function setMeta(attr, name, value) {
55
+ if (!value) return;
56
+ let el = document.querySelector(\`meta[\${attr}="\${name}"]\`);
57
+ if (!el) {
58
+ el = document.createElement('meta');
59
+ el.setAttribute(attr, name);
60
+ document.head.appendChild(el);
61
+ }
62
+ el.setAttribute('content', String(value).replace(/[<>]/g, ''));
63
+ }
64
+
65
+ // Apply metadata
66
+ function applyMetadata() {
67
+ const id = getArticleId();
68
+ if (!id || !window.METADATA) return false;
69
+
70
+ const meta = window.METADATA[id];
71
+ if (!meta) {
72
+ console.warn('⚠️ No metadata for ID:', id);
73
+ return false;
74
+ }
75
+
76
+ // Don't reapply if same ID
77
+ if (id === appliedId) return true;
78
+
79
+ debugLog('Applying metadata for ID:', id);
80
+
81
+ // Set title - FIXED: Don't remove the title tag, just update it
82
+ if (meta.title) {
83
+ // Find existing title tag or create new one
84
+ let titleTag = document.querySelector('title');
85
+ if (!titleTag) {
86
+ titleTag = document.createElement('title');
87
+ document.head.appendChild(titleTag);
88
+ }
89
+ titleTag.textContent = meta.title;
90
+ // Also set document.title for good measure
91
+ document.title = meta.title;
92
+ }
93
+
94
+ // Set description
95
+ const desc = meta.content || meta.description;
96
+ if (desc) setMeta('name', 'description', desc);
97
+
98
+ // Open Graph
99
+ if (meta.title) setMeta('property', 'og:title', meta.title);
100
+ if (desc) setMeta('property', 'og:description', desc);
101
+ setMeta('property', 'og:url', window.location.href.split('?')[0]);
102
+
103
+ // Image
104
+ const img = meta.image_url || meta.image;
105
+ if (img) {
106
+ setMeta('property', 'og:image', img);
107
+ setMeta('name', 'twitter:image', img);
108
+ setMeta('name', 'twitter:card', 'summary_large_image');
109
+ } else {
110
+ setMeta('name', 'twitter:card', 'summary');
111
+ }
112
+
113
+ // Twitter
114
+ if (meta.title) setMeta('name', 'twitter:title', meta.title);
115
+ if (desc) setMeta('name', 'twitter:description', desc);
116
+
117
+ // Canonical
118
+ let canonical = document.querySelector('link[rel="canonical"]');
119
+ if (!canonical) {
120
+ canonical = document.createElement('link');
121
+ canonical.setAttribute('rel', 'canonical');
122
+ document.head.appendChild(canonical);
123
+ }
124
+ canonical.setAttribute('href', window.location.href.split('?')[0]);
125
+
126
+ // Structured data - FIXED: Don't remove if we don't have data
127
+ if (meta.title || desc || img) {
128
+ // Remove existing JSON-LD
129
+ document.querySelectorAll('script[type="application/ld+json"]').forEach(el => el.remove());
130
+
131
+ const ldJson = {
132
+ '@context': 'https://schema.org',
133
+ '@type': 'Article',
134
+ headline: meta.title || '',
135
+ description: desc || '',
136
+ image: img || '',
137
+ url: window.location.href.split('?')[0]
138
+ };
139
+ const script = document.createElement('script');
140
+ script.type = 'application/ld+json';
141
+ script.textContent = JSON.stringify(ldJson);
142
+ document.head.appendChild(script);
143
+ }
144
+
145
+ currentMetadata = meta;
146
+ appliedId = id;
147
+
148
+ console.log('✅ Metadata applied for', id);
149
+ return true;
150
+ }
151
+
152
+ // Initialize
153
+ function init() {
154
+ if (applyMetadata()) return;
155
+
156
+ // Wait for metadata if not loaded
157
+ if (!window.METADATA) {
158
+ const timeout = setTimeout(() => {
159
+ console.log('⏰ Metadata timeout');
160
+ clearInterval(checkInterval);
161
+ }, CONFIG.META_TIMEOUT);
162
+
163
+ const checkInterval = setInterval(() => {
164
+ if (window.METADATA && applyMetadata()) {
165
+ clearInterval(checkInterval);
166
+ clearTimeout(timeout);
167
+ }
168
+ }, 50);
169
+ }
170
+ }
171
+
172
+ // Start immediately
173
+ if (document.readyState === 'loading') {
174
+ document.addEventListener('DOMContentLoaded', init);
175
+ } else {
176
+ init();
177
+ }
178
+
179
+ // Handle SPA navigation
180
+ let lastUrl = location.href;
181
+ setInterval(() => {
182
+ if (location.href !== lastUrl) {
183
+ lastUrl = location.href;
184
+ appliedId = null;
185
+ init();
186
+ }
187
+ }, 500);
188
+
189
+ })();
190
+ </script>
191
+ `;
@@ -0,0 +1,65 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { logger } from './logger.js';
4
+
5
+ const CONFIG_SCHEMA = {
6
+ supabase: { required: true, type: 'object' },
7
+ pages: { required: true, type: 'array', min: 1 }
8
+ };
9
+
10
+ export async function loadConfig() {
11
+ const configPath = path.join(process.cwd(), 'weweb.config.js');
12
+
13
+ if (!await fs.pathExists(configPath)) {
14
+ throw new Error(`Config not found: ${configPath}`);
15
+ }
16
+
17
+ try {
18
+ const config = (await import(`file://${configPath}`)).default;
19
+ return formatConfig(config);
20
+ } catch (error) {
21
+ throw new Error(`Invalid config: ${error.message}`);
22
+ }
23
+ }
24
+
25
+ export function validateConfig(config) {
26
+ const errors = [];
27
+
28
+ for (const [key, rules] of Object.entries(CONFIG_SCHEMA)) {
29
+ if (rules.required && !config[key]) {
30
+ errors.push(`Missing required field: ${key}`);
31
+ }
32
+ }
33
+
34
+ if (!Array.isArray(config.pages) || config.pages.length === 0) {
35
+ errors.push('At least one page required');
36
+ }
37
+
38
+ config.pages.forEach((page, i) => {
39
+ if (!page.route) errors.push(`Page ${i}: missing route`);
40
+ if (!page.table) errors.push(`Page ${i}: missing table`);
41
+ if (!page.metadata) errors.push(`Page ${i}: missing metadata fields`);
42
+ });
43
+
44
+ if (errors.length > 0) {
45
+ throw new Error(`Config validation failed:\n${errors.join('\n')}`);
46
+ }
47
+
48
+ return true;
49
+ }
50
+
51
+ function formatConfig(config) {
52
+ return {
53
+ supabase: config.supabase,
54
+ pages: config.pages.map(page => ({
55
+ route: page.route,
56
+ table: page.table,
57
+ metadataEndpoint: `${config.supabase.url}/rest/v1/${page.table}?id=eq.{id}&select=${Object.values(page.metadata).join(',')}`,
58
+ headers: {
59
+ 'apikey': config.supabase.anonKey,
60
+ 'Authorization': `Bearer ${config.supabase.anonKey}`
61
+ },
62
+ metadataFields: Object.values(page.metadata)
63
+ }))
64
+ };
65
+ }
@@ -0,0 +1,48 @@
1
+ // src/utils/paths.js
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+
5
+ export function getConfigPath() {
6
+ return path.join(process.cwd(), 'weweb.config.js');
7
+ }
8
+
9
+ export function getOutputDir() {
10
+ const projectRoot = process.cwd();
11
+
12
+ // First, check if 'dist' exists (production build)
13
+ const distPath = path.join(projectRoot, 'dist');
14
+ if (fs.existsSync(distPath)) {
15
+ return distPath;
16
+ }
17
+
18
+ // Check common WeWeb export locations
19
+ const possiblePaths = [
20
+ path.join(projectRoot, 'out'), // WeWeb default export
21
+ path.join(projectRoot, 'build'), // Common build folder
22
+ path.join(projectRoot, 'www'), // Another common one
23
+ path.join(projectRoot, 'public'), // Static folder
24
+ ];
25
+
26
+ for (const testPath of possiblePaths) {
27
+ if (fs.existsSync(testPath)) {
28
+ return testPath;
29
+ }
30
+ }
31
+
32
+ // If no existing build found, ask user to specify
33
+ throw new Error(
34
+ 'Could not find your WeWeb build folder.\n' +
35
+ 'Please ensure you have run "weweb export" first,\n' +
36
+ 'or specify the path in weweb.config.js'
37
+ );
38
+ }
39
+
40
+ export function getRoutePaths(baseDir, route) {
41
+ const routeName = route.split('/')[1];
42
+ return {
43
+ paramDir: path.join(baseDir, routeName, '_param'),
44
+ articleRoot: path.join(baseDir, routeName),
45
+ templatePath: path.join(baseDir, routeName, '_param', 'index.html'),
46
+ metadataPath: path.join(baseDir, routeName, 'metadata.js')
47
+ };
48
+ }
@@ -0,0 +1,82 @@
1
+ // src/utils/template-injector.js
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { METADATA_INJECTOR_SCRIPT } from '../templates/metadata-injector.js';
5
+
6
+ export async function injectScriptInTemplate(templatePath) {
7
+ try {
8
+ // Check if file exists
9
+ if (!await fs.pathExists(templatePath)) {
10
+ console.log(`⚠️ Template not found: ${templatePath}`);
11
+ return false;
12
+ }
13
+
14
+ // Read template
15
+ let template = await fs.readFile(templatePath, 'utf-8');
16
+
17
+ // Check if script is already injected using multiple markers
18
+ const hasInjector =
19
+ template.includes('__METADATA_INJECTOR_LOADED') ||
20
+ template.includes('METADATA INJECTOR') ||
21
+ (template.includes('/article/metadata.js') && template.includes('applyMetadata'));
22
+
23
+ if (hasInjector) {
24
+ console.log(`⏭️ Metadata injector already present in: ${templatePath}`);
25
+
26
+ // Optional: Remove duplicates if they exist
27
+ const cleanedTemplate = removeDuplicateInjectors(template);
28
+ if (cleanedTemplate !== template) {
29
+ await fs.writeFile(templatePath, cleanedTemplate, 'utf-8');
30
+ console.log(`🧹 Cleaned up duplicate injectors in: ${templatePath}`);
31
+ }
32
+
33
+ return true;
34
+ }
35
+
36
+ // Insert script before </head>
37
+ template = template.replace('</head>', METADATA_INJECTOR_SCRIPT + '\n</head>');
38
+
39
+ // Write back
40
+ await fs.writeFile(templatePath, template, 'utf-8');
41
+ console.log(`✅ Injected metadata script into: ${templatePath}`);
42
+
43
+ return true;
44
+
45
+ } catch (error) {
46
+ console.error(`❌ Failed to inject script:`, error.message);
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // Helper function to remove duplicate injectors
52
+ function removeDuplicateInjectors(html) {
53
+ // Count occurrences of the injector marker
54
+ const markerCount = (html.match(/__METADATA_INJECTOR_LOADED/g) || []).length;
55
+
56
+ if (markerCount <= 1) return html;
57
+
58
+ console.log(`🧹 Found ${markerCount} duplicate injectors, cleaning up...`);
59
+
60
+ // Split by the injector and keep only the first one
61
+ const parts = html.split('<!-- METADATA INJECTOR -->');
62
+
63
+ if (parts.length > 2) {
64
+ // Keep first injector, remove others
65
+ return parts[0] + '<!-- METADATA INJECTOR -->' + parts[1].split('</script>')[0] + '</script>\n' + parts.slice(2).join('').replace(/<script[\s\S]*?<\/script>/g, '');
66
+ }
67
+
68
+ return html;
69
+ }
70
+
71
+ // Optional: Function to check if injector exists without modifying
72
+ export async function hasMetadataInjector(templatePath) {
73
+ try {
74
+ if (!await fs.pathExists(templatePath)) return false;
75
+
76
+ const template = await fs.readFile(templatePath, 'utf-8');
77
+ return template.includes('__METADATA_INJECTOR_LOADED') ||
78
+ template.includes('METADATA INJECTOR');
79
+ } catch {
80
+ return false;
81
+ }
82
+ }