@picloom/create-theme 0.0.0-stage → 0.1.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 +33 -2
- package/dist/template/LICENSE +21 -0
- package/dist/template/README.md +57 -0
- package/dist/template/dev/context.ts +363 -0
- package/dist/template/dev/env.d.ts +6 -0
- package/dist/template/dev/helpers.d.ts +18 -0
- package/dist/template/dev/helpers.js +114 -0
- package/dist/template/dev/index.html +12 -0
- package/dist/template/dev/main.ts +179 -0
- package/dist/template/dev/virtual.d.ts +5 -0
- package/dist/template/gitignore +4 -0
- package/dist/template/package.json +27 -0
- package/dist/template/public/README.txt +3 -0
- package/dist/template/scripts/build.ts +119 -0
- package/dist/template/scripts/manifest.ts +61 -0
- package/dist/template/scripts/serve.ts +127 -0
- package/dist/template/src/Page.vue +405 -0
- package/dist/template/src/entry.ts +13 -0
- package/dist/template/src/style.css +121 -0
- package/dist/template/theme.json +108 -0
- package/dist/template/tsconfig.json +22 -0
- package/dist/template/vite.config.ts +20 -0
- package/index.js +63 -0
- package/package.json +29 -4
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { manifest, config, production } from "virtual:picloom-preview";
|
|
2
|
+
import { createPreview, scenarios, type Scenario } from "./context";
|
|
3
|
+
import type { Page, ThemeModule } from "@picloom/theme-sdk";
|
|
4
|
+
const root = document.getElementById("app")!;
|
|
5
|
+
let instance: { dispose(): void } | undefined,
|
|
6
|
+
generation = 0;
|
|
7
|
+
const sourceModule = production ? "/entry.js" : "/src/entry.ts";
|
|
8
|
+
const runtime = createPreview(manifest, config, navigate);
|
|
9
|
+
const preview = document.createElement("aside");
|
|
10
|
+
preview.id = "picloom-preview-tools";
|
|
11
|
+
const shadow = preview.attachShadow({ mode: "open" });
|
|
12
|
+
shadow.innerHTML = `<style>:host{position:fixed;right:12px;bottom:12px;z-index:2147483647;font:13px system-ui;color:#eee}details{background:#15212e;border:1px solid #617080;border-radius:10px;padding:12px;max-width:300px;box-shadow:0 4px 20px #0005}summary{cursor:pointer;font-weight:bold}label{display:grid;gap:4px;margin-top:10px}select{background:#fff;color:#17212c;padding:5px;min-width:220px}p{max-width:260px;font-size:12px}output{display:block;color:#ffd37c;white-space:pre-wrap;overflow-wrap:anywhere}</style>
|
|
13
|
+
<details open><summary>Picloom · Preview / 预览</summary><p>${production ? "Production build / 构建产物" : "Source HMR / 源码热更新"}<br>SIMULATED uploads and login<br>模拟上传与登录,不连接真实服务</p>
|
|
14
|
+
<label>Page / 页面<select id="page"><option value="home">Home / 首页</option><option value="login">Login / 登录</option></select></label>
|
|
15
|
+
<label>Language / 语言<select id="language"></select></label>
|
|
16
|
+
<label>Appearance / 外观<select id="mode"><option value="auto">System / 跟随系统</option><option value="light">Light / 浅色</option><option value="dark">Dark / 深色</option></select></label>
|
|
17
|
+
<label>Identity / 身份<select id="identity"><option value="guest">Guest / 访客</option><option value="user">Signed in / 已登录</option></select></label>
|
|
18
|
+
<label>Scenario / 场景<select id="scenario"></select></label><output role="alert"></output></details>`;
|
|
19
|
+
document.body.append(preview);
|
|
20
|
+
const select = (id: string) => shadow.getElementById(id) as HTMLSelectElement;
|
|
21
|
+
const errors = shadow.querySelector("output")!;
|
|
22
|
+
function option(value: string, label: string) {
|
|
23
|
+
const node = document.createElement("option");
|
|
24
|
+
node.value = value;
|
|
25
|
+
node.textContent = label;
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
for (const language of runtime.ctx.getSnapshot().languages)
|
|
29
|
+
select("language").append(option(language.tag, language.name));
|
|
30
|
+
const labels: Record<Scenario, string> = {
|
|
31
|
+
ready: "Ready / 就绪",
|
|
32
|
+
loading: "Loading / 加载中",
|
|
33
|
+
uploading: "Uploading / 上传中",
|
|
34
|
+
success: "Success / 上传成功",
|
|
35
|
+
failed: "Failed / 上传失败",
|
|
36
|
+
"no-profiles": "No profiles / 无可用方案",
|
|
37
|
+
"login-error": "Login error / 登录失败",
|
|
38
|
+
};
|
|
39
|
+
for (const scenario of scenarios)
|
|
40
|
+
select("scenario").append(option(scenario, labels[scenario]));
|
|
41
|
+
select("page").onchange = () => navigate(select("page").value as Page);
|
|
42
|
+
select("language").onchange = () =>
|
|
43
|
+
runtime.ctx.setLocale(select("language").value);
|
|
44
|
+
select("mode").onchange = () =>
|
|
45
|
+
runtime.ctx.setMode(select("mode").value as "auto" | "light" | "dark");
|
|
46
|
+
select("identity").onchange = () =>
|
|
47
|
+
runtime.setUser(select("identity").value === "user");
|
|
48
|
+
select("scenario").onchange = () =>
|
|
49
|
+
runtime.setScenario(select("scenario").value as Scenario);
|
|
50
|
+
const stop = runtime.ctx.subscribe((state) => {
|
|
51
|
+
select("page").value = state.page;
|
|
52
|
+
select("language").value = state.locale;
|
|
53
|
+
select("mode").value = state.mode;
|
|
54
|
+
select("identity").value = state.user ? "user" : "guest";
|
|
55
|
+
});
|
|
56
|
+
function cleanup() {
|
|
57
|
+
const previous = instance;
|
|
58
|
+
instance = undefined;
|
|
59
|
+
try {
|
|
60
|
+
previous?.dispose();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
errors.textContent = String(error);
|
|
63
|
+
} finally {
|
|
64
|
+
root.replaceChildren();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function mount() {
|
|
68
|
+
const current = ++generation;
|
|
69
|
+
errors.textContent = "";
|
|
70
|
+
cleanup();
|
|
71
|
+
const subscriptions = new Set<() => void>();
|
|
72
|
+
const links: HTMLLinkElement[] = [];
|
|
73
|
+
const container = document.createElement("div");
|
|
74
|
+
root.append(container);
|
|
75
|
+
let mounted: Awaited<ReturnType<ThemeModule["mount"]>> | undefined;
|
|
76
|
+
let cleaned = false;
|
|
77
|
+
const context = {
|
|
78
|
+
...runtime.ctx,
|
|
79
|
+
subscribe(listener: Parameters<typeof runtime.ctx.subscribe>[0]) {
|
|
80
|
+
// An async mount may continue after its page has already been unmounted.
|
|
81
|
+
if (cleaned) return () => {};
|
|
82
|
+
const stop = runtime.ctx.subscribe(listener);
|
|
83
|
+
subscriptions.add(stop);
|
|
84
|
+
return () => {
|
|
85
|
+
stop();
|
|
86
|
+
subscriptions.delete(stop);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const dispose = () => {
|
|
91
|
+
if (cleaned) return;
|
|
92
|
+
cleaned = true;
|
|
93
|
+
try {
|
|
94
|
+
if (typeof mounted?.dispose === "function") mounted.dispose();
|
|
95
|
+
} finally {
|
|
96
|
+
for (const stop of subscriptions) stop();
|
|
97
|
+
subscriptions.clear();
|
|
98
|
+
for (const link of links) link.remove();
|
|
99
|
+
container.remove();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
instance = { dispose };
|
|
103
|
+
try {
|
|
104
|
+
const page = runtime.ctx.getSnapshot().page;
|
|
105
|
+
const declaration = manifest.pages[page];
|
|
106
|
+
if (!declaration)
|
|
107
|
+
throw Error(
|
|
108
|
+
`Theme does not declare ${page}; the real host will use its default page`,
|
|
109
|
+
);
|
|
110
|
+
const styles = new Set([
|
|
111
|
+
...(manifest.styles ?? []),
|
|
112
|
+
...(declaration.styles ?? []),
|
|
113
|
+
]);
|
|
114
|
+
await Promise.all(
|
|
115
|
+
[...styles]
|
|
116
|
+
.filter((path) => production || path !== "style.css")
|
|
117
|
+
.map(
|
|
118
|
+
(path) =>
|
|
119
|
+
new Promise<void>((resolve, reject) => {
|
|
120
|
+
// The source entry imports src/style.css; other declared styles are public assets.
|
|
121
|
+
const link = document.createElement("link");
|
|
122
|
+
link.rel = "stylesheet";
|
|
123
|
+
link.href = runtime.ctx.asset(path);
|
|
124
|
+
link.onload = () => resolve();
|
|
125
|
+
link.onerror = () =>
|
|
126
|
+
reject(Error(`Missing theme style: ${path}`));
|
|
127
|
+
links.push(link);
|
|
128
|
+
document.head.append(link);
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
if (current !== generation || cleaned) return;
|
|
133
|
+
const module: ThemeModule = await import(/* @vite-ignore */ sourceModule);
|
|
134
|
+
if (typeof module.mount !== "function")
|
|
135
|
+
throw Error("Theme must export mount(container, context)");
|
|
136
|
+
if (current !== generation || cleaned) return;
|
|
137
|
+
mounted = await module.mount(container, context);
|
|
138
|
+
if (!mounted || typeof mounted.dispose !== "function")
|
|
139
|
+
throw Error("mount() must return { dispose() }");
|
|
140
|
+
if (current !== generation || cleaned) {
|
|
141
|
+
if (cleaned) mounted.dispose();
|
|
142
|
+
else dispose();
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
try {
|
|
146
|
+
dispose();
|
|
147
|
+
} finally {
|
|
148
|
+
if (current === generation) errors.textContent = String(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function navigate(page: Page) {
|
|
153
|
+
history.pushState(null, "", page === "login" ? "/login" : "/");
|
|
154
|
+
runtime.setPage(page);
|
|
155
|
+
void mount();
|
|
156
|
+
}
|
|
157
|
+
function popstate() {
|
|
158
|
+
runtime.setPage(location.pathname === "/login" ? "login" : "home");
|
|
159
|
+
void mount();
|
|
160
|
+
}
|
|
161
|
+
window.addEventListener("popstate", popstate);
|
|
162
|
+
function dispose() {
|
|
163
|
+
generation++;
|
|
164
|
+
cleanup();
|
|
165
|
+
stop();
|
|
166
|
+
runtime.dispose();
|
|
167
|
+
preview.remove();
|
|
168
|
+
window.removeEventListener("popstate", popstate);
|
|
169
|
+
}
|
|
170
|
+
function pagehide(event: PageTransitionEvent) {
|
|
171
|
+
if (!event.persisted) dispose();
|
|
172
|
+
}
|
|
173
|
+
window.addEventListener("pagehide", pagehide);
|
|
174
|
+
import.meta.hot?.dispose(() => {
|
|
175
|
+
window.removeEventListener("pagehide", pagehide);
|
|
176
|
+
dispose();
|
|
177
|
+
});
|
|
178
|
+
// Vue SFC/CSS updates use normal HMR. Entry changes invalidate the importer and reload.
|
|
179
|
+
await mount();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-theme",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">=1.4.3"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "bun scripts/serve.ts",
|
|
11
|
+
"typecheck": "bun --bun vue-tsc --noEmit",
|
|
12
|
+
"build": "bun run typecheck && bun scripts/build.ts",
|
|
13
|
+
"preview": "bun scripts/serve.ts --preview"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@picloom/theme-sdk": "0.1.0",
|
|
17
|
+
"@tailwindcss/vite": "4.3.3",
|
|
18
|
+
"@types/bun": "1.3.10",
|
|
19
|
+
"@vitejs/plugin-vue": "6.0.10",
|
|
20
|
+
"fflate": "0.8.2",
|
|
21
|
+
"tailwindcss": "4.3.3",
|
|
22
|
+
"typescript": "5.9.3",
|
|
23
|
+
"vite": "8.3.4",
|
|
24
|
+
"vue": "3.5.44",
|
|
25
|
+
"vue-tsc": "3.3.13"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readdir,
|
|
4
|
+
readFile,
|
|
5
|
+
lstat,
|
|
6
|
+
writeFile,
|
|
7
|
+
cp,
|
|
8
|
+
rm,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { resolve, relative, dirname } from "node:path";
|
|
11
|
+
import { build } from "vite";
|
|
12
|
+
import { zipSync, strToU8 } from "fflate";
|
|
13
|
+
import { assetPath, readManifest } from "./manifest";
|
|
14
|
+
const manifest = await readManifest();
|
|
15
|
+
const output = resolve("dist/package");
|
|
16
|
+
const archive = resolve("dist", `${manifest.id}-${manifest.version}.zip`);
|
|
17
|
+
// A failed build must not leave an older ZIP looking like the new result.
|
|
18
|
+
await rm(archive, { force: true });
|
|
19
|
+
await build();
|
|
20
|
+
async function files(root: string): Promise<string[]> {
|
|
21
|
+
if ((await lstat(root)).isSymbolicLink())
|
|
22
|
+
throw Error(`Do not package symbolic links: ${root}`);
|
|
23
|
+
const result: string[] = [];
|
|
24
|
+
for (const name of await readdir(root)) {
|
|
25
|
+
const path = resolve(root, name),
|
|
26
|
+
stat = await lstat(path);
|
|
27
|
+
if (stat.isSymbolicLink())
|
|
28
|
+
throw Error(`Do not package symbolic links: ${path}`);
|
|
29
|
+
if (stat.isDirectory()) result.push(...(await files(path)));
|
|
30
|
+
else if (stat.isFile()) result.push(path);
|
|
31
|
+
else throw Error(`Unsupported file: ${path}`);
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
for (const path of await files(resolve("public/assets"))) {
|
|
37
|
+
const name = relative(resolve("public"), path).replaceAll("\\", "/");
|
|
38
|
+
if (
|
|
39
|
+
!assetPath(name) ||
|
|
40
|
+
/(?:^|\/)\./.test(name) ||
|
|
41
|
+
/\.(?:map|env|pem|key|ts|vue)$/i.test(name)
|
|
42
|
+
)
|
|
43
|
+
throw Error(`Not a distributable theme asset: ${name}`);
|
|
44
|
+
const destination = resolve(output, name);
|
|
45
|
+
if (await Bun.file(destination).exists())
|
|
46
|
+
throw Error(`Asset collides with build output: ${name}`);
|
|
47
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
48
|
+
await cp(path, destination);
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
52
|
+
}
|
|
53
|
+
await cp("LICENSE", output + "/LICENSE");
|
|
54
|
+
const declaration = JSON.stringify(manifest, null, 2) + "\n";
|
|
55
|
+
if (strToU8(declaration).byteLength > 256 * 1024)
|
|
56
|
+
throw Error("theme.json exceeds 256 KiB");
|
|
57
|
+
await writeFile(output + "/theme.json", declaration);
|
|
58
|
+
const entries: Record<string, Uint8Array> = {};
|
|
59
|
+
const names = new Set<string>();
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (const path of await files(output)) {
|
|
62
|
+
const name = relative(output, path).replaceAll("\\", "/");
|
|
63
|
+
if (
|
|
64
|
+
!assetPath(name) ||
|
|
65
|
+
names.has(name.toLowerCase()) ||
|
|
66
|
+
/(?:^|\/)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|\/|$)/i.test(name) ||
|
|
67
|
+
/(?:^|\/)\./.test(name) ||
|
|
68
|
+
name.endsWith(".map")
|
|
69
|
+
)
|
|
70
|
+
throw Error(`Invalid or duplicate package path: ${name}`);
|
|
71
|
+
names.add(name.toLowerCase());
|
|
72
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
73
|
+
total += bytes.byteLength;
|
|
74
|
+
if (total > 50 * 1024 * 1024 || names.size > 512)
|
|
75
|
+
throw Error("Theme exceeds 50 MiB expanded or 512 files");
|
|
76
|
+
if (/\.m?js$/.test(name)) {
|
|
77
|
+
const code = new TextDecoder().decode(bytes);
|
|
78
|
+
const imports = new Bun.Transpiler({
|
|
79
|
+
loader: "js",
|
|
80
|
+
target: "browser",
|
|
81
|
+
}).scanImports(code);
|
|
82
|
+
if (imports.length)
|
|
83
|
+
throw Error(
|
|
84
|
+
`Unbundled runtime imports in ${name}; this template produces a self-contained entry`,
|
|
85
|
+
);
|
|
86
|
+
if (
|
|
87
|
+
/["'`]\/(?:@vite|@id|@fs|src|dev)\/|localhost:\d|127\.0\.0\.1:\d|\/\/[#@]\s*sourceMappingURL=/.test(
|
|
88
|
+
code,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
throw Error(`Development reference in ${name}`);
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
name.endsWith(".css") &&
|
|
95
|
+
/localhost:\d|127\.0\.0\.1:\d|(?:url|@import)\s*\(?["']?\/(?:src|dev|@vite)\//.test(
|
|
96
|
+
new TextDecoder().decode(bytes),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
throw Error(`Development reference in ${name}`);
|
|
100
|
+
entries[name] = bytes;
|
|
101
|
+
}
|
|
102
|
+
if (entries["style.css"] && !manifest.styles?.includes("style.css"))
|
|
103
|
+
throw Error(
|
|
104
|
+
"The template bundles imported CSS into style.css; declare it in theme.json styles",
|
|
105
|
+
);
|
|
106
|
+
for (const name of [
|
|
107
|
+
...(manifest.styles ?? []),
|
|
108
|
+
...(manifest.preview ? [manifest.preview] : []),
|
|
109
|
+
...Object.values(manifest.pages).flatMap((page) => [
|
|
110
|
+
page.entry,
|
|
111
|
+
...(page.styles ?? []),
|
|
112
|
+
]),
|
|
113
|
+
]) {
|
|
114
|
+
if (!entries[name]) throw Error(`Missing theme resource: ${name}`);
|
|
115
|
+
}
|
|
116
|
+
const zip = zipSync(entries);
|
|
117
|
+
if (zip.byteLength > 20 * 1024 * 1024) throw Error("Theme ZIP exceeds 20 MiB");
|
|
118
|
+
await Bun.write(archive, zip);
|
|
119
|
+
console.log(archive);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { Manifest } from "@picloom/theme-sdk";
|
|
3
|
+
import { supportedLocales, safeName, assetPath } from "../dev/helpers.js";
|
|
4
|
+
export { assetPath } from "../dev/helpers.js";
|
|
5
|
+
export async function readManifest(path = "theme.json"): Promise<Manifest> {
|
|
6
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
7
|
+
const object = (v: unknown): v is Record<string, any> =>
|
|
8
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
9
|
+
if (
|
|
10
|
+
!object(value) ||
|
|
11
|
+
!safeName(value.id) ||
|
|
12
|
+
value.id.startsWith("builtin.") ||
|
|
13
|
+
!safeName(value.version) ||
|
|
14
|
+
value.apiVersion !== 1 ||
|
|
15
|
+
!Array.isArray(value.supportedLocales) ||
|
|
16
|
+
!value.supportedLocales.length ||
|
|
17
|
+
value.supportedLocales.some((v: string) => !supportedLocales.includes(v)) ||
|
|
18
|
+
new Set(value.supportedLocales).size !== value.supportedLocales.length ||
|
|
19
|
+
!value.supportedLocales.includes(value.defaultLocale) ||
|
|
20
|
+
!object(value.pages) ||
|
|
21
|
+
!Object.keys(value.pages).length ||
|
|
22
|
+
Object.keys(value.pages).some((key) => !["home", "login"].includes(key)) ||
|
|
23
|
+
!object(value.messages) ||
|
|
24
|
+
!object(value.messages[value.defaultLocale]) ||
|
|
25
|
+
typeof value.nameKey !== "string" ||
|
|
26
|
+
typeof value.messages[value.defaultLocale][value.nameKey] !== "string" ||
|
|
27
|
+
!value.messages[value.defaultLocale][value.nameKey].trim()
|
|
28
|
+
)
|
|
29
|
+
throw Error(
|
|
30
|
+
"Invalid theme.json: check identity, API, pages and language declarations",
|
|
31
|
+
);
|
|
32
|
+
for (const page of Object.values(value.pages) as Array<{
|
|
33
|
+
entry: string;
|
|
34
|
+
styles?: string[];
|
|
35
|
+
}>) {
|
|
36
|
+
if (!object(page) || page.entry !== "entry.js")
|
|
37
|
+
throw Error(
|
|
38
|
+
"This template builds src/entry.ts as entry.js; use that entry for each page",
|
|
39
|
+
);
|
|
40
|
+
if (
|
|
41
|
+
page.styles !== undefined &&
|
|
42
|
+
(!Array.isArray(page.styles) ||
|
|
43
|
+
page.styles.some((p) => !assetPath(p) || !p.endsWith(".css")))
|
|
44
|
+
)
|
|
45
|
+
throw Error("Invalid page styles");
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
value.styles !== undefined &&
|
|
49
|
+
(!Array.isArray(value.styles) ||
|
|
50
|
+
value.styles.some((p: unknown) => !assetPath(p) || !p.endsWith(".css")))
|
|
51
|
+
)
|
|
52
|
+
throw Error("Invalid styles");
|
|
53
|
+
if (
|
|
54
|
+
value.preview !== undefined &&
|
|
55
|
+
(!assetPath(value.preview) || !/\.(png|jpe?g|webp)$/i.test(value.preview))
|
|
56
|
+
)
|
|
57
|
+
throw Error("Invalid preview image");
|
|
58
|
+
if (value.configSchema !== undefined && value.configSchema?.type !== "object")
|
|
59
|
+
throw Error("configSchema must be an object schema");
|
|
60
|
+
return value as Manifest;
|
|
61
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createServer, type Plugin } from "vite";
|
|
2
|
+
import vue from "@vitejs/plugin-vue";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { initialValue } from "../dev/helpers.js";
|
|
7
|
+
import { assetPath, readManifest } from "./manifest";
|
|
8
|
+
const args = process.argv.slice(2),
|
|
9
|
+
production = args.includes("--preview");
|
|
10
|
+
const portIndex = args.indexOf("--port");
|
|
11
|
+
const port = portIndex === -1 ? 5173 : Number(args[portIndex + 1]);
|
|
12
|
+
if (
|
|
13
|
+
!Number.isInteger(port) ||
|
|
14
|
+
port < 1 ||
|
|
15
|
+
port > 65535 ||
|
|
16
|
+
args.some(
|
|
17
|
+
(arg, i) =>
|
|
18
|
+
arg !== "--preview" &&
|
|
19
|
+
arg !== "--port" &&
|
|
20
|
+
!(portIndex >= 0 && i === portIndex + 1),
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
throw Error(
|
|
24
|
+
"Usage: bun run dev [--port 5173] / bun run preview [--port 5173]",
|
|
25
|
+
);
|
|
26
|
+
if (production && !(await Bun.file("dist/package/entry.js").exists()))
|
|
27
|
+
throw Error("No production build. Run bun run build first.");
|
|
28
|
+
const manifestPath = production ? "dist/package/theme.json" : "theme.json";
|
|
29
|
+
try {
|
|
30
|
+
if (!(await Bun.file("dev/config.json").exists())) {
|
|
31
|
+
const manifest = await readManifest(manifestPath);
|
|
32
|
+
await mkdir("dev", { recursive: true });
|
|
33
|
+
await writeFile(
|
|
34
|
+
"dev/config.json",
|
|
35
|
+
JSON.stringify(
|
|
36
|
+
initialValue(manifest.configSchema ?? { type: "object" }) ?? {},
|
|
37
|
+
null,
|
|
38
|
+
2,
|
|
39
|
+
) + "\n",
|
|
40
|
+
{ flag: "wx" },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
|
|
45
|
+
console.error(String(error));
|
|
46
|
+
}
|
|
47
|
+
const virtual = "virtual:picloom-preview",
|
|
48
|
+
resolved = "\0" + virtual;
|
|
49
|
+
const plugin: Plugin = {
|
|
50
|
+
name: "picloom-preview",
|
|
51
|
+
resolveId(id) {
|
|
52
|
+
if (id === virtual) return resolved;
|
|
53
|
+
},
|
|
54
|
+
async load(id) {
|
|
55
|
+
if (id !== resolved) return;
|
|
56
|
+
const manifest = await readManifest(manifestPath);
|
|
57
|
+
const config = JSON.parse(await readFile("dev/config.json", "utf8"));
|
|
58
|
+
if (!config || Array.isArray(config) || typeof config !== "object")
|
|
59
|
+
throw Error("dev/config.json must contain an object");
|
|
60
|
+
return `export const manifest=${JSON.stringify(manifest)};export const config=${JSON.stringify(config)};export const production=${production};`;
|
|
61
|
+
},
|
|
62
|
+
configureServer(server) {
|
|
63
|
+
server.watcher.add([manifestPath, "dev/config.json"]);
|
|
64
|
+
server.watcher.on("all", (_event, path) => {
|
|
65
|
+
if (
|
|
66
|
+
[manifestPath, "dev/config.json"].some(
|
|
67
|
+
(name) => resolve(path) === resolve(name),
|
|
68
|
+
)
|
|
69
|
+
) {
|
|
70
|
+
const module = server.moduleGraph.getModuleById(resolved);
|
|
71
|
+
if (module) server.moduleGraph.invalidateModule(module);
|
|
72
|
+
server.ws.send({ type: "full-reload" });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
server.middlewares.use(async (req, res, next) => {
|
|
76
|
+
try {
|
|
77
|
+
const path = decodeURIComponent(req.url?.split("?")[0] ?? "");
|
|
78
|
+
// Built resources are served verbatim, including encoded names and ?import.
|
|
79
|
+
if (production && assetPath(path.slice(1))) {
|
|
80
|
+
const file = Bun.file(resolve("dist/package", path.slice(1)));
|
|
81
|
+
if (await file.exists()) {
|
|
82
|
+
res.setHeader("Content-Type", file.type);
|
|
83
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
84
|
+
res.end(new Uint8Array(await file.arrayBuffer()));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (path !== "/" && path !== "/login") return next();
|
|
89
|
+
const html = await server.transformIndexHtml(
|
|
90
|
+
req.url!,
|
|
91
|
+
await readFile("dev/index.html", "utf8"),
|
|
92
|
+
);
|
|
93
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
94
|
+
res.end(html);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
next(error);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const server = await createServer({
|
|
102
|
+
configFile: false,
|
|
103
|
+
appType: "mpa",
|
|
104
|
+
plugins: [plugin, vue(), tailwindcss()],
|
|
105
|
+
publicDir: production ? false : "public",
|
|
106
|
+
server: {
|
|
107
|
+
host: "127.0.0.1",
|
|
108
|
+
port,
|
|
109
|
+
strictPort: true,
|
|
110
|
+
// Bun's native filesystem watcher can miss repeated replace/write events.
|
|
111
|
+
watch: { usePolling: true, interval: 150 },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
await server.listen();
|
|
115
|
+
console.log(
|
|
116
|
+
`${production ? "Production build" : "Source HMR"} · SIMULATED uploads and login / 模拟上传与登录`,
|
|
117
|
+
);
|
|
118
|
+
server.printUrls();
|
|
119
|
+
let closing = false;
|
|
120
|
+
async function close() {
|
|
121
|
+
if (closing) return;
|
|
122
|
+
closing = true;
|
|
123
|
+
await server.close();
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
process.on("SIGINT", close);
|
|
127
|
+
process.on("SIGTERM", close);
|