foss-design 0.4.2 → 0.6.0

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.
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = dark ? 'dark' : 'light'
15
15
  } catch (e) {}
16
16
  </script>
17
- <script type="module" crossorigin src="./assets/index-k3bj-XTr.js"></script>
17
+ <script type="module" crossorigin src="./assets/index-D6sHcSw6.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="./assets/index-BL2Cxj98.css">
19
19
  </head>
20
20
  <body>
@@ -7078,6 +7078,78 @@ async function copyText(text, label = "Copied") {
7078
7078
  }
7079
7079
  }
7080
7080
  //#endregion
7081
+ //#region src/shared/routes.ts
7082
+ function split(path) {
7083
+ return path.split("/").filter(Boolean);
7084
+ }
7085
+ function compileRoute(pattern) {
7086
+ return {
7087
+ pattern,
7088
+ segments: split(pattern).map((part) => {
7089
+ if (part === "*") return { kind: "splat" };
7090
+ if (part.startsWith(":")) return {
7091
+ kind: "param",
7092
+ optional: part.endsWith("?")
7093
+ };
7094
+ return {
7095
+ kind: "static",
7096
+ value: part
7097
+ };
7098
+ })
7099
+ };
7100
+ }
7101
+ function decode(part) {
7102
+ try {
7103
+ return decodeURIComponent(part);
7104
+ } catch {
7105
+ return part;
7106
+ }
7107
+ }
7108
+ /** How well a route matches a path (higher is more specific), or null when it does not. */
7109
+ function scoreRoute(route, path) {
7110
+ const parts = split(path.split(/[?#]/)[0] ?? "").map(decode);
7111
+ const walk = (s, p) => {
7112
+ if (s === route.segments.length) return p === parts.length ? 0 : null;
7113
+ const segment = route.segments[s];
7114
+ if (segment.kind === "splat") return 0;
7115
+ if (segment.kind === "static") {
7116
+ if (parts[p] !== segment.value) return null;
7117
+ const rest = walk(s + 1, p + 1);
7118
+ return rest === null ? null : rest + 4;
7119
+ }
7120
+ if (p < parts.length) {
7121
+ const rest = walk(s + 1, p + 1);
7122
+ if (rest !== null) return rest + 2;
7123
+ }
7124
+ return segment.optional ? walk(s + 1, p) : null;
7125
+ };
7126
+ return walk(0, 0);
7127
+ }
7128
+ /** The most specific match for a path; the first one listed wins a tie. */
7129
+ function matchRoute(entries, path) {
7130
+ let best = null;
7131
+ for (const entry of entries) for (const route of entry.routes) {
7132
+ const score = scoreRoute(route, path);
7133
+ if (score !== null && (!best || score > best.score)) best = {
7134
+ score,
7135
+ value: entry.value
7136
+ };
7137
+ }
7138
+ return best?.value ?? null;
7139
+ }
7140
+ /**
7141
+ * The app path a link points at, from its `href` as written: an absolute path as it is, a
7142
+ * relative one against the route of the screen it is in (or `/`). Null for links that do not
7143
+ * lead to another page of the app: `#…`, other schemes, other sites.
7144
+ */
7145
+ function linkPath(href, base = "/") {
7146
+ const raw = href.trim();
7147
+ if (!raw || raw.startsWith("#")) return null;
7148
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("//")) return null;
7149
+ const url = new URL(raw, `http://app.invalid${base.replace(/\/:[^/]+\??|\/\*$/g, "/x")}`);
7150
+ return url.pathname + url.search + url.hash;
7151
+ }
7152
+ //#endregion
7081
7153
  //#region viewer/src/lib/frames.ts
7082
7154
  /** Routes runtime messages to whoever listens to the frame that sent them. */
7083
7155
  var handlers = /* @__PURE__ */ new Map();
@@ -7157,11 +7229,13 @@ function absoluteUrl(url) {
7157
7229
  return new URL(url, document.baseURI).toString();
7158
7230
  }
7159
7231
  /**
7160
- * A link or form the runtime kept from leaving its frame. A path whose last part names a screen
7161
- * or URL item on the canvas (`/settings`, `settings.html`) goes there; a link to another site
7162
- * opens in a new tab; anything else says where it pointed.
7232
+ * A link, form or history push the runtime kept from leaving its frame. The path goes to the
7233
+ * screen whose `route` matches it best (a relative `href` resolves against the route of the
7234
+ * screen it is in); without routes, a path whose last part names a screen still opens it. A
7235
+ * link to another site opens in a new tab. A push nobody's route matches belongs to the
7236
+ * screen's own router; a link or form that leads nowhere stays put with a note.
7163
7237
  */
7164
- function followLink(message, canvas, go) {
7238
+ function followLink(message, canvas, go, from) {
7165
7239
  if (message.form) {
7166
7240
  toast("Forms are not sent from a screen", void 0, "info");
7167
7241
  return;
@@ -7172,14 +7246,25 @@ function followLink(message, canvas, go) {
7172
7246
  else toast("Link to another site", message.href, "info");
7173
7247
  return;
7174
7248
  }
7175
- const path = message.path.split(/[?#]/)[0].replace(/\/+$/, "");
7176
- let name = path.slice(path.lastIndexOf("/") + 1).replace(/\.html?$/, "");
7249
+ const frames = canvas.pages.flatMap((page) => page.sections.flatMap((section) => section.items.filter((item) => item.kind === "screen" || item.kind === "url")));
7250
+ const here = frames.find((item) => item.id === from);
7251
+ const path = message.raw === void 0 ? message.path : linkPath(message.raw, here?.routes?.[0]);
7252
+ if (path === null) return;
7253
+ const routed = matchRoute(frames.map((item) => ({
7254
+ routes: (item.routes ?? []).map(compileRoute),
7255
+ value: item.id
7256
+ })), path);
7257
+ if (routed) {
7258
+ if (routed !== from) go(routed);
7259
+ return;
7260
+ }
7261
+ const bare = path.split(/[?#]/)[0].replace(/\/+$/, "");
7262
+ let name = bare.slice(bare.lastIndexOf("/") + 1).replace(/\.html?$/, "");
7177
7263
  try {
7178
7264
  name = decodeURIComponent(name);
7179
7265
  } catch {}
7180
- const ids = new Set(canvas.pages.flatMap((page) => page.sections.flatMap((section) => section.items.filter((item) => item.kind === "screen" || item.kind === "url").map((item) => item.id))));
7181
- if (name && ids.has(name)) go(name);
7182
- else toast("Not a screen on this canvas", message.path, "info");
7266
+ if (name && name !== from && frames.some((item) => item.id === name)) go(name);
7267
+ else if (!message.pushed) toast("No screen for this link", path, "info");
7183
7268
  }
7184
7269
  //#endregion
7185
7270
  //#region viewer/src/components/inspector.tsx
@@ -8854,7 +8939,7 @@ function FullPage({ canvasId, itemId }) {
8854
8939
  else if (message.type === "go") {
8855
8940
  const id = message.target.includes("/") ? message.target.split("/")[1] : message.target;
8856
8941
  if (items.some((entry) => entry.id === id)) navigate(fullHref(canvasId, id, theme), { replace: true });
8857
- } else if (message.type === "link" && canvas) followLink(message, canvas, (id) => navigate(fullHref(canvasId, id, theme), { replace: true }));
8942
+ } else if (message.type === "link" && canvas) followLink(message, canvas, (id) => navigate(fullHref(canvasId, id, theme), { replace: true }), itemId);
8858
8943
  });
8859
8944
  }, [url, item?.kind]);
8860
8945
  if (error) return /* @__PURE__ */ jsx(NotFound, {
@@ -12160,7 +12245,7 @@ function CanvasView({ canvas, page }) {
12160
12245
  console.warn(`[design] go("${target}"): no such screen`);
12161
12246
  },
12162
12247
  onLink(from, message) {
12163
- followLink(message, canvas, (id) => this.onGo(from, id));
12248
+ followLink(message, canvas, (id) => this.onGo(from, id), from);
12164
12249
  },
12165
12250
  onWheel(frame, message) {
12166
12251
  const root = frame.closest(".canvas-root");
@@ -12669,7 +12754,7 @@ function Player({ canvas, itemId }) {
12669
12754
  } else if (message.type === "link") followLink(message, canvas, (id) => {
12670
12755
  const target = all.find((e) => e.item.id === id);
12671
12756
  if (target) navigate(playHref(canvas.id, target.item.id, target.pageId));
12672
- });
12757
+ }, item?.id);
12673
12758
  });
12674
12759
  }, [
12675
12760
  item,
@@ -12946,7 +13031,7 @@ function PhoneViewer({ canvas, pageId, itemId }) {
12946
13031
  else if (message.type === "error") setErrors((list) => list.includes(message.message) ? list : [...list, message.message]);
12947
13032
  else if (message.type === "updated") setErrors([]);
12948
13033
  else if (message.type === "go") goTo(message.target.includes("/") ? message.target.split("/")[1] : message.target);
12949
- else if (message.type === "link") followLink(message, canvas, goTo);
13034
+ else if (message.type === "link") followLink(message, canvas, goTo, item.id);
12950
13035
  });
12951
13036
  }, [frameEl, src]);
