@fresh-editor/fresh-editor 0.3.7 → 0.3.9

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/plugins/pkg.ts CHANGED
@@ -232,6 +232,12 @@ interface ParsedPackageUrl {
232
232
  name: string;
233
233
  /** Whether this is a local file path (not a remote URL) */
234
234
  isLocal: boolean;
235
+ /**
236
+ * Whether the URL points directly to a single downloadable file (e.g. a
237
+ * theme JSON) rather than to a git repository. When true, the URL should
238
+ * be fetched over HTTP instead of cloned with git.
239
+ */
240
+ isDirectFile: boolean;
235
241
  }
236
242
 
237
243
  // =============================================================================
@@ -296,6 +302,24 @@ function isLocalPath(str: string): boolean {
296
302
  return false;
297
303
  }
298
304
 
305
+ /**
306
+ * File extensions that indicate a URL points to a single downloadable file
307
+ * (e.g. a Fresh theme JSON) rather than to a git repository.
308
+ */
309
+ const DIRECT_FILE_EXTENSIONS = /\.(jsonc?|JSONC?)$/;
310
+
311
+ /**
312
+ * Check if a URL points directly to a single downloadable file rather than
313
+ * to a git repository. Examples:
314
+ * - `https://github.com/user-attachments/files/123/synthwave-84.json` → true
315
+ * - `https://github.com/user/repo` → false
316
+ */
317
+ function isDirectFileUrl(url: string): boolean {
318
+ // Strip query string for extension check
319
+ const pathOnly = url.split("?")[0];
320
+ return DIRECT_FILE_EXTENSIONS.test(pathOnly);
321
+ }
322
+
299
323
  /**
300
324
  * Parse a package URL that may contain a subpath fragment.
301
325
  *
@@ -305,6 +329,7 @@ function isLocalPath(str: string): boolean {
305
329
  * - `https://github.com/user/repo.git#packages/my-plugin` - with .git suffix
306
330
  * - `/path/to/local/repo#subdir` - local path with subpath
307
331
  * - `/path/to/local/package` - direct local package path
332
+ * - `https://example.com/path/to/theme.json` - direct file (downloaded over HTTP)
308
333
  *
309
334
  * The fragment (after #) specifies a subdirectory within the repo.
310
335
  */
@@ -329,19 +354,28 @@ function parsePackageUrl(url: string): ParsedPackageUrl {
329
354
  // Determine if this is a local path
330
355
  const isLocal = isLocalPath(repoUrl);
331
356
 
357
+ // A remote URL ending in a recognized file extension (and without a
358
+ // subpath fragment) is treated as a direct file download, not a git repo.
359
+ const isDirectFile = !isLocal && !subpath && isDirectFileUrl(repoUrl);
360
+
332
361
  // Extract package name
333
362
  let name: string;
334
363
  if (subpath) {
335
364
  // For monorepo/directory, use the last component of the subpath
336
365
  const parts = subpath.split("/");
337
366
  name = parts[parts.length - 1].replace(/^fresh-/, "");
367
+ } else if (isDirectFile) {
368
+ // For direct file downloads, use the filename without the extension
369
+ const pathOnly = repoUrl.split("?")[0];
370
+ const match = pathOnly.match(/\/([^\/]+?)\.(jsonc?|JSONC?)$/);
371
+ name = match ? match[1].replace(/^fresh-/, "") : "unknown";
338
372
  } else {
339
373
  // For regular repo/path, use the last component
340
374
  const match = repoUrl.match(/\/([^\/]+?)(\.git)?$/);
341
375
  name = match ? match[1].replace(/^fresh-/, "") : "unknown";
342
376
  }
343
377
 
344
- return { repoUrl, subpath, name, isLocal };
378
+ return { repoUrl, subpath, name, isLocal, isDirectFile };
345
379
  }
346
380
 
