@frontlit/preview 0.0.0-stage → 0.1.0-alpha.1

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 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,40 @@
1
- # Temporary Holding Version
1
+ # @frontlit/preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only local previews of FrontLit repository pages using the existing page-builder React renderer, compiled styles, fonts and shadow-root embeds. Node 20.19+ (or 22.12+) is required.
4
+
5
+ After publication:
6
+
7
+ ```sh
8
+ npx @frontlit/preview ./frontlit
9
+ npx @frontlit/preview ./frontlit --port 4311 --context ./preview.context.json
10
+ ```
11
+
12
+ In the monorepo, build `@frontlit/text-editor` and `@frontlit/page-builder`, then run `bun packages/preview/cli.mjs <site-folder>`. The default address is http://127.0.0.1:4310. Changes to page JSON, site JSON, images or context refresh the browser. Invalid files display diagnostics and recover on the next edit. Use the toolbar to select pages or switch color mode; browser device emulation works normally.
13
+
14
+ The site folder contains `pages/*.json` and optional `site.json`. Shared chrome and theme come from `site.json`. Missing values can fall back to a local context file (`preview.context.json` in the site folder, or `--context`). Branding lives outside FrontLit's Git site format, so provide it explicitly:
15
+
16
+ ```json
17
+ {
18
+ "branding": {
19
+ "title": "John's Piping Pizza",
20
+ "subtitle": "Dough. Heat. Repeat.",
21
+ "logo": { "url": "assets/logo.svg", "alt": "John's Piping Pizza" }
22
+ },
23
+ "site": {},
24
+ "feeds": {
25
+ "/blog": [
26
+ { "title": "Our pizza notebook", "href": "/", "description": "A local feed fixture." }
27
+ ]
28
+ }
29
+ }
30
+ ```
31
+
32
+ `site` accepts the same chrome/theme structure as `site.json`; local site fields win. An optional `themeId` selects a bundled preset when there is no theme object. This is a local snapshot, not an authenticated export endpoint. No FrontLit credentials or backend are required. Use a gitignored context file for private fixture data.
33
+
34
+ Repository images resolve relative to their owning JSON file, or relative to the repository root for paths starting with `/`. Only image files inside that root are served through the asset endpoint, including symlink checks. Without a Git root, the site folder's parent is the root. HTTPS media remains remote. URLs inside raw embed HTML are not rewritten; use a permanent HTTPS URL or the local asset URL (`/__frontlit/assets/<repository-relative-path>`).
35
+
36
+ Page routes correspond to `pages/*.json`. Draft pages are deliberately previewable. Backend article/doc detail routes, redirects, chatbot, search and production image optimization are not implemented. Feeds use explicit local fixtures or a visible placeholder; forms receive no team/API identity and use page-builder's local success behavior. This previews the client renderer; the hosted site uses server rendering, so the release preview remains the final integration check. Keep the renderer dependency aligned with the release being targeted.
37
+
38
+ The package ships its Vite client and CLI as source and needs no separate build. `bun run --cwd packages/preview test` checks normalization, local media boundaries and missing/invalid input behavior.
39
+
40
+ Release prerequisite: the renderer dependency must include its existing `schema` and `theme` entry points (the current workspace alpha.2); the older npm alpha.1 does not export them. Publish compatible workspace dependencies before this utility. No page-builder implementation changes are needed.
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>
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
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.3",
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,107 @@
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(fileURLToPath(import.meta.resolve("@frontlit/page-builder/styles.css"))),
36
+ ],
37
+ },
38
+ },
39
+ plugins: [
40
+ {
41
+ name: "frontlit-local-files",
42
+ configureServer(vite) {
43
+ vite.watcher.add([folder, contextFile]);
44
+ let timer;
45
+ const refresh = (file) => {
46
+ if (file === contextFile || file.startsWith(folder + "/")) {
47
+ clearTimeout(timer);
48
+ timer = setTimeout(() => vite.ws.send({ type: "full-reload" }), 80);
49
+ }
50
+ };
51
+ vite.watcher.on("all", (_, file) => refresh(file));
52
+ vite.httpServer?.once("close", () => clearTimeout(timer));
53
+ vite.middlewares.use((req, res, next) => {
54
+ const url = new URL(req.url, "http://localhost");
55
+ if (url.pathname === "/__frontlit/data") {
56
+ res.setHeader("Content-Type", "application/json");
57
+ res.setHeader("Cache-Control", "no-store");
58
+ try {
59
+ res.end(
60
+ JSON.stringify(
61
+ loadSite(folder, url.searchParams.get("path") ?? "/", contextFile),
62
+ ),
63
+ );
64
+ } catch (error) {
65
+ res.statusCode = 400;
66
+ res.end(JSON.stringify({ error: error.message }));
67
+ }
68
+ return;
69
+ }
70
+ if (url.pathname.startsWith("/__frontlit/assets/")) {
71
+ try {
72
+ const file = localAsset(
73
+ root,
74
+ decodeURIComponent(url.pathname.slice("/__frontlit/assets/".length)),
75
+ );
76
+ const mime = {
77
+ ".svg": "image/svg+xml",
78
+ ".png": "image/png",
79
+ ".jpg": "image/jpeg",
80
+ ".jpeg": "image/jpeg",
81
+ ".webp": "image/webp",
82
+ ".avif": "image/avif",
83
+ ".gif": "image/gif",
84
+ };
85
+ res.setHeader("Cache-Control", "no-store");
86
+ res.setHeader(
87
+ "Content-Type",
88
+ mime[file.slice(file.lastIndexOf(".")).toLowerCase()],
89
+ );
90
+ createReadStream(file)
91
+ .on("error", () => res.destroy())
92
+ .pipe(res);
93
+ } catch {
94
+ res.statusCode = 404;
95
+ res.end("Image not found");
96
+ }
97
+ return;
98
+ }
99
+ next();
100
+ });
101
+ },
102
+ },
103
+ ],
104
+ });
105
+ await server.listen();
106
+ return server;
107
+ }
package/site.mjs ADDED
@@ -0,0 +1,120 @@
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
+ export function loadSite(
44
+ folder,
45
+ pathname = "/",
46
+ contextFile = join(folder, "preview.context.json"),
47
+ ) {
48
+ const root = repositoryRoot(folder);
49
+ const slug = decodeURIComponent(pathname).replace(/^\/+|\/+$/g, "");
50
+ if (slug && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
51
+ throw new Error(
52
+ `No local page for ${pathname}. Only pages/*.json routes are previewed.`,
53
+ );
54
+ const file = join(folder, "pages", `${slug || "index"}.json`);
55
+ if (!existsSync(file)) throw new Error(`Page not found: ${file}`);
56
+ const context = existsSync(contextFile)
57
+ ? JSON.parse(readFileSync(contextFile, "utf8"))
58
+ : {};
59
+ const siteFile = join(folder, "site.json");
60
+ const site = existsSync(siteFile)
61
+ ? parseSiteFile(readFileSync(siteFile, "utf8"))
62
+ : {};
63
+ const fallback = parseSiteFile(context.site ?? {});
64
+ const effective = { ...fallback, ...site };
65
+ const page = parsePageFile(readFileSync(file, "utf8"));
66
+ const instance = (name, settings, index) => ({
67
+ name,
68
+ settings,
69
+ widgetId: `preview-${index}`,
70
+ shared: name === "header" || name === "footer",
71
+ deletable: false,
72
+ moveable: false,
73
+ });
74
+ const layout = [
75
+ instance(
76
+ "header",
77
+ media(
78
+ site.header ?? fallback.header ?? { links: [], ctaLabel: "", ctaHref: "" },
79
+ site.header ? siteFile : contextFile,
80
+ root,
81
+ ),
82
+ "header",
83
+ ),
84
+ ...page.layout.map((b, i) =>
85
+ instance(b.name, media(b.settings ?? {}, file, root), i),
86
+ ),
87
+ instance(
88
+ "footer",
89
+ media(
90
+ site.footer ??
91
+ fallback.footer ?? { columns: [], tagline: "", copyrightText: "" },
92
+ site.footer ? siteFile : contextFile,
93
+ root,
94
+ ),
95
+ "footer",
96
+ ),
97
+ ];
98
+ const branding = media(context.branding ?? {}, contextFile, root);
99
+ return {
100
+ title: page.title ?? (slug || "Local preview"),
101
+ layout,
102
+ themeStyle: effective.theme ?? null,
103
+ themeId: context.themeId ?? null,
104
+ pageData: {
105
+ siteTitle: branding.title ?? "Local preview",
106
+ siteSubtitle: branding.subtitle ?? null,
107
+ siteLogoUrl: branding.logo?.url ?? null,
108
+ siteLogoAlt: branding.logo?.alt ?? null,
109
+ pageSlug: slug,
110
+ },
111
+ pages: readdirSync(join(folder, "pages"))
112
+ .filter((f) => /^(index|[a-z0-9]+(?:-[a-z0-9]+)*)\.json$/.test(f))
113
+ .map((f) => ({
114
+ path: f === "index.json" ? "/" : "/" + f.slice(0, -5),
115
+ name: f.slice(0, -5),
116
+ })),
117
+ feeds: context.feeds ?? {},
118
+ missingBranding: !branding.title,
119
+ };
120
+ }