12952
13037
  useEffect(() => {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Screen routes: the app URL a screen stands for (`/databases/:id`), so a plain link in any
3
+ * screen (`href="/databases/orders-db"`) opens the screen that shows that URL. Patterns work
4
+ * like a router's: `:param` matches one segment, `:param?` an optional one, a trailing `*` the
5
+ * rest of the path.
6
+ */
7
+ type Segment = {
8
+ kind: 'static';
9
+ value: string;
10
+ } | {
11
+ kind: 'param';
12
+ optional: boolean;
13
+ } | {
14
+ kind: 'splat';
15
+ };
16
+ export interface CompiledRoute {
17
+ pattern: string;
18
+ segments: Segment[];
19
+ }
20
+ /** What is wrong with a pattern, or null. */
21
+ export declare function routeError(pattern: string): string | null;
22
+ export declare function compileRoute(pattern: string): CompiledRoute;
23
+ /** How well a route matches a path (higher is more specific), or null when it does not. */
24
+ export declare function scoreRoute(route: CompiledRoute, path: string): number | null;
25
+ export interface Routed<T> {
26
+ routes: CompiledRoute[];
27
+ value: T;
28
+ }
29
+ /** The most specific match for a path; the first one listed wins a tie. */
30
+ export declare function matchRoute<T>(entries: Routed<T>[], path: string): T | null;
31
+ /**
32
+ * The app path a link points at, from its `href` as written: an absolute path as it is, a
33
+ * relative one against the route of the screen it is in (or `/`). Null for links that do not
34
+ * lead to another page of the app: `#…`, other schemes, other sites.
35
+ */
36
+ export declare function linkPath(href: string, base?: string): string | null;
37
+ export {};
@@ -47,6 +47,8 @@ interface ItemBase {
47
47
  id: string;
48
48
  title: string;
49
49
  description?: string;
50
+ /** Screens and url items: the app URL patterns they stand for (`/databases/:id`). */
51
+ routes?: string[];
50
52
  /** Free-layout position on the page, in canvas pixels. */
51
53
  x?: number;
52
54
  y?: number;
@@ -296,6 +298,10 @@ export type RuntimeMessage = {
296
298
  href: string;
297
299
  path: string | null;
298
300
  form: boolean;
301
+ /** The `href` as written (0.4.3+), resolved by the viewer against the screen's route. */
302
+ raw?: string;
303
+ /** Sent by the screen's own `history.pushState`, not a click: the screen already moved. */
304
+ pushed?: boolean;
299
305
  } | {
300
306
  source: 'design-runtime';
301
307
  key: string;
@@ -364,4 +370,11 @@ export type ViewerMessage = {
364
370
  type: 'inspect-select';
365
371
  ref: number | null;
366
372
  };
373
+ /** design.json `public` as a frame sees it. */
374
+ export interface PublicFolder {
375
+ /** Where the folder's files are, relative to the frame's URL. */
376
+ base: string;
377
+ /** Its files, relative to the folder, with forward slashes. */
378
+ files: string[];
379
+ }
367
380
  export {};
@@ -23,11 +23,13 @@ export declare function frameSrc(url: string, theme: Theme, extra?: Record<strin
23
23
  /** Absolute URL for opening a frame in a new tab. */
24
24
  export declare function absoluteUrl(url: string): string;
25
25
  /**
26
- * A link or form the runtime kept from leaving its frame. A path whose last part names a screen
27
- * or URL item on the canvas (`/settings`, `settings.html`) goes there; a link to another site
28
- * opens in a new tab; anything else says where it pointed.
26
+ * A link, form or history push the runtime kept from leaving its frame. The path goes to the
27
+ * screen whose `route` matches it best (a relative `href` resolves against the route of the
28
+ * screen it is in); without routes, a path whose last part names a screen still opens it. A
29
+ * link to another site opens in a new tab. A push nobody's route matches belongs to the
30
+ * screen's own router; a link or form that leads nowhere stays put with a note.
29
31
  */
30
32
  export declare function followLink(message: Extract<RuntimeMessage, {
31
33
  type: 'link';
32
- }>, canvas: CanvasDoc, go: (id: string) => void): void;
34
+ }>, canvas: CanvasDoc, go: (id: string) => void, from?: string): void;
33
35
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foss-design",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Design canvases and design systems for coding agents: live, animated, clickable screens on an infinite canvas, served locally from a .design folder.",
5
5
  "type": "module",
6
6
  "license": "MIT",