347
381
  /**
@@ -784,11 +818,12 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
784
818
  }
785
819
 
786
820
  /**
787
- * Install a package from git URL or local path.
821
+ * Install a package from a git URL, direct file URL, or local path.
788
822
  *
789
823
  * Supports:
790
824
  * - `https://github.com/user/repo` - standard git repo
791
825
  * - `https://github.com/user/repo#packages/my-plugin` - monorepo with subpath
826
+ * - `https://example.com/path/to/theme.json` - direct file download (theme JSON)
792
827
  * - `/path/to/local/repo#subdir` - local path with subpath
793
828
  * - `/path/to/local/package` - direct local package path
794
829
  *
@@ -808,6 +843,9 @@ async function installPackage(
808
843
  if (parsed.isLocal) {
809
844
  // Local path installation: copy directly
810
845
  return await installFromLocalPath(parsed, packageName);
846
+ } else if (parsed.isDirectFile) {
847
+ // Direct file URL (e.g. theme JSON): download with curl, not git clone
848
+ return await installFromDirectFile(parsed.repoUrl, packageName);
811
849
  } else if (parsed.subpath) {
812
850
  // Remote monorepo installation: clone to temp, copy subdirectory
813
851
  return await installFromMonorepo(parsed, packageName, version);
@@ -817,6 +855,133 @@ async function installPackage(
817
855
  }
818
856
  }
819
857
 
858
+ /**
859
+ * Install from a direct file URL (e.g. a single theme JSON hosted on a CDN
860
+ * or GitHub user-attachments). Downloads the file with curl, validates it
861
+ * looks like a Fresh package (currently themes only), and writes it into
862
+ * the appropriate packages directory with a synthetic package.json manifest.
863
+ *
864
+ * Supported file types:
865
+ * - `.json` / `.jsonc` containing a Fresh theme (object with a `name` field
866
+ * and at least one theme section like `editor`, `ui`, `syntax`, etc.)
867
+ */
868
+ async function installFromDirectFile(
869
+ url: string,
870
+ packageName: string
871
+ ): Promise<boolean> {
872
+ const tempFile = editor.pathJoin(
873
+ editor.getTempDir(),
874
+ `fresh-pkg-file-${hashString(url)}-${Date.now()}.json`
875
+ );
876
+
877
+ editor.setStatus(`Downloading ${url}...`);
878
+ const result = await editor.httpFetch(url, tempFile);
879
+
880
+ if (result.exit_code !== 0) {
881
+ // exit_code mirrors editor.httpFetch's contract: 0 on 2xx, the HTTP
882
+ // status code on non-2xx, -1 on transport errors.
883
+ const errorMsg = result.exit_code === 404
884
+ ? "File not found"
885
+ : result.exit_code > 0
886
+ ? `HTTP ${result.exit_code}`
887
+ : result.stderr.split("\n")[0] || "Download failed";
888
+ editor.setStatus(`Failed to download ${packageName}: ${errorMsg}`);
889
+ editor.removePath(tempFile);
890
+ return false;
891
+ }
892
+
893
+ const content = editor.readFile(tempFile);
894
+ if (!content) {
895
+ editor.setStatus(`Failed to read downloaded file`);
896
+ editor.removePath(tempFile);
897
+ return false;
898
+ }
899
+
900
+ let parsed: Record<string, unknown>;
901
+ try {
902
+ parsed = JSON.parse(content) as Record<string, unknown>;
903
+ } catch (e) {
904
+ editor.setStatus(`Downloaded file is not valid JSON: ${e}`);
905
+ editor.removePath(tempFile);
906
+ return false;
907
+ }
908
+
909
+ // Heuristic: a Fresh theme JSON has a `name` string and at least one of
910
+ // the recognized theme sections. This rules out arbitrary JSON files
911
+ // (configs, package manifests, etc.) being installed as themes.
912
+ const themeName = typeof parsed.name === "string" ? parsed.name : null;
913
+ const looksLikeTheme = themeName !== null && (
914
+ parsed.editor !== undefined ||
915
+ parsed.ui !== undefined ||
916
+ parsed.syntax !== undefined ||
917
+ parsed.search !== undefined ||
918
+ parsed.diagnostics !== undefined ||
919
+ parsed.base !== undefined
920
+ );
921
+
922
+ if (!looksLikeTheme) {
923
+ editor.setStatus(
924
+ `Unrecognized file format at ${url} - direct file install currently supports Fresh theme JSON only`
925
+ );
926
+ editor.removePath(tempFile);
927
+ return false;
928
+ }
929
+
930
+ // Use the theme's own name as the package name when available.
931
+ if (themeName) packageName = themeName;
932
+
933
+ // Sanitize for use as a directory name.
934
+ const safeName = packageName.replace(/[^a-zA-Z0-9_.-]/g, "-");
935
+ const targetDir = editor.pathJoin(THEMES_PACKAGES_DIR, safeName);
936
+
937
+ if (editor.fileExists(targetDir)) {
938
+ editor.setStatus(`Package '${safeName}' is already installed`);
939
+ editor.removePath(tempFile);
940
+ return false;
941
+ }
942
+
943
+ ensureDir(THEMES_PACKAGES_DIR);
944
+ if (!ensureDir(targetDir)) {
945
+ editor.setStatus(`Failed to create package directory ${targetDir}`);
946
+ editor.removePath(tempFile);
947
+ return false;
948
+ }
949
+
950
+ const themeFileName = "theme.json";
951
+ if (!editor.writeFile(editor.pathJoin(targetDir, themeFileName), content)) {
952
+ editor.setStatus(`Failed to write theme file`);
953
+ editor.removePath(tempFile);
954
+ editor.removePath(targetDir);
955
+ return false;
956
+ }
957
+
958
+ const manifest: PackageManifest = {
959
+ name: safeName,
960
+ version: "1.0.0",
961
+ description: `Theme installed from ${url}`,
962
+ type: "theme",
963
+ fresh: {
964
+ themes: [{ file: themeFileName, name: themeName ?? safeName }],
965
+ },
966
+ };
967
+ if (!await writeJsonFile(editor.pathJoin(targetDir, "package.json"), manifest)) {
968
+ editor.setStatus(`Failed to write package manifest`);
969
+ editor.removePath(tempFile);
970
+ editor.removePath(targetDir);
971
+ return false;
972
+ }
973
+
974
+ await writeJsonFile(editor.pathJoin(targetDir, ".fresh-source.json"), {
975
+ url,
976
+ installed_at: new Date().toISOString(),
977
+ });
978
+
979
+ editor.removePath(tempFile);
980
+ editor.reloadThemes();
981
+ editor.setStatus(`Installed theme ${themeName ?? safeName}`);
982
+ return true;
983
+ }
984
+
820
985
  /**
821
986
  * Install from a standard git repository (no subpath)
822
987
  * Clones to temp first to detect type, then moves to correct location.
@@ -2748,7 +2913,7 @@ async function pkg_install_theme() : Promise<void> {
2748
2913
  registerHandler("pkg_install_theme", pkg_install_theme);
2749
2914
 
2750
2915
  /**
2751
- * Install from git URL or local path
2916
+ * Install from git URL, direct file URL, or local path
2752
2917
  */
