@fluid-app/fluid-cli-theme-dev 0.1.15 → 0.1.16
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/.turbo/turbo-build.log +5 -5
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/index.mjs +436 -56
- package/dist/index.mjs.map +1 -1
- package/jest.config.cjs +21 -0
- package/jest.mocks/fluid-cli.ts +33 -0
- package/package.json +9 -4
- package/src/__tests__/plugin-state.test.ts +186 -0
- package/src/commands/dev.ts +36 -16
- package/src/commands/lint.ts +175 -0
- package/src/commands/navigate.ts +2 -4
- package/src/commands/theme.ts +3 -1
- package/src/plugin-state.ts +156 -11
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
testEnvironment: "node",
|
|
3
|
+
testMatch: ["**/src/**/__tests__/**/*.test.ts"],
|
|
4
|
+
transform: {
|
|
5
|
+
"^.+\\.(t|j)sx?$": [
|
|
6
|
+
"@swc/jest",
|
|
7
|
+
{
|
|
8
|
+
module: {
|
|
9
|
+
type: "commonjs",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
|
15
|
+
moduleNameMapper: {
|
|
16
|
+
// The real package only ships ESM, which Jest can't resolve in CJS mode;
|
|
17
|
+
// map it to a stateful in-memory mock for plugin-state tests.
|
|
18
|
+
"^@fluid-app/fluid-cli$": "<rootDir>/jest.mocks/fluid-cli.ts",
|
|
19
|
+
"^(\\.{1,2}/.*)\\.js$": "$1",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest mock for @fluid-app/fluid-cli.
|
|
3
|
+
*
|
|
4
|
+
* The real package only exports ESM (.mjs), which Jest can't resolve in CJS
|
|
5
|
+
* mode. plugin-state.ts only uses `readConfig`/`updateConfig`, so this mock
|
|
6
|
+
* backs them with an in-memory config. Tests seed/reset state through the
|
|
7
|
+
* normal `updateConfig` surface, so no test-only exports are needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface FakeConfig {
|
|
11
|
+
activeProfile: string | null;
|
|
12
|
+
profiles: Record<string, unknown>;
|
|
13
|
+
plugins: Record<string, unknown>;
|
|
14
|
+
enabledPlugins: string[] | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let config: FakeConfig = {
|
|
18
|
+
activeProfile: null,
|
|
19
|
+
profiles: {},
|
|
20
|
+
plugins: {},
|
|
21
|
+
enabledPlugins: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function readConfig(): FakeConfig {
|
|
25
|
+
return config;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function updateConfig(
|
|
29
|
+
updater: (config: FakeConfig) => FakeConfig,
|
|
30
|
+
): FakeConfig {
|
|
31
|
+
config = updater(config);
|
|
32
|
+
return config;
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fluid-app/fluid-cli-theme-dev",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Fluid CLI plugin for theme developer workflows — dev server, push, pull, init",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -23,16 +23,20 @@
|
|
|
23
23
|
"open": "^10.0.0",
|
|
24
24
|
"ora": "^8.0.0",
|
|
25
25
|
"prompts": "^2.4.2",
|
|
26
|
-
"@fluid-app/fluid-cli": "0.1.
|
|
26
|
+
"@fluid-app/fluid-cli": "0.1.8"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
+
"@swc/core": "^1.15.18",
|
|
30
|
+
"@swc/jest": "^0.2.39",
|
|
31
|
+
"@types/jest": "^29.5.14",
|
|
29
32
|
"@types/node": "^24",
|
|
30
33
|
"@types/prompts": "^2.4.9",
|
|
34
|
+
"jest": "^29.7.0",
|
|
31
35
|
"tsdown": "^0.21.0",
|
|
32
36
|
"typescript": "^5",
|
|
33
37
|
"@fluid-app/api-client-core": "0.1.0",
|
|
34
|
-
"@fluid-app/typescript-config": "0.0.0",
|
|
35
38
|
"@fluid-app/theme-schema": "0.1.0",
|
|
39
|
+
"@fluid-app/typescript-config": "0.0.0",
|
|
36
40
|
"@fluid-app/themes-api-client": "0.1.0"
|
|
37
41
|
},
|
|
38
42
|
"engines": {
|
|
@@ -41,6 +45,7 @@
|
|
|
41
45
|
"scripts": {
|
|
42
46
|
"build": "tsdown",
|
|
43
47
|
"dev": "tsdown --watch",
|
|
44
|
-
"typecheck": "tsc --noEmit"
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "jest"
|
|
45
50
|
}
|
|
46
51
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
readConfig,
|
|
4
|
+
updateConfig,
|
|
5
|
+
type FluidConfig,
|
|
6
|
+
} from "@fluid-app/fluid-cli";
|
|
7
|
+
import {
|
|
8
|
+
devThemeKey,
|
|
9
|
+
getDevTheme,
|
|
10
|
+
setDevTheme,
|
|
11
|
+
clearDevTheme,
|
|
12
|
+
setLastDevThemeId,
|
|
13
|
+
getLastDevThemeId,
|
|
14
|
+
} from "../plugin-state.js";
|
|
15
|
+
|
|
16
|
+
// `@fluid-app/fluid-cli` is mapped to an in-memory mock (jest.mocks/fluid-cli.ts)
|
|
17
|
+
// so these helpers never touch the real ~/.fluid/config.json. State is seeded
|
|
18
|
+
// and reset through the normal `updateConfig` surface.
|
|
19
|
+
jest.mock("node:fs", () => ({ existsSync: jest.fn() }));
|
|
20
|
+
const existsSyncMock = existsSync as unknown as jest.Mock;
|
|
21
|
+
|
|
22
|
+
const PLUGIN_KEY = "theme-dev";
|
|
23
|
+
|
|
24
|
+
function freshConfig(): FluidConfig {
|
|
25
|
+
return {
|
|
26
|
+
activeProfile: null,
|
|
27
|
+
profiles: {},
|
|
28
|
+
plugins: {},
|
|
29
|
+
enabledPlugins: null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setPluginState(state: Record<string, unknown>): void {
|
|
34
|
+
updateConfig((config) => ({
|
|
35
|
+
...config,
|
|
36
|
+
plugins: { ...config.plugins, [PLUGIN_KEY]: state },
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pluginState(): Record<string, unknown> {
|
|
41
|
+
return readConfig().plugins[PLUGIN_KEY] as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
updateConfig(() => freshConfig());
|
|
46
|
+
// Default: every stored project directory still exists (no pruning).
|
|
47
|
+
existsSyncMock.mockReset();
|
|
48
|
+
existsSyncMock.mockReturnValue(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("devThemeKey", () => {
|
|
52
|
+
it("combines company and theme root", () => {
|
|
53
|
+
expect(devThemeKey("acme", "/themes/store")).toBe("acme:/themes/store");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to 'default' when company is undefined", () => {
|
|
57
|
+
expect(devThemeKey(undefined, "/themes/store")).toBe(
|
|
58
|
+
"default:/themes/store",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("setDevTheme / getDevTheme", () => {
|
|
64
|
+
it("stores and retrieves a dev theme per key", () => {
|
|
65
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
66
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("isolates dev themes across keys", () => {
|
|
70
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
71
|
+
setDevTheme("acme:/b", { id: 2, name: "B" });
|
|
72
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
|
|
73
|
+
expect(getDevTheme("acme:/b")).toEqual({ id: 2, name: "B" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns undefined for an unknown key", () => {
|
|
77
|
+
expect(getDevTheme("acme:/missing")).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("marks the most recently stored theme as lastDevThemeId", () => {
|
|
81
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
82
|
+
setDevTheme("acme:/b", { id: 2, name: "B" });
|
|
83
|
+
expect(getLastDevThemeId()).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("clearDevTheme", () => {
|
|
88
|
+
it("removes only the targeted key", () => {
|
|
89
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
90
|
+
setDevTheme("acme:/b", { id: 2, name: "B" });
|
|
91
|
+
clearDevTheme("acme:/a");
|
|
92
|
+
expect(getDevTheme("acme:/a")).toBeUndefined();
|
|
93
|
+
expect(getDevTheme("acme:/b")).toEqual({ id: 2, name: "B" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("is a no-op for an unknown key", () => {
|
|
97
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
98
|
+
clearDevTheme("acme:/missing");
|
|
99
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("clears lastDevThemeId when it pointed at the removed theme", () => {
|
|
103
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
104
|
+
expect(getLastDevThemeId()).toBe(1);
|
|
105
|
+
clearDevTheme("acme:/a");
|
|
106
|
+
expect(getLastDevThemeId()).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("leaves lastDevThemeId when it points at a different theme", () => {
|
|
110
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
111
|
+
setDevTheme("acme:/b", { id: 2, name: "B" });
|
|
112
|
+
clearDevTheme("acme:/a"); // most-recent is /b (id 2)
|
|
113
|
+
expect(getLastDevThemeId()).toBe(2);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("devThemes map stays bounded", () => {
|
|
118
|
+
it("prunes entries whose theme directory no longer exists on the next write", () => {
|
|
119
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
120
|
+
setDevTheme("acme:/b", { id: 2, name: "B" });
|
|
121
|
+
|
|
122
|
+
// /a is deleted; /b and the new /c still exist.
|
|
123
|
+
existsSyncMock.mockImplementation((p: string) => p !== "/a");
|
|
124
|
+
setDevTheme("acme:/c", { id: 3, name: "C" });
|
|
125
|
+
|
|
126
|
+
expect(getDevTheme("acme:/a")).toBeUndefined(); // pruned
|
|
127
|
+
expect(getDevTheme("acme:/b")).toEqual({ id: 2, name: "B" });
|
|
128
|
+
expect(getDevTheme("acme:/c")).toEqual({ id: 3, name: "C" });
|
|
129
|
+
expect(
|
|
130
|
+
Object.keys(pluginState()["devThemes"] as Record<string, unknown>),
|
|
131
|
+
).toEqual(["acme:/b", "acme:/c"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps the entry being written even if its own directory check fails", () => {
|
|
135
|
+
existsSyncMock.mockReturnValue(false);
|
|
136
|
+
setDevTheme("acme:/a", { id: 1, name: "A" });
|
|
137
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("setLastDevThemeId", () => {
|
|
142
|
+
it("updates the navigate pointer without recording a dev theme", () => {
|
|
143
|
+
setLastDevThemeId(123);
|
|
144
|
+
expect(getLastDevThemeId()).toBe(123);
|
|
145
|
+
expect(getDevTheme("acme:/a")).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("legacy migration", () => {
|
|
150
|
+
it("adopts a legacy global devThemeId for the first requesting key, then clears it", () => {
|
|
151
|
+
setPluginState({ devThemeId: 99, devThemeName: "Legacy" });
|
|
152
|
+
|
|
153
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 99, name: "Legacy" });
|
|
154
|
+
|
|
155
|
+
// Legacy fields are cleared so a second project can't adopt the same theme.
|
|
156
|
+
const state = pluginState();
|
|
157
|
+
expect(state["devThemeId"]).toBeUndefined();
|
|
158
|
+
expect(state["devThemeName"]).toBeUndefined();
|
|
159
|
+
expect(getDevTheme("acme:/b")).toBeUndefined();
|
|
160
|
+
expect(getLastDevThemeId()).toBe(99);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("synthesizes a name when the legacy name is absent", () => {
|
|
164
|
+
setPluginState({ devThemeId: 42 });
|
|
165
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 42, name: "Development #42" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("prefers an explicit per-key entry over the legacy id", () => {
|
|
169
|
+
setPluginState({
|
|
170
|
+
devThemeId: 99,
|
|
171
|
+
devThemes: { "acme:/a": { id: 7, name: "Real" } },
|
|
172
|
+
});
|
|
173
|
+
expect(getDevTheme("acme:/a")).toEqual({ id: 7, name: "Real" });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("getLastDevThemeId", () => {
|
|
178
|
+
it("falls back to the legacy id before any per-project run", () => {
|
|
179
|
+
setPluginState({ devThemeId: 5 });
|
|
180
|
+
expect(getLastDevThemeId()).toBe(5);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns undefined when nothing is stored", () => {
|
|
184
|
+
expect(getLastDevThemeId()).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/commands/dev.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { requireToken, createApiClient } from "../api.js";
|
|
3
3
|
import { readThemeConfig } from "../theme-config.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
devThemeKey,
|
|
6
|
+
getDevTheme,
|
|
7
|
+
setDevTheme,
|
|
8
|
+
setLastDevThemeId,
|
|
9
|
+
clearDevTheme,
|
|
10
|
+
} from "../plugin-state.js";
|
|
5
11
|
import { ThemeRoot } from "../theme/root.js";
|
|
6
12
|
import { startDevServer } from "../theme/dev-server/index.js";
|
|
7
13
|
import { themes } from "@fluid-app/themes-api-client";
|
|
@@ -14,24 +20,34 @@ interface CompanyMe {
|
|
|
14
20
|
|
|
15
21
|
async function ensureDevTheme(
|
|
16
22
|
api: ReturnType<typeof createApiClient>,
|
|
23
|
+
projectKey: string,
|
|
17
24
|
identifier?: string,
|
|
18
25
|
): Promise<ApplicationTheme> {
|
|
19
26
|
if (identifier) {
|
|
20
|
-
|
|
27
|
+
const theme = await findTheme(api, identifier);
|
|
28
|
+
// Keep `navigate` pointed at whatever the dev server is actually serving.
|
|
29
|
+
setLastDevThemeId(theme.id);
|
|
30
|
+
return theme;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
// Reuse stored dev theme if it still exists
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
// Reuse this project's stored dev theme if it still exists and is still a
|
|
34
|
+
// development theme (a published/promoted theme must not be edited in place).
|
|
35
|
+
const stored = getDevTheme(projectKey);
|
|
36
|
+
if (stored) {
|
|
26
37
|
try {
|
|
27
|
-
const body = await themes.getApplicationTheme(api,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
const body = await themes.getApplicationTheme(api, stored.id);
|
|
39
|
+
const existing = body.application_theme;
|
|
40
|
+
if (existing && existing.status === "development") {
|
|
41
|
+
console.log(`Using existing dev theme #${existing.id}`);
|
|
42
|
+
// Refresh the stored name and mark it most-recent for `navigate`.
|
|
43
|
+
setDevTheme(projectKey, { id: existing.id, name: existing.name });
|
|
44
|
+
return existing;
|
|
31
45
|
}
|
|
32
46
|
} catch {
|
|
33
|
-
// Theme no longer exists — create a new one
|
|
47
|
+
// Theme no longer exists — fall through to create a new one.
|
|
34
48
|
}
|
|
49
|
+
// Stored theme is gone or no longer a dev theme; forget it.
|
|
50
|
+
clearDevTheme(projectKey);
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
// Create a new development theme
|
|
@@ -47,7 +63,7 @@ async function ensureDevTheme(
|
|
|
47
63
|
application_theme: { name, status: "development" },
|
|
48
64
|
});
|
|
49
65
|
const theme = body.application_theme;
|
|
50
|
-
|
|
66
|
+
setDevTheme(projectKey, { id: theme.id, name: theme.name });
|
|
51
67
|
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
52
68
|
return theme;
|
|
53
69
|
}
|
|
@@ -121,12 +137,16 @@ export function createDevCommand(): Command {
|
|
|
121
137
|
}
|
|
122
138
|
}
|
|
123
139
|
|
|
124
|
-
//
|
|
140
|
+
// Always iterate on an isolated dev theme: reuse the stored one or
|
|
141
|
+
// create a fresh `development` theme. `--theme` is the explicit
|
|
142
|
+
// escape hatch for targeting an existing theme. We deliberately do NOT
|
|
143
|
+
// fall back to `.fluid-theme.json`'s themeId — that points at whatever
|
|
144
|
+
// was pulled (often the active/production theme), and the dev server's
|
|
145
|
+
// `delete: true` sync would overwrite it in place.
|
|
146
|
+
const projectKey = devThemeKey(company, themeRoot.root);
|
|
125
147
|
const theme = opts.theme
|
|
126
|
-
? await ensureDevTheme(api, opts.theme)
|
|
127
|
-
:
|
|
128
|
-
? await ensureDevTheme(api, String(config.themeId))
|
|
129
|
-
: await ensureDevTheme(api);
|
|
148
|
+
? await ensureDevTheme(api, projectKey, opts.theme)
|
|
149
|
+
: await ensureDevTheme(api, projectKey);
|
|
130
150
|
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
131
151
|
|
|
132
152
|
let stop: (() => void) | undefined;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
findMissingSectionReferences,
|
|
5
|
+
validateSchemaText,
|
|
6
|
+
type BlocksSchemaType,
|
|
7
|
+
type Diagnostic,
|
|
8
|
+
type TemplateInput,
|
|
9
|
+
} from "@fluid-app/theme-schema";
|
|
10
|
+
import { ThemeRoot } from "../theme/root.js";
|
|
11
|
+
import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
|
|
12
|
+
|
|
13
|
+
interface FileDiagnostics {
|
|
14
|
+
path: string;
|
|
15
|
+
diagnostics: Diagnostic[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A theme section is defined by a liquid file under `templates/sections/`.
|
|
19
|
+
// Returns the section name a `{% section %}` tag would reference, or null if
|
|
20
|
+
// the file is not a section definition. Handles both the flat layout
|
|
21
|
+
// (`templates/sections/hero.liquid`) and the nested one
|
|
22
|
+
// (`templates/sections/hero/index.liquid`).
|
|
23
|
+
function sectionNameOf(relativePath: string): string | null {
|
|
24
|
+
const parts = relativePath.split(/[/\\]/);
|
|
25
|
+
if (
|
|
26
|
+
parts[0] === "templates" &&
|
|
27
|
+
parts[1] === "sections" &&
|
|
28
|
+
parts.length >= 3
|
|
29
|
+
) {
|
|
30
|
+
return parts[2]!.replace(/\.liquid$/, "");
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createLintCommand(): Command {
|
|
36
|
+
return new Command("lint")
|
|
37
|
+
.description("Validate theme files locally (read-only — no upload)")
|
|
38
|
+
.option("--root <path>", "Theme root directory", ".")
|
|
39
|
+
.option("--json", "Output results as compact JSON")
|
|
40
|
+
.action(async (opts: { root: string; json?: boolean }) => {
|
|
41
|
+
// Resolve the theme root the same way push/dev do: when left at the
|
|
42
|
+
// default, prefer the workspace's theme root if we're inside one.
|
|
43
|
+
let rootPath = opts.root;
|
|
44
|
+
if (rootPath === ".") {
|
|
45
|
+
const workspace = findWorkspace();
|
|
46
|
+
if (workspace) {
|
|
47
|
+
rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
52
|
+
if (!themeRoot.isValid()) {
|
|
53
|
+
const message = `'${rootPath}' does not look like a theme directory.`;
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
console.log(JSON.stringify({ ok: false, error: message }));
|
|
56
|
+
} else {
|
|
57
|
+
console.error(message);
|
|
58
|
+
}
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const files = themeRoot.files();
|
|
63
|
+
// Read each liquid file once and reuse the content for both passes
|
|
64
|
+
// (validateSchemaText and the section scan) to avoid a double disk read.
|
|
65
|
+
const liquidFiles = files
|
|
66
|
+
.filter((f) => f.isLiquid)
|
|
67
|
+
.map((f) => ({ file: f, content: f.read() }));
|
|
68
|
+
|
|
69
|
+
const byFile = new Map<string, Diagnostic[]>();
|
|
70
|
+
const record = (path: string, diagnostic: Diagnostic): void => {
|
|
71
|
+
const existing = byFile.get(path);
|
|
72
|
+
if (existing) existing.push(diagnostic);
|
|
73
|
+
else byFile.set(path, [diagnostic]);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ── Schema pass — the same {% schema %} validation `fluid theme push`
|
|
77
|
+
// runs. blocksSchemaType mirrors ThemeFile.validateSchema: page/layout
|
|
78
|
+
// templates use object blocks, sections use array blocks.
|
|
79
|
+
for (const { file, content } of liquidFiles) {
|
|
80
|
+
const blocksSchemaType: BlocksSchemaType = file.isTemplate
|
|
81
|
+
? "object"
|
|
82
|
+
: "array";
|
|
83
|
+
for (const diagnostic of validateSchemaText(content, {
|
|
84
|
+
blocksSchemaType,
|
|
85
|
+
})) {
|
|
86
|
+
record(file.relativePath, diagnostic);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Section pass — flag `{% section 'x' %}` references to a section
|
|
91
|
+
// that has no definition on disk. Section definitions and assets are
|
|
92
|
+
// not themselves referrers, so they are excluded from the scan.
|
|
93
|
+
const existingSectionNames = new Set<string>();
|
|
94
|
+
for (const { file } of liquidFiles) {
|
|
95
|
+
const name = sectionNameOf(file.relativePath);
|
|
96
|
+
if (name) existingSectionNames.add(name);
|
|
97
|
+
}
|
|
98
|
+
const referrers: TemplateInput[] = liquidFiles
|
|
99
|
+
.filter(({ file }) => sectionNameOf(file.relativePath) === null)
|
|
100
|
+
.map(({ file, content }) => ({ path: file.relativePath, content }));
|
|
101
|
+
for (const missing of findMissingSectionReferences(
|
|
102
|
+
referrers,
|
|
103
|
+
existingSectionNames,
|
|
104
|
+
)) {
|
|
105
|
+
record(missing.templatePath, missing.diagnostic);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const results: FileDiagnostics[] = [...byFile.entries()]
|
|
109
|
+
.map(([path, diagnostics]) => ({ path, diagnostics }))
|
|
110
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
111
|
+
|
|
112
|
+
let errors = 0;
|
|
113
|
+
let warnings = 0;
|
|
114
|
+
for (const { diagnostics } of results) {
|
|
115
|
+
for (const d of diagnostics) {
|
|
116
|
+
if (d.severity === "error") errors++;
|
|
117
|
+
else warnings++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (opts.json) {
|
|
122
|
+
console.log(
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
ok: errors === 0,
|
|
125
|
+
errors,
|
|
126
|
+
warnings,
|
|
127
|
+
filesChecked: liquidFiles.length,
|
|
128
|
+
files: results,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
printText(results, errors, warnings, liquidFiles.length);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
process.exit(errors > 0 ? 1 : 0);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function plural(count: number, noun: string): string {
|
|
140
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function printText(
|
|
144
|
+
results: FileDiagnostics[],
|
|
145
|
+
errors: number,
|
|
146
|
+
warnings: number,
|
|
147
|
+
filesChecked: number,
|
|
148
|
+
): void {
|
|
149
|
+
for (const { path, diagnostics } of results) {
|
|
150
|
+
console.log(chalk.bold(path));
|
|
151
|
+
for (const d of diagnostics) {
|
|
152
|
+
const label =
|
|
153
|
+
d.severity === "error"
|
|
154
|
+
? chalk.red("error".padEnd(7))
|
|
155
|
+
: chalk.yellow("warning".padEnd(7));
|
|
156
|
+
// Only the first line — diagnostics like the invalid-setting-type list
|
|
157
|
+
// carry a long multi-line body that the `--json` output preserves.
|
|
158
|
+
const message = d.message.split("\n")[0];
|
|
159
|
+
console.log(` ${label} ${message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const suffix = `(${plural(filesChecked, "file")} checked)`;
|
|
164
|
+
if (errors > 0) {
|
|
165
|
+
console.log(
|
|
166
|
+
`\n${chalk.red(`✖ ${plural(errors, "error")}, ${plural(warnings, "warning")}`)} ${suffix}`,
|
|
167
|
+
);
|
|
168
|
+
} else if (warnings > 0) {
|
|
169
|
+
console.log(
|
|
170
|
+
`\n${chalk.yellow(`⚠ ${plural(warnings, "warning")}`)} ${suffix}`,
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
console.log(`${chalk.green("✓ No problems found")} ${suffix}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/commands/navigate.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import prompts from "prompts";
|
|
3
3
|
import { requireToken, createApiClient } from "../api.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getLastDevThemeId } from "../plugin-state.js";
|
|
5
5
|
import { themes } from "@fluid-app/themes-api-client";
|
|
6
6
|
|
|
7
7
|
function localSuggest(
|
|
@@ -148,9 +148,7 @@ export function createNavigateCommand(): Command {
|
|
|
148
148
|
.action(async (opts: { host: string; port: string; theme?: string }) => {
|
|
149
149
|
requireToken();
|
|
150
150
|
|
|
151
|
-
const themeId = opts.theme
|
|
152
|
-
? Number(opts.theme)
|
|
153
|
-
: getPluginState().devThemeId;
|
|
151
|
+
const themeId = opts.theme ? Number(opts.theme) : getLastDevThemeId();
|
|
154
152
|
|
|
155
153
|
if (!themeId) {
|
|
156
154
|
console.error(
|
package/src/commands/theme.ts
CHANGED
|
@@ -3,17 +3,19 @@ import type { PluginContext } from "@fluid-app/fluid-cli";
|
|
|
3
3
|
import { createDevCommand } from "./dev.js";
|
|
4
4
|
import { createPushCommand } from "./push.js";
|
|
5
5
|
import { createPullCommand } from "./pull.js";
|
|
6
|
+
import { createLintCommand } from "./lint.js";
|
|
6
7
|
import { createInitCommand } from "./init.js";
|
|
7
8
|
import { createNavigateCommand } from "./navigate.js";
|
|
8
9
|
|
|
9
10
|
export function registerThemeCommand(ctx: PluginContext): void {
|
|
10
11
|
const cmd = new Command("theme").description(
|
|
11
|
-
"Theme developer workflow — dev server, push, pull, init",
|
|
12
|
+
"Theme developer workflow — dev server, push, pull, lint, init",
|
|
12
13
|
);
|
|
13
14
|
|
|
14
15
|
cmd.addCommand(createDevCommand());
|
|
15
16
|
cmd.addCommand(createPushCommand());
|
|
16
17
|
cmd.addCommand(createPullCommand());
|
|
18
|
+
cmd.addCommand(createLintCommand());
|
|
17
19
|
cmd.addCommand(createInitCommand());
|
|
18
20
|
cmd.addCommand(createNavigateCommand());
|
|
19
21
|
|