@backmatter/studio 0.0.12 → 0.0.14

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.
@@ -3,7 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
5
5
  import * as stylex from "@stylexjs/stylex";
6
- import { notifyTreeChanged, saveDoc, type DocListItem, type DocStatus } from "../api";
6
+ import {
7
+ notifyTreeChanged,
8
+ saveDoc,
9
+ type DocListItem,
10
+ type DocStatus,
11
+ type TreeChangeDetail,
12
+ } from "../api";
7
13
  import { useReplicaRevision, useReplicaState } from "../replica/useReplica";
8
14
  import { colors, radius, space, type } from "../styles/tokens.stylex";
9
15
  import { Tooltip } from "../ui/Tooltip";
@@ -121,10 +127,7 @@ function ContentExplorerSidebar({
121
127
  return map;
122
128
  }, [replica, replicaRevision, workspace.workspaceId]);
123
129
 
124
- const route = useMemo(
125
- () => parseContentRoute(location.pathname, searchParams.get("locale") ?? ""),
126
- [location.pathname, searchParams],
127
- );
130
+ const route = useMemo(() => parseContentRoute(location.pathname), [location.pathname]);
128
131
  const git = replica.git(workspace.workspaceId);
129
132
  const pendingCount =
130
133
  git?.changes.filter((c) => c.status !== "deleted").length ?? 0;
@@ -162,6 +165,28 @@ function ContentExplorerSidebar({
162
165
  });
163
166
  }, [workspace, route, models, docsByType]);
164
167
 
168
+ useEffect(() => {
169
+ function onTree(event: Event) {
170
+ const detail = (event as CustomEvent<TreeChangeDetail>).detail;
171
+ if (!detail?.type || !detail.slug || detail.remove) return;
172
+ if (
173
+ detail.workspaceId &&
174
+ detail.workspaceId !== workspace.workspaceId &&
175
+ detail.workspaceId !== workspace.workspaceSlug
176
+ ) {
177
+ return;
178
+ }
179
+ const model = models.find((item) => item.type === detail.type);
180
+ if (!model) return;
181
+ const repoPath = docRepoPath(model, { slug: detail.slug });
182
+ const parents = folderKeysOnPathTo(treePathFromRepo(repoPath));
183
+ const folder = parents[parents.length - 1];
184
+ if (folder) expandFolderPath(folder);
185
+ }
186
+ window.addEventListener("backmatter:tree-changed", onTree);
187
+ return () => window.removeEventListener("backmatter:tree-changed", onTree);
188
+ }, [models, workspace, expandFolderPath]);
189
+
165
190
  const empty = workspace.contentTypes.length === 0;
166
191
  const settingsActive = isWorkspaceSettingsPath(location.pathname);
167
192
  const treeOpen = useContentTreeOpen();
@@ -374,31 +399,21 @@ function ExplorerSection({
374
399
  const leaves: PathTreeFile[] = [];
375
400
  for (const model of models) {
376
401
  for (const doc of docsByType.get(model.type) ?? []) {
377
- const repoPath = docRepoPath(model, doc, workspace.defaultLocale);
402
+ if (doc.status === "deleted") continue;
403
+ const repoPath = docRepoPath(model, doc);
378
404
  const treePath = treePathFromRepo(repoPath);
379
405
  leaves.push({
380
406
  kind: "file",
381
407
  name: treePath.split("/").pop() ?? treePath,
382
408
  repoPath: treePath,
383
- href: contentDocPath(
384
- workspace.workspaceSlug,
385
- doc.type,
386
- doc.slug,
387
- doc.locale ?? "",
388
- workspace.defaultLocale,
389
- ),
390
- active: docMatchesRoute(route, doc, workspace.defaultLocale),
409
+ href: contentDocPath(workspace.workspaceSlug, doc.type, doc.slug),
410
+ active: docMatchesRoute(route, doc),
391
411
  dirty: isDirtyStatus(doc.status),
392
412
  });
393
413
  }
394
414
  }
395
415
  const built = buildPathTree(leaves);
396
- return withModelFolderPlaceholders(
397
- built,
398
- models,
399
- workspace.locales,
400
- workspace.defaultLocale,
401
- );
416
+ return withModelFolderPlaceholders(built, models);
402
417
  }, [models, docsByType, workspace, route]);
