@ofidj/generator-fidj 1.0.9 → 1.1.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.
@@ -6,6 +6,7 @@
6
6
  "localDemo": false,
7
7
  "allowAnonymous": true,
8
8
  "moduleEntry": "",
9
+ "moduleMount": null,
9
10
  "description": "A space to explore, with an account that puts you in control.",
10
11
  "highlights": [],
11
12
  "badges": [],
@@ -187,8 +187,13 @@ async function action(task: () => Promise<void>) {
187
187
  render();
188
188
  }
189
189
  }
190
+ // A person moving between screens is making history, so push an entry: Back has
191
+ // to return to the screen before, not to whatever preceded the application.
192
+ // Replacing is right everywhere else in this file — stripping a single-use token
193
+ // out of the address, correcting a route the person did not choose, and clearing
194
+ // the sign-in callback — because none of those is a place they navigated to.
190
195
  function navigate(route: string) {
191
- window.history.replaceState(null, "", "#/" + route);
196
+ if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
192
197
  if (!busy) render();
193
198
  }
194
199
  function moduleRoute() {
@@ -199,19 +204,60 @@ function moduleRoute() {
199
204
  ["signin", "content", "privacy", ...accountRoutes].includes(route)
200
205
  )
201
206
  return null;
202
- const target = new URL(config.moduleEntry, window.location.href);
203
- target.hash = window.location.hash;
204
- return target.href;
207
+ return route;
208
+ }
209
+ // The mounted app starts inside this document, at this address. It used to be a
210
+ // second document under /module/, which put a generator word in the address bar
211
+ // and reloaded the page in the middle of signing in. Starting it here costs one
212
+ // insertion and is not undone: the app owns the document from then on, and
213
+ // leaving it (signing out) reloads the shell from its own address.
214
+ let moduleStarted = false;
215
+ function startModule() {
216
+ if (moduleStarted) return;
217
+ const mount = config.moduleMount as unknown as {
218
+ styles: string[];
219
+ scripts: Array<{ src: string; module: boolean }>;
220
+ markup: string;
221
+ } | null;
222
+ if (!mount) return;
223
+ moduleStarted = true;
224
+ document.body.classList.add("has-module");
225
+ // The app takes the whole document, not a corner of the shell's. A built
226
+ // single-page app positions itself against the body — Ionic, for one, fixes
227
+ // the body and scrolls inside its own container — so leaving the shell's
228
+ // header and <main> wrapper around it produces a page that cannot scroll and
229
+ // a second header above its own.
230
+ document.body.replaceChildren(
231
+ new Range().createContextualFragment(mount.markup),
232
+ );
233
+ for (const href of mount.styles) {
234
+ const link = document.createElement("link");
235
+ link.rel = "stylesheet";
236
+ link.href = href;
237
+ document.head.appendChild(link);
238
+ }
239
+ for (const script of mount.scripts) {
240
+ const element = document.createElement("script");
241
+ if (script.module) element.type = "module";
242
+ element.src = script.src;
243
+ document.body.appendChild(element);
244
+ }
205
245
  }
