@antelopejs/dms-frontend 0.8.0 → 0.9.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/dist/commands/build-action.js +3 -0
- package/dist/commands/dev-action.d.ts +2 -0
- package/dist/commands/dev-action.js +12 -5
- package/dist/commands/dev.js +3 -1
- package/dist/commands/start-action.js +36 -5
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.js +3 -0
- package/dist/derived-outputs.d.ts +1 -5
- package/dist/derived-outputs.js +46 -33
- package/dist/dev-modules.d.ts +5 -0
- package/dist/dev-modules.js +88 -0
- package/dist/entry-weight.d.ts +24 -0
- package/dist/entry-weight.js +86 -0
- package/dist/help.js +5 -1
- package/dist/layer-watch.js +3 -2
- package/dist/layers.d.ts +1 -0
- package/dist/locale-files.d.ts +6 -0
- package/dist/locale-files.js +75 -0
- package/dist/manifest.js +2 -0
- package/dist/private-values.d.ts +1 -0
- package/dist/private-values.js +16 -0
- package/dist/project-modules-child.d.ts +13 -0
- package/dist/project-modules-child.js +26 -0
- package/dist/project-modules.d.ts +1 -0
- package/dist/project-modules.js +73 -38
- package/dist/workspace-setup.js +41 -3
- package/dist/workspace.d.ts +6 -0
- package/package.json +24 -7
- package/skills/dms-frontend/SKILL.md +1 -0
- package/templates/vue/app-runtime.ts +8 -1
- package/templates/vue/entry-weight.mjs +132 -0
- package/templates/vue/frontend-build-loader.ts +227 -9
- package/templates/vue/frontend-build.ts +34 -1
- package/templates/vue/frontend-module.ts +144 -1
- package/templates/vue/gitignore +1 -0
- package/templates/vue/main.ts +21 -0
- package/templates/vue/network-error.ts +3 -1
- package/templates/vue/package.json +1 -1
- package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
- package/templates/vue/pnpm-workspace.yaml +5 -0
- package/templates/vue/server/client-manifest.mjs +12 -0
- package/templates/vue/server/in-place-render.mjs +110 -0
- package/templates/vue/server/page-props.mjs +10 -0
- package/templates/vue/server/proxy-transfers.mjs +35 -9
- package/templates/vue/server.mjs +39 -29
- package/templates/vue/sfc-global-types.mjs +119 -0
- package/templates/vue/ssr-renderer.ts +8 -0
- package/templates/vue/ui-kbd.mjs +45 -0
- package/templates/vue/vite.config.ts +34 -2
|
@@ -18,7 +18,9 @@ const NETWORK_ERROR_KEY = "error.network";
|
|
|
18
18
|
const DEFAULT_LOCALE = "en";
|
|
19
19
|
// Shipped with the renderer, since no module is guaranteed to translate the
|
|
20
20
|
// case; a module overrides them with `error.network.title` and
|
|
21
|
-
// `error.network.description` in its own locale files.
|
|
21
|
+
// `error.network.description` in its own locale files. `te()` reads the
|
|
22
|
+
// visitor's locale alone, and the generated catalogs leave these keys out of
|
|
23
|
+
// their English fill, so only that locale's own message replaces them.
|
|
22
24
|
const NETWORK_ERROR_MESSAGES: Record<
|
|
23
25
|
string,
|
|
24
26
|
Record<"title" | "description", string>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
diff --git a/dist/index.js b/dist/index.js
|
|
2
|
+
index 772a5e9..d433a57 100644
|
|
3
|
+
--- a/dist/index.js
|
|
4
|
+
+++ b/dist/index.js
|
|
5
|
+
@@ -2047,11 +2047,16 @@ function useColorMode(options = {}) {
|
|
6
|
+
let attributeToChange = null;
|
|
7
|
+
if (attribute === "class") {
|
|
8
|
+
const current = value.split(/\s/g);
|
|
9
|
+
+ // Patched by @antelopejs/dms-frontend until vueuse ships the fix: only
|
|
10
|
+
+ // the classes `el` lacks or still carries count as a change, so a mode
|
|
11
|
+
+ // already applied (by the server render, another instance or the
|
|
12
|
+
+ // mount re-run) skips the forced style recalculation below.
|
|
13
|
+
Object.values(modes).flatMap((i) => (i || "").split(/\s/g)).filter(Boolean).forEach((v) => {
|
|
14
|
+
- if (current.includes(v)) classesToAdd.add(v);
|
|
15
|
+
- else classesToRemove.add(v);
|
|
16
|
+
+ if (current.includes(v)) {
|
|
17
|
+
+ if (!el.classList.contains(v)) classesToAdd.add(v);
|
|
18
|
+
+ } else if (el.classList.contains(v)) classesToRemove.add(v);
|
|
19
|
+
});
|
|
20
|
+
- } else attributeToChange = {
|
|
21
|
+
+ } else if (el.getAttribute(attribute) !== value) attributeToChange = {
|
|
22
|
+
key: attribute,
|
|
23
|
+
value
|
|
24
|
+
};
|
|
@@ -9,3 +9,8 @@ patchedDependencies:
|
|
|
9
9
|
# Menu and breadcrumb links active on a sibling route (`/test` on `/test-a`):
|
|
10
10
|
# nuxt/ui#6803, fixed by nuxt/ui#6809.
|
|
11
11
|
"@nuxt/ui@4.10.0": patches/@nuxt__ui@4.10.0.patch
|
|
12
|
+
# useColorMode re-applies a color mode `<html>` already carries, forcing a
|
|
13
|
+
# full style recalculation on every call (6 on the dashboard home, 216 ms of
|
|
14
|
+
# main thread at 4x CPU): the guard of vueuse#4001 compares the modes with the
|
|
15
|
+
# requested value instead of the element. vueuse 15.0.0 still ships it.
|
|
16
|
+
"@vueuse/core@14.4.0": patches/@vueuse__core@14.4.0.patch
|
|
@@ -78,3 +78,15 @@ export function renderedModuleStyleTags(modules, template) {
|
|
|
78
78
|
.map((file) => `<link rel="stylesheet" href="/${file}">`)
|
|
79
79
|
.join("");
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The link that preloads the built catalog of `locale`, the one the page was
|
|
84
|
+
* rendered with: the browser fetches it next to the entry rather than once the
|
|
85
|
+
* entry runs and asks for it, which would put a round trip before hydration.
|
|
86
|
+
* Nothing without a production build (Vite serves the sources in development).
|
|
87
|
+
*/
|
|
88
|
+
export function localeCatalogPreloadTag(locale) {
|
|
89
|
+
const manifest = productionClientManifest();
|
|
90
|
+
const file = locale && manifest?.[`locales.generated/${locale}.ts`]?.file;
|
|
91
|
+
return file ? `<link rel="modulepreload" crossorigin href="/${file}">` : "";
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A page that completes its query as it renders, rendered at that query in
|
|
2
|
+
// the same request rather than answered with a redirect.
|
|
3
|
+
//
|
|
4
|
+
// Lives apart from server.mjs so the file stays under the size the linter
|
|
5
|
+
// allows.
|
|
6
|
+
|
|
7
|
+
import { ofetch } from "ofetch";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How many times one request renders a page in place of a same-path redirect
|
|
11
|
+
* before it answers the redirect: a page that keeps changing its query.
|
|
12
|
+
*/
|
|
13
|
+
const IN_PLACE_RENDERS = 2;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The key of a backend read a later render of the same request may reuse: a
|
|
17
|
+
* plain JSON `GET`, without hooks that would expect to see the response.
|
|
18
|
+
*/
|
|
19
|
+
function reusableReadKey(path, options, headers) {
|
|
20
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
21
|
+
if (method !== "GET" || options.body !== undefined) return undefined;
|
|
22
|
+
if (options.responseType && options.responseType !== "json") return undefined;
|
|
23
|
+
if (Object.keys(options).some((name) => /^on[A-Z]/.test(name)))
|
|
24
|
+
return undefined;
|
|
25
|
+
return JSON.stringify([
|
|
26
|
+
path,
|
|
27
|
+
options.query ?? options.params ?? null,
|
|
28
|
+
[...headers],
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The backend reads of a request's server renders, sent with the request's
|
|
34
|
+
* `backendHeaders()` and the read's own. After `reuseCompletedReads()`, a later
|
|
35
|
+
* render of the same request is given a copy of each read a previous render
|
|
36
|
+
* completed instead of reading the backend again.
|
|
37
|
+
*/
|
|
38
|
+
export function serverReads(backendHeaders) {
|
|
39
|
+
const completed = new Map();
|
|
40
|
+
let reuse = false;
|
|
41
|
+
const read = (path, options = {}) => {
|
|
42
|
+
const headers = new Headers(backendHeaders());
|
|
43
|
+
new Headers(options.headers).forEach((value, name) => {
|
|
44
|
+
headers.set(name, value);
|
|
45
|
+
});
|
|
46
|
+
const key = reusableReadKey(path, options, headers);
|
|
47
|
+
if (reuse && key && completed.has(key))
|
|
48
|
+
return Promise.resolve(structuredClone(completed.get(key)));
|
|
49
|
+
const reading = ofetch(path, {
|
|
50
|
+
...options,
|
|
51
|
+
baseURL: process.env.DMS_API_BASE_URL,
|
|
52
|
+
headers,
|
|
53
|
+
});
|
|
54
|
+
if (!key) return reading;
|
|
55
|
+
// A copy: the render that read it may still change the object it got. A
|
|
56
|
+
// body that cannot be copied is read again.
|
|
57
|
+
return reading.then((data) => {
|
|
58
|
+
try {
|
|
59
|
+
completed.set(key, structuredClone(data));
|
|
60
|
+
} catch {}
|
|
61
|
+
return data;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
return Object.assign(read, {
|
|
65
|
+
reuseCompletedReads: () => {
|
|
66
|
+
reuse = true;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a document request renders `location` in place of redirecting to
|
|
73
|
+
* it: a `GET` whose page only completes its query (`?workspace=`), on the
|
|
74
|
+
* same path. Any other redirect keeps its own response.
|
|
75
|
+
*/
|
|
76
|
+
function rendersInPlace(request, from, location) {
|
|
77
|
+
if (request.method !== "GET" || location.includes("#")) return false;
|
|
78
|
+
const current = new URL(from, "http://frontend.local");
|
|
79
|
+
const target = new URL(location, current);
|
|
80
|
+
return (
|
|
81
|
+
target.origin === current.origin && target.pathname === current.pathname
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Renders a page document with `render(page)`. A page that completes its
|
|
87
|
+
* query as it renders (`router.replace` to the same path) is rendered again at
|
|
88
|
+
* that query, with the props `propsAt(props, url)` gives, reusing the reads
|
|
89
|
+
* the first render made (`fetch`), instead of being answered with a redirect
|
|
90
|
+
* the browser would follow with a whole new request. The page served then
|
|
91
|
+
* names the URL the document shows, which the browser puts in the address bar.
|
|
92
|
+
*/
|
|
93
|
+
export async function renderInPlace(request, page, fetch, render, propsAt) {
|
|
94
|
+
let rendered = await render(page);
|
|
95
|
+
let served = page;
|
|
96
|
+
for (
|
|
97
|
+
let pass = 0;
|
|
98
|
+
rendered.redirect &&
|
|
99
|
+
pass < IN_PLACE_RENDERS &&
|
|
100
|
+
rendersInPlace(request, served.url, rendered.redirect);
|
|
101
|
+
pass++
|
|
102
|
+
) {
|
|
103
|
+
const url = new URL(rendered.redirect, "http://frontend.local");
|
|
104
|
+
const props = await propsAt(served.props, url);
|
|
105
|
+
served = { ...served, url: `${url.pathname}${url.search}`, props };
|
|
106
|
+
fetch.reuseCompletedReads();
|
|
107
|
+
rendered = await render(served);
|
|
108
|
+
}
|
|
109
|
+
return { rendered, served };
|
|
110
|
+
}
|
|
@@ -100,6 +100,16 @@ async function loadPage(
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The props of the same page at another query of its path (`url`): the page
|
|
105
|
+
* is read again, since its layout reads the query, and the rest is kept,
|
|
106
|
+
* the site layout included.
|
|
107
|
+
*/
|
|
108
|
+
export async function propsForQuery(props, url, backend, auth) {
|
|
109
|
+
const { page } = await loadPage(url, false, backend, auth.signInPath);
|
|
110
|
+
return { ...props, page };
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
/**
|
|
104
114
|
* The props of the page a request visits, with the once props to declare.
|
|
105
115
|
*
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// Large transfers through the proxy, a file upload above all. The request
|
|
2
2
|
// body is streamed to the backend, never buffered, and held to the
|
|
3
3
|
// `maxBodyBytes` of the proxy it reached: a declared Content-Length over the
|
|
4
|
-
// limit is refused before a byte is
|
|
5
|
-
// it goes over.
|
|
4
|
+
// limit is refused before a byte is passed on, a chunked body is cut off as
|
|
5
|
+
// soon as it goes over.
|
|
6
6
|
|
|
7
7
|
const PAYLOAD_TOO_LARGE = 413;
|
|
8
8
|
const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// The refused body is never passed on: the iteration stops where it went
|
|
10
|
+
// over, and the request stream stays open for the lingering close below.
|
|
11
11
|
const BODY_ITERATION = { destroyOnReturn: false };
|
|
12
|
+
// How long a refused body is read and dropped before the connection closes
|
|
13
|
+
// anyway: long enough for a client to finish sending what was in flight,
|
|
14
|
+
// short enough to bound what a refused client can make the server read.
|
|
15
|
+
const LINGER_MS = 5_000;
|
|
12
16
|
|
|
13
17
|
const BODILESS_METHODS = new Set(["GET", "HEAD"]);
|
|
14
18
|
const REQUEST_TIMEOUT_MS = 60 * 60 * 1000;
|
|
@@ -54,13 +58,35 @@ function isBodyTooLarge(error) {
|
|
|
54
58
|
);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Answers 413 and closes the connection once the client stopped sending.
|
|
63
|
+
*
|
|
64
|
+
* Closing a socket with unread bytes makes the kernel reset the connection,
|
|
65
|
+
* and a client stack that drops what it had not read yet when the reset
|
|
66
|
+
* arrives (macOS, BSD, Windows) reports a network error instead of the 413,
|
|
67
|
+
* the browser's upload failing without a reason. The rest of the body is read
|
|
68
|
+
* and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
|
|
69
|
+
* at most; the response ends with it and the connection then closes cleanly.
|
|
70
|
+
*/
|
|
71
|
+
function refuseTooLargeBody(request, response) {
|
|
59
72
|
response.writeHead(PAYLOAD_TOO_LARGE, {
|
|
60
73
|
"content-type": "application/json",
|
|
61
74
|
connection: "close",
|
|
62
75
|
});
|
|
63
|
-
response.
|
|
76
|
+
response.write(TOO_LARGE_BODY);
|
|
77
|
+
if (request.complete) return response.end();
|
|
78
|
+
const close = () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
response.end();
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
response.end();
|
|
84
|
+
request.socket.destroy();
|
|
85
|
+
}, LINGER_MS);
|
|
86
|
+
timer.unref();
|
|
87
|
+
request.once("end", close);
|
|
88
|
+
request.once("error", close);
|
|
89
|
+
request.resume();
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
/**
|
|
@@ -74,11 +100,11 @@ function refuseTooLargeBody(response) {
|
|
|
74
100
|
*/
|
|
75
101
|
export async function sendWithinLimit(request, response, maxBodyBytes, send) {
|
|
76
102
|
if (declaresTooLargeBody(request, maxBodyBytes))
|
|
77
|
-
return refuseTooLargeBody(response);
|
|
103
|
+
return refuseTooLargeBody(request, response);
|
|
78
104
|
try {
|
|
79
105
|
return await send(requestBody(request, maxBodyBytes));
|
|
80
106
|
} catch (error) {
|
|
81
107
|
if (!isBodyTooLarge(error)) throw error;
|
|
82
|
-
return refuseTooLargeBody(response);
|
|
108
|
+
return refuseTooLargeBody(request, response);
|
|
83
109
|
}
|
|
84
110
|
}
|
package/templates/vue/server.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { createServer, maxHeaderSize } from "node:http";
|
|
3
3
|
import { extname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { ofetch } from "ofetch";
|
|
6
5
|
import {
|
|
7
6
|
CLIENT_HEADERS,
|
|
8
7
|
relayedHeaders,
|
|
@@ -26,7 +25,10 @@ import {
|
|
|
26
25
|
} from "./server/cli-report.mjs";
|
|
27
26
|
import { publicBuildFile } from "./server/built-files.mjs";
|
|
28
27
|
import { serveStaticFile } from "./server/static-files.mjs";
|
|
29
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
localeCatalogPreloadTag,
|
|
30
|
+
productionHtmlTemplate,
|
|
31
|
+
} from "./server/client-manifest.mjs";
|
|
30
32
|
import { writeContent } from "./server/content.mjs";
|
|
31
33
|
import {
|
|
32
34
|
captureViteErrors,
|
|
@@ -50,7 +52,8 @@ import {
|
|
|
50
52
|
} from "./server/module-server.mjs";
|
|
51
53
|
import { HOMEPAGE } from "./server/homepage.mjs";
|
|
52
54
|
import { publicOptionsScript } from "./server/public-options.mjs";
|
|
53
|
-
import {
|
|
55
|
+
import { renderInPlace, serverReads } from "./server/in-place-render.mjs";
|
|
56
|
+
import { loadPageProps, propsForQuery } from "./server/page-props.mjs";
|
|
54
57
|
import { htmlTag } from "./server/html-tag.mjs";
|
|
55
58
|
import { listenHost } from "./server/listen-host.mjs";
|
|
56
59
|
import * as icons from "./server/icons.mjs";
|
|
@@ -149,17 +152,7 @@ function backendHeaders(request, access = { anonymous: false, headers: [] }) {
|
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
function serverComponentFetch(request) {
|
|
152
|
-
return (
|
|
153
|
-
const headers = new Headers(backendHeaders(request));
|
|
154
|
-
new Headers(options.headers).forEach((value, name) => {
|
|
155
|
-
headers.set(name, value);
|
|
156
|
-
});
|
|
157
|
-
return ofetch(path, {
|
|
158
|
-
...options,
|
|
159
|
-
baseURL: process.env.DMS_API_BASE_URL,
|
|
160
|
-
headers,
|
|
161
|
-
});
|
|
162
|
-
};
|
|
155
|
+
return serverReads(() => backendHeaders(request));
|
|
163
156
|
}
|
|
164
157
|
|
|
165
158
|
async function backendJson(path, request) {
|
|
@@ -177,18 +170,18 @@ async function currentAuth() {
|
|
|
177
170
|
return (await loadServerModules(await developmentServer())).serverAuth;
|
|
178
171
|
}
|
|
179
172
|
|
|
173
|
+
function pageBackend(request) {
|
|
174
|
+
return {
|
|
175
|
+
fetchJson: (path) => backendJson(path, request),
|
|
176
|
+
isFrontendPage: async (pathname) =>
|
|
177
|
+
(await ssrRenderer(await developmentServer())).isDmsFrontendPage(
|
|
178
|
+
pathname,
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
180
183
|
function pageProps(request, auth) {
|
|
181
|
-
return loadPageProps(
|
|
182
|
-
request,
|
|
183
|
-
{
|
|
184
|
-
fetchJson: (path) => backendJson(path, request),
|
|
185
|
-
isFrontendPage: async (pathname) =>
|
|
186
|
-
(await ssrRenderer(await developmentServer())).isDmsFrontendPage(
|
|
187
|
-
pathname,
|
|
188
|
-
),
|
|
189
|
-
},
|
|
190
|
-
auth,
|
|
191
|
-
);
|
|
184
|
+
return loadPageProps(request, pageBackend(request), auth);
|
|
192
185
|
}
|
|
193
186
|
|
|
194
187
|
function redirectToHomepage(request, response, pathname) {
|
|
@@ -320,6 +313,7 @@ export async function renderHtml(
|
|
|
320
313
|
return { html: "", status: 200, redirect: rendered.redirect };
|
|
321
314
|
const headEnd =
|
|
322
315
|
(await documentStyleTags(devServer, rendered.modules, template)) +
|
|
316
|
+
(devServer ? "" : localeCatalogPreloadTag(rendered.catalogLocale)) +
|
|
323
317
|
publicOptionsScript();
|
|
324
318
|
const html = template
|
|
325
319
|
.replace("<title>Antelope DMS</title>", () =>
|
|
@@ -338,13 +332,28 @@ export async function renderHtml(
|
|
|
338
332
|
return { html, status: rendered.error?.statusCode ?? 200 };
|
|
339
333
|
}
|
|
340
334
|
|
|
341
|
-
function renderRequestHtml(page, request, rule = {}) {
|
|
342
|
-
const fetch = serverComponentFetch(request);
|
|
335
|
+
function renderRequestHtml(page, request, rule = {}, reads) {
|
|
336
|
+
const fetch = reads ?? serverComponentFetch(request);
|
|
343
337
|
return renderHtml(page, request.url, fetch, request.headers.cookie, {
|
|
344
338
|
ssr: rule.ssr,
|
|
345
339
|
});
|
|
346
340
|
}
|
|
347
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Renders a page document, at the query the page completes as it renders:
|
|
344
|
+
* see `renderInPlace`.
|
|
345
|
+
*/
|
|
346
|
+
function renderDocument(page, request, rule, auth) {
|
|
347
|
+
const fetch = serverComponentFetch(request);
|
|
348
|
+
return renderInPlace(
|
|
349
|
+
request,
|
|
350
|
+
page,
|
|
351
|
+
fetch,
|
|
352
|
+
(served) => renderRequestHtml(served, request, rule, fetch),
|
|
353
|
+
(props, url) => propsForQuery(props, url, pageBackend(request), auth),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
348
357
|
async function writeBackendError(error, request, response) {
|
|
349
358
|
const auth = await currentAuth();
|
|
350
359
|
const status = Number.isInteger(error?.status) ? error.status : 502;
|
|
@@ -477,16 +486,17 @@ export async function handleRequest(request, response) {
|
|
|
477
486
|
JSON.stringify(page),
|
|
478
487
|
);
|
|
479
488
|
}
|
|
480
|
-
const rendered = await
|
|
489
|
+
const { rendered, served } = await renderDocument(page, request, rule, auth);
|
|
481
490
|
if (rendered.redirect) {
|
|
482
491
|
redirectFrontendVisit(request, response, rendered.redirect);
|
|
483
492
|
return;
|
|
484
493
|
}
|
|
494
|
+
const location = served === page ? {} : { "content-location": served.url };
|
|
485
495
|
return writeContent(
|
|
486
496
|
request,
|
|
487
497
|
response,
|
|
488
498
|
rendered.status,
|
|
489
|
-
{ ...htmlHeaders(), ...headersFor(rendered.status) },
|
|
499
|
+
{ ...htmlHeaders(), ...headersFor(rendered.status), ...location },
|
|
490
500
|
rendered.html,
|
|
491
501
|
);
|
|
492
502
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Vue's SFC compiler turns `defineProps<Props>()` into runtime prop types, and
|
|
2
|
+
// it reads a type from the component's own imports only. A type a module
|
|
3
|
+
// auto-imports (its declared `app/types`, `app/composables`…, and Vue's own
|
|
4
|
+
// `Ref`, `Component`…) is visible to TypeScript through auto-imports.d.ts but
|
|
5
|
+
// not to the compiler, which drops it from the prop without a word:
|
|
6
|
+
// `boolean | RowActionConfig` became `{ type: Boolean }` in a production build.
|
|
7
|
+
//
|
|
8
|
+
// The compiler does read ambient declarations from `script.globalTypeFiles`,
|
|
9
|
+
// but not the `export type { … } from` re-exports auto-imports.d.ts is made
|
|
10
|
+
// of. This writes those types as ambient aliases it can follow, from the same
|
|
11
|
+
// directories and through the same scanner as the auto-imports, before Vite
|
|
12
|
+
// compiles anything, and rewrites them when a dev server sees a module's
|
|
13
|
+
// declared directories change.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
// The very unimport the auto-import plugin scans with, so both read the
|
|
20
|
+
// directories the same way. Imported by URL, not by path: on Windows,
|
|
21
|
+
// `import()` reads `C:\...` as a URL whose scheme is `c:` and refuses it.
|
|
22
|
+
const unimportUrl = pathToFileURL(
|
|
23
|
+
createRequire(
|
|
24
|
+
createRequire(import.meta.url).resolve("unplugin-auto-import/package.json"),
|
|
25
|
+
).resolve("unimport"),
|
|
26
|
+
);
|
|
27
|
+
const { builtinPresets, resolvePreset, scanDirExports } = await import(
|
|
28
|
+
unimportUrl.href
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const HEADER =
|
|
32
|
+
"// Generated by @antelopejs/dms-frontend from the auto-imported types, for\n" +
|
|
33
|
+
"// Vue's SFC compiler (script.globalTypeFiles). Not read by TypeScript.\n";
|
|
34
|
+
|
|
35
|
+
const withoutExtension = (path) => path.replace(/\.[cm]?[jt]sx?$/, "");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The ambient aliases of the auto-imported types: one per name, in the order
|
|
39
|
+
* the auto-import plugin reads them (Vue's preset, then the scanned
|
|
40
|
+
* directories), the last one winning, as unimport's own deduplication keeps
|
|
41
|
+
* the last of two imports of the same name. A module that exports its own
|
|
42
|
+
* `Component` thus shadows Vue's, for the compiler as for TypeScript.
|
|
43
|
+
*
|
|
44
|
+
* @param {(string | { glob: string, types?: boolean })[]} dirs the auto-import
|
|
45
|
+
* directories, as handed to the auto-import plugin
|
|
46
|
+
* @param {string} cwd the workspace
|
|
47
|
+
*/
|
|
48
|
+
export async function sfcGlobalTypes(dirs, cwd) {
|
|
49
|
+
const vue = await resolvePreset(builtinPresets.vue);
|
|
50
|
+
const scanned = await scanDirExports(dirs, { cwd, types: true });
|
|
51
|
+
const aliases = new Map();
|
|
52
|
+
for (const entry of [...vue, ...scanned]) {
|
|
53
|
+
if (!entry.type) continue;
|
|
54
|
+
const name = entry.as ?? entry.name;
|
|
55
|
+
const source = JSON.stringify(withoutExtension(entry.from));
|
|
56
|
+
aliases.set(
|
|
57
|
+
name,
|
|
58
|
+
`declare type ${name} = import(${source}).${entry.name};`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return `${HEADER}${[...aliases.values()].join("\n")}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeIfChanged(file, content) {
|
|
65
|
+
let current;
|
|
66
|
+
try {
|
|
67
|
+
current = readFileSync(file, "utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
current = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (current === content) return false;
|
|
72
|
+
// Through a temporary file: a build starting meanwhile reads the previous
|
|
73
|
+
// declarations or the new ones, never a half-written file the compiler
|
|
74
|
+
// would then keep in its cache.
|
|
75
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
76
|
+
writeFileSync(temporary, content);
|
|
77
|
+
renameSync(temporary, file);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Writes the declarations once, before Vite's config is returned. */
|
|
82
|
+
export async function writeSfcGlobalTypes(dirs, cwd, file) {
|
|
83
|
+
writeIfChanged(file, await sfcGlobalTypes(dirs, cwd));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Keeps the declarations in step on a dev server: a type file added, renamed
|
|
88
|
+
* or removed rewrites them, and the Vue plugin reloads the components whose
|
|
89
|
+
* props read the file.
|
|
90
|
+
*/
|
|
91
|
+
export function sfcGlobalTypesWatcher(dirs, cwd, file) {
|
|
92
|
+
return {
|
|
93
|
+
name: "dms-sfc-global-types",
|
|
94
|
+
apply: "serve",
|
|
95
|
+
configureServer(server) {
|
|
96
|
+
let pending;
|
|
97
|
+
const refresh = (path) => {
|
|
98
|
+
if (!/\.[cm]?[jt]sx?$/.test(path) || path === file) return;
|
|
99
|
+
clearTimeout(pending);
|
|
100
|
+
pending = setTimeout(() => {
|
|
101
|
+
// The file is under the workspace, which Vite watches: rewriting it
|
|
102
|
+
// is the change the Vue plugin reloads the dependent components on.
|
|
103
|
+
// A failed scan or write keeps the previous declarations: it must
|
|
104
|
+
// not end the dev server.
|
|
105
|
+
sfcGlobalTypes(dirs, cwd)
|
|
106
|
+
.then((content) => writeIfChanged(file, content))
|
|
107
|
+
.catch((error) =>
|
|
108
|
+
server.config.logger.warn(
|
|
109
|
+
`Could not update ${file}, the types Vue's SFC compiler ` +
|
|
110
|
+
`reads: ${error instanceof Error ? error.message : error}`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}, 100);
|
|
114
|
+
};
|
|
115
|
+
for (const event of ["add", "change", "unlink"])
|
|
116
|
+
server.watcher.on(event, refresh);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -63,6 +63,11 @@ export interface DmsSsrResult {
|
|
|
63
63
|
modules: string[];
|
|
64
64
|
error?: DmsErrorData;
|
|
65
65
|
redirect?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The locale of the catalog the page was rendered with: the server preloads
|
|
68
|
+
* that catalog's file, which the browser loads before it hydrates.
|
|
69
|
+
*/
|
|
70
|
+
catalogLocale?: string;
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
export interface DmsSsrHead {
|
|
@@ -189,6 +194,7 @@ async function renderDmsPageWithRuntime(
|
|
|
189
194
|
let icons = new Set<string>();
|
|
190
195
|
let iconSymbols = new Map<string, string>();
|
|
191
196
|
let payload: DmsPayload = { data: {}, errors: {}, state: {} };
|
|
197
|
+
let catalogLocale: (() => string | undefined) | undefined;
|
|
192
198
|
const inertiaResult = await createInertiaApp({
|
|
193
199
|
page,
|
|
194
200
|
// Read as the HTML is complete: data a run settles afterwards was never
|
|
@@ -214,6 +220,7 @@ async function renderDmsPageWithRuntime(
|
|
|
214
220
|
runtime,
|
|
215
221
|
serverFetch,
|
|
216
222
|
});
|
|
223
|
+
catalogLocale = configured.catalogLocale;
|
|
217
224
|
return app;
|
|
218
225
|
},
|
|
219
226
|
});
|
|
@@ -256,6 +263,7 @@ async function renderDmsPageWithRuntime(
|
|
|
256
263
|
},
|
|
257
264
|
overlays: ssrContext.teleports?.["#dms-overlays"] ?? "",
|
|
258
265
|
modules: [...(ssrContext.modules ?? [])],
|
|
266
|
+
catalogLocale: catalogLocale?.(),
|
|
259
267
|
};
|
|
260
268
|
} finally {
|
|
261
269
|
// Only once the head and the async data are serialized: both still read
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Nuxt UI prints the `meta`, `ctrl` and `alt` of a key hint (`UKbd`, the
|
|
2
|
+
// `kbds` of its menus) from a map every hint shares, blank until the first
|
|
3
|
+
// hint mounts: a hint hydrating after that, in an async component, printed
|
|
4
|
+
// "⌘" or "Ctrl" over the server's blank, a hydration mismatch on every
|
|
5
|
+
// platform. Its `useKbd` is routed to the DMS runtime's `useDmsKbd`, which
|
|
6
|
+
// keeps each hint on the server's value until it mounts and lets the
|
|
7
|
+
// application give the platform to the server render
|
|
8
|
+
// (`provideDmsKbdPlatform`). The build fails if Nuxt UI changes the form
|
|
9
|
+
// rewritten here, rather than letting the mismatch come back unnoticed.
|
|
10
|
+
|
|
11
|
+
/** Nuxt UI's key hint composable. */
|
|
12
|
+
const USE_KBD = /\/@nuxt\/ui\/dist\/runtime\/composables\/useKbd\.js$/;
|
|
13
|
+
/** Its export as Nuxt UI 4 writes it: one composable shared by every hint. */
|
|
14
|
+
const SHARED_EXPORT =
|
|
15
|
+
/export const useKbd = \/\* @__PURE__ \*\/ createSharedComposable\(_useKbd\);/;
|
|
16
|
+
const ROUTED_EXPORT =
|
|
17
|
+
'import { useDmsKbd } from "#dms/frontend-module";\n' +
|
|
18
|
+
"const useSharedKbd = /* @__PURE__ */ createSharedComposable(_useKbd);\n" +
|
|
19
|
+
"export const useKbd = () => useDmsKbd(useSharedKbd, kbdKeysMap);";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The rewrite, as a Vite plugin.
|
|
23
|
+
*
|
|
24
|
+
* @param isNuxtUiFile `(id) => boolean`, whether a module is Nuxt UI's
|
|
25
|
+
*/
|
|
26
|
+
export function uiKbd(isNuxtUiFile) {
|
|
27
|
+
return {
|
|
28
|
+
name: "dms-ui-kbd",
|
|
29
|
+
enforce: "pre",
|
|
30
|
+
transform(code, id) {
|
|
31
|
+
const path = id.replaceAll("\\", "/").split("?")[0];
|
|
32
|
+
if (!isNuxtUiFile(id) || !USE_KBD.test(path)) return null;
|
|
33
|
+
if (
|
|
34
|
+
!SHARED_EXPORT.test(code) ||
|
|
35
|
+
!code.includes("export const kbdKeysMap")
|
|
36
|
+
)
|
|
37
|
+
this.error(
|
|
38
|
+
`${id} defines useKbd in a form the DMS does not rewrite: its key ` +
|
|
39
|
+
"hints would not hydrate cleanly. Update ui-kbd.mjs for this " +
|
|
40
|
+
"Nuxt UI release.",
|
|
41
|
+
);
|
|
42
|
+
return { code: code.replace(SHARED_EXPORT, ROUTED_EXPORT), map: null };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|