@arcote.tech/platform 0.5.1 → 0.5.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.5.1",
4
+ "version": "0.5.5",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Arc Platform — module system, router, layout, theme, i18n, platform app shell",
@@ -18,11 +18,11 @@
18
18
  "type-check": "tsc --noEmit"
19
19
  },
20
20
  "dependencies": {
21
- "@arcote.tech/arc-ds": "^0.5.1",
22
- "@arcote.tech/arc-react": "^0.5.1"
21
+ "@arcote.tech/arc-ds": "^0.5.5",
22
+ "@arcote.tech/arc-react": "^0.5.5"
23
23
  },
24
24
  "peerDependencies": {
25
- "@arcote.tech/arc": "^0.5.1",
25
+ "@arcote.tech/arc": "^0.5.5",
26
26
  "@lingui/core": "^5.0.0",
27
27
  "@lingui/react": "^5.0.0",
28
28
  "framer-motion": "^12.0.0",
package/src/index.ts CHANGED
@@ -109,6 +109,7 @@ export type {
109
109
  ArcFragment,
110
110
  ArcLayoutComponent,
111
111
  ArcModule,
112
+ BuildManifest,
112
113
  BuiltModule,
113
114
  ContextElementFragment,
114
115
  ModuleAccess,
@@ -9,6 +9,7 @@ import type { PageFragment } from "../types";
9
9
  type MatchResult = {
10
10
  page: PageFragment;
11
11
  child?: PageFragment;
12
+ grandchild?: PageFragment;
12
13
  params: Record<string, string>;
13
14
  };
14
15
 
@@ -23,6 +24,26 @@ function matchPage(path: string, pages: PageFragment[]): MatchResult | undefined
23
24
  if (p.children?.length) {
24
25
  if (path === p.path) return { page: p, params: {} };
25
26
 
27
+ // Check grandchildren first (deeper match wins)
28
+ for (const c of p.children) {
29
+ if (c.children?.length) {
30
+ if (path === c.path) return { page: p, child: c, params: {} };
31
+
32
+ const exactGrandchild = c.children.find((gc) => gc.path === path);
33
+ if (exactGrandchild) return { page: p, child: c, grandchild: exactGrandchild, params: {} };
34
+
35
+ for (const gc of c.children) {
36
+ if (hasRouteParams(gc.path)) {
37
+ const params = matchRoutePath(gc.path, path);
38
+ if (params) return { page: p, child: c, grandchild: gc, params };
39
+ }
40
+ }
41
+
42
+ const prefixGrandchild = c.children.find((gc) => path.startsWith(gc.path));
43
+ if (prefixGrandchild) return { page: p, child: c, grandchild: prefixGrandchild, params: {} };
44
+ }
45
+ }
46
+
26
47
  // Try exact child match first
27
48
  const exactChild = p.children.find((c) => c.path === path);
28
49
  if (exactChild) return { page: p, child: exactChild, params: {} };
@@ -94,7 +115,7 @@ export function PageRouter() {
94
115
  return null;
95
116
  }
96
117
 
97
- const { page, child } = match;
118
+ const { page, child, grandchild } = match;
98
119
  const Layout = page.layout ?? DefaultLayout;
99
120
 
100
121
  // Parent with children — redirect to first child if at parent path
@@ -103,6 +124,29 @@ export function PageRouter() {
103
124
  return null;
104
125
  }
105
126
 
127
+ // Grandchild — nested shell: OuterShell > InnerShell > GrandchildComponent
128
+ if (page.children?.length && child?.children?.length && grandchild) {
129
+ const OuterShell = page.component;
130
+ const InnerShell = child.component;
131
+ const GrandchildComponent = grandchild.component;
132
+ const content = (
133
+ <OuterShell tabs={page.children}>
134
+ <InnerShell tabs={child.children}>
135
+ <Suspense fallback={null}>
136
+ <GrandchildComponent />
137
+ </Suspense>
138
+ </InnerShell>
139
+ </OuterShell>
140
+ );
141
+ return Layout ? <Layout>{content}</Layout> : content;
142
+ }
143
+
144
+ // Child with its own children — redirect to first grandchild
145
+ if (page.children?.length && child?.children?.length && !grandchild) {
146
+ navigate(child.children[0].path);
147
+ return null;
148
+ }
149
+
106
150
  // Parent with active child — render shell with tabs
107
151
  if (page.children?.length && child) {
108
152
  const Shell = page.component;
@@ -3,11 +3,26 @@ import type { PageFragment } from "../types";
3
3
  import { useArcNavigate, useArcRoute } from "../router";
4
4
  import { SubNavShell } from "@arcote.tech/arc-ds";
5
5
 
6
+ /**
7
+ * Decide whether a child page should be shown as a tab in the sub-nav.
8
+ *
9
+ * Rules:
10
+ * - Explicit `nav: false` always hides the tab.
11
+ * - Parameterized paths (e.g. `/strategy/assistant/:stage`) are hidden by
12
+ * default — the literal path with `:param` isn't navigable.
13
+ * - Otherwise auto-show iff the page has both `icon` and `label`.
14
+ */
15
+ function shouldShowTab(page: PageFragment): boolean {
16
+ if (page.nav === false) return false;
17
+ if (page.path.includes("/:")) return false;
18
+ return Boolean(page.icon && page.label);
19
+ }
20
+
6
21
  export function PageSubNavShell({ children, tabs }: { children: ReactNode; tabs: readonly PageFragment[] }) {
7
22
  const navigate = useArcNavigate();
8
23
  const route = useArcRoute();
9
24
 
10
- const subNavTabs = tabs.map((t) => ({
25
+ const subNavTabs = tabs.filter(shouldShowTab).map((t) => ({
11
26
  path: t.path,
12
27
  icon: t.icon,
13
28
  label: t.label,
@@ -19,6 +19,7 @@ const slotDisplayModes: Record<string, DisplayMode> = {
19
19
  "main-content": "default",
20
20
  "sidebar-left": "default",
21
21
  "sidebar-right": "default",
22
+ "preview-pane": "default",
22
23
  };
23
24
 
24
25
  export function SlotRenderer({
@@ -1,17 +1,23 @@
1
1
  import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
2
- import type { ModuleDescriptor } from "./types";
2
+ import type { BuildManifest, ModuleDescriptor } from "./types";
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
 
5
- export interface ModuleManifest {
6
- modules: ModuleDescriptor[];
7
- buildTime: string;
8
- }
5
+ /** @deprecated Use BuildManifest from "./types" */
6
+ export type ModuleManifest = BuildManifest;
9
7
 
10
8
  export type ModuleLoaderState = "loading" | "ready" | "error";
11
9
 
10
+ /**
11
+ * URL for a module's JS file with cache-bust based on its content hash.
12
+ * When a module's bytes change, hash changes → URL changes → ES module cache invalidated.
13
+ * For explicit full reloads, pass `bust` (e.g. timestamp) to override.
14
+ */
12
15
  function moduleUrl(baseUrl: string, mod: ModuleDescriptor, bust?: string): string {
13
16
  const base = mod.url ?? `${baseUrl}/modules/${mod.file}`;
14
- return bust ? `${base}${base.includes("?") ? "&" : "?"}t=${bust}` : base;
17
+ const busterKey = bust ? "t" : "v";
18
+ const busterVal = bust ?? mod.hash;
19
+ if (!busterVal) return base;
20
+ return `${base}${base.includes("?") ? "&" : "?"}${busterKey}=${busterVal}`;
15
21
  }
16
22
 
17
23
  /** Read all persisted arc tokens from localStorage */
@@ -50,7 +56,7 @@ function buildHeaders(tokens?: Record<string, string>): HeadersInit {
50
56
  async function fetchManifest(
51
57
  baseUrl: string,
52
58
  tokens?: Record<string, string>,
53
- ): Promise<ModuleManifest> {
59
+ ): Promise<BuildManifest> {
54
60
  const headers = buildHeaders(tokens);
55
61
  const res = await fetch(`${baseUrl}/api/modules`, { headers });
56
62
  if (!res.ok)
@@ -58,45 +64,54 @@ async function fetchManifest(
58
64
  return res.json();
59
65
  }
60
66
 
67
+ /** Track hashes of modules we've already imported so hot-swaps re-import on change. */
68
+ const importedModuleHashes = new Map<string, string>();
69
+
61
70
  /**
62
71
  * Load all modules from manifest. Each module auto-registers via module().build().
63
72
  */
64
73
  export async function loadModules(
65
74
  baseUrl: string,
66
75
  tokens?: Record<string, string>,
67
- ): Promise<ModuleManifest> {
76
+ ): Promise<BuildManifest> {
68
77
  const manifest = await fetchManifest(baseUrl, tokens);
69
78
 
70
79
  await Promise.all(
71
- manifest.modules.map(
72
- (mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod)),
73
- ),
80
+ manifest.modules.map((mod) => {
81
+ importedModuleHashes.set(mod.name, mod.hash);
82
+ return import(/* @vite-ignore */ moduleUrl(baseUrl, mod));
83
+ }),
74
84
  );
75
85
 
76
86
  return manifest;
77
87
  }
78
88
 
79
89
  /**
80
- * Sync modules with server import any NEW modules, then set active filter.
81
- * Modules are never unregistered (ES module cache prevents re-execution).
82
- * Instead, visibility is controlled via an active set.
90
+ * Sync modules with server. Two triggers produce different behaviors:
91
+ * - new module (name unseen) dynamic import
92
+ * - hash changed (hot-swap after arc platform deploy) → dynamic import with new cache-bust
93
+ * Unchanged modules stay cached. Visibility is controlled via an active set.
83
94
  */
84
95
  export async function syncModules(
85
96
  baseUrl: string,
86
97
  tokens?: Record<string, string>,
87
- ): Promise<ModuleManifest> {
98
+ ): Promise<BuildManifest> {
88
99
  const manifest = await fetchManifest(baseUrl, tokens);
89
100
 
90
101
  const manifestNames = new Set(manifest.modules.map((m) => m.name));
91
- // Check against ALL registered modules (not just active ones)
92
- const registeredNames = new Set(getAllRegisteredModules().map((m) => m.name));
93
102
 
94
- // Import only truly new modules (never imported before)
95
- const newModules = manifest.modules.filter((m) => !registeredNames.has(m.name));
103
+ // A module needs reimporting if it's new OR its hash changed since last import
104
+ const toImport = manifest.modules.filter((m) => {
105
+ const prevHash = importedModuleHashes.get(m.name);
106
+ return prevHash !== m.hash;
107
+ });
96
108
 
97
- if (newModules.length > 0) {
109
+ if (toImport.length > 0) {
98
110
  await Promise.all(
99
- newModules.map((mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod))),
111
+ toImport.map((mod) => {
112
+ importedModuleHashes.set(mod.name, mod.hash);
113
+ return import(/* @vite-ignore */ moduleUrl(baseUrl, mod));
114
+ }),
100
115
  );
101
116
  }
102
117
 
package/src/types.ts CHANGED
@@ -102,9 +102,21 @@ export interface ModuleAccess {
102
102
  export interface ModuleDescriptor {
103
103
  readonly file: string;
104
104
  readonly name: string;
105
+ /** sha256 hex of the bundled .js content — used by deploy diff and client cache-bust. */
106
+ readonly hash: string;
105
107
  readonly url?: string;
106
108
  }
107
109
 
110
+ /** Build manifest written to .arc/platform/modules/manifest.json. */
111
+ export interface BuildManifest {
112
+ readonly modules: readonly ModuleDescriptor[];
113
+ /** sha256 hex over all shell bundle outputs concatenated. */
114
+ readonly shellHash: string;
115
+ /** sha256 hex over styles.css (+ theme.css if present). */
116
+ readonly stylesHash: string;
117
+ readonly buildTime: string;
118
+ }
119
+
108
120
  // ---------------------------------------------------------------------------
109
121
  // Moduł
110
122
  // ---------------------------------------------------------------------------