@ofidj/generator-fidj 1.0.10 → 1.2.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": [],
@@ -1,4 +1,4 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage} from "./service-agreement";
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn} from "./service-agreement";
2
2
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
3
  import config from "../app.config.json";
4
4
  import "./style.css";
@@ -58,10 +58,20 @@ function badges() {
58
58
  .map((entry) => `<span>${escape(entry)}</span>`)
59
59
  .join("")}</footer>`;
60
60
  }
61
+ // Signing out of this app revokes this app's access and nothing else: the Fidj
62
+ // session survives, which is what makes the next app free to enter. That is a
63
+ // good design and a bad surprise, so the notice says what actually happened and
64
+ // where the rest of it lives — on a shared computer the difference is the whole
65
+ // point.
66
+ let leftTheApp = false;
61
67
  function banner() {
62
- return message
63
- ? `<p role="${failed ? "alert" : "status"}" class="${failed ? "error" : "notice"}">${escape(message)}</p>`
68
+ if (!message) return "";
69
+ const role = failed ? "alert" : "status";
70
+ const kind = failed ? "error" : "notice";
71
+ const finish = leftTheApp
72
+ ? ` <a href="${escape(config.dashboardUrl)}/#/my/profile" target="_blank" rel="noopener">Sign out of Fidj too</a>`
64
73
  : "";
74
+ return `<p role="${role}" class="${kind}">${escape(message)}${finish}</p>`;
65
75
  }
66
76
 
67
77
  // One navigation bar for every in-app screen, so signing out stays one click
@@ -83,9 +93,15 @@ function wireNav() {
83
93
  "click",
84
94
  () =>
85
95
  void action(async () => {
96
+ const wasSignedIn = signedIn;
86
97
  if (signedIn) await sdk.logout(true);
98
+ forgetSignIn(config.appId);
87
99
  signedIn = false;
88
100
  anonymous = false;
101
+ if (wasSignedIn) {
102
+ leftTheApp = true;
103
+ message = `Signed out of ${config.title}. You are still signed in to Fidj.`;
104
+ }
89
105
  navigate("signin");
90
106
  }),
91
107
  );
@@ -131,8 +147,13 @@ async function refresh() {
131
147
  request(appPath + "/consents"),
132
148
  request(appPath + "/consents/history").then((result) => result.history),
133
149
  ]);
134
- emailVerified = (await request("/me")).user?.verified === true;
150
+ const me = (await request("/me")).user;
151
+ emailVerified = me?.verified === true;
135
152
  signedIn = true;
153
+ // The ID token of a code flow carries only the subject, by design, so the
154
+ // address the entry can offer next time comes from the membership the app
155
+ // just read — not from a claim it does not have.
156
+ rememberSignIn(config.appId, String(me?.poc?.email || me?.username || ""));
136
157
  }
137
158
  async function action(task: () => Promise<void>) {
138
159
  if (busy) return;
@@ -146,7 +167,10 @@ async function action(task: () => Promise<void>) {
146
167
  const submit = root.querySelector<HTMLButtonElement>("button.primary");
147
168
  if (submit) submit.textContent = "Please wait…";
148
169
  failed = false;
149
- if (initialized) message = "";
170
+ if (initialized) {
171
+ message = "";
172
+ leftTheApp = false;
173
+ }
150
174
  try {
151
175
  await task();
152
176
  } catch (error) {
@@ -187,8 +211,13 @@ async function action(task: () => Promise<void>) {
187
211
  render();
188
212
  }
189
213
  }
214
+ // A person moving between screens is making history, so push an entry: Back has
215
+ // to return to the screen before, not to whatever preceded the application.
216
+ // Replacing is right everywhere else in this file — stripping a single-use token
217
+ // out of the address, correcting a route the person did not choose, and clearing
218
+ // the sign-in callback — because none of those is a place they navigated to.
190
219
  function navigate(route: string) {
191
- window.history.replaceState(null, "", "#/" + route);
220
+ if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
192
221
  if (!busy) render();
193
222
  }
194
223
  function moduleRoute() {
@@ -199,19 +228,60 @@ function moduleRoute() {
199
228
  ["signin", "content", "privacy", ...accountRoutes].includes(route)
200
229
  )
201
230
  return null;
202
- const target = new URL(config.moduleEntry, window.location.href);
203
- target.hash = window.location.hash;
204
- return target.href;
231
+ return route;
232
+ }
233
+ // The mounted app starts inside this document, at this address. It used to be a
234
+ // second document under /module/, which put a generator word in the address bar
235
+ // and reloaded the page in the middle of signing in. Starting it here costs one
236
+ // insertion and is not undone: the app owns the document from then on, and
237
+ // leaving it (signing out) reloads the shell from its own address.
238
+ let moduleStarted = false;
239
+ function startModule() {
240
+ if (moduleStarted) return;
241
+ const mount = config.moduleMount as unknown as {
242
+ styles: string[];
243
+ scripts: Array<{ src: string; module: boolean }>;
244
+ markup: string;
245
+ } | null;
246
+ if (!mount) return;
247
+ moduleStarted = true;
248
+ document.body.classList.add("has-module");
249
+ // The app takes the whole document, not a corner of the shell's. A built
250
+ // single-page app positions itself against the body — Ionic, for one, fixes
251
+ // the body and scrolls inside its own container — so leaving the shell's
252
+ // header and <main> wrapper around it produces a page that cannot scroll and
253
+ // a second header above its own.
254
+ document.body.replaceChildren(
255
+ new Range().createContextualFragment(mount.markup),
256
+ );
257
+ for (const href of mount.styles) {
258
+ const link = document.createElement("link");
259
+ link.rel = "stylesheet";
260
+ link.href = href;
261
+ document.head.appendChild(link);
262
+ }
263
+ for (const script of mount.scripts) {
264
+ const element = document.createElement("script");
265
+ if (script.module) element.type = "module";
266
+ element.src = script.src;
267
+ document.body.appendChild(element);
268
+ }
205
269
  }
206
270
  function render() {
207
271
  if (!initialized) {
208
272
  root.innerHTML = '<p role="status">Loading your session…</p>';
209
273
  return;
210
274
  }
211
- const applicationRoute = moduleRoute();
212
- if (applicationRoute) {
213
- root.innerHTML = '<p role="status">Opening your app…</p>';
214
- window.location.replace(applicationRoute);
275
+ // Which of its routes need a session is the mounted app's business, not the
276
+ // shell's: Fidj's own console serves /pub to anyone. So any address the shell
277
+ // does not own is handed over as it stands.
278
+ if (moduleRoute()) {
279
+ startModule();
280
+ return;
281
+ }
282
+ // The mounted app has taken the document; the shell cannot draw over it.
283
+ if (moduleStarted) {
284
+ window.location.reload();
215
285
  return;
216
286
  }
217
287
  let route = currentRoute();
@@ -299,7 +369,12 @@ function render() {
299
369
  anonymous = true;
300
370
  navigate("content");
301
371
  });
302
- if (oidc && element("signin")) element("signin")!.innerHTML = agreementMarkup() + '<p>Continue securely with your Fidj account. Your password stays with Fidj.</p><button class="primary" type="submit">Continue with Fidj</button>';
372
+ if (oidc && element("signin"))
373
+ element("signin")!.innerHTML = providerEntry(config.title, config.appId);
374
+ element("forget-hint")?.addEventListener("click", () => {
375
+ forgetSignIn(config.appId);
376
+ render();
377
+ });
303
378
  void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId, signInAgreementAccepted);
304
379
  element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
305
380
  event.preventDefault();
@@ -338,8 +413,11 @@ function render() {
338
413
  () =>
339
414
  void action(async () => {
340
415
  await sdk.logout(true);
416
+ forgetSignIn(config.appId);
341
417
  signedIn = false;
342
418
  anonymous = false;
419
+ leftTheApp = true;
420
+ message = `Signed out of ${config.title}. You are still signed in to Fidj.`;
343
421
  navigate("signin");
344
422
  }),
345
423
  );
@@ -1,4 +1,4 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage} from "./service-agreement";
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn} from "./service-agreement";
2
2
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
3
  import "./style.css";
4
4
  import { showVersionBadge } from "./version";
@@ -66,6 +66,9 @@ async function api(path: string, method = "GET", data?: unknown) {
66
66
  }
67
67
  async function load() {
68
68
  session = await api("session");
69
+ // Remembered on this app's own origin so the entry can offer to continue as
70
+ // them next time; signing out forgets it.
71
+ if (session?.username) rememberSignIn(settings.appId, session.username);
69
72
  [notes, privacy] = await Promise.all([
70
73
  api("notes").then((result) => result.notes),
71
74
  api("privacy"),
@@ -94,7 +97,7 @@ function render() {
94
97
  <main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
95
98
  ${
96
99
  !session
97
- ? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2>${oidc ? `<p>Continue securely with your Fidj account. Your password stays with Fidj.</p>${agreementMarkup()}<button class="primary" type="submit">Continue with Fidj</button>` : `<p>Sign in with your Fidj account.</p><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password" required>${agreementMarkup()}<button class="primary" type="submit">Continue</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}`}</form></section>`
100
+ ? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2>${oidc ? providerEntry(settings.title, settings.appId) : `<p>Sign in with your Fidj account.</p><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password" required>${agreementMarkup()}<button class="primary" type="submit">Continue</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}`}</form></section>`
98
101
  : `
