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 +21 -0
- package/README.md +412 -0
- package/package.json +38 -0
- package/src/core/file-processor.js +264 -0
- package/src/index.js +50 -0
- package/src/templates/metadata-injector.js +191 -0
- package/src/utils/config.js +65 -0
- package/src/utils/paths.js +48 -0
- package/src/utils/template-injector.js +82 -0
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
|
+
[](https://www.npmjs.com/package/weweb-dynamic-metadata)
|
|
5
|
+
[](https://www.npmjs.com/package/weweb-dynamic-metadata)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](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
|
+
}
|