403
418
 
404
419
  const startNewFile = useCallback(
@@ -422,12 +437,7 @@ function ExplorerSection({
422
437
  return;
423
438
  }
424
439
  const list = docsByType.get(ctx.type) ?? [];
425
- const localeForDoc = ctx.locale || workspace.defaultLocale;
426
- const exists = list.some(
427
- (d) =>
428
- d.slug === slug &&
429
- (d.locale || workspace.defaultLocale) === localeForDoc,
430
- );
440
+ const exists = list.some((d) => d.slug === slug);
431
441
  if (exists) {
432
442
  setNewFileError("A file with that name already exists.");
433
443
  return;
@@ -436,30 +446,18 @@ function ExplorerSection({
436
446
  setNewFileError(null);
437
447
  try {
438
448
  const frontmatter = ctx.pageFormat ? { blocks: [] } : {};
439
- const { doc } = await saveDoc(
440
- workspace.workspaceId,
441
- ctx.locale,
442
- ctx.type,
443
- slug,
444
- { frontmatter, body: "" },
445
- );
449
+ const { doc } = await saveDoc(workspace.workspaceId, ctx.type, slug, {
450
+ frontmatter,
451
+ body: "",
452
+ });
446
453
  notifyTreeChanged({
447
454
  workspaceId: workspace.workspaceId,
448
- locale: doc.locale,
449
455
  type: doc.type,
450
456
  slug: doc.slug,
451
457
  status: doc.status,
452
458
  });
453
459
  setNewFileFolder(null);
454
- navigate(
455
- contentDocPath(
456
- workspace.workspaceSlug,
457
- doc.type,
458
- doc.slug,
459
- doc.locale ?? "",
460
- workspace.defaultLocale,
461
- ),
462
- );
460
+ navigate(contentDocPath(workspace.workspaceSlug, doc.type, doc.slug));
463
461
  } catch (err) {
464
462
  setNewFileError(err instanceof Error ? err.message : "Could not create file.");
465
463
  } finally {
@@ -483,8 +481,6 @@ function ExplorerSection({
483
481
  expanded={expanded}
484
482
  onToggleFolder={onToggleFolder}
485
483
  models={models}
486
- locales={workspace.locales}
487
- defaultLocale={workspace.defaultLocale}
488
484
  canWrite={canWrite}
489
485
  newFileFolder={newFileFolder}
490
486
  newFileError={newFileError}
@@ -507,8 +503,6 @@ function PathTree({
507
503
  expanded,
508
504
  onToggleFolder,
509
505
  models,
510
- locales,
511
- defaultLocale,
512
506
  canWrite,
513
507
  newFileFolder,
514
508
  newFileError,
@@ -522,8 +516,6 @@ function PathTree({
522
516
  expanded: Set<string>;
523
517
  onToggleFolder: (pathKey: string) => void;
524
518
  models: ContentTypeNav[];
525
- locales: string[];
526
- defaultLocale: string;
527
519
  canWrite: boolean;
528
520
  newFileFolder: string | null;
529
521
  newFileError: string | null;
@@ -548,12 +540,7 @@ function PathTree({
548
540
  onToggle={() => onToggleFolder(node.pathKey)}
549
541
  createContext={
550
542
  canWrite
551
- ? resolveFolderCreateContext(
552
- node.pathKey,
553
- models,
554
- locales,
555
- defaultLocale,
556
- )
543
+ ? resolveFolderCreateContext(node.pathKey, models)
557
544
  : null
558
545
  }
559
546
  onNewFile={
@@ -566,23 +553,13 @@ function PathTree({
566
553
  <NewFileTreeRow
567
554
  depth={depth + 1}
568
555
  ext={
569
- resolveFolderCreateContext(
570
- node.pathKey,
571
- models,
572
- locales,
573
- defaultLocale,
574
- )?.ext ?? "mdx"
556
+ resolveFolderCreateContext(node.pathKey, models)?.ext ?? "mdx"
575
557
  }
576
558
  error={newFileError}
577
559
  disabled={creating}
578
560
  onCancel={onCancelNewFile}
579
561
  onSubmit={(name) => {
580
- const ctx = resolveFolderCreateContext(
581
- node.pathKey,
582
- models,
583
- locales,
584
- defaultLocale,
585
- );
562
+ const ctx = resolveFolderCreateContext(node.pathKey, models);
586
563
  if (ctx) onSubmitNewFile(node.pathKey, ctx, name);
587
564
  }}
588
565
  />
@@ -594,8 +571,6 @@ function PathTree({
594
571
  expanded={expanded}
595
572
  onToggleFolder={onToggleFolder}
596
573
  models={models}
597
- locales={locales}
598
- defaultLocale={defaultLocale}
599
574
  canWrite={canWrite}
600
575
  newFileFolder={newFileFolder}
601
576
  newFileError={newFileError}
@@ -807,11 +782,9 @@ function findActiveRepoPath(
807
782
  if (!route.type || !route.docSlug) return null;
808
783
  const model = models.find((m) => m.type === route.type);
809
784
  if (!model) return null;
810
- const doc = (docsByType.get(route.type) ?? []).find((d) =>
811
- docMatchesRoute(route, d, workspace.defaultLocale),
812
- );
785
+ const doc = (docsByType.get(route.type) ?? []).find((d) => docMatchesRoute(route, d));
813
786
  if (!doc) return null;
814
- return docRepoPath(model, doc, workspace.defaultLocale);
787
+ return docRepoPath(model, doc);
815
788
  }
816
789
 
817
790
  function IconButton({
@@ -840,28 +813,18 @@ function IconButton({
840
813
  type ContentRoute = {
841
814
  type: string | null;
842
815
  docSlug: string | null;
843
- locale: string;
844
816
  };
845
817
 
846
- function parseContentRoute(pathname: string, localeParam: string): ContentRoute {
818
+ function parseContentRoute(pathname: string): ContentRoute {
847
819
  const match = pathname.match(/\/content\/([^/]+)(?:\/([^/?]+))?/);
848
- if (!match) return { type: null, docSlug: null, locale: "" };
820
+ if (!match) return { type: null, docSlug: null };
849
821
  const type = decodeURIComponent(match[1]!);
850
822
  const docSlug = match[2] ? decodeURIComponent(match[2]) : null;
851
- return { type, docSlug, locale: localeParam };
823
+ return { type, docSlug };
852
824
  }
853
825
 
854
- function docMatchesRoute(
855
- route: ContentRoute,
856
- doc: DocListItem,
857
- defaultLocale: string,
858
- ) {
859
- if (!route.type || route.docSlug !== doc.slug || route.type !== doc.type) {
860
- return false;
861
- }
862
- const docLocale = doc.locale || defaultLocale;
863
- const urlLocale = route.locale || defaultLocale;
864
- return docLocale === urlLocale;
826
+ function docMatchesRoute(route: ContentRoute, doc: DocListItem) {
827
+ return Boolean(route.type && route.docSlug === doc.slug && route.type === doc.type);
865
828
  }
866
829
 
867
830
  function isDirtyStatus(status: DocStatus) {
@@ -11,7 +11,7 @@ import {
11
11
  useState,
12
12
  } from "react";
13
13
 
14
- export type DocRef = { locale: string; type: string; slug: string };
14
+ export type DocRef = { type: string; slug: string };
15
15
 
16
16
  export type EditorSelection = { start: number; end: number };
17
17
 
@@ -23,12 +23,12 @@ export type EditorFocus = {
23
23
  };
24
24
 
25
25
  export function docKey(ref: DocRef): string {
26
- return `${ref.locale}/${ref.type}/${ref.slug}`;
26
+ return `${ref.type}/${ref.slug}`;
27
27
  }
28
28
 
29
29
  export function sameDocRef(a: DocRef | null | undefined, b: DocRef | null | undefined) {
30
30
  if (!a || !b) return false;
31
- return a.locale === b.locale && a.type === b.type && a.slug === b.slug;
31
+ return a.type === b.type && a.slug === b.slug;
32
32
  }
33
33
 
34
34
  type EditorFocusContextValue = {
@@ -8,7 +8,6 @@ import {
8
8
  } from "react";
9
9
 
10
10
  export type EditorStatus = {
11
- locale: string | null;
12
11
  /** committed | uncommitted | conflict */
13
12
  status: string | null;
14
13
  saveLabel: string | null;
@@ -16,7 +15,6 @@ export type EditorStatus = {
16
15
  };
17
16
 
18
17
  const EMPTY: EditorStatus = {
19
- locale: null,
20
18
  status: null,
21
19
  saveLabel: null,
22
20
  saveTone: "muted",
@@ -38,7 +36,6 @@ const EditorStatusContext = createContext<EditorStatusContextValue | null>(
38
36
 
39
37
  function sameStatus(a: EditorStatus, b: EditorStatus) {
40
38
  return (
41
- a.locale === b.locale &&
42
39
  a.status === b.status &&
43
40
  a.saveLabel === b.saveLabel &&
44
41
  a.saveTone === b.saveTone
@@ -13,8 +13,6 @@ export type WorkspaceNav = {
13
13
  workspaceSlug: string;
14
14
  workspaceName: string;
15
15
  role: string;
16
- defaultLocale: string;
17
- locales: string[];
18
16
  contentTypes: string[];
19
17
  contentModels: ContentTypeNav[];
20
18
  siteUrls: string[];
@@ -1,7 +1,7 @@
1
1
  // packages/studio/src/chrome/content-api-url.ts
2
2
  // Public content GET the host reads — same URLs as @backmatter/core api-loader.
3
3
 
4
- export type ContentApiDoc = { locale: string; type: string; slug: string };
4
+ export type ContentApiDoc = { type: string; slug: string };
5
5
 
6
6
  export type ContentApiContext = {
7
7
  doc: ContentApiDoc | null;
@@ -10,16 +10,12 @@ export type ContentApiContext = {
10
10
 
11
11
  export function contentApiContextFromPath(
12
12
  pathname: string,
13
- search = "",
13
+ _search = "",
14
14
  ): ContentApiContext {
15
- const localeFromQuery = new URLSearchParams(
16
- search.startsWith("?") ? search.slice(1) : search,
17
- ).get("locale");
18
15
  const doc = pathname.match(/\/content\/([^/]+)\/([^/]+)\/?$/);
19
16
  if (doc) {
20
17
  return {
21
18
  doc: {
22
- locale: localeFromQuery || "en",
23
19
  type: decodeURIComponent(doc[1]!),
24
20
  slug: decodeURIComponent(doc[2]!),
25
21
  },
@@ -34,7 +30,6 @@ export function contentApiContextFromPath(
34
30
  if (legacyDoc) {
35
31
  return {
36
32
  doc: {
37
- locale: decodeURIComponent(legacyDoc[1]!),
38
33
  type: decodeURIComponent(legacyDoc[2]!),
39
34
  slug: decodeURIComponent(legacyDoc[3]!),
40
35
  },
@@ -53,15 +48,12 @@ export function publicContentApiUrl(input: {
53
48
  workspaceId: string;
54
49
  branch?: string | null;
55
50
  defaultBranch?: string | null;
56
- locale?: string;
57
51
  type?: string;
58
52
  slug?: string;
59
53
  }): string {
60
54
  const origin = input.apiUrl.replace(/\/$/, "");
61
55
  const path =
62
- input.locale && input.type && input.slug
63
- ? `/${input.locale}/${input.type}/${input.slug}`
64
- : "";
56
+ input.type && input.slug ? `/${input.type}/${input.slug}` : "";
65
57
  if (!input.workspaceId.startsWith("ws_")) {
66
58
  throw new Error("public content URLs must use the workspace id (ws_…), not the slug");
67
59
  }
@@ -94,7 +86,6 @@ export function contentApiLinks(input: {
94
86
  label: "This document",
95
87
  href: publicContentApiUrl({
96
88
  ...base,
97
- locale: ctx.doc.locale,
98
89
  type: ctx.doc.type,
99
90
  slug: ctx.doc.slug,
100
91
  }),
@@ -1,28 +1,17 @@
1
1
  // packages/studio/src/chrome/content-repo-path.ts
2
2
  import { IMPLICIT_SINGLETON_SLUG, repoPathFromModel } from "@backmatter/core";
3
3
  import type { DocListItem } from "../api";
4
- import { modelHasLocale, type ContentTypeNav } from "./content-type-groups";
4
+ import type { ContentTypeNav } from "./content-type-groups";
5
5
 
6
6
  export function docRepoPath(
7
7
  model: ContentTypeNav,
8
- doc: Pick<DocListItem, "locale" | "slug">,
9
- defaultLocale: string,
8
+ doc: Pick<DocListItem, "slug">,
10
9
  ): string {
11
- const locale = modelHasLocale(model.path)
12
- ? doc.locale || defaultLocale
13
- : undefined;
14
- return repoPathFromModel(model, locale, doc.slug);
10
+ return repoPathFromModel(model, doc.slug);
15
11
  }
16
12
 
17
- export function singletonRepoPath(
18
- model: ContentTypeNav,
19
- defaultLocale: string,
20
- ): string {
21
- return docRepoPath(
22
- model,
23
- { slug: IMPLICIT_SINGLETON_SLUG, locale: defaultLocale },
24
- defaultLocale,
25
- );
13
+ export function singletonRepoPath(model: ContentTypeNav): string {
14
+ return docRepoPath(model, { slug: IMPLICIT_SINGLETON_SLUG });
26
15
  }
27
16
 
28
17
  export function repoPathBasename(repoPath: string): string {
@@ -39,14 +28,8 @@ export function fileExtFromRepoPath(repoPath: string): string {
39
28
  /** Label for a row in the content tree (repo-relative filename). */
40
29
  export function docTreeLabel(
41
30
  model: ContentTypeNav,
42
- doc: Pick<DocListItem, "locale" | "slug">,
43
- defaultLocale: string,
31
+ doc: Pick<DocListItem, "slug">,
44
32
  ): string {
45
- const repoPath = docRepoPath(model, doc, defaultLocale);
46
- const name = repoPathBasename(repoPath);
47
- const locale = doc.locale || defaultLocale;
48
- if (modelHasLocale(model.path) && locale !== defaultLocale) {
49
- return `${locale}/${name}`;
50
- }
51
- return name;
33
+ const repoPath = docRepoPath(model, doc);
34
+ return repoPathBasename(repoPath);
52
35
  }
@@ -27,6 +27,3 @@ export function groupContentTypes(models: ContentTypeNav[]) {
27
27
  return { pages, data, globals };
28
28
  }
29
29
 
30
- export function modelHasLocale(path: string) {
31
- return path.includes("{locale}");
32
- }
@@ -1,7 +1,6 @@
1
1
  // packages/studio/src/chrome/folder-create-context.ts
2
2
  import { contentExtFromPath } from "@backmatter/core";
3
3
  import type { ContentTypeNav } from "./content-type-groups";
4
- import { modelHasLocale } from "./content-type-groups";
5
4
  import { treePathFromRepo } from "./content-path-tree";
6
5
  import type { PathTreeFolder, PathTreeNode } from "./content-path-tree";
7
6
  import { docRepoPath, repoPathBasename } from "./content-repo-path";
@@ -10,7 +9,6 @@ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
9
 
11
10
  export type FolderCreateContext = {
12
11
  type: string;
13
- locale: string;
14
12
  ext: string;
15
13
  /** JSON landing pages need `blocks: []` in frontmatter on create. */
16
14
  pageFormat: boolean;
@@ -35,19 +33,8 @@ export function slugFromNewFileName(name: string, expectedExt: string): string {
35
33
  return base;
36
34
  }
37
35
 
38
- export function folderPathKeyForModel(
39
- model: ContentTypeNav,
40
- locale: string | undefined,
41
- defaultLocale: string,
42
- ): string {
43
- const loc = modelHasLocale(model.path)
44
- ? locale || defaultLocale
45
- : undefined;
46
- const repo = docRepoPath(
47
- model,
48
- { slug: "new", locale: loc ?? "" },
49
- defaultLocale,
50
- );
36
+ export function folderPathKeyForModel(model: ContentTypeNav): string {
37
+ const repo = docRepoPath(model, { slug: "new" });
51
38
  const tree = treePathFromRepo(repo);
52
39
  const parts = tree.split("/").filter(Boolean);
53
40
  parts.pop();
@@ -57,26 +44,15 @@ export function folderPathKeyForModel(
57
44
  export function resolveFolderCreateContext(
58
45
  pathKey: string,
59
46
  models: ContentTypeNav[],
60
- locales: string[],
61
- defaultLocale: string,
62
47
  ): FolderCreateContext | null {
63
48
  const matches: FolderCreateContext[] = [];
64
49
  for (const model of models) {
65
50
  if (model.singleton) continue;
66
51
  const ext = contentExtFromPath(model.path);
67
52
  const pageFormat = ext === "json";
68
- if (modelHasLocale(model.path)) {
69
- for (const locale of locales) {
70
- const key = folderPathKeyForModel(model, locale, defaultLocale);
71
- if (key === pathKey) {
72
- matches.push({ type: model.type, locale, ext, pageFormat });
73
- }
74
- }
75
- } else {
76
- const key = folderPathKeyForModel(model, undefined, defaultLocale);
77
- if (key === pathKey) {
78
- matches.push({ type: model.type, locale: "", ext, pageFormat });
79
- }
53
+ const key = folderPathKeyForModel(model);
54
+ if (key === pathKey) {
55
+ matches.push({ type: model.type, ext, pageFormat });
80
56
  }
81
57
  }
82
58
  if (matches.length !== 1) return null;
@@ -119,23 +95,23 @@ function insertFolderPath(nodes: PathTreeNode[], segments: string[], pathKey: st
119
95
  return next;
120
96
  }
121
97
 
122
- /** Ensure each model's content directory exists as a folder (even with no files yet). */
98
+ function treeHasFile(nodes: PathTreeNode[]): boolean {
99
+ for (const node of nodes) {
100
+ if (node.kind === "file") return true;
101
+ if (treeHasFile(node.children)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /** Empty type folders so the first file in a model still has a folder row. */
123
107
  export function withModelFolderPlaceholders(
124
108
  tree: PathTreeNode[],
125
109
  models: ContentTypeNav[],
126
- locales: string[],
127
- defaultLocale: string,
128
110
  ): PathTreeNode[] {
129
111
  const keys = new Set<string>();
130
112
  for (const model of models) {
131
113
  if (model.singleton) continue;
132
- if (modelHasLocale(model.path)) {
133
- for (const locale of locales) {
134
- keys.add(folderPathKeyForModel(model, locale, defaultLocale));
135
- }
136
- } else {
137
- keys.add(folderPathKeyForModel(model, undefined, defaultLocale));
138
- }
114
+ keys.add(folderPathKeyForModel(model));
139
115
  }
140
116
  let out = tree;
141
117
  for (const key of [...keys].sort(compareName)) {