arcane-os 0.26.0 → 0.27.1

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/src/pwa.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import Is from 'strong-type';
2
2
  import {randomUUID} from 'node:crypto';
3
+ import path from 'node:path';
3
4
  import {createPwaWorkerScript} from './pwa-worker.mjs';
4
5
  import {versionAssetUrl} from './import-map.mjs';
5
6
 
@@ -202,6 +203,8 @@ export function createPwaArtifacts(
202
203
  mode = 'release',
203
204
  runtimeBase = './arcane/sdk/',
204
205
  appBase,
206
+ installationId,
207
+ legacyAppPath,
205
208
  appPath = '',
206
209
  navigationAliases,
207
210
  revision
@@ -218,7 +221,7 @@ export function createPwaArtifacts(
218
221
  // Relocating app files must not change an existing installed app's default identity.
219
222
  const installationBase = appBase ?? (mode === 'development' ? applicationBase : './');
220
223
  const manifest = {
221
- id: installationBase,
224
+ id: installationId ?? installationBase,
222
225
  name: app.displayName,
223
226
  short_name: app.displayName,
224
227
  start_url: manifestUrl(app.entry, appPath ? basePath : applicationBase),
@@ -304,18 +307,60 @@ controller.ready.catch(
304
307
  const entryAssets = {manifest: PWA_MANIFEST_NAME, bootstrap: PWA_BOOTSTRAP_NAME};
305
308
  const manifestHref = resourceUrl(basePath, entryAssets.manifest);
306
309
  const bootstrapHref = resourceUrl(basePath, entryAssets.bootstrap);
310
+ const generatedFiles = [
311
+ {path: PWA_MANIFEST_NAME, content: json(manifest)},
312
+ {path: PWA_OFFLINE_MANIFEST_NAME, content: json(offlineManifest)},
313
+ {
314
+ path: PWA_WORKER_NAME,
315
+ content: createPwaWorkerScript(offlineManifest, `${runtimeBase}pwa.mjs`)
316
+ },
317
+ {path: PWA_BOOTSTRAP_NAME, content: bootstrap}
318
+ ];
319
+ if (legacyAppPath) {
320
+ const directory = legacyAppPath.endsWith('/') ? legacyAppPath : `${legacyAppPath}/`;
321
+ const priorDirectory = new URL(resourceUrl('./', directory), 'https://arcane.invalid/').pathname;
322
+ // Existing registrations continue updating their own script and inventory URLs.
323
+ function priorScopeUrl(value) {
324
+ if (value.startsWith('/') || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value)) return value;
325
+ const resolved = new URL(value, 'https://arcane.invalid/');
326
+ let relative = path.posix.relative(priorDirectory, resolved.pathname);
327
+ if (!relative) {
328
+ relative = resolved.pathname.endsWith('/')
329
+ ? './'
330
+ : `../${path.posix.basename(resolved.pathname)}`;
331
+ }
332
+ else {
333
+ if (!relative.startsWith('.')) relative = `./${relative}`;
334
+ if (resolved.pathname.endsWith('/') && !relative.endsWith('/')) relative += '/';
335
+ }
336
+ return `${relative}${resolved.search}${resolved.hash}`;
337
+ }
338
+ const legacyOfflineManifest = {
339
+ ...offlineManifest,
340
+ assets: [...new Set([
341
+ ...offlineManifest.assets.map(priorScopeUrl),
342
+ `./${PWA_OFFLINE_MANIFEST_NAME}`
343
+ ])],
344
+ navigationAliases: Object.fromEntries(
345
+ Object.entries(offlineManifest.navigationAliases).map(
346
+ function priorScopeNavigation([alias, target]) {
347
+ return [priorScopeUrl(alias), priorScopeUrl(target)];
348
+ }
349
+ )
350
+ )
351
+ };
352
+ generatedFiles.push(
353
+ {
354
+ path: `${directory}${PWA_WORKER_NAME}`,
355
+ content: createPwaWorkerScript(legacyOfflineManifest, priorScopeUrl(`${runtimeBase}pwa.mjs`))
356
+ },
357
+ {path: `${directory}${PWA_OFFLINE_MANIFEST_NAME}`, content: json(legacyOfflineManifest)}
358
+ );
359
+ }
307
360
  return {
308
361
  manifest,
309
362
  offlineManifest,
310
- files: [
311
- {path: PWA_MANIFEST_NAME, content: json(manifest)},
312
- {path: PWA_OFFLINE_MANIFEST_NAME, content: json(offlineManifest)},
313
- {
314
- path: PWA_WORKER_NAME,
315
- content: createPwaWorkerScript(offlineManifest, `${runtimeBase}pwa.mjs`)
316
- },
317
- {path: PWA_BOOTSTRAP_NAME, content: bootstrap}
318
- ],
363
+ files: generatedFiles,
319
364
  entryAssets,
320
365
  entryMarkup: `<link rel="manifest" href="${htmlAttribute(manifestHref)}">\n`
321
366
  + `<script type="module" async data-arcane-pwa src="${htmlAttribute(bootstrapHref)}"></script>\n`
package/src/scaffold.mjs CHANGED
@@ -8,6 +8,7 @@ import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
8
8
  import {SDK_NAME,SDK_VERSION,workspaceTemplate} from './templates/workspace-template.mjs';
9
9
  import {inspectWorkspaceProfile,resolveSdkPackageDeclaration} from './workspace.mjs';
10
10
  import {parseSemver} from './packager/core.mjs';
11
+ import {resolveAppRoot} from './app-layout.mjs';
11
12
 
12
13
  const is = new Is(false);
13
14
 
@@ -310,6 +311,7 @@ async function runGitInit(workspaceRoot,signal,onEvent){
310
311
  export async function createWorkspace({
311
312
  targetPath,
312
313
  appId,
314
+ appsRoot='apps',
313
315
  displayName,
314
316
  target='browser',
315
317
  initializeGit=false,
@@ -318,6 +320,7 @@ export async function createWorkspace({
318
320
  }){
319
321
  validateInputs(appId,displayName);
320
322
  validateScaffoldTarget(target);
323
+ if(!['apps','.'].includes(appsRoot))fail('appsRoot must be apps or .','ARCANE_USAGE');
321
324
  if(!is.string(targetPath)||!targetPath.trim())fail('targetPath is required.','ARCANE_USAGE');
322
325
  if(!is.boolean(initializeGit))fail('initializeGit must be a boolean.','ARCANE_USAGE');
323
326
  throwIfAborted(signal);
@@ -332,20 +335,21 @@ export async function createWorkspace({
332
335
  },async workspaceOperationLease=>{
333
336
  const template=workspaceTemplate({
334
337
  appId,
338
+ appsRoot,
335
339
  displayName,
336
340
  target,
337
341
  appIcon:await scaffoldIcon(target)
338
342
  });
339
343
  const writtenFiles=await writeMissingFiles(workspaceRoot,template.files,{signal,onEvent});
340
- const workspaceRuntime=await materializeWorkspaceRuntimeContent({
344
+ const workspaceRuntime=appsRoot==='.'?null:await materializeWorkspaceRuntimeContent({
341
345
  workspaceRoot,
342
346
  signal,
343
347
  onEvent
344
348
  });
345
- const importMap=await generateImportMap({
349
+ const importMap=appsRoot==='.'?{pending:true,reason:'sdk-install-required'}:await generateImportMap({
346
350
  workspaceRoot,
347
351
  appId,
348
- appRoot:path.join(workspaceRoot,'apps',appId),
352
+ appRoot:resolveAppRoot(workspaceRoot,{appsRoot},appId),
349
353
  workspaceOperationLease,
350
354
  signal,
351
355
  onEvent
@@ -354,6 +358,7 @@ export async function createWorkspace({
354
358
  const result={
355
359
  workspaceRoot,
356
360
  appId,
361
+ appsRoot,
357
362
  displayName:template.name,
358
363
  target,
359
364
  ...writtenFiles,
@@ -370,12 +375,13 @@ export async function createWorkspace({
370
375
  export async function initWorkspace({
371
376
  workspaceRoot=process.cwd(),
372
377
  appId,
378
+ appsRoot,
373
379
  displayName,
374
380
  target='browser',
375
381
  signal,
376
382
  onEvent
377
383
  }){
378
- validateInputs(appId,displayName);
384
+ if(appId!==undefined)validateInputs(appId,displayName);
379
385
  validateScaffoldTarget(target);
380
386
  throwIfAborted(signal);
381
387
  const resolvedRoot=path.resolve(workspaceRoot);
@@ -389,6 +395,28 @@ export async function initWorkspace({
389
395
  },async workspaceOperationLease=>{
390
396
  const profile=await existingWorkspaceProfile(resolvedRoot);
391
397
  const workspaceMode=profile?.workspaceMode??'external';
398
+ const selectedAppsRoot=appsRoot??profile?.config.appsRoot??'apps';
399
+ if(!['apps','.'].includes(selectedAppsRoot))fail('appsRoot must be apps or .','ARCANE_USAGE');
400
+ if(profile&&selectedAppsRoot!==profile.config.appsRoot){
401
+ fail('appsRoot must match the existing workspace layout; init does not relocate an application.','ARCANE_USAGE');
402
+ }
403
+ if(workspaceMode==='integrated'&&selectedAppsRoot==='.'){
404
+ fail('The integrated workspace keeps applications under apps; root scaffolding selects a standalone workspace.','ARCANE_USAGE');
405
+ }
406
+ if(selectedAppsRoot==='.'){
407
+ let existingId;
408
+ try{existingId=JSON.parse(await readFile(path.join(resolvedRoot,'arcane-package.json'),'utf8')).id;}
409
+ catch(error){if(error?.code!=='ENOENT')throw error;}
410
+ if(existingId!==undefined){
411
+ if(appId!==undefined&&appId!==existingId){
412
+ fail(`The root application id is ${existingId}; init does not replace its identity.`,'ARCANE_USAGE');
413
+ }
414
+ appId=existingId;
415
+ }
416
+ }
417
+ appId??=path.basename(resolvedRoot).normalize('NFKD').toLowerCase()
418
+ .replace(/[^a-z0-9]+/gu,'-').replace(/^-+|-+$/gu,'')||'arcane-app';
419
+ validateInputs(appId,displayName);
392
420
  const existingPackage=workspaceMode==='external'
393
421
  ?await readExistingPackage(resolvedRoot)
394
422
  :null;
@@ -415,6 +443,7 @@ export async function initWorkspace({
415
443
  })
416
444
  :workspaceTemplate({
417
445
  appId,
446
+ appsRoot:selectedAppsRoot,
418
447
  displayName,
419
448
  sdkDependencyName:sdkDeclaration.dependencyName,
420
449
  sdkDependencySpecifier:sdkDeclaration.specifier,
@@ -451,17 +480,23 @@ export async function initWorkspace({
451
480
  await emit(onEvent,{type:'scaffold.file.replaced',path:'arcane.lock.json'});
452
481
  }
453
482
  const packageUpdated=await applyPackageMerge(resolvedRoot,packagePlan,{signal,onEvent});
454
- const workspaceRuntime=workspaceMode==='external'
483
+ const directRuntime=workspaceMode==='external'&&selectedAppsRoot==='.';
484
+ const workspaceRuntime=workspaceMode==='external'&&!directRuntime
455
485
  ?await materializeWorkspaceRuntimeContent({
456
486
  workspaceRoot:resolvedRoot,
457
487
  signal,
458
488
  onEvent
459
489
  })
460
490
  :null;
461
- const importMap=await generateImportMap({
491
+ let sdkInstalled=true;
492
+ if(directRuntime){
493
+ try{await lstat(path.join(resolvedRoot,sdkDeclaration.packageSource,'package.json'));}
494
+ catch(error){if(error?.code==='ENOENT')sdkInstalled=false;else throw error;}
495
+ }
496
+ const importMap=!sdkInstalled?{pending:true,reason:'sdk-install-required'}:await generateImportMap({
462
497
  workspaceRoot:resolvedRoot,
463
498
  appId,
464
- appRoot:path.join(resolvedRoot,'apps',appId),
499
+ appRoot:resolveAppRoot(resolvedRoot,{appsRoot:selectedAppsRoot},appId),
465
500
  workspaceOperationLease,
466
501
  signal,
467
502
  onEvent
@@ -470,6 +505,7 @@ export async function initWorkspace({
470
505
  workspaceRoot:resolvedRoot,
471
506
  workspaceMode,
472
507
  appId,
508
+ appsRoot:selectedAppsRoot,
473
509
  displayName:template.name,
474
510
  target,
475
511
  ...writtenFiles,
@@ -4,9 +4,9 @@ import path from 'node:path';
4
4
 
5
5
  const is=new Is(false);
6
6
 
7
- // The browser URLs stay the same; only the source of their files changes.
8
- export function installedSdkRoutes(packageSource,{security=false}={}){
9
- return [
7
+ // Direct routes use the installed npm paths in both the browser and selected output.
8
+ export function installedSdkRoutes(packageSource,{security=false,direct=false}={}){
9
+ const routes=[
10
10
  {
11
11
  source:`${packageSource}/runtime/arcane`,destination:'arcane',
12
12
  include:['components','css','entities','img','modules',...(security?['security']:[])],exclude:[]
@@ -24,6 +24,7 @@ export function installedSdkRoutes(packageSource,{security=false}={}){
24
24
  include:['LICENSE','COMMERCIAL-LICENSE.md','NOTICE'],exclude:[]
25
25
  }
26
26
  ];
27
+ return direct?routes.map(route=>({...route,destination:route.source})):routes;
27
28
  }
28
29
 
29
30
  export function installedSdkPackageSource(config){
@@ -31,7 +32,10 @@ export function installedSdkPackageSource(config){
31
32
  if(!is.array(routes)||routes.length!==4)return null;
32
33
  const source=routes[3]?.source;
33
34
  if(!is.string(source)||!/^node_modules\/(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/u.test(source))return null;
34
- const expected=installedSdkRoutes(source,{security:routes[0]?.include?.at(-1)==='security'});
35
+ const expected=installedSdkRoutes(source,{
36
+ security:routes[0]?.include?.at(-1)==='security',
37
+ direct:routes[0]?.destination===routes[0]?.source
38
+ });
35
39
  return routes.every(function matchesInstalledRoute(route,index){
36
40
  const wanted=expected[index];
37
41
  return route?.source===wanted.source&&route?.destination===wanted.destination
@@ -56,7 +60,13 @@ export async function readInstalledSdkLayout(workspaceRoot,config){
56
60
  error.code='ARCANE_WORKSPACE_INVALID';
57
61
  throw error;
58
62
  }
59
- return {packageSource,packageRoot,versionPath,version:manifest.version,routes:config.sharedPayloads['browser-runtime']};
63
+ const routes=config.sharedPayloads['browser-runtime'];
64
+ return {
65
+ packageSource,packageRoot,versionPath,version:manifest.version,routes,
66
+ direct:routes[0].destination===routes[0].source,
67
+ runtimeBase:`/${routes[0].destination}/`,
68
+ browserRuntimeBase:`/${routes[1].destination}/`
69
+ };
60
70
  }
61
71
 
62
72
  export async function installedRuntimeFiles(workspaceRoot,layout,signal){
@@ -71,9 +81,9 @@ export async function installedRuntimeFiles(workspaceRoot,layout,signal){
71
81
  else if(entry.isFile())files.push(relative);
72
82
  }
73
83
  }
74
- for(const route of layout.routes){
75
- if(route.destination!=='arcane'&&!route.destination.startsWith('arcane/'))continue;
76
- const prefix=route.destination==='arcane'?'':route.destination.slice('arcane/'.length);
84
+ for(const [index,route] of layout.routes.entries()){
85
+ if(index===3)continue;
86
+ const prefix=['','sdk','dependencies/strong-type'][index];
77
87
  for(const selected of route.include){
78
88
  const suffix=selected==='.'?'':selected;
79
89
  await visit(
@@ -85,15 +95,18 @@ export async function installedRuntimeFiles(workspaceRoot,layout,signal){
85
95
  return {files:files.sort()};
86
96
  }
87
97
 
88
- export function installedRuntimeTarget(relative,layout){
89
- for(const route of [...layout.routes].sort(function longestDestinationFirst(left,right){
90
- return right.destination.length-left.destination.length;
98
+ export function installedRuntimeTarget(relative,layout,{browser=false}={}){
99
+ const logicalRoots=['arcane','arcane/sdk','arcane/dependencies/strong-type','licenses/arcane-os'];
100
+ const routes=layout.routes.map((route,index)=>({...route,logical:logicalRoots[index]}));
101
+ for(const route of routes.sort(function longestDestinationFirst(left,right){
102
+ return right.logical.length-left.logical.length;
91
103
  })){
92
- if(!relative.startsWith(`${route.destination}/`))continue;
93
- const suffix=relative.slice(route.destination.length+1);
104
+ if(!relative.startsWith(`${route.logical}/`))continue;
105
+ const suffix=relative.slice(route.logical.length+1);
94
106
  if(route.include.some(function includesRuntimeTarget(selected){
95
- return selected==='.'||suffix===selected||suffix.startsWith(`${selected}/`);
96
- }))return `${route.source}/${suffix}`;
107
+ const pathname=suffix.split(/[?#]/u)[0];
108
+ return selected==='.'||pathname===selected||pathname.startsWith(`${selected}/`);
109
+ }))return `${browser?route.destination:route.source}/${suffix}`;
97
110
  }
98
111
  return relative;
99
112
  }
@@ -1,4 +1,5 @@
1
1
  import Is from 'strong-type';
2
+ import {installedSdkRoutes} from '../sdk-runtime-layout.mjs';
2
3
  import {
3
4
  ARCANE_PROTOCOL,
4
5
  CLI_EVENT_PROTOCOL,
@@ -59,6 +60,7 @@ export function createWorkspaceLockDocument({
59
60
 
60
61
  export function workspaceTemplate({
61
62
  appId,
63
+ appsRoot='apps',
62
64
  displayName,
63
65
  sdkDependencyName=SDK_NAME,
64
66
  sdkDependencySpecifier=SDK_VERSION,
@@ -86,6 +88,12 @@ export function workspaceTemplate({
86
88
  if(!supportedTargets.includes(target)){
87
89
  throw new Error(`Unsupported scaffold target: ${String(target)}.`);
88
90
  }
91
+ if(!['apps','.'].includes(appsRoot))throw new Error('appsRoot must be apps or .');
92
+ if(appOnly&&appsRoot==='.')throw new Error('Root scaffolding selects a standalone workspace.');
93
+ const appPrefix=appsRoot==='.'?'':`apps/${appId}/`;
94
+ const directRuntime=appsRoot==='.'&&!appOnly;
95
+ const runtimePrefix=directRuntime?`./${sdkPackageSource}/runtime/arcane`:'./arcane';
96
+ const baseHref=appsRoot==='.'?'./':'../../';
89
97
  const native=target!=='browser';
90
98
  const buildTarget=native?target:'browser';
91
99
  const runTarget=native&&target!=='portable'?target:'browser';
@@ -125,7 +133,7 @@ appropriate.
125
133
  </script>
126
134
  `:'';
127
135
  const bootstrapMarkup=namedImports?'':
128
- ' <script type="module" src="./arcane/modules/ThemeBootstrap.js?v=1"></script>\n';
136
+ ` <script type="module" src="${runtimePrefix}/modules/ThemeBootstrap.js?v=1"></script>\n`;
129
137
  const files=new Map();
130
138
  files.set('.gitignore',[
131
139
  'node_modules/',
@@ -150,8 +158,8 @@ appropriate.
150
158
  files.set('AGENTS.md',`# ${name} development instructions
151
159
 
152
160
  - Use plain JavaScript, HTML, and CSS; do not introduce TypeScript or TSX.
153
- - Keep reusable portable mechanisms in the Arcane SDK and app-specific behavior under \`apps/${appId}/\`.
154
- - Keep \`arcane/css/theme.css\` before app styles and import \`arcane/ThemeBootstrap\` before app code runs.
161
+ - Keep reusable portable mechanisms in the Arcane SDK and app-specific behavior under \`${appPrefix||'./'}\`.
162
+ - Keep \`${runtimePrefix}/css/theme.css\` before app styles and import \`arcane/ThemeBootstrap\` before app code runs.
155
163
  - Use \`rgb(...)\` or \`rgba(...)\` for new CSS colors.
156
164
  - Build one named app and one explicit target at a time. Native targets may be unavailable until their adapters are installed.
157
165
  - Preserve complete application, model, document, message, log, diagnostic, process, and tool content. Do not truncate, clip, tail, elide, or silently discard it.
@@ -167,10 +175,11 @@ This repository contains the portable Arcane application \`${appId}\`. It includ
167
175
 
168
176
  \`\`\`sh
169
177
  npm install
178
+ npm run import-map
170
179
  npm run dev
171
180
  \`\`\`
172
181
 
173
- Open the loopback URL printed by the development server. The source server exposes this app and its physical \`arcane/\` runtime; it does not expose an Ollama HTTP endpoint.
182
+ Open the loopback URL printed by the development server. ${directRuntime?'This root app reads SDK files directly from its installed npm package; an ordinary static host uses the same resource paths.':'This app uses the existing physical arcane/ runtime layout.'} The SDK server does not expose an Ollama HTTP endpoint.
174
183
 
175
184
  Commit the generated \`package-lock.json\` after dependency installation. CI intentionally uses \`npm ci\` and therefore requires that lock. Before the SDK is published, install a locally packed \`${SDK_NAME}\` \`.tgz\` with \`npm install --save-dev --save-exact <path-to-tarball>\`; keep that tarball at the lock file's relative path for repeatable local \`npm ci\` runs.
176
185
 
@@ -185,13 +194,12 @@ npm run run
185
194
  \`\`\`
186
195
 
187
196
  The explicit \`import-map\` command refreshes
188
- \`apps/${appId}/modules/arcane.importmap.json\` and the managed inline browser
197
+ \`${appPrefix}modules/arcane.importmap.json\` and the managed inline browser
189
198
  import map in every directly navigable descriptor-admitted \`.html\`/\`.htm\`
190
199
  document. HTML component fragments remain package files but do not receive a
191
200
  document-level base or managed import map.
192
201
  Development, package, and build refresh that shared inventory when the selected operation needs it.
193
- Named \`arcane/*\` imports resolve against the physical workspace \`arcane/\`
194
- tree. Packaging copies the complete selected application, runtime, and specifier
202
+ Named \`arcane/*\` imports resolve through the managed map to the selected SDK files. Packaging copies the complete selected application, runtime, and specifier
195
203
  map to \`dist/${appId}\` without running application tests. Run \`verify\` only when
196
204
  the user explicitly selects verification or a release artifact that requires it;
197
205
  \`bundle\` creates the distributable archive and \`run\` launches the selected
@@ -201,7 +209,7 @@ Native targets are provider-supplied and must be scaffolded and selected
201
209
  explicitly; this browser workflow does not imply a standalone native executable.
202
210
  ${nativeGuide}
203
211
 
204
- Every browser release also carries Arcane OS licensing material under \`licenses/arcane-os/\`. Review those terms before distribution.
212
+ Every browser release also carries Arcane OS licensing material under \`${directRuntime?sdkPackageSource:'licenses/arcane-os'}\`. Review those terms before distribution.
205
213
  `);
206
214
  files.set('package.json',json({
207
215
  name:packageName,
@@ -230,10 +238,10 @@ Every browser release also carries Arcane OS licensing material under \`licenses
230
238
  }));
231
239
  files.set('arcane-packager.json',json({
232
240
  schemaVersion:1,
233
- appsRoot:'apps',
241
+ appsRoot,
234
242
  distRoot:'dist',
235
243
  sharedPayloads:{
236
- 'browser-runtime':[
244
+ 'browser-runtime':directRuntime?installedSdkRoutes(sdkPackageSource,{direct:true}):[
237
245
  {
238
246
  source:'arcane',
239
247
  destination:'arcane',
@@ -249,11 +257,11 @@ Every browser release also carries Arcane OS licensing material under \`licenses
249
257
  ]
250
258
  }
251
259
  }));
252
- files.set('arcane.lock.json',json(createWorkspaceLockDocument({
260
+ if(!directRuntime)files.set('arcane.lock.json',json(createWorkspaceLockDocument({
253
261
  dependencyName:sdkDependencyName,
254
262
  packageSource:sdkPackageSource
255
263
  })));
256
- files.set(`apps/${appId}/arcane-app.json`,json({
264
+ files.set(`${appPrefix}arcane-app.json`,json({
257
265
  schemaVersion:2,
258
266
  id:appId,
259
267
  displayName:name,
@@ -283,7 +291,7 @@ Every browser release also carries Arcane OS licensing material under \`licenses
283
291
  },
284
292
  targets:native?['browser',target].sort():['browser']
285
293
  }));
286
- files.set(`apps/${appId}/arcane-package.json`,json({
294
+ files.set(`${appPrefix}arcane-package.json`,json({
287
295
  schemaVersion:1,
288
296
  id:appId,
289
297
  displayName:name,
@@ -294,7 +302,7 @@ Every browser release also carries Arcane OS licensing material under \`licenses
294
302
  exclude:[],
295
303
  shared:['browser-runtime']
296
304
  }));
297
- files.set(`apps/${appId}/manifest.json`,json({
305
+ files.set(`${appPrefix}manifest.json`,json({
298
306
  name,
299
307
  short_name:titleCase(appId),
300
308
  start_url:'./index.html',
@@ -303,19 +311,19 @@ Every browser release also carries Arcane OS licensing material under \`licenses
303
311
  theme_color:'rgb(23, 34, 56)',
304
312
  icons:[]
305
313
  }));
306
- files.set(`apps/${appId}/index.html`,`<!doctype html>
314
+ files.set(`${appPrefix}index.html`,`<!doctype html>
307
315
  <html lang="en">
308
316
  <head>
309
317
  <meta charset="utf-8">
310
318
  <meta name="arcane-app-id" content="${html(appId)}">
311
- <base href="../../">
319
+ <base href="${baseHref}">
312
320
  ${importMapMarkup} <meta name="viewport" content="width=device-width, initial-scale=1">
313
321
  <meta name="theme-color" content="rgb(23, 34, 56)">
314
322
  <title>${html(name)}</title>
315
- <link rel="manifest" href="./apps/${html(appId)}/manifest.json">
316
- <link rel="stylesheet" href="./arcane/css/theme.css?v=1">
317
- <link rel="stylesheet" href="./arcane/css/primitives.css?v=1">
318
- <link rel="stylesheet" href="./apps/${html(appId)}/${html(appId)}.css?v=1">
323
+ <link rel="manifest" href="./${html(appPrefix)}manifest.json">
324
+ <link rel="stylesheet" href="${runtimePrefix}/css/theme.css?v=1">
325
+ <link rel="stylesheet" href="${runtimePrefix}/css/primitives.css?v=1">
326
+ <link rel="stylesheet" href="./${html(appPrefix)}${html(appId)}.css?v=1">
319
327
  ${bootstrapMarkup}</head>
320
328
  <body>
321
329
  <main class="app-shell">
@@ -332,11 +340,11 @@ ${bootstrapMarkup}</head>
332
340
  </div>
333
341
  </section>
334
342
  </main>
335
- <script type="module" src="./apps/${html(appId)}/modules/App.js?v=1"></script>
343
+ <script type="module" src="./${html(appPrefix)}modules/App.js?v=1"></script>
336
344
  </body>
337
345
  </html>
338
346
  `);
339
- files.set(`apps/${appId}/${appId}.css`,`body {
347
+ files.set(`${appPrefix}${appId}.css`,`body {
340
348
  margin: 0;
341
349
  min-height: 100vh;
342
350
  background: var(--background, rgb(13, 18, 32));
@@ -358,15 +366,15 @@ ${bootstrapMarkup}</head>
358
366
  }
359
367
  `);
360
368
  if(namedImports){
361
- files.set(`apps/${appId}/modules/arcane.importmap.json`,json({imports:{}}));
369
+ files.set(`${appPrefix}modules/arcane.importmap.json`,json({imports:{}}));
362
370
  }
363
371
  const themeSpecifier=namedImports
364
- ?'arcane/ThemeBootstrap':'../../../arcane/modules/ThemeBootstrap.js';
372
+ ?'arcane/ThemeBootstrap':`${appsRoot==='.'?'../':'../../../'}${runtimePrefix.slice(2)}/modules/ThemeBootstrap.js`;
365
373
  const appDataSpecifier=namedImports
366
- ?'arcane/AppDataScope':'../../../arcane/modules/AppDataScope.js';
374
+ ?'arcane/AppDataScope':`${appsRoot==='.'?'../':'../../../'}${runtimePrefix.slice(2)}/modules/AppDataScope.js`;
367
375
  const strongTypeSpecifier=namedImports
368
- ?'strong-type':'../../../arcane/dependencies/strong-type/index.js';
369
- files.set(`apps/${appId}/modules/App.js`,`import Is from '${strongTypeSpecifier}';
376
+ ?'strong-type':directRuntime?`../${sdkPackageSource}/runtime/strong-type/index.js`:'../../../arcane/dependencies/strong-type/index.js';
377
+ files.set(`${appPrefix}modules/App.js`,`import Is from '${strongTypeSpecifier}';
370
378
  import arcaneThemeReady from '${themeSpecifier}';
371
379
  import {
372
380
  resolveApplicationId,
@@ -407,9 +415,9 @@ action?.addEventListener('click',()=>{
407
415
  });
408
416
  `);
409
417
  if(native){
410
- files.set(`apps/${appId}/img/icon.png`,Buffer.from(appIcon));
418
+ files.set(`${appPrefix}img/icon.png`,Buffer.from(appIcon));
411
419
  }
412
- files.set(`apps/${appId}/test/app.test.mjs`,`import assert from 'node:assert/strict';
420
+ files.set(`${appPrefix}test/app.test.mjs`,`import assert from 'node:assert/strict';
413
421
  import {readFile} from 'node:fs/promises';
414
422
  import test from '${SDK_NAME}/testing';
415
423
 
@@ -420,13 +428,13 @@ test('application shell uses the shared Arcane theme in order',async()=>{
420
428
  readFile(new URL('index.html',appRoot),'utf8'),
421
429
  readFile(new URL('modules/App.js',appRoot),'utf8')
422
430
  ]);
423
- const theme=source.indexOf('./arcane/css/theme.css');
424
- const primitives=source.indexOf('./arcane/css/primitives.css');
425
- const appStyle=source.indexOf('./apps/${appId}/${appId}.css');
426
- const importMap=source.indexOf('${namedImports?'data-arcane-import-map':'./arcane/modules/ThemeBootstrap.js'}');
427
- const appModule=source.indexOf('./apps/${appId}/modules/App.js');
431
+ const theme=source.indexOf('${runtimePrefix}/css/theme.css');
432
+ const primitives=source.indexOf('${runtimePrefix}/css/primitives.css');
433
+ const appStyle=source.indexOf('./${appPrefix}${appId}.css');
434
+ const importMap=source.indexOf('${namedImports?'data-arcane-import-map':`${runtimePrefix}/modules/ThemeBootstrap.js`}');
435
+ const appModule=source.indexOf('./${appPrefix}modules/App.js');
428
436
 
429
- assert.match(source,/<base href="\\.\\.\\/\\.\\.\\/">/);
437
+ assert.ok(source.includes('<base href="${baseHref}">'));
430
438
  assert.match(source,/<meta name="arcane-app-id" content="${appId}">/);
431
439
  assert.ok(theme>=0&&primitives>theme&&appStyle>primitives);
432
440
  assert.ok(importMap>=0&&appModule>importMap);
@@ -445,7 +453,7 @@ test('application package identity matches its directory',async()=>{
445
453
  if(appOnly){
446
454
  return {
447
455
  name,
448
- files:new Map([...files].filter(([relative])=>relative.startsWith(`apps/${appId}/`)))
456
+ files:new Map([...files].filter(([relative])=>relative.startsWith(`${appPrefix}`)))
449
457
  };
450
458
  }
451
459
  return {name,files};
package/src/toolchain.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Is from 'strong-type';
2
2
  import path from 'node:path';
3
- import {readdir,lstat,realpath} from 'node:fs/promises';
3
+ import {readdir,lstat,realpath,mkdir,readFile,writeFile} from 'node:fs/promises';
4
4
  import {createWorkspace,initWorkspace} from './scaffold.mjs';
5
5
  import {
6
6
  discoverApps as discoverWorkspaceApps,
@@ -11,7 +11,10 @@ import {
11
11
  } from './workspace.mjs';
12
12
  import {loadArcaneIntegratedProvider} from './integrated-provider-loader.mjs';
13
13
  import {startDevServer} from './dev-server.mjs';
14
- import {generateImportMap,readApplicationTestImportMapContext} from './import-map.mjs';
14
+ import {applyPwaEntryReferences,generateImportMap,readApplicationTestImportMapContext} from './import-map.mjs';
15
+ import {rootAppNavigation} from './app-layout.mjs';
16
+ import {createPwaArtifacts} from './pwa.mjs';
17
+ import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
15
18
  import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
16
19
  import {refreshAppPackageProjection} from './app-descriptor.mjs';
17
20
  import {
@@ -303,7 +306,7 @@ async function refreshPreparedImportMap(prepared,{signal,onEvent,workspaceOperat
303
306
  'The selected application package descriptor changed before import-map refresh.'
304
307
  );
305
308
  }
306
- return generateImportMap({
309
+ const importMap=await generateImportMap({
307
310
  workspaceRoot:prepared.workspaceRoot,
308
311
  appId:prepared.appId,
309
312
  appRoot:prepared.appRoot,
@@ -315,6 +318,76 @@ async function refreshPreparedImportMap(prepared,{signal,onEvent,workspaceOperat
315
318
  signal,
316
319
  onEvent
317
320
  });
321
+ if(prepared.validation.config.appsRoot==='.'){
322
+ await refreshRootApplicationFiles(prepared,inspected,importMap,{signal,onEvent});
323
+ }
324
+ return importMap;
325
+ }
326
+
327
+ async function refreshRootApplicationFiles(prepared,inspected,importMap,{signal,onEvent}){
328
+ const {workspaceRoot,appId}=prepared;
329
+ const manifest=prepared.validation.app.manifest;
330
+ const navigation=rootAppNavigation(appId,manifest.entry,inspected.browserDocuments.map(document=>document.path));
331
+ // These aliases belong to the SDK only after generation; retained app files stay authored.
332
+ for(const redirect of navigation){
333
+ throwIfAborted(signal);
334
+ try{
335
+ const current=await readFile(path.join(workspaceRoot,...redirect.path.split('/')),'utf8');
336
+ if(!current.includes('<!-- Arcane root application navigation -->')){
337
+ throw new ArcaneError(ERROR_CODES.workspaceInvalid,
338
+ `Root application navigation would replace authored content: ${redirect.path}.`);
339
+ }
340
+ }catch(error){if(error.code!=='ENOENT')throw error;}
341
+ }
342
+ const installed=await readInstalledSdkLayout(workspaceRoot,prepared.validation.config);
343
+ const entry=`/${manifest.entry.split('/').map(encodeURIComponent).join('/')}`;
344
+ const navigationAliases={
345
+ '/':entry,
346
+ [`/apps/${appId}`]:entry,
347
+ [`/apps/${appId}/`]:entry,
348
+ ...Object.fromEntries(navigation.map(redirect=>[`/${redirect.path}`,redirect.target]))
349
+ };
350
+ // The source host serves the installed files in place. There is no runtime projection.
351
+ const files=[...new Set([
352
+ ...inspected.files.filter(file=>file!=='index.html'||manifest.include.includes('index.html')),
353
+ importMap.artifactRelativePath,
354
+ ...navigation.map(redirect=>redirect.path)
355
+ ])];
356
+ const pwa=installed?.direct&&manifest.pwa?.enabled?createPwaArtifacts({
357
+ app:{id:appId,displayName:manifest.displayName,version:manifest.version,entry},
358
+ sdkVersion:installed.version,
359
+ pwa:manifest.pwa,
360
+ files,
361
+ basePath:'/',
362
+ appBase:'/',
363
+ installationId:`/apps/${appId}/`,
364
+ legacyAppPath:`apps/${appId}`,
365
+ runtimeBase:installed.browserRuntimeBase,
366
+ mode:'development',
367
+ navigationAliases
368
+ }):null;
369
+ for(const file of [...navigation,...(pwa?.files??[])]){
370
+ throwIfAborted(signal);
371
+ const filePath=path.join(workspaceRoot,...file.path.split('/'));
372
+ await mkdir(path.dirname(filePath),{recursive:true});
373
+ await writeFile(filePath,file.content,'utf8');
374
+ }
375
+ if(pwa){
376
+ for(const document of inspected.browserDocuments){
377
+ throwIfAborted(signal);
378
+ const filePath=path.join(prepared.appRoot,...document.path.split('/'));
379
+ const content=await readFile(filePath,'utf8');
380
+ await writeFile(filePath,applyPwaEntryReferences(content,{
381
+ manifestUrl:`/${pwa.entryAssets.manifest}`,
382
+ bootstrapUrl:`/${pwa.entryAssets.bootstrap}`
383
+ }),'utf8');
384
+ }
385
+ }
386
+ await emit(onEvent,{
387
+ type:'import-map.root-files.completed',appId,
388
+ navigation:navigation.map(redirect=>redirect.path),
389
+ pwa:pwa?.files.map(file=>file.path)??[]
390
+ });
318
391
  }
319
392
 
320
393
  async function importMapApplication(options={}){