206
246
  function render() {
207
247
  if (!initialized) {
208
248
  root.innerHTML = '<p role="status">Loading your session…</p>';
209
249
  return;
210
250
  }
211
- const applicationRoute = moduleRoute();
212
- if (applicationRoute) {
213
- root.innerHTML = '<p role="status">Opening your app…</p>';
214
- window.location.replace(applicationRoute);
251
+ // Which of its routes need a session is the mounted app's business, not the
252
+ // shell's: Fidj's own console serves /pub to anyone. So any address the shell
253
+ // does not own is handed over as it stands.
254
+ if (moduleRoute()) {
255
+ startModule();
256
+ return;
257
+ }
258
+ // The mounted app has taken the document; the shell cannot draw over it.
259
+ if (moduleStarted) {
260
+ window.location.reload();
215
261
  return;
216
262
  }
217
263
  let route = currentRoute();
@@ -474,6 +474,12 @@ body.signin-view main {
474
474
  margin: 0;
475
475
  padding: 0;
476
476
  }
477
+ /* A mounted app takes the document: the shell removes its own chrome rather
478
+ * than hiding it, so nothing of the shell's layout reaches the app. The class
479
+ * stays as the hook for anything an app's own stylesheet wants to key on. */
480
+ body.has-module {
481
+ margin: 0;
482
+ }
477
483
  /* The sign-in entry: ink panel carrying the app's identity, paper panel
478
484
  * carrying the form. It stacks below 860px, ink first. */
479
485
  .signin-shell {
@@ -54,11 +54,9 @@ function inspectModule(source, entry = "index.html", destination) {
54
54
  }
55
55
  };
56
56
  visit(root);
57
- // Address a directory rather than its index file: the generated server and
58
- // any static host resolve it, and no index.html shows up in the address bar.
59
- const address = file.endsWith("index.html")
60
- ? file.slice(0, -"index.html".length)
61
- : file;
62
- return { root, file, entry: "./module/" + address + url.hash };
57
+ // The entry is now an address inside the shell, not a second document: the
58
+ // shell starts the module in place, so what it needs is the hash the module
59
+ // opens on. Without one there is nothing to hand over to.
60
+ return { root, file, entry: url.hash || "#/" };
63
61
  }
64
62
  module.exports = { inspectModule };
@@ -0,0 +1,61 @@
1
+ const path = require("node:path");
2
+
3
+ // A mounted app used to be a second document: the shell sent the browser to
4
+ // /module/index.html and the person read "module" — a generator word — in the
5
+ // address bar of their privacy console, after a full page reload in the middle
6
+ // of signing in. Instead, read what the built app needs in order to start, and
7
+ // let the shell start it inside its own document at its own address.
8
+ //
9
+ // Only the entry HTML is interpreted, and only for the three things a built
10
+ // single-page app puts there: its stylesheets, its scripts, and the element it
11
+ // mounts into. Anything else in that file is the build tool's business.
12
+ function describeMount(html, file) {
13
+ // Relative URLs in the entry resolve against its own directory, which is where
14
+ // the module's files are copied. The shell addresses them from the site root.
15
+ const base = path.posix.join("module", path.posix.dirname(file));
16
+ const resolve = (url) => {
17
+ if (/^(?:[a-z]+:|\/\/|\/|#)/i.test(url)) return url;
18
+ return path.posix.normalize(path.posix.join(base, url));
19
+ };
20
+ const attribute = (tag, name) =>
21
+ (tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i")) || [])[1];
22
+
23
+ const head = html.slice(0, html.search(/<\/head>/i));
24
+ const body = html.slice(html.search(/<body\b[^>]*>/i)).replace(/^<body\b[^>]*>/i, "");
25
+
26
+ const styles = [];
27
+ for (const tag of head.match(/<link\b[^>]*>/gi) || []) {
28
+ const rel = (attribute(tag, "rel") || "").toLowerCase();
29
+ const href = attribute(tag, "href");
30
+ if (rel === "stylesheet" && href) styles.push(resolve(href));
31
+ }
32
+
33
+ const scripts = [];
34
+ for (const tag of html.match(/<script\b[^>]*>/gi) || []) {
35
+ const src = attribute(tag, "src");
36
+ if (!src) continue;
37
+ scripts.push({
38
+ src: resolve(src),
39
+ module: (attribute(tag, "type") || "").toLowerCase() === "module",
40
+ });
41
+ }
42
+ if (!scripts.length)
43
+ throw new Error(
44
+ "Module entry loads no script; supply a built single-page app.",
45
+ );
46
+
47
+ // Everything the app expects to find in the document, minus the scripts the
48
+ // shell adds itself once the styles are in place.
49
+ const markup = body
50
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
51
+ .replace(/<\/body>[\s\S]*$/i, "")
52
+ .trim();
53
+ if (!markup)
54
+ throw new Error(
55
+ "Module entry has an empty body; supply a built single-page app.",
56
+ );
57
+
58
+ return { styles, scripts, markup };
59
+ }
60
+
61
+ module.exports = { describeMount };
package/lib/scaffold.cjs CHANGED
@@ -1,11 +1,26 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const { inspectModule } = require("./app-module.cjs");
4
+ const { describeMount } = require("./module-mount.cjs");
4
5
  const templateRoot = path.join(
5
6
  __dirname,
6
7
  "../generators/app/templates/typescript",
7
8
  );
8
9
 
10
+ const escapeHtml = (value) =>
11
+ String(value ?? "").replace(
12
+ /[&<>"']/g,
13
+ (char) =>
14
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
15
+ char
16
+ ],
17
+ );
18
+
19
+ // From the module entry's own directory back up to the site root, so the stub
20
+ // left behind forwards correctly however deep the entry sits.
21
+ const moduleRedirectRoot = (file) =>
22
+ (path.posix.relative(path.posix.dirname("module/" + file), ".") || ".") + "/";
23
+
9
24
  function scaffold(destination, options) {
10
25
  const anonymous = options.anonymous ?? true;
11
26
  if (![true, false, "true", "false"].includes(anonymous))
@@ -135,7 +150,12 @@ function scaffold(destination, options) {
135
150
  : api.href.replace(/\/$/, "");
136
151
  const configuration = {
137
152
  appId: options.appId,
138
- oidcIssuer: options.oidcIssuer || null,
153
+ // A local build talks to the loopback API, so it must use that API's own
154
+ // provider: the client refuses an issuer on a different origin, and keeping
155
+ // the hosted one would send local sign-ins to production.
156
+ oidcIssuer: options.oidcIssuer
157
+ ? new URL("/oidc", localApi).href
158
+ : null,
139
159
  apiEndpoint: localApi,
140
160
  dashboardUrl: options.local ? "http://localhost:4200" : "https://fidj.ovh",
141
161
  title,
@@ -154,6 +174,7 @@ function scaffold(destination, options) {
154
174
  logo: logo ? "./brand/logo" + logo.extension : "./fidj-logo.png",
155
175
  favicon: favicon ? "./brand/favicon" + favicon.extension : "./fidj-logo.png",
156
176
  moduleEntry: appModule?.entry || "",
177
+ moduleMount: null,
157
178
  domain: options.domain,
158
179
  };
159
180
  if (logo || favicon) fs.mkdirSync(path.join(dir, "public/brand"), { recursive: true });
@@ -177,23 +198,30 @@ function scaffold(destination, options) {
177
198
  recursive: true,
178
199
  });
179
200
  const entryFile = path.join(dir, "public/module", appModule.file);
180
- // Point back at the shell's directory, not at its index file, for the same
181
- // reason the module entry does: no index.html in the address bar.
182
- const toRoot =
183
- path.posix.relative(
184
- path.posix.dirname("module/" + appModule.file),
185
- ".",
186
- ) || ".";
187
- const relativeSignin = toRoot + "/#/signin";
188
201
  const html = fs.readFileSync(entryFile, "utf8");
189
202
  if (!html.includes("</head>"))
190
203
  throw new Error("Module entry must have an HTML head.");
204
+ configuration.moduleMount = describeMount(html, appModule.file);
205
+ // The module's own document is no longer where the app runs — the shell
206
+ // starts it in place. Addresses people already have must still work, so what
207
+ // is left at that path forwards them, hash and all, to the same screen.
208
+ const toRoot = moduleRedirectRoot(appModule.file);
209
+ // The generated Content-Security-Policy allows scripts from this origin and
210
+ // no inline ones, so the forward lives in a file beside the stub rather than
211
+ // in a <script> block that every browser would refuse to run.
212
+ fs.writeFileSync(
213
+ path.join(path.dirname(entryFile), "moved.js"),
214
+ `var to=${JSON.stringify(toRoot)}+location.hash;` +
215
+ `var go=document.getElementById("go");if(go)go.href=to;` +
216
+ `location.replace(to);\n`,
217
+ );
191
218
  fs.writeFileSync(
192
219
  entryFile,
193
- html.replace(
194
- "</head>",
195
- `<meta name="fidj-signin" content="${relativeSignin}"></head>`,
196
- ),
220
+ `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
221
+ `<title>${escapeHtml(configuration.title)}</title>` +
222
+ `<meta name="robots" content="noindex"></head><body>` +
223
+ `<p>This address moved. <a id="go" href="${toRoot}">Continue</a></p>` +
224
+ `<script src="moved.js"></script></body></html>\n`,
197
225
  );
198
226
  }
199
227
  fs.writeFileSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "Generate a TypeScript app with Fidj sign-in, live server-side roles and per-app privacy.",
5
5
  "homepage": "https://fidj.ovh",
6
6
  "bugs": {