@frontlit/preview 0.0.0-stage → 0.1.0-alpha.2
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 +135 -2
- package/cli.mjs +37 -0
- package/client/index.html +16 -0
- package/client/main.jsx +93 -0
- package/client/preview.css +45 -0
- package/package.json +37 -4
- package/server.mjs +115 -0
- package/site.mjs +145 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FrontLit
|
|
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
CHANGED
|
@@ -1,3 +1,136 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frontlit/preview
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See your FrontLit pages in the browser while you edit them.
|
|
4
|
+
|
|
5
|
+
If your website's pages live in a Git repository as JSON files (a `frontlit/` folder with `pages/*.json` and `site.json`), this tool shows them on your computer the way FrontLit draws them: same blocks, theme, fonts, header and footer. Save a file and the browser updates by itself. You don't need a FrontLit account, a login or an internet connection to your site.
|
|
6
|
+
|
|
7
|
+
## Before you start
|
|
8
|
+
|
|
9
|
+
You need [Node.js](https://nodejs.org) **20.19 or newer** (22.12+ also works). Check with:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
node --version
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
1. Open a terminal in your repository (the folder that contains `frontlit/`).
|
|
18
|
+
2. Run:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npx @frontlit/preview@alpha
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The first run downloads the tool, which takes a few seconds.
|
|
25
|
+
3. Open **http://127.0.0.1:4310** in your browser.
|
|
26
|
+
4. Edit any file in `frontlit/` and save. The page reloads with your change.
|
|
27
|
+
|
|
28
|
+
Press **Ctrl+C** in the terminal to stop.
|
|
29
|
+
|
|
30
|
+
> **Why `@alpha`?** The tool is in early release. `@alpha` always gets the newest version.
|
|
31
|
+
|
|
32
|
+
## Using the preview
|
|
33
|
+
|
|
34
|
+
- **Switch pages** with the drop-down at the top. Each file in `pages/` is one page: `index.json` is `/`, `pricing.json` is `/pricing`. Pages marked `"published": false` show too, so you can check drafts.
|
|
35
|
+
- **Light and dark mode:** use the button in the top bar.
|
|
36
|
+
- **Phone and tablet sizes:** use your browser's device mode (in Chrome: right-click → *Inspect* → the phone icon, then pick a width such as 390 px).
|
|
37
|
+
- **Mistakes are fine.** If a file has invalid JSON or a setting FrontLit doesn't accept, the page shows the error instead. Fix it, save, and the page comes back.
|
|
38
|
+
|
|
39
|
+
## Show your site name and logo
|
|
40
|
+
|
|
41
|
+
The header shows "Local preview" until you tell the tool your site's name. Your name and logo are stored in your FrontLit settings rather than the repository, so the tool can't find them alone.
|
|
42
|
+
|
|
43
|
+
Create `frontlit/preview.context.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"branding": {
|
|
48
|
+
"title": "Acme",
|
|
49
|
+
"subtitle": "Invoices that chase themselves",
|
|
50
|
+
"logo": { "url": "assets/logo.svg", "alt": "Acme" }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `title` is your site name, the same one set in FrontLit.
|
|
56
|
+
- `subtitle` and `logo` are optional. The logo path is relative to this file, so `assets/logo.svg` means `frontlit/assets/logo.svg`.
|
|
57
|
+
|
|
58
|
+
This file is only for your computer. Add it to `.gitignore` so it isn't committed:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
echo "frontlit/preview.context.json" >> .gitignore
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Optional: sample posts for blog-style pages
|
|
65
|
+
|
|
66
|
+
Pages with a content feed (for example a blog listing) normally show articles from FrontLit. Locally they show a placeholder box. To see real-looking entries, add sample items for that page's address to the same file:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"branding": { "title": "Acme" },
|
|
71
|
+
"feeds": {
|
|
72
|
+
"/blog": [
|
|
73
|
+
{ "title": "Our first post", "href": "/", "description": "A short summary." }
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Options
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
npx @frontlit/preview@alpha [site-folder] [--port <number>] [--context <file>]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Option | What it does | Default |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `site-folder` | The folder that has `pages/` in it. Use `.` if your pages are at the repository root. | `frontlit` |
|
|
88
|
+
| `--port` | Serve on another port, if 4310 is busy. | `4310` |
|
|
89
|
+
| `--context` | Use a branding file from somewhere else. | `<site-folder>/preview.context.json` |
|
|
90
|
+
| `--help` | Print the usage line. | |
|
|
91
|
+
|
|
92
|
+
Example: `npx @frontlit/preview@alpha website --port 5000`.
|
|
93
|
+
|
|
94
|
+
## Images
|
|
95
|
+
|
|
96
|
+
Put images in your repository (for example `frontlit/assets/`) and point to them from page JSON:
|
|
97
|
+
|
|
98
|
+
- **Relative paths** start from the JSON file: from `pages/index.json`, use `../assets/hero.webp`.
|
|
99
|
+
- **Paths starting with `/`** start from the repository root: `/frontlit/assets/hero.webp`.
|
|
100
|
+
- **`https://` links** load from the web as usual.
|
|
101
|
+
|
|
102
|
+
Only image files inside your repository are shown (`.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.gif`, `.svg`).
|
|
103
|
+
|
|
104
|
+
**Embed snippets are different.** The tool doesn't change URLs written inside an embed's HTML. Use a full `https://` URL there, or `/__frontlit/assets/frontlit/assets/<file>` for an image in your repository.
|
|
105
|
+
|
|
106
|
+
## What it doesn't show
|
|
107
|
+
|
|
108
|
+
This tool shows your pages, not everything your live site does:
|
|
109
|
+
|
|
110
|
+
- Blog posts, docs pages and other content stored in FrontLit (only the pages in `pages/` exist)
|
|
111
|
+
- Redirects from `site.json`
|
|
112
|
+
- Search and the chat assistant
|
|
113
|
+
- Real form submissions: forms only show their success message
|
|
114
|
+
- FrontLit's production image optimization
|
|
115
|
+
|
|
116
|
+
Before you publish, open your pull request on GitHub and check the **FrontLit / pages preview** link. That's the real thing, built the same way as your live site.
|
|
117
|
+
|
|
118
|
+
## Troubleshooting
|
|
119
|
+
|
|
120
|
+
| Message or problem | Fix |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `No pages directory in …` | Run the command from your repository, or pass the folder that contains `pages/`, e.g. `npx @frontlit/preview@alpha website`. |
|
|
123
|
+
| Port 4310 already in use | Another preview is probably still running. Stop it with Ctrl+C, or use `--port 4311`. |
|
|
124
|
+
| `Page not found` | There is no `pages/<name>.json` for that address. Check the file name. Names use lowercase letters, numbers and hyphens. |
|
|
125
|
+
| An image doesn't show | Check the path is relative to the JSON file, and that the file is inside the repository and is an image. |
|
|
126
|
+
| The header says "Local preview" | Add `preview.context.json` with your site name (see above). |
|
|
127
|
+
| `Unsupported engine` or a syntax error on start | Update Node.js to 20.19 or newer. |
|
|
128
|
+
|
|
129
|
+
## For FrontLit contributors
|
|
130
|
+
|
|
131
|
+
The package ships its CLI (`cli.mjs`), server (`server.mjs`, `site.mjs`) and Vite client (`client/`) as source, so it has no build step. It renders with `@frontlit/page-builder`'s client renderer, `schema` and `theme` entry points.
|
|
132
|
+
|
|
133
|
+
- Run from the monorepo: build `@frontlit/text-editor` and `@frontlit/page-builder`, then `bun packages/preview/cli.mjs <site-folder>`.
|
|
134
|
+
- Tests: `bun run --cwd packages/preview test`.
|
|
135
|
+
- Once installed from npm, the client runs from `node_modules`. The server therefore pre-bundles the JSX runtime and allows page-builder's package folder for its fonts. Test packaging changes from a packed tarball: `bun pm pack`, then `npm install <tarball>` in an empty folder. A workspace run alone isn't enough.
|
|
136
|
+
- The context file can also hold `site` (the same shape as `site.json`; `site.json` values win) and `themeId` (a bundled theme preset, used when there is no theme object).
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startPreview } from "./server.mjs";
|
|
3
|
+
const usage =
|
|
4
|
+
"Usage: frontlit-preview [site-folder] [--port 4310] [--context snapshot.json]";
|
|
5
|
+
try {
|
|
6
|
+
let folder,
|
|
7
|
+
port = 4310,
|
|
8
|
+
context;
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
11
|
+
console.log(usage);
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
if (args[i] === "--port") {
|
|
16
|
+
port = Number(args[++i]);
|
|
17
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
18
|
+
throw new Error("Invalid port");
|
|
19
|
+
} else if (args[i] === "--context") {
|
|
20
|
+
context = args[++i];
|
|
21
|
+
if (!context || context.startsWith("--"))
|
|
22
|
+
throw new Error("--context requires a file");
|
|
23
|
+
} else if (args[i].startsWith("-") || folder) throw new Error(usage);
|
|
24
|
+
else folder = args[i];
|
|
25
|
+
}
|
|
26
|
+
const server = await startPreview(folder ?? "frontlit", { port, context });
|
|
27
|
+
console.log(`FrontLit local preview — ${folder ?? "frontlit"}`);
|
|
28
|
+
server.printUrls();
|
|
29
|
+
for (const signal of ["SIGINT", "SIGTERM"])
|
|
30
|
+
process.once(signal, async () => {
|
|
31
|
+
await server.close();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(error.message);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<meta name="robots" content="noindex,nofollow">
|
|
8
|
+
<title>FrontLit preview</title>
|
|
9
|
+
</head>
|
|
10
|
+
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/main.jsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
|
|
16
|
+
</html>
|
package/client/main.jsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { PageRenderer } from "@frontlit/page-builder/renderer";
|
|
4
|
+
import { resolveTheme } from "@frontlit/page-builder/theme";
|
|
5
|
+
import "@frontlit/page-builder/styles.css";
|
|
6
|
+
import "./preview.css";
|
|
7
|
+
|
|
8
|
+
function App({ data }) {
|
|
9
|
+
const [mode, setMode] = useState(
|
|
10
|
+
localStorage.getItem("frontlit-preview-mode") ?? "light",
|
|
11
|
+
);
|
|
12
|
+
if (data.error)
|
|
13
|
+
return (
|
|
14
|
+
<main className="preview-error">
|
|
15
|
+
<h1>Cannot render this page</h1>
|
|
16
|
+
<pre>{data.error}</pre>
|
|
17
|
+
<p>Fix the source file; the preview will reload.</p>
|
|
18
|
+
<a href="/">Home</a>
|
|
19
|
+
</main>
|
|
20
|
+
);
|
|
21
|
+
const theme = resolveTheme(data.themeId, data.themeStyle);
|
|
22
|
+
const change = (next) => {
|
|
23
|
+
const resolved =
|
|
24
|
+
next === "system"
|
|
25
|
+
? matchMedia("(prefers-color-scheme: dark)").matches
|
|
26
|
+
? "dark"
|
|
27
|
+
: "light"
|
|
28
|
+
: next;
|
|
29
|
+
localStorage.setItem("frontlit-preview-mode", resolved);
|
|
30
|
+
setMode(resolved);
|
|
31
|
+
};
|
|
32
|
+
const fixtures = data.feeds[location.pathname.replace(/\/$/, "") || "/"];
|
|
33
|
+
const slots = {
|
|
34
|
+
"content-feed": (
|
|
35
|
+
<section className="preview-feed">
|
|
36
|
+
<h2>{fixtures ? "Local feed fixture" : "Content feed — no local fixture"}</h2>
|
|
37
|
+
{fixtures?.map((entry, i) => (
|
|
38
|
+
<article key={i}>
|
|
39
|
+
<a href={entry.href}>{entry.title}</a>
|
|
40
|
+
<p>{entry.description}</p>
|
|
41
|
+
</article>
|
|
42
|
+
))}
|
|
43
|
+
{!fixtures && (
|
|
44
|
+
<p>
|
|
45
|
+
Add this route to feeds in preview.context.json. FrontLit-hosted content is
|
|
46
|
+
not fetched automatically.
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
</section>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<nav className="preview-toolbar" aria-label="Local preview controls">
|
|
55
|
+
<strong>Local preview</strong>
|
|
56
|
+
<select
|
|
57
|
+
aria-label="Page"
|
|
58
|
+
value={location.pathname}
|
|
59
|
+
onChange={(e) => location.assign(e.target.value)}
|
|
60
|
+
>
|
|
61
|
+
{data.pages.map((p) => (
|
|
62
|
+
<option key={p.path} value={p.path}>
|
|
63
|
+
{p.name}
|
|
64
|
+
</option>
|
|
65
|
+
))}
|
|
66
|
+
</select>
|
|
67
|
+
<button onClick={() => change(mode === "light" ? "dark" : "light")}>
|
|
68
|
+
{mode === "light" ? "Dark" : "Light"} mode
|
|
69
|
+
</button>
|
|
70
|
+
{data.missingBranding && <span>Set branding in preview.context.json</span>}
|
|
71
|
+
</nav>
|
|
72
|
+
<PageRenderer
|
|
73
|
+
layout={data.layout}
|
|
74
|
+
theme={theme}
|
|
75
|
+
pageData={data.pageData}
|
|
76
|
+
dataSlots={slots}
|
|
77
|
+
nextTheme={mode}
|
|
78
|
+
onThemeModeChange={change}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
fetch("/__frontlit/data?path=" + encodeURIComponent(location.pathname))
|
|
84
|
+
.then((r) => r.json())
|
|
85
|
+
.then((data) => {
|
|
86
|
+
document.title = (data.title ?? "FrontLit") + " · local preview";
|
|
87
|
+
createRoot(document.getElementById("root")).render(<App data={data} />);
|
|
88
|
+
})
|
|
89
|
+
.catch((error) => {
|
|
90
|
+
createRoot(document.getElementById("root")).render(
|
|
91
|
+
<App data={{ error: error.message }} />,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
html,
|
|
2
|
+
body,
|
|
3
|
+
#root {
|
|
4
|
+
margin: 0;
|
|
5
|
+
min-height: 100%;
|
|
6
|
+
}
|
|
7
|
+
.preview-toolbar {
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: 12px;
|
|
11
|
+
padding: 10px 16px;
|
|
12
|
+
flex-wrap: wrap;
|
|
13
|
+
background: #fff;
|
|
14
|
+
color: #18181b;
|
|
15
|
+
border-bottom: 1px solid #ddd;
|
|
16
|
+
font:
|
|
17
|
+
13px system-ui,
|
|
18
|
+
sans-serif;
|
|
19
|
+
}
|
|
20
|
+
.preview-toolbar button,
|
|
21
|
+
.preview-toolbar select {
|
|
22
|
+
padding: 6px 10px;
|
|
23
|
+
border: 1px solid #aaa;
|
|
24
|
+
border-radius: 4px;
|
|
25
|
+
background: white;
|
|
26
|
+
color: #18181b;
|
|
27
|
+
}
|
|
28
|
+
.preview-error {
|
|
29
|
+
padding: 24px;
|
|
30
|
+
font:
|
|
31
|
+
16px system-ui,
|
|
32
|
+
sans-serif;
|
|
33
|
+
}
|
|
34
|
+
.preview-error pre {
|
|
35
|
+
white-space: pre-wrap;
|
|
36
|
+
overflow-wrap: anywhere;
|
|
37
|
+
}
|
|
38
|
+
.preview-feed {
|
|
39
|
+
margin: 24px;
|
|
40
|
+
padding: 24px;
|
|
41
|
+
border: 1px dashed currentColor;
|
|
42
|
+
}
|
|
43
|
+
.preview-feed a {
|
|
44
|
+
text-decoration: underline;
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontlit/preview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
|
+
"description": "Local, file-watching previews for FrontLit page JSON",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"frontlit-preview": "cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"cli.mjs",
|
|
12
|
+
"server.mjs",
|
|
13
|
+
"site.mjs",
|
|
14
|
+
"client",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/codelitdev/frontlit.git",
|
|
27
|
+
"directory": "packages/preview"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test *.test.mjs",
|
|
31
|
+
"check-types": "node --check cli.mjs && node --check server.mjs && node --check site.mjs"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@frontlit/page-builder": "0.1.0-alpha.4",
|
|
35
|
+
"react": "19.2.8",
|
|
36
|
+
"react-dom": "19.2.8",
|
|
37
|
+
"vite": "^7.3.6"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createServer, searchForWorkspaceRoot } from "vite";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { existsSync, createReadStream } from "node:fs";
|
|
5
|
+
import { loadSite, localAsset, repositoryRoot } from "./site.mjs";
|
|
6
|
+
|
|
7
|
+
export async function startPreview(folder, { port = 4310, context } = {}) {
|
|
8
|
+
folder = resolve(folder);
|
|
9
|
+
if (!existsSync(join(folder, "pages")))
|
|
10
|
+
throw new Error(`No pages directory in ${folder}`);
|
|
11
|
+
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const root = repositoryRoot(folder);
|
|
13
|
+
const contextFile = context ? resolve(context) : join(folder, "preview.context.json");
|
|
14
|
+
if (context && !existsSync(contextFile))
|
|
15
|
+
throw new Error(`Context file not found: ${contextFile}`);
|
|
16
|
+
const server = await createServer({
|
|
17
|
+
configFile: false,
|
|
18
|
+
envFile: false,
|
|
19
|
+
root: join(packageRoot, "client"),
|
|
20
|
+
appType: "spa",
|
|
21
|
+
resolve: { dedupe: ["react", "react-dom"] },
|
|
22
|
+
// Installed from npm, the client lives in node_modules, where Vite does not
|
|
23
|
+
// discover the automatic JSX runtime; it would be served as raw CommonJS.
|
|
24
|
+
optimizeDeps: { include: ["react/jsx-dev-runtime"] },
|
|
25
|
+
esbuild: { jsx: "automatic" },
|
|
26
|
+
server: {
|
|
27
|
+
host: "127.0.0.1",
|
|
28
|
+
port,
|
|
29
|
+
strictPort: true,
|
|
30
|
+
// Fonts referenced by page-builder's stylesheet live outside this package
|
|
31
|
+
// when installed from npm.
|
|
32
|
+
fs: {
|
|
33
|
+
allow: [
|
|
34
|
+
searchForWorkspaceRoot(packageRoot),
|
|
35
|
+
dirname(
|
|
36
|
+
fileURLToPath(import.meta.resolve("@frontlit/page-builder/styles.css")),
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
plugins: [
|
|
42
|
+
{
|
|
43
|
+
name: "frontlit-local-files",
|
|
44
|
+
configureServer(vite) {
|
|
45
|
+
vite.watcher.add([folder, contextFile]);
|
|
46
|
+
let timer;
|
|
47
|
+
const refresh = (file) => {
|
|
48
|
+
if (file === contextFile || file.startsWith(folder + "/")) {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
timer = setTimeout(() => vite.ws.send({ type: "full-reload" }), 80);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
vite.watcher.on("all", (_, file) => refresh(file));
|
|
54
|
+
vite.httpServer?.once("close", () => clearTimeout(timer));
|
|
55
|
+
vite.middlewares.use((req, res, next) => {
|
|
56
|
+
const url = new URL(req.url, "http://localhost");
|
|
57
|
+
if (url.pathname === "/__frontlit/data") {
|
|
58
|
+
res.setHeader("Content-Type", "application/json");
|
|
59
|
+
res.setHeader("Cache-Control", "no-store");
|
|
60
|
+
try {
|
|
61
|
+
res.end(
|
|
62
|
+
JSON.stringify(
|
|
63
|
+
loadSite(folder, url.searchParams.get("path") ?? "/", contextFile),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
res.statusCode = 400;
|
|
68
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (url.pathname.startsWith("/__frontlit/assets/")) {
|
|
73
|
+
try {
|
|
74
|
+
const file = localAsset(
|
|
75
|
+
root,
|
|
76
|
+
decodeURIComponent(url.pathname.slice("/__frontlit/assets/".length)),
|
|
77
|
+
);
|
|
78
|
+
const mime = {
|
|
79
|
+
".svg": "image/svg+xml",
|
|
80
|
+
".png": "image/png",
|
|
81
|
+
".jpg": "image/jpeg",
|
|
82
|
+
".jpeg": "image/jpeg",
|
|
83
|
+
".webp": "image/webp",
|
|
84
|
+
".avif": "image/avif",
|
|
85
|
+
".gif": "image/gif",
|
|
86
|
+
};
|
|
87
|
+
res.setHeader("Cache-Control", "no-store");
|
|
88
|
+
res.setHeader(
|
|
89
|
+
"Content-Type",
|
|
90
|
+
mime[file.slice(file.lastIndexOf(".")).toLowerCase()],
|
|
91
|
+
);
|
|
92
|
+
createReadStream(file)
|
|
93
|
+
.on("error", () => res.destroy())
|
|
94
|
+
.pipe(res);
|
|
95
|
+
} catch {
|
|
96
|
+
res.statusCode = 404;
|
|
97
|
+
res.end("Image not found");
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
next();
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
await server.listen();
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Vite's file watcher would otherwise keep the process alive.
|
|
111
|
+
await server.close();
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
return server;
|
|
115
|
+
}
|
package/site.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve, relative, extname, sep, join } from "node:path";
|
|
3
|
+
import { parsePageFile, parseSiteFile } from "@frontlit/page-builder/schema";
|
|
4
|
+
|
|
5
|
+
const imageTypes = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif", ".svg"]);
|
|
6
|
+
export function repositoryRoot(folder) {
|
|
7
|
+
for (let dir = folder; ; dir = dirname(dir)) {
|
|
8
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
9
|
+
if (dirname(dir) === dir) return dirname(folder);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function localAsset(root, path) {
|
|
13
|
+
const file = realpathSync(resolve(root, path));
|
|
14
|
+
const rel = relative(realpathSync(root), file);
|
|
15
|
+
if (
|
|
16
|
+
rel === ".." ||
|
|
17
|
+
rel.startsWith(`..${sep}`) ||
|
|
18
|
+
!imageTypes.has(extname(file).toLowerCase())
|
|
19
|
+
)
|
|
20
|
+
throw new Error("Asset must be an image inside the repository.");
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
function media(value, owner, root) {
|
|
24
|
+
if (Array.isArray(value)) return value.map((v) => media(v, owner, root));
|
|
25
|
+
if (!value || typeof value !== "object") return value;
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.entries(value).map(([key, v]) => {
|
|
28
|
+
if (key === "url" && typeof v === "string" && !/^https:\/\//i.test(v)) {
|
|
29
|
+
const file = localAsset(
|
|
30
|
+
root,
|
|
31
|
+
v.startsWith("/") ? v.slice(1) : relative(root, resolve(dirname(owner), v)),
|
|
32
|
+
);
|
|
33
|
+
return [
|
|
34
|
+
key,
|
|
35
|
+
"/__frontlit/assets/" +
|
|
36
|
+
relative(root, file).split(sep).map(encodeURIComponent).join("/"),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
return [key, media(v, owner, root)];
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
// The client renders fixtures directly, so reject shapes it would crash on.
|
|
44
|
+
function feedFixtures(feeds, contextFile) {
|
|
45
|
+
if (feeds === undefined) return {};
|
|
46
|
+
const fail = (where) => {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${contextFile}: ${where} must be an array of { "title", "href", "description" } strings, keyed by route.`,
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
if (!feeds || typeof feeds !== "object" || Array.isArray(feeds)) fail("feeds");
|
|
52
|
+
for (const [route, entries] of Object.entries(feeds)) {
|
|
53
|
+
if (!Array.isArray(entries)) fail(`feeds["${route}"]`);
|
|
54
|
+
entries.forEach((entry, i) => {
|
|
55
|
+
if (
|
|
56
|
+
!entry ||
|
|
57
|
+
typeof entry !== "object" ||
|
|
58
|
+
Array.isArray(entry) ||
|
|
59
|
+
["title", "href", "description"].some(
|
|
60
|
+
(key) => entry[key] !== undefined && typeof entry[key] !== "string",
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
fail(`feeds["${route}"][${i}]`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return feeds;
|
|
67
|
+
}
|
|
68
|
+
export function loadSite(
|
|
69
|
+
folder,
|
|
70
|
+
pathname = "/",
|
|
71
|
+
contextFile = join(folder, "preview.context.json"),
|
|
72
|
+
) {
|
|
73
|
+
const root = repositoryRoot(folder);
|
|
74
|
+
const slug = decodeURIComponent(pathname).replace(/^\/+|\/+$/g, "");
|
|
75
|
+
if (slug && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
|
|
76
|
+
throw new Error(
|
|
77
|
+
`No local page for ${pathname}. Only pages/*.json routes are previewed.`,
|
|
78
|
+
);
|
|
79
|
+
const file = join(folder, "pages", `${slug || "index"}.json`);
|
|
80
|
+
if (!existsSync(file)) throw new Error(`Page not found: ${file}`);
|
|
81
|
+
const context = existsSync(contextFile)
|
|
82
|
+
? JSON.parse(readFileSync(contextFile, "utf8"))
|
|
83
|
+
: {};
|
|
84
|
+
const siteFile = join(folder, "site.json");
|
|
85
|
+
const site = existsSync(siteFile)
|
|
86
|
+
? parseSiteFile(readFileSync(siteFile, "utf8"))
|
|
87
|
+
: {};
|
|
88
|
+
const fallback = parseSiteFile(context.site ?? {});
|
|
89
|
+
const effective = { ...fallback, ...site };
|
|
90
|
+
const page = parsePageFile(readFileSync(file, "utf8"));
|
|
91
|
+
const instance = (name, settings, index) => ({
|
|
92
|
+
name,
|
|
93
|
+
settings,
|
|
94
|
+
widgetId: `preview-${index}`,
|
|
95
|
+
shared: name === "header" || name === "footer",
|
|
96
|
+
deletable: false,
|
|
97
|
+
moveable: false,
|
|
98
|
+
});
|
|
99
|
+
const layout = [
|
|
100
|
+
instance(
|
|
101
|
+
"header",
|
|
102
|
+
media(
|
|
103
|
+
site.header ?? fallback.header ?? { links: [], ctaLabel: "", ctaHref: "" },
|
|
104
|
+
site.header ? siteFile : contextFile,
|
|
105
|
+
root,
|
|
106
|
+
),
|
|
107
|
+
"header",
|
|
108
|
+
),
|
|
109
|
+
...page.layout.map((b, i) =>
|
|
110
|
+
instance(b.name, media(b.settings ?? {}, file, root), i),
|
|
111
|
+
),
|
|
112
|
+
instance(
|
|
113
|
+
"footer",
|
|
114
|
+
media(
|
|
115
|
+
site.footer ??
|
|
116
|
+
fallback.footer ?? { columns: [], tagline: "", copyrightText: "" },
|
|
117
|
+
site.footer ? siteFile : contextFile,
|
|
118
|
+
root,
|
|
119
|
+
),
|
|
120
|
+
"footer",
|
|
121
|
+
),
|
|
122
|
+
];
|
|
123
|
+
const branding = media(context.branding ?? {}, contextFile, root);
|
|
124
|
+
return {
|
|
125
|
+
title: page.title ?? (slug || "Local preview"),
|
|
126
|
+
layout,
|
|
127
|
+
themeStyle: effective.theme ?? null,
|
|
128
|
+
themeId: context.themeId ?? null,
|
|
129
|
+
pageData: {
|
|
130
|
+
siteTitle: branding.title ?? "Local preview",
|
|
131
|
+
siteSubtitle: branding.subtitle ?? null,
|
|
132
|
+
siteLogoUrl: branding.logo?.url ?? null,
|
|
133
|
+
siteLogoAlt: branding.logo?.alt ?? null,
|
|
134
|
+
pageSlug: slug,
|
|
135
|
+
},
|
|
136
|
+
pages: readdirSync(join(folder, "pages"))
|
|
137
|
+
.filter((f) => /^(index|[a-z0-9]+(?:-[a-z0-9]+)*)\.json$/.test(f))
|
|
138
|
+
.map((f) => ({
|
|
139
|
+
path: f === "index.json" ? "/" : "/" + f.slice(0, -5),
|
|
140
|
+
name: f.slice(0, -5),
|
|
141
|
+
})),
|
|
142
|
+
feeds: feedFixtures(context.feeds, contextFile),
|
|
143
|
+
missingBranding: !branding.title,
|
|
144
|
+
};
|
|
145
|
+
}
|