@fluid-app/fluid-cli-theme-dev 0.1.21 → 0.1.23
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/README.md +14 -0
- package/dist/index.mjs +155 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -4
- package/.turbo/turbo-build.log +0 -16
- package/.turbo/turbo-typecheck.log +0 -4
- package/jest.config.cjs +0 -21
- package/jest.mocks/fluid-cli.ts +0 -33
- package/src/__tests__/plugin-state.test.ts +0 -186
- package/src/api.ts +0 -28
- package/src/commands/dev.ts +0 -186
- package/src/commands/init.ts +0 -51
- package/src/commands/lint.ts +0 -186
- package/src/commands/navigate.ts +0 -259
- package/src/commands/pull.ts +0 -242
- package/src/commands/push.ts +0 -220
- package/src/commands/theme.ts +0 -23
- package/src/index.ts +0 -12
- package/src/plugin-state.ts +0 -171
- package/src/theme/dev-server/hot-reload.ts +0 -65
- package/src/theme/dev-server/index.ts +0 -145
- package/src/theme/dev-server/proxy.ts +0 -125
- package/src/theme/dev-server/sse.ts +0 -43
- package/src/theme/dev-server/watcher.ts +0 -54
- package/src/theme/file.ts +0 -104
- package/src/theme/fluid-ignore.ts +0 -64
- package/src/theme/mime-type.ts +0 -45
- package/src/theme/root.ts +0 -54
- package/src/theme/syncer.ts +0 -338
- package/src/theme-config.ts +0 -34
- package/src/theme-picker.ts +0 -164
- package/src/workspace.ts +0 -71
- package/tsconfig.json +0 -10
- package/tsdown.config.ts +0 -19
- /package/{skills → dist/skills}/themes-review/SKILL.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/blocks-vs-sections.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/css-js-hygiene.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/dead-code.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/dynamism.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/editor-attributes.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/examples.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/fairshare-attributes.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/global-settings.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/liquid-correctness.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/navigation.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/performance.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/security-accessibility.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/setting-types.md +0 -0
package/src/commands/push.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
|
-
import { Command } from "commander";
|
|
3
|
-
import ora from "ora";
|
|
4
|
-
import prompts from "prompts";
|
|
5
|
-
import { requireToken, createApiClient } from "../api.js";
|
|
6
|
-
import { readThemeConfig, writeThemeConfig } from "../theme-config.js";
|
|
7
|
-
import { ThemeRoot } from "../theme/root.js";
|
|
8
|
-
import { Syncer } from "../theme/syncer.js";
|
|
9
|
-
import { themes } from "@fluid-app/themes-api-client";
|
|
10
|
-
import {
|
|
11
|
-
selectTheme,
|
|
12
|
-
findTheme,
|
|
13
|
-
type ApplicationTheme,
|
|
14
|
-
} from "../theme-picker.js";
|
|
15
|
-
import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Detect files where the remote has changed since the last pull,
|
|
19
|
-
* and we also have local changes (i.e. we'd overwrite someone else's work).
|
|
20
|
-
*/
|
|
21
|
-
function detectRemoteDrift(
|
|
22
|
-
storedChecksums: Record<string, string>,
|
|
23
|
-
remoteChecksums: Record<string, string>,
|
|
24
|
-
themeRoot: ThemeRoot,
|
|
25
|
-
): string[] {
|
|
26
|
-
const conflicts: string[] = [];
|
|
27
|
-
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
28
|
-
const remoteChecksum = remoteChecksums[key];
|
|
29
|
-
if (remoteChecksum === undefined) continue;
|
|
30
|
-
if (remoteChecksum === storedChecksum) continue; // remote unchanged since pull
|
|
31
|
-
|
|
32
|
-
// Remote changed — check if we also have this file locally (and it differs)
|
|
33
|
-
const file = themeRoot.file(key);
|
|
34
|
-
if (!file.exists) continue;
|
|
35
|
-
const localChecksum = file.checksum();
|
|
36
|
-
if (localChecksum === remoteChecksum) continue; // local matches remote already
|
|
37
|
-
|
|
38
|
-
conflicts.push(key);
|
|
39
|
-
}
|
|
40
|
-
return conflicts;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function createPushCommand(): Command {
|
|
44
|
-
return new Command("push")
|
|
45
|
-
.description("Push local theme files to a remote theme")
|
|
46
|
-
.option("-t, --theme <name-or-id>", "Theme name or ID to push to")
|
|
47
|
-
.option("-n, --nodelete", "Do not delete remote files missing locally")
|
|
48
|
-
.option("-f, --force", "Skip schema validation")
|
|
49
|
-
.option("-p, --publish", "Publish the theme after pushing")
|
|
50
|
-
.option(
|
|
51
|
-
"-u, --unpublished",
|
|
52
|
-
"Create a new unpublished theme and push to it",
|
|
53
|
-
)
|
|
54
|
-
.option("--root <path>", "Theme root directory", ".")
|
|
55
|
-
.action(
|
|
56
|
-
async (opts: {
|
|
57
|
-
theme?: string;
|
|
58
|
-
nodelete?: boolean;
|
|
59
|
-
force?: boolean;
|
|
60
|
-
publish?: boolean;
|
|
61
|
-
unpublished?: boolean;
|
|
62
|
-
root: string;
|
|
63
|
-
}) => {
|
|
64
|
-
requireToken();
|
|
65
|
-
|
|
66
|
-
// If no explicit --root and we're inside a workspace, resolve to the theme root
|
|
67
|
-
let rootPath = opts.root;
|
|
68
|
-
if (rootPath === ".") {
|
|
69
|
-
const workspace = findWorkspace();
|
|
70
|
-
if (workspace) {
|
|
71
|
-
rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const themeRoot = new ThemeRoot(rootPath);
|
|
76
|
-
if (!themeRoot.isValid()) {
|
|
77
|
-
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
78
|
-
process.exit(1);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const api = createApiClient();
|
|
82
|
-
const config = readThemeConfig(themeRoot.root);
|
|
83
|
-
let theme: ApplicationTheme;
|
|
84
|
-
|
|
85
|
-
if (opts.unpublished) {
|
|
86
|
-
const { name } = await prompts(
|
|
87
|
-
{
|
|
88
|
-
type: "text",
|
|
89
|
-
name: "name",
|
|
90
|
-
message: "Name for the new theme",
|
|
91
|
-
},
|
|
92
|
-
{ onCancel: () => process.exit(130) },
|
|
93
|
-
);
|
|
94
|
-
if (!name) {
|
|
95
|
-
console.error("Theme name is required.");
|
|
96
|
-
process.exit(1);
|
|
97
|
-
}
|
|
98
|
-
const body = await themes.createApplicationTheme(api, {
|
|
99
|
-
application_theme: { name, status: "draft" },
|
|
100
|
-
});
|
|
101
|
-
theme = body.application_theme;
|
|
102
|
-
console.log(
|
|
103
|
-
`Created unpublished theme: ${theme.name} (#${theme.id})`,
|
|
104
|
-
);
|
|
105
|
-
} else if (opts.theme) {
|
|
106
|
-
theme = await findTheme(api, opts.theme);
|
|
107
|
-
} else if (config) {
|
|
108
|
-
// Use .fluid-theme.json as the default
|
|
109
|
-
console.log(
|
|
110
|
-
` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`,
|
|
111
|
-
);
|
|
112
|
-
const body = await themes.getApplicationTheme(api, config.themeId);
|
|
113
|
-
theme = body.application_theme;
|
|
114
|
-
} else {
|
|
115
|
-
theme = await selectTheme(api, "Select a theme to push to");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Check for remote drift if we have stored checksums
|
|
119
|
-
if (config?.checksums && !opts.force) {
|
|
120
|
-
const driftSpinner = ora("Checking for remote changes…").start();
|
|
121
|
-
const driftSyncer = new Syncer(api, theme.id, themeRoot);
|
|
122
|
-
await driftSyncer.fetchChecksums();
|
|
123
|
-
const remoteChecksums = driftSyncer.remoteChecksums();
|
|
124
|
-
const conflicts = detectRemoteDrift(
|
|
125
|
-
config.checksums,
|
|
126
|
-
remoteChecksums,
|
|
127
|
-
themeRoot,
|
|
128
|
-
);
|
|
129
|
-
driftSpinner.stop();
|
|
130
|
-
|
|
131
|
-
if (conflicts.length > 0) {
|
|
132
|
-
console.log(
|
|
133
|
-
chalk.yellow(
|
|
134
|
-
`\n⚠ ${conflicts.length} file(s) changed on remote since last pull:\n`,
|
|
135
|
-
),
|
|
136
|
-
);
|
|
137
|
-
for (const key of conflicts) {
|
|
138
|
-
console.log(` ${key}`);
|
|
139
|
-
}
|
|
140
|
-
console.log();
|
|
141
|
-
|
|
142
|
-
const { resolution } = await prompts(
|
|
143
|
-
{
|
|
144
|
-
type: "select",
|
|
145
|
-
name: "resolution",
|
|
146
|
-
message: "How do you want to handle this?",
|
|
147
|
-
choices: [
|
|
148
|
-
{
|
|
149
|
-
title: "Push anyway (overwrite remote changes)",
|
|
150
|
-
value: "push",
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
title: "Pull first, then push",
|
|
154
|
-
value: "pull-first",
|
|
155
|
-
},
|
|
156
|
-
{ title: "Abort", value: "abort" },
|
|
157
|
-
],
|
|
158
|
-
},
|
|
159
|
-
{ onCancel: () => process.exit(130) },
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
if (resolution === "abort") {
|
|
163
|
-
console.log("Aborted.");
|
|
164
|
-
process.exit(0);
|
|
165
|
-
}
|
|
166
|
-
if (resolution === "pull-first") {
|
|
167
|
-
console.log(
|
|
168
|
-
`Run ${chalk.cyan("fluid theme pull")} first, then push again.`,
|
|
169
|
-
);
|
|
170
|
-
process.exit(0);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
176
|
-
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
177
|
-
|
|
178
|
-
const result = await syncer.uploadTheme({
|
|
179
|
-
delete: !opts.nodelete,
|
|
180
|
-
validate: !opts.force,
|
|
181
|
-
onProgress: (d, total) => {
|
|
182
|
-
spinner.text = `Pushing ${d}/${total} files…`;
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
if (result.validationFailed) {
|
|
187
|
-
spinner.fail(
|
|
188
|
-
`Schema validation failed (${result.errors.length} error(s)). Use --force to skip.`,
|
|
189
|
-
);
|
|
190
|
-
for (const e of result.errors) console.error(` ${e}`);
|
|
191
|
-
process.exit(1);
|
|
192
|
-
} else if (result.errors.length) {
|
|
193
|
-
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
194
|
-
for (const e of result.errors) console.error(` ${e}`);
|
|
195
|
-
} else {
|
|
196
|
-
spinner.succeed(
|
|
197
|
-
`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Update stored checksums after successful push
|
|
202
|
-
if (config) {
|
|
203
|
-
writeThemeConfig(themeRoot.root, {
|
|
204
|
-
...config,
|
|
205
|
-
checksums: syncer.remoteChecksums(),
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (opts.publish) {
|
|
210
|
-
const pubSpinner = ora("Publishing theme…").start();
|
|
211
|
-
try {
|
|
212
|
-
await themes.publishApplicationTheme(api, theme.id);
|
|
213
|
-
pubSpinner.succeed("Theme published.");
|
|
214
|
-
} catch (e) {
|
|
215
|
-
pubSpinner.fail(`Publish failed: ${e}`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
},
|
|
219
|
-
);
|
|
220
|
-
}
|
package/src/commands/theme.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import type { PluginContext } from "@fluid-app/fluid-cli";
|
|
3
|
-
import { createDevCommand } from "./dev.js";
|
|
4
|
-
import { createPushCommand } from "./push.js";
|
|
5
|
-
import { createPullCommand } from "./pull.js";
|
|
6
|
-
import { createLintCommand } from "./lint.js";
|
|
7
|
-
import { createInitCommand } from "./init.js";
|
|
8
|
-
import { createNavigateCommand } from "./navigate.js";
|
|
9
|
-
|
|
10
|
-
export function registerThemeCommand(ctx: PluginContext): void {
|
|
11
|
-
const cmd = new Command("theme").description(
|
|
12
|
-
"Theme developer workflow — dev server, push, pull, lint, init",
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
cmd.addCommand(createDevCommand());
|
|
16
|
-
cmd.addCommand(createPushCommand());
|
|
17
|
-
cmd.addCommand(createPullCommand());
|
|
18
|
-
cmd.addCommand(createLintCommand());
|
|
19
|
-
cmd.addCommand(createInitCommand());
|
|
20
|
-
cmd.addCommand(createNavigateCommand());
|
|
21
|
-
|
|
22
|
-
ctx.program.addCommand(cmd);
|
|
23
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { FluidPlugin, PluginContext } from "@fluid-app/fluid-cli";
|
|
2
|
-
import { registerThemeCommand } from "./commands/theme.js";
|
|
3
|
-
|
|
4
|
-
const plugin: FluidPlugin = {
|
|
5
|
-
name: "@fluid-app/fluid-cli-theme-dev",
|
|
6
|
-
version: "0.1.0",
|
|
7
|
-
register(ctx: PluginContext) {
|
|
8
|
-
registerThemeCommand(ctx);
|
|
9
|
-
},
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export default plugin;
|
package/src/plugin-state.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { readConfig, updateConfig } from "@fluid-app/fluid-cli";
|
|
3
|
-
|
|
4
|
-
export interface DevThemeRef {
|
|
5
|
-
id: number;
|
|
6
|
-
name: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface ThemeDevState {
|
|
10
|
-
/**
|
|
11
|
-
* Dev themes keyed per project, so `theme dev` in one working copy never
|
|
12
|
-
* reuses (and clobbers) another project's sandbox theme. See `devThemeKey`.
|
|
13
|
-
* Entries are pruned once their theme directory no longer exists, so the map
|
|
14
|
-
* can't grow without bound as projects (and one-off/temp dirs) come and go.
|
|
15
|
-
*/
|
|
16
|
-
devThemes?: Record<string, DevThemeRef>;
|
|
17
|
-
/** Most recently started dev theme — `navigate`'s default target. */
|
|
18
|
-
lastDevThemeId?: number;
|
|
19
|
-
/**
|
|
20
|
-
* Legacy single global dev theme id. Older CLI versions stored one dev theme
|
|
21
|
-
* here regardless of project. Read once for migration (see `getDevTheme`),
|
|
22
|
-
* then dropped in favour of `devThemes`.
|
|
23
|
-
*/
|
|
24
|
-
devThemeId?: number;
|
|
25
|
-
/** Legacy companion to `devThemeId`. */
|
|
26
|
-
devThemeName?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const PLUGIN_KEY = "theme-dev";
|
|
30
|
-
|
|
31
|
-
function getState(): ThemeDevState {
|
|
32
|
-
const config = readConfig();
|
|
33
|
-
return (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Extract the absolute theme root from a `company:themeRoot` key. */
|
|
37
|
-
function themeRootFromKey(key: string): string {
|
|
38
|
-
const sep = key.indexOf(":");
|
|
39
|
-
return sep === -1 ? key : key.slice(sep + 1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Set `key` to `theme`, dropping any entries whose theme directory no longer
|
|
44
|
-
* exists. Tying an entry's lifetime to its directory keeps the map bounded —
|
|
45
|
-
* abandoned/deleted projects fall out the next time `theme dev` runs anywhere.
|
|
46
|
-
*/
|
|
47
|
-
function withDevTheme(
|
|
48
|
-
existing: Record<string, DevThemeRef> | undefined,
|
|
49
|
-
key: string,
|
|
50
|
-
theme: DevThemeRef,
|
|
51
|
-
): Record<string, DevThemeRef> {
|
|
52
|
-
const next: Record<string, DevThemeRef> = {};
|
|
53
|
-
for (const [k, v] of Object.entries(existing ?? {})) {
|
|
54
|
-
if (existsSync(themeRootFromKey(k))) next[k] = v;
|
|
55
|
-
}
|
|
56
|
-
next[key] = theme;
|
|
57
|
-
return next;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Stable key identifying a dev theme's owning project: the Fluid company
|
|
62
|
-
* (subdomains are globally unique) plus the absolute theme root. Two working
|
|
63
|
-
* copies — or the same copy pulled from two companies — get distinct keys.
|
|
64
|
-
*/
|
|
65
|
-
export function devThemeKey(
|
|
66
|
-
company: string | undefined,
|
|
67
|
-
themeRoot: string,
|
|
68
|
-
): string {
|
|
69
|
-
return `${company ?? "default"}:${themeRoot}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The dev theme stored for a project key, if any. Falls back once to the legacy
|
|
74
|
-
* global `devThemeId` (older CLI versions) and adopts it for this key — clearing
|
|
75
|
-
* the legacy fields so a second project can't adopt the same theme and collide.
|
|
76
|
-
*/
|
|
77
|
-
export function getDevTheme(key: string): DevThemeRef | undefined {
|
|
78
|
-
const state = getState();
|
|
79
|
-
const existing = state.devThemes?.[key];
|
|
80
|
-
if (existing) return existing;
|
|
81
|
-
|
|
82
|
-
if (state.devThemeId) {
|
|
83
|
-
const migrated: DevThemeRef = {
|
|
84
|
-
id: state.devThemeId,
|
|
85
|
-
name: state.devThemeName ?? `Development #${state.devThemeId}`,
|
|
86
|
-
};
|
|
87
|
-
updateConfig((config) => {
|
|
88
|
-
const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
|
|
89
|
-
const { devThemeId: _id, devThemeName: _name, ...rest } = current;
|
|
90
|
-
return {
|
|
91
|
-
...config,
|
|
92
|
-
plugins: {
|
|
93
|
-
...config.plugins,
|
|
94
|
-
[PLUGIN_KEY]: {
|
|
95
|
-
...rest,
|
|
96
|
-
devThemes: withDevTheme(rest.devThemes, key, migrated),
|
|
97
|
-
lastDevThemeId: migrated.id,
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
});
|
|
102
|
-
return migrated;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Store (or refresh) the dev theme for a project key and mark it most-recent. */
|
|
109
|
-
export function setDevTheme(key: string, theme: DevThemeRef): void {
|
|
110
|
-
updateConfig((config) => {
|
|
111
|
-
const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
|
|
112
|
-
return {
|
|
113
|
-
...config,
|
|
114
|
-
plugins: {
|
|
115
|
-
...config.plugins,
|
|
116
|
-
[PLUGIN_KEY]: {
|
|
117
|
-
...current,
|
|
118
|
-
devThemes: withDevTheme(current.devThemes, key, theme),
|
|
119
|
-
lastDevThemeId: theme.id,
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Forget a project's dev theme (it was deleted remotely or is no longer a dev theme). */
|
|
127
|
-
export function clearDevTheme(key: string): void {
|
|
128
|
-
updateConfig((config) => {
|
|
129
|
-
const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
|
|
130
|
-
const removed = current.devThemes?.[key];
|
|
131
|
-
if (!removed) return config;
|
|
132
|
-
const { [key]: _removed, ...rest } = current.devThemes ?? {};
|
|
133
|
-
const next: ThemeDevState = { ...current, devThemes: rest };
|
|
134
|
-
// Don't leave `navigate` pointing at a theme we just forgot.
|
|
135
|
-
if (current.lastDevThemeId === removed.id) {
|
|
136
|
-
next.lastDevThemeId = undefined;
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
...config,
|
|
140
|
-
plugins: { ...config.plugins, [PLUGIN_KEY]: next },
|
|
141
|
-
};
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Mark a theme as the most recently started dev server (`navigate`'s default)
|
|
147
|
-
* without recording it as a project's dev theme — used for the `--theme`
|
|
148
|
-
* escape hatch, which may target an arbitrary (non-dev) theme.
|
|
149
|
-
*/
|
|
150
|
-
export function setLastDevThemeId(id: number): void {
|
|
151
|
-
updateConfig((config) => {
|
|
152
|
-
const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
|
|
153
|
-
return {
|
|
154
|
-
...config,
|
|
155
|
-
plugins: {
|
|
156
|
-
...config.plugins,
|
|
157
|
-
[PLUGIN_KEY]: { ...current, lastDevThemeId: id },
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* The dev theme to target by default in `navigate` — the most recently started
|
|
165
|
-
* dev server. Falls back to the legacy global id for users who haven't yet run
|
|
166
|
-
* the per-project `theme dev`.
|
|
167
|
-
*/
|
|
168
|
-
export function getLastDevThemeId(): number | undefined {
|
|
169
|
-
const state = getState();
|
|
170
|
-
return state.lastDevThemeId ?? state.devThemeId;
|
|
171
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
export function buildHotReloadScript(mode: "full-page" | "off"): string {
|
|
2
|
-
return `
|
|
3
|
-
<script>
|
|
4
|
-
(() => {
|
|
5
|
-
window.__FLUID_CLI_ENV__ = ${JSON.stringify({ mode })};
|
|
6
|
-
|
|
7
|
-
class HotReload {
|
|
8
|
-
static reloadMode() { return window.__FLUID_CLI_ENV__.mode; }
|
|
9
|
-
static isActive() { return HotReload.reloadMode() !== "off"; }
|
|
10
|
-
static setHotReloadCookie(files) {
|
|
11
|
-
const expires = new Date(Date.now() + 3000).toUTCString();
|
|
12
|
-
document.cookie = \`hot_reload_files=\${files.join(",")};expires=\${expires};path=/\`;
|
|
13
|
-
}
|
|
14
|
-
static refresh(files) {
|
|
15
|
-
HotReload.setHotReloadCookie(files);
|
|
16
|
-
console.log("[HotReload] Refreshing page");
|
|
17
|
-
window.location.reload();
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
class SSEClient {
|
|
22
|
-
constructor(url, handler) {
|
|
23
|
-
if (typeof EventSource === "undefined") {
|
|
24
|
-
console.error("[HotReload] EventSource not supported in this browser.");
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
console.log("[HotReload] Initializing…");
|
|
28
|
-
this.url = url;
|
|
29
|
-
this.handler = handler;
|
|
30
|
-
}
|
|
31
|
-
connect() {
|
|
32
|
-
const es = new EventSource(this.url);
|
|
33
|
-
es.onopen = () => console.log("[HotReload] SSE connected.");
|
|
34
|
-
es.onerror = () => {
|
|
35
|
-
console.log("[HotReload] SSE closed. Reconnecting in 5s…");
|
|
36
|
-
es.close();
|
|
37
|
-
setTimeout(() => this.connect(), 5000);
|
|
38
|
-
};
|
|
39
|
-
es.onmessage = (msg) => {
|
|
40
|
-
const data = JSON.parse(msg.data);
|
|
41
|
-
if (data.reload_page) { HotReload.refresh([]); return; }
|
|
42
|
-
this.handler(data);
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (HotReload.isActive()) {
|
|
48
|
-
new SSEClient("/hot-reload", (data) => {
|
|
49
|
-
if (data.modified) HotReload.refresh(data.modified);
|
|
50
|
-
}).connect();
|
|
51
|
-
}
|
|
52
|
-
})();
|
|
53
|
-
</script>`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function injectHotReload(
|
|
57
|
-
html: string,
|
|
58
|
-
mode: "full-page" | "off",
|
|
59
|
-
): string {
|
|
60
|
-
const script = buildHotReloadScript(mode);
|
|
61
|
-
if (html.includes("</body>")) {
|
|
62
|
-
return html.replace("</body>", `${script}\n</body>`);
|
|
63
|
-
}
|
|
64
|
-
return html + script;
|
|
65
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import { SSEStream } from "./sse.js";
|
|
3
|
-
import { proxyRequest } from "./proxy.js";
|
|
4
|
-
import { watchTheme } from "./watcher.js";
|
|
5
|
-
import { Syncer } from "../syncer.js";
|
|
6
|
-
import type { ThemeRoot } from "../root.js";
|
|
7
|
-
import type { ApiClient } from "../../api.js";
|
|
8
|
-
|
|
9
|
-
export interface DevServerOptions {
|
|
10
|
-
host: string;
|
|
11
|
-
port: number;
|
|
12
|
-
reloadMode: "full-page" | "off";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface DevServerTheme {
|
|
16
|
-
id: number;
|
|
17
|
-
name: string;
|
|
18
|
-
company: string;
|
|
19
|
-
editorUrl?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function startDevServer(
|
|
23
|
-
api: ApiClient,
|
|
24
|
-
theme: DevServerTheme,
|
|
25
|
-
themeRoot: ThemeRoot,
|
|
26
|
-
opts: DevServerOptions & { validate?: boolean },
|
|
27
|
-
onReady?: (address: string) => void,
|
|
28
|
-
): Promise<() => void> {
|
|
29
|
-
const sse = new SSEStream();
|
|
30
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
31
|
-
|
|
32
|
-
const pendingUpdates = new Set<string>();
|
|
33
|
-
|
|
34
|
-
// ── Initial sync ─────────────────────────────────────────────────────────
|
|
35
|
-
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
36
|
-
const syncResult = await syncer.uploadTheme({
|
|
37
|
-
delete: true,
|
|
38
|
-
validate: opts.validate,
|
|
39
|
-
onProgress: (done, total) => {
|
|
40
|
-
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
process.stdout.write("\n");
|
|
44
|
-
if (syncResult.validationFailed) {
|
|
45
|
-
console.error(
|
|
46
|
-
`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`,
|
|
47
|
-
);
|
|
48
|
-
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
49
|
-
process.exit(1);
|
|
50
|
-
} else if (syncResult.errors.length > 0) {
|
|
51
|
-
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ── File watcher ─────────────────────────────────────────────────────────
|
|
55
|
-
const stopWatcher = watchTheme(
|
|
56
|
-
themeRoot,
|
|
57
|
-
async (modified, added, removed) => {
|
|
58
|
-
const changed = [...modified, ...added];
|
|
59
|
-
|
|
60
|
-
for (const file of changed) {
|
|
61
|
-
// Validate schema on liquid files during dev (warn, don't block)
|
|
62
|
-
if (opts.validate && file.isLiquid) {
|
|
63
|
-
const diagnostics = file.validateSchema();
|
|
64
|
-
for (const d of diagnostics) {
|
|
65
|
-
const prefix =
|
|
66
|
-
d.severity === "error" ? "Schema error" : "Schema warning";
|
|
67
|
-
console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
pendingUpdates.add(file.relativePath);
|
|
72
|
-
try {
|
|
73
|
-
await syncer.uploadFile(file);
|
|
74
|
-
} catch (e) {
|
|
75
|
-
console.error(
|
|
76
|
-
`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`,
|
|
77
|
-
);
|
|
78
|
-
} finally {
|
|
79
|
-
pendingUpdates.delete(file.relativePath);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
for (const file of removed) {
|
|
84
|
-
try {
|
|
85
|
-
await syncer.deleteRemoteFile(file.relativePath);
|
|
86
|
-
} catch {
|
|
87
|
-
// ignore
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (removed.length > 0) {
|
|
92
|
-
sse.broadcast(JSON.stringify({ reload_page: true }));
|
|
93
|
-
} else if (changed.length > 0) {
|
|
94
|
-
sse.broadcast(
|
|
95
|
-
JSON.stringify({ modified: changed.map((f) => f.relativePath) }),
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
// ── HTTP server ───────────────────────────────────────────────────────────
|
|
102
|
-
const server = http.createServer(async (req, res) => {
|
|
103
|
-
if (req.url === "/hot-reload") {
|
|
104
|
-
sse.add(res);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
await proxyRequest(req, res, {
|
|
110
|
-
company: theme.company,
|
|
111
|
-
themeId: theme.id,
|
|
112
|
-
reloadMode: opts.reloadMode,
|
|
113
|
-
pendingFiles: () =>
|
|
114
|
-
[...pendingUpdates]
|
|
115
|
-
.map((p) => themeRoot.file(p))
|
|
116
|
-
.filter((f) => f.isText)
|
|
117
|
-
.map((f) => ({
|
|
118
|
-
relativePath: f.relativePath,
|
|
119
|
-
read: () => f.read(),
|
|
120
|
-
})),
|
|
121
|
-
});
|
|
122
|
-
} catch (e) {
|
|
123
|
-
console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
|
|
124
|
-
if (!res.headersSent) {
|
|
125
|
-
res.writeHead(502);
|
|
126
|
-
res.end("Bad Gateway");
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
await new Promise<void>((resolve, reject) => {
|
|
132
|
-
server.listen(opts.port, opts.host, () => resolve());
|
|
133
|
-
server.on("error", reject);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const address = `http://${opts.host}:${opts.port}`;
|
|
137
|
-
onReady?.(address);
|
|
138
|
-
|
|
139
|
-
// ── Teardown ──────────────────────────────────────────────────────────────
|
|
140
|
-
return function stop() {
|
|
141
|
-
sse.close();
|
|
142
|
-
stopWatcher();
|
|
143
|
-
server.close();
|
|
144
|
-
};
|
|
145
|
-
}
|