@fluid-app/fluid-cli-theme-dev 0.1.3 → 0.1.5

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.
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import prompts from "prompts";
3
3
  import { requireToken, createApiClient } from "../api.js";
4
4
  import { getPluginState } from "../plugin-state.js";
5
+ import { themes } from "@fluid-app/themes-api-client";
5
6
 
6
7
  const STATIC_ROUTES = [
7
8
  { label: "Home", path: "/home" },
@@ -56,6 +57,12 @@ const RESOURCE_ROUTES = [
56
57
  template: "/home/enrollments/%s",
57
58
  fallback: "/home/join",
58
59
  },
60
+ {
61
+ label: "Page",
62
+ type: "page",
63
+ template: "/home/pages/%s",
64
+ fallback: "/home/pages",
65
+ },
59
66
  ] as const;
60
67
 
61
68
  export function createNavigateCommand(): Command {
@@ -123,12 +130,11 @@ export function createNavigateCommand(): Command {
123
130
  path = dest;
124
131
  } else {
125
132
  const api = createApiClient();
126
- const body = await api.get<{
127
- available_themeables: Array<{ slug: string; title?: string }>;
128
- }>(`/api/application_themes/${themeId}/available_themeables`, {
129
- themeable: dest.resourceType,
130
- per_page: 50,
131
- });
133
+ const body = await themes.getApplicationThemeAvailableThemeables(
134
+ api,
135
+ themeId,
136
+ { themeable: dest.resourceType, per_page: 50 },
137
+ );
132
138
  const resources = body.available_themeables ?? [];
133
139
 
134
140
  if (!resources.length) {
@@ -141,7 +147,7 @@ export function createNavigateCommand(): Command {
141
147
  name: "slug",
142
148
  message: `Select a ${dest.label.toLowerCase()}`,
143
149
  choices: resources.map((r) => ({
144
- title: r.title ?? r.slug,
150
+ title: r.title ?? r.slug ?? "Untitled",
145
151
  value: r.slug,
146
152
  })),
147
153
  },
@@ -4,29 +4,25 @@ import prompts from "prompts";
4
4
  import { requireToken, createApiClient } from "../api.js";
5
5
  import { ThemeRoot } from "../theme/root.js";
6
6
  import { Syncer } from "../theme/syncer.js";
7
+ import { themes, type components } from "@fluid-app/themes-api-client";
7
8
 
8
- interface ApplicationTheme {
9
- id: number;
10
- name: string;
11
- }
9
+ type ApplicationTheme = components["schemas"]["ApplicationTheme"];
12
10
 
13
11
  async function selectOrFindTheme(
14
12
  api: ReturnType<typeof createApiClient>,
15
13
  identifier?: string,
16
14
  ): Promise<ApplicationTheme> {
17
- const body = await api.get<{ application_themes: ApplicationTheme[] }>(
18
- "/api/application_themes",
19
- );
20
- const themes = body.application_themes ?? [];
21
- if (!themes.length) {
15
+ const body = await themes.listApplicationThemes(api);
16
+ const themeList = body.application_themes ?? [];
17
+ if (!themeList.length) {
22
18
  console.error("No themes found.");
23
19
  process.exit(1);
24
20
  }
25
21
 
26
22
  if (identifier) {
27
23
  const found =
28
- themes.find((t) => String(t.id) === identifier) ??
29
- themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
24
+ themeList.find((t) => String(t.id) === identifier) ??
25
+ themeList.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
30
26
  if (!found) {
31
27
  console.error(`No theme found with identifier: ${identifier}`);
32
28
  process.exit(1);
@@ -39,7 +35,7 @@ async function selectOrFindTheme(
39
35
  type: "select",
40
36
  name: "id",
41
37
  message: "Select a theme to pull",
42
- choices: themes.map((t) => ({
38
+ choices: themeList.map((t) => ({
43
39
  title: `${t.name} (#${t.id})`,
44
40
  value: t.id,
45
41
  })),
@@ -50,7 +46,7 @@ async function selectOrFindTheme(
50
46
  console.error("No theme selected.");
51
47
  process.exit(1);
52
48
  }
53
- return themes.find((t) => t.id === id)!;
49
+ return themeList.find((t) => t.id === id)!;
54
50
  }
55
51
 
56
52
  export function createPullCommand(): Command {
@@ -4,20 +4,16 @@ import prompts from "prompts";
4
4
  import { requireToken, createApiClient } from "../api.js";
5
5
  import { ThemeRoot } from "../theme/root.js";
6
6
  import { Syncer } from "../theme/syncer.js";
7
+ import { themes, type components } from "@fluid-app/themes-api-client";
7
8
 
8
- interface ApplicationTheme {
9
- id: number;
10
- name: string;
11
- }
9
+ type ApplicationTheme = components["schemas"]["ApplicationTheme"];
12
10
 
13
11
  async function selectTheme(
14
12
  api: ReturnType<typeof createApiClient>,
15
13
  ): Promise<ApplicationTheme> {
16
- const body = await api.get<{ application_themes: ApplicationTheme[] }>(
17
- "/api/application_themes",
18
- );
19
- const themes = body.application_themes ?? [];
20
- if (!themes.length) {
14
+ const body = await themes.listApplicationThemes(api);
15
+ const themeList = body.application_themes ?? [];
16
+ if (!themeList.length) {
21
17
  console.error("No themes found.");
22
18
  process.exit(1);
23
19
  }
@@ -26,7 +22,7 @@ async function selectTheme(
26
22
  type: "select",
27
23
  name: "id",
28
24
  message: "Select a theme to push to",
29
- choices: themes.map((t) => ({
25
+ choices: themeList.map((t) => ({
30
26
  title: `${t.name} (#${t.id})`,
31
27
  value: t.id,
32
28
  })),
@@ -37,20 +33,18 @@ async function selectTheme(
37
33
  console.error("No theme selected.");
38
34
  process.exit(1);
39
35
  }
40
- return themes.find((t) => t.id === id)!;
36
+ return themeList.find((t) => t.id === id)!;
41
37
  }
42
38
 
43
39
  async function findTheme(
44
40
  api: ReturnType<typeof createApiClient>,
45
41
  identifier: string,
46
42
  ): Promise<ApplicationTheme> {
47
- const body = await api.get<{ application_themes: ApplicationTheme[] }>(
48
- "/api/application_themes",
49
- );
50
- const themes = body.application_themes ?? [];
43
+ const body = await themes.listApplicationThemes(api);
44
+ const themeList = body.application_themes ?? [];
51
45
  const found =
52
- themes.find((t) => String(t.id) === identifier) ??
53
- themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
46
+ themeList.find((t) => String(t.id) === identifier) ??
47
+ themeList.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
54
48
  if (!found) {
55
49
  console.error(`No theme found with identifier: ${identifier}`);
56
50
  process.exit(1);
@@ -65,6 +59,10 @@ export function createPushCommand(): Command {
65
59
  .option("-n, --nodelete", "Do not delete remote files missing locally")
66
60
  .option("-f, --force", "Skip schema validation")
67
61
  .option("-p, --publish", "Publish the theme after pushing")
62
+ .option(
63
+ "-u, --unpublished",
64
+ "Create a new unpublished theme and push to it",
65
+ )
68
66
  .option("--root <path>", "Theme root directory", ".")
69
67
  .action(
70
68
  async (opts: {
@@ -72,6 +70,7 @@ export function createPushCommand(): Command {
72
70
  nodelete?: boolean;
73
71
  force?: boolean;
74
72
  publish?: boolean;
73
+ unpublished?: boolean;
75
74
  root: string;
76
75
  }) => {
77
76
  requireToken();
@@ -83,9 +82,33 @@ export function createPushCommand(): Command {
83
82
  }
84
83
 
85
84
  const api = createApiClient();
86
- const theme = opts.theme
87
- ? await findTheme(api, opts.theme)
88
- : await selectTheme(api);
85
+ let theme: ApplicationTheme;
86
+
87
+ if (opts.unpublished) {
88
+ const { name } = await prompts(
89
+ {
90
+ type: "text",
91
+ name: "name",
92
+ message: "Name for the new theme",
93
+ },
94
+ { onCancel: () => process.exit(130) },
95
+ );
96
+ if (!name) {
97
+ console.error("Theme name is required.");
98
+ process.exit(1);
99
+ }
100
+ const body = await themes.createApplicationTheme(api, {
101
+ application_theme: { name, status: "draft" },
102
+ });
103
+ theme = body.application_theme;
104
+ console.log(
105
+ `Created unpublished theme: ${theme.name} (#${theme.id})`,
106
+ );
107
+ } else {
108
+ theme = opts.theme
109
+ ? await findTheme(api, opts.theme)
110
+ : await selectTheme(api);
111
+ }
89
112
 
90
113
  const syncer = new Syncer(api, theme.id, themeRoot);
91
114
  const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
@@ -109,7 +132,7 @@ export function createPushCommand(): Command {
109
132
  if (opts.publish) {
110
133
  const pubSpinner = ora("Publishing theme…").start();
111
134
  try {
112
- await api.post(`/api/application_themes/${theme.id}/publish`);
135
+ await themes.publishApplicationTheme(api, theme.id);
113
136
  pubSpinner.succeed("Theme published.");
114
137
  } catch (e) {
115
138
  pubSpinner.fail(`Publish failed: ${e}`);
@@ -2,14 +2,9 @@ import { sep } from "node:path";
2
2
  import type { ApiClient } from "../api.js";
3
3
  import type { ThemeFile } from "./file.js";
4
4
  import type { ThemeRoot } from "./root.js";
5
+ import { themes, type components } from "@fluid-app/themes-api-client";
5
6
 
6
- export interface RemoteResource {
7
- key: string;
8
- checksum: string;
9
- content?: string;
10
- url?: string;
11
- resource_type: string;
12
- }
7
+ type RemoteResource = components["schemas"]["ApplicationThemeResource"];
13
8
 
14
9
  export interface SyncResult {
15
10
  uploaded: number;
@@ -30,15 +25,13 @@ export class Syncer {
30
25
  // ─── Checksum Management ──────────────────────────────────────────────────
31
26
 
32
27
  async fetchChecksums(): Promise<void> {
33
- const body = await this.api.get<{
34
- application_theme_resources: RemoteResource[];
35
- }>(`/api/application_themes/${this.themeId}/resources`);
28
+ const body = await themes.listThemeResources(this.api, this.themeId);
36
29
  this.updateChecksums(body.application_theme_resources ?? []);
37
30
  }
38
31
 
39
32
  private updateChecksums(resources: RemoteResource[]): void {
40
33
  for (const r of resources) {
41
- if (r.key) this.checksums.set(r.key, r.checksum);
34
+ if (r.key && r.checksum) this.checksums.set(r.key, r.checksum);
42
35
  }
43
36
  for (const key of this.checksums.keys()) {
44
37
  if (this.checksums.has(`${key}.liquid`)) this.checksums.delete(key);
@@ -56,23 +49,19 @@ export class Syncer {
56
49
  // ─── Upload ───────────────────────────────────────────────────────────────
57
50
 
58
51
  async uploadFile(file: ThemeFile): Promise<void> {
59
- const path = `/api/application_themes/${this.themeId}/resources`;
60
52
  if (file.isText) {
61
- await this.api.put(path, {
53
+ await themes.updateThemeResource(this.api, this.themeId, {
62
54
  application_theme_resource: {
63
55
  key: file.relativePath,
64
56
  content: file.read(),
65
57
  },
66
58
  });
67
59
  } else {
68
- await this.uploadBinaryFile(file, path);
60
+ await this.uploadBinaryFile(file);
69
61
  }
70
62
  }
71
63
 
72
- private async uploadBinaryFile(
73
- file: ThemeFile,
74
- resourcePath: string,
75
- ): Promise<void> {
64
+ private async uploadBinaryFile(file: ThemeFile): Promise<void> {
76
65
  // Step 1: Create DAM placeholder
77
66
  const placeholderBody = await this.api.post<{
78
67
  asset: { id: number; canonical_path: string };
@@ -147,7 +136,7 @@ export class Syncer {
147
136
  }>("/api/dam/assets/backfill_imagekit", backfillPayload);
148
137
 
149
138
  // Step 5: Associate with theme resource
150
- await this.api.put(resourcePath, {
139
+ await themes.updateThemeResource(this.api, this.themeId, {
151
140
  application_theme_resource: {
152
141
  key: file.relativePath,
153
142
  dam_asset: {
@@ -181,8 +170,8 @@ export class Syncer {
181
170
  // ─── Delete ───────────────────────────────────────────────────────────────
182
171
 
183
172
  async deleteRemoteFile(relativePath: string): Promise<void> {
184
- await this.api.delete(`/api/application_themes/${this.themeId}/resources`, {
185
- body: { application_theme_resource: { key: relativePath } },
173
+ await themes.deleteThemeResource(this.api, this.themeId, {
174
+ application_theme_resource: { key: relativePath },
186
175
  });
187
176
  this.checksums.delete(relativePath);
188
177
  }
@@ -190,11 +179,10 @@ export class Syncer {
190
179
  // ─── Download ─────────────────────────────────────────────────────────────
191
180
 
192
181
  async downloadAll(): Promise<RemoteResource[]> {
193
- const body = await this.api.get<{
194
- application_theme_resources: RemoteResource[];
195
- }>(`/api/application_themes/${this.themeId}/resources`);
196
- this.updateChecksums(body.application_theme_resources ?? []);
197
- return body.application_theme_resources ?? [];
182
+ const body = await themes.listThemeResources(this.api, this.themeId);
183
+ const resources = body.application_theme_resources ?? [];
184
+ this.updateChecksums(resources);
185
+ return resources;
198
186
  }
199
187
 
200
188
  async downloadBinaryAsset(url: string): Promise<Buffer> {
@@ -280,8 +268,15 @@ export class Syncer {
280
268
  if (resource.resource_type === "FileResource" && resource.url) {
281
269
  const buf = await this.downloadBinaryAsset(resource.url);
282
270
  file.write(buf);
283
- } else if (resource.content !== undefined) {
284
- file.write(resource.content);
271
+ } else if (
272
+ resource.content !== undefined &&
273
+ resource.content !== null
274
+ ) {
275
+ const content =
276
+ typeof resource.content === "string"
277
+ ? resource.content
278
+ : JSON.stringify(resource.content);
279
+ file.write(content);
285
280
  }
286
281
  result.downloaded++;
287
282
  } catch (e) {