99
102
  <div class="page-heading"><div><p class="eyebrow">YOUR WORKSPACE</p><h1>A place to think.</h1><p>${escape(session.username)} <span class="roles">${session.roles.map(escape).join(" · ") || "No assigned roles"}</span></p></div><button id="signout">Sign out</button></div>
100
103
  <nav><button id="workspace-tab" class="${view === "workspace" ? "selected" : ""}">My notes</button><button id="privacy-tab" class="${view === "privacy" ? "selected" : ""}">My privacy</button><button id="refresh">Refresh access</button></nav>
@@ -157,6 +160,7 @@ function render() {
157
160
  () =>
158
161
  void action(async () => {
159
162
  await sdk.logout(true);
163
+ forgetSignIn(settings.appId);
160
164
  session = null;
161
165
  notes = [];
162
166
  privacy = null;
@@ -60,3 +60,55 @@ export async function bindAgreement(form: HTMLFormElement | null, title: string,
60
60
  retry.addEventListener("click", load);
61
61
  await load();
62
62
  }
63
+
64
+ // The entry every app that delegates to the provider renders, in one place
65
+ // because both app shapes render it and they had drifted apart.
66
+ //
67
+ // "Continue with Fidj" alone borrows the grammar of an optional social login —
68
+ // that button always sits next to an email and a password — so on an app whose
69
+ // accounts *are* Fidj accounts, the missing form reads as something broken. The
70
+ // explanation therefore comes before the button, not as reassurance after it,
71
+ // and nothing presupposes an account the person may not have yet.
72
+ const hintKey = (appId: string) => "fidj.entry." + appId;
73
+
74
+ export function signInHint(appId: string) {
75
+ try {
76
+ return localStorage.getItem(hintKey(appId)) || "";
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+
82
+ // Remembered on this app's own origin, about this app's own member: no
83
+ // cross-site question is asked, and none is answered. Signing out forgets, so a
84
+ // shared browser does not show the next person an address.
85
+ export function rememberSignIn(appId: string, label: string) {
86
+ try {
87
+ if (label) localStorage.setItem(hintKey(appId), label);
88
+ } catch {}
89
+ }
90
+
91
+ export function forgetSignIn(appId: string) {
92
+ try {
93
+ localStorage.removeItem(hintKey(appId));
94
+ } catch {}
95
+ }
96
+
97
+ export function providerEntry(title: string, appId: string) {
98
+ const escapeText = (value: unknown) =>
99
+ String(value ?? "").replace(
100
+ /[&<>"']/g,
101
+ (char) =>
102
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
103
+ char
104
+ ]!,
105
+ );
106
+ const hint = signInHint(appId);
107
+ const lead = hint
108
+ ? `<p class="signin-lead">You signed in here with Fidj before. ${escapeText(title)} accounts are Fidj accounts, and this site never sees your password — you can also create or use another one.</p>`
109
+ : `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. You will sign in — or create yours — on Fidj's own page, so this site never sees your password.</p>`;
110
+ const action = hint
111
+ ? `<button class="primary" type="submit">Continue as ${escapeText(hint)}</button><button type="button" id="forget-hint" class="quiet">Use a different account</button>`
112
+ : `<button class="primary" type="submit">Sign in with Fidj</button>`;
113
+ return lead + agreementMarkup() + action;
114
+ }
@@ -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 {
@@ -929,3 +935,25 @@ summary:focus-visible {
929
935
  .signin-agreement button { margin-block: 0.5rem; padding: 0; border: 0; background: transparent; text-decoration: underline; }
930
936
  #agreement-dialog { color: var(--fidj-text); background: var(--fidj-paper); border: 1px solid var(--fidj-line); border-radius: var(--fidj-radius); padding: 1.5rem; max-width: min(40rem, calc(100vw - 2rem)); max-height: 80vh; overflow: auto; }
931
937
  #agreement-text { white-space: pre-wrap; overflow-wrap: anywhere; }
938
+
939
+ /* The entry explains itself before it offers its button. A person whose account
940
+ * lives somewhere else needs to know that before they look for the form that is
941
+ * not coming, so this is body text above the action, not fineprint below it. */
942
+ .signin-lead {
943
+ margin: 0 0 18px;
944
+ color: var(--fidj-ink-soft, inherit);
945
+ line-height: 1.5;
946
+ }
947
+ /* Present but not competing: "use a different account" is the way out of a
948
+ * remembered address, not an alternative worth the weight of the primary. */
949
+ button.quiet {
950
+ margin-top: 10px;
951
+ background: none;
952
+ border: none;
953
+ color: inherit;
954
+ text-decoration: underline;
955
+ padding: 6px 0;
956
+ font: inherit;
957
+ cursor: pointer;
958
+ width: auto;
959
+ }
@@ -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))
@@ -159,6 +174,7 @@ function scaffold(destination, options) {
159
174
  logo: logo ? "./brand/logo" + logo.extension : "./fidj-logo.png",
160
175
  favicon: favicon ? "./brand/favicon" + favicon.extension : "./fidj-logo.png",
161
176
  moduleEntry: appModule?.entry || "",
177
+ moduleMount: null,
162
178
  domain: options.domain,
163
179
  };
164
180
  if (logo || favicon) fs.mkdirSync(path.join(dir, "public/brand"), { recursive: true });
@@ -182,23 +198,30 @@ function scaffold(destination, options) {
182
198
  recursive: true,
183
199
  });
184
200
  const entryFile = path.join(dir, "public/module", appModule.file);
185
- // Point back at the shell's directory, not at its index file, for the same
186
- // reason the module entry does: no index.html in the address bar.
187
- const toRoot =
188
- path.posix.relative(
189
- path.posix.dirname("module/" + appModule.file),
190
- ".",
191
- ) || ".";
192
- const relativeSignin = toRoot + "/#/signin";
193
201
  const html = fs.readFileSync(entryFile, "utf8");
194
202
  if (!html.includes("</head>"))
195
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
+ );
196
218
  fs.writeFileSync(
197
219
  entryFile,
198
- html.replace(
199
- "</head>",
200
- `<meta name="fidj-signin" content="${relativeSignin}"></head>`,
201
- ),
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`,
202
225
  );
203
226
  }
204
227
  fs.writeFileSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "1.0.10",
3
+ "version": "1.2.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": {