2753
2918
  function pkg_install_url() : void {
2754
2919
  editor.startPrompt("Git URL or local path:", "pkg-install-url");
@@ -73,6 +73,9 @@
73
73
  38,
74
74
  30
75
75
  ],
76
+ "diff_add_collision_fg": null,
77
+ "diff_remove_collision_fg": null,
78
+ "diff_modify_collision_fg": null,
76
79
  "ruler_bg": [
77
80
  50,
78
81
  50,
@@ -202,17 +205,17 @@
202
205
  79,
203
206
  120
204
207
  ],
208
+ "text_input_selection_bg": [
209
+ 58,
210
+ 79,
211
+ 120
212
+ ],
205
213
  "popup_selection_fg": [
206
214
  255,
207
215
  255,
208
216
  255
209
217
  ],
210
218
  "popup_text_fg": "White",
211
- "text_input_selection_bg": [
212
- 58,
213
- 79,
214
- 120
215
- ],
216
219
  "suggestion_bg": [
217
220
  30,
218
221
  30,
@@ -577,6 +580,42 @@
577
580
  30
578
581
  ]
579
582
  },
583
+ "diff_add_collision_fg": {
584
+ "description": "Fallback fg for cells whose existing fg matches `diff_add_bg`\n(e.g. ANSI Green-on-Green). Only applied on collision; other\ntokens keep their syntax colour.",
585
+ "anyOf": [
586
+ {
587
+ "$ref": "#/$defs/ColorDef"
588
+ },
589
+ {
590
+ "type": "null"
591
+ }
592
+ ],
593
+ "default": null
594
+ },
595
+ "diff_remove_collision_fg": {
596
+ "description": "Collision-only fallback fg for `diff_remove_bg`.",
597
+ "anyOf": [
598
+ {
599
+ "$ref": "#/$defs/ColorDef"
600
+ },
601
+ {
602
+ "type": "null"
603
+ }
604
+ ],
605
+ "default": null
606
+ },
607
+ "diff_modify_collision_fg": {
608
+ "description": "Collision-only fallback fg for `diff_modify_bg`.",
609
+ "anyOf": [
610
+ {
611
+ "$ref": "#/$defs/ColorDef"
612
+ },
613
+ {
614
+ "type": "null"
615
+ }
616
+ ],
617
+ "default": null
618
+ },
580
619
  "ruler_bg": {
581
620
  "description": "Vertical ruler background color",
582
621
  "$ref": "#/$defs/ColorDef",
@@ -953,6 +992,15 @@
953
992
  120
954
993
  ]
955
994
  },
995
+ "text_input_selection_bg": {
996
+ "description": "Selection background inside a widget Text input. Reads\nagainst `prompt_bg`, so it needs higher contrast against\nthat tint than `editor.selection_bg` (which targets the\neditor surface). Defaults to the same `popup_selection_bg`\nblue used everywhere \"selected item inside a chrome\nsurface\" is shown — same key the prompt selection uses, so\nthe cue reads consistently across selection UIs.",
997
+ "$ref": "#/$defs/ColorDef",
998
+ "default": [
999
+ 58,
1000
+ 79,
1001
+ 120
1002
+ ]
1003
+ },
956
1004
  "popup_selection_fg": {
957
1005
  "description": "Popup selected item text color",
958
1006
  "$ref": "#/$defs/ColorDef",
@@ -967,15 +1015,6 @@
967
1015
  "$ref": "#/$defs/ColorDef",
968
1016
  "default": "White"
969
1017
  },
970
- "text_input_selection_bg": {
971
- "description": "Selection background inside a widget Text input. Reads against prompt_bg, so it needs higher contrast against that tint than editor.selection_bg.",
972
- "$ref": "#/$defs/ColorDef",
973
- "default": [
974
- 58,
975
- 79,
976
- 120
977
- ]
978
- },
979
1018
  "suggestion_bg": {
980
1019
  "description": "Autocomplete suggestion background",
981
1020
  "$ref": "#/$defs/ColorDef",