@plitzi/sdk-server 0.38.9 → 0.38.11
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/CHANGELOG.md +263 -0
- package/README.md +3 -3
- package/dist/auth.js +1 -1
- package/dist/core/auth/createAuth.js +1 -1
- package/dist/core/http/stages/signInRoute.js +1 -1
- package/dist/core/server/baseServer.js +11 -6
- package/dist/core/server/fleet/stores.d.ts +7 -0
- package/dist/core/server/fleet/stores.js +16 -2
- package/dist/core/server/fleet/supervisor.d.ts +2 -0
- package/dist/core/server/fleet/supervisor.js +14 -2
- package/dist/core/server/pageServer.js +1 -1
- package/dist/functions-api.d.ts +29 -0
- package/dist/helpers/cache/cacheManager.d.ts +9 -2
- package/dist/helpers/cache/cacheManager.js +46 -22
- package/dist/helpers/cache/keys.d.ts +12 -5
- package/dist/helpers/cache/keys.js +56 -11
- package/dist/modules/actions/index.js +6 -2
- package/dist/modules/actions/jobs/later.d.ts +43 -0
- package/dist/modules/actions/jobs/later.js +66 -0
- package/dist/modules/actions/jobs/later.test.d.ts +1 -0
- package/dist/modules/actions/jobs/memoryQueue.js +14 -1
- package/dist/modules/actions/jobs/worker.js +2 -2
- package/dist/modules/actions/runtime/credentials.d.ts +10 -0
- package/dist/modules/actions/runtime/credentials.js +28 -0
- package/dist/modules/actions/runtime/credentials.test.d.ts +1 -0
- package/dist/modules/actions/runtime/fileKv.d.ts +8 -3
- package/dist/modules/actions/runtime/fileKv.js +79 -7
- package/dist/modules/actions/runtime/precheck.js +1 -1
- package/dist/modules/actions/runtime/runAction.d.ts +4 -1
- package/dist/modules/actions/runtime/runAction.js +26 -4
- package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
- package/dist/modules/actions/runtime/taskContext.js +7 -1
- package/dist/modules/actions/tasks/flow.js +113 -35
- package/dist/modules/actions/types.d.ts +8 -0
- package/dist/modules/functions/build.js +1 -1
- package/dist/modules/functions/capabilities.js +21 -0
- package/dist/modules/functions/context.js +6 -0
- package/dist/modules/functions/contract.d.ts +10 -0
- package/dist/modules/functions/driver.js +8 -0
- package/dist/modules/functions/local.d.ts +1 -1
- package/dist/modules/functions/local.js +2 -1
- package/dist/modules/functions/manifest.js +6 -1
- package/dist/modules/functions/protocol.d.ts +7 -0
- package/dist/modules/mongo/jobQueue.js +26 -0
- package/dist/modules/mysql/jobs/jobQueue.js +21 -2
- package/dist/modules/mysql/jobs/schema.d.ts +3 -2
- package/dist/modules/mysql/jobs/schema.js +24 -11
- package/dist/modules/project/serveProject.d.ts +5 -2
- package/dist/modules/project/serveProject.js +31 -6
- package/dist/modules/realtime/handlers.d.ts +4 -1
- package/dist/modules/realtime/handlers.js +8 -4
- package/dist/modules/realtime/hub.d.ts +3 -1
- package/dist/modules/realtime/hub.js +8 -4
- package/dist/modules/ssr/prepareRender.js +8 -6
- package/dist/modules/ssr/resolvePageSeo.d.ts +4 -1
- package/dist/modules/ssr/resolvePageSeo.js +7 -3
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
- package/skills/plitzi-authoring/recipes/filter-a-list.ts +0 -2
- package/skills/plitzi-authoring/recipes/forms-and-modals.ts +2 -1
- package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
- package/skills/plitzi-authoring/reference/accessibility.md +1 -0
- package/skills/plitzi-authoring/reference/authoring-errors.md +9 -4
- package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
- package/skills/plitzi-authoring/reference/flows.md +2 -2
- package/skills/plitzi-authoring/reference/layouts.md +5 -2
- package/skills/plitzi-authoring/reference/lists.md +1 -0
- package/skills/plitzi-authoring/reference/plugins.md +3 -2
- package/skills/plitzi-authoring/reference/realtime.md +4 -1
- package/skills/plitzi-authoring/reference/structure.md +3 -1
- package/skills/plitzi-authoring/reference/typed-sources.md +6 -4
|
@@ -7,11 +7,14 @@ import { freePort } from "../../core/freePort.js";
|
|
|
7
7
|
import { createCloudAdapters } from "../../adapters/cloudAdapters.js";
|
|
8
8
|
import { createJsonAdapters } from "../../adapters/jsonAdapters.js";
|
|
9
9
|
import { createFileKv } from "../actions/runtime/fileKv.js";
|
|
10
|
+
import { credentialsFromEnv } from "../actions/runtime/credentials.js";
|
|
10
11
|
import { loadRuntime, loadRuntimeModule } from "../runtime/bundle.js";
|
|
11
12
|
import { serveRuntime } from "../runtime/stages.js";
|
|
12
13
|
import { projectPlugins, watchProjectPlugins } from "./plugins.js";
|
|
13
14
|
import { createSpaceFailure } from "./spaceFailure.js";
|
|
14
15
|
import { watchSpace } from "./watchSpace.js";
|
|
16
|
+
import { fleetKv } from "../../core/server/fleet/stores.js";
|
|
17
|
+
import { networkInterfaces } from "node:os";
|
|
15
18
|
import path from "node:path";
|
|
16
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
20
|
import { assertProjectLayout, layoutFindingText } from "@plitzi/sdk-shared/project/layout";
|
|
@@ -60,8 +63,19 @@ var cloudAdapters = (pluginNames) => {
|
|
|
60
63
|
deployment: { pluginNames }
|
|
61
64
|
});
|
|
62
65
|
};
|
|
63
|
-
var
|
|
66
|
+
var LOOPBACK = /* @__PURE__ */ new Set([
|
|
67
|
+
"127.0.0.1",
|
|
68
|
+
"::1",
|
|
69
|
+
"localhost"
|
|
70
|
+
]);
|
|
71
|
+
/** Where another device reaches a server listening on `host`: this machine's own network addresses, when it is open. */
|
|
72
|
+
var networkUrls = (host, port) => {
|
|
73
|
+
if (LOOPBACK.has(host)) return [];
|
|
74
|
+
return (host === "0.0.0.0" || host === "::" ? Object.values(networkInterfaces()).flat().flatMap((entry) => entry && entry.family === "IPv4" && !entry.internal ? [entry.address] : []) : [host]).map((address) => `http://${address}:${String(port)}/`);
|
|
75
|
+
};
|
|
76
|
+
var actionLookups = (actions, connectors, credentials) => ({
|
|
64
77
|
getAction: (_spaceId, actionId) => Promise.resolve(actions.find((entry) => entry.id === actionId)),
|
|
78
|
+
getCredential: (_spaceId, identifier) => Promise.resolve(Object.hasOwn(credentials, identifier) ? credentials[identifier] : void 0),
|
|
65
79
|
listActions: () => Promise.resolve([...actions]),
|
|
66
80
|
getConnector: (_spaceId, connectorId) => Promise.resolve(connectors.get(connectorId)),
|
|
67
81
|
listScheduledSpaces: () => Promise.resolve([SPACE_ID])
|
|
@@ -137,13 +151,21 @@ var startProject = async (options) => {
|
|
|
137
151
|
env: process.env,
|
|
138
152
|
publicUrl
|
|
139
153
|
});
|
|
154
|
+
let keptKv;
|
|
155
|
+
const kv = fleetKv(() => {
|
|
156
|
+
keptKv = createFileKv({
|
|
157
|
+
file: path.join(root, KV_FILE),
|
|
158
|
+
owner: `the server of "${name}", on port ${String(port)}`
|
|
159
|
+
});
|
|
160
|
+
return keptKv;
|
|
161
|
+
});
|
|
140
162
|
const server = createServer({
|
|
141
163
|
logLevel: process.argv.includes("--verbose") ? "info" : "warn",
|
|
142
164
|
logger: consoleLogger,
|
|
143
165
|
...serverOptions,
|
|
144
166
|
port,
|
|
145
|
-
devMode: developing,
|
|
146
|
-
devReload: developing,
|
|
167
|
+
devMode: serverOptions.devMode ?? developing,
|
|
168
|
+
devReload: serverOptions.devReload ?? serverOptions.devMode ?? developing,
|
|
147
169
|
health: { name },
|
|
148
170
|
adapters,
|
|
149
171
|
plugins: plugins.sources,
|
|
@@ -155,12 +177,12 @@ var startProject = async (options) => {
|
|
|
155
177
|
},
|
|
156
178
|
action: {
|
|
157
179
|
signingSecret: process.env.PLITZI_SIGNING_SECRET,
|
|
158
|
-
kv
|
|
180
|
+
kv,
|
|
159
181
|
...serverOptions.action,
|
|
160
|
-
...actions ? { lookups: actionLookups(actions, connectors) } : {}
|
|
182
|
+
...actions ? { lookups: actionLookups(actions, connectors, credentialsFromEnv()) } : {}
|
|
161
183
|
}
|
|
162
184
|
}, { preAuth: [spaceFailure.stage, ...runtime ? [runtime.stage] : []] });
|
|
163
|
-
server.listen(port, host);
|
|
185
|
+
await server.listen(port, host);
|
|
164
186
|
const url = `http://127.0.0.1:${String(port)}`;
|
|
165
187
|
if (speaks) {
|
|
166
188
|
mkdirSync(path.join(root, PROJECT_TMP), { recursive: true });
|
|
@@ -170,6 +192,8 @@ var startProject = async (options) => {
|
|
|
170
192
|
url
|
|
171
193
|
}, null, 2)}\n`);
|
|
172
194
|
console.log(`pages on ${url}/`);
|
|
195
|
+
const network = networkUrls(host, port);
|
|
196
|
+
if (network.length > 0) console.log(`also on ${network.join(", ")} — anyone on this network can open it (HOST=${host})`);
|
|
173
197
|
}
|
|
174
198
|
const watching = developing ? [...held ? [watchSpace(root, (authored) => {
|
|
175
199
|
held.documents = authored;
|
|
@@ -180,6 +204,7 @@ var startProject = async (options) => {
|
|
|
180
204
|
watching.forEach((stop) => stop());
|
|
181
205
|
await server.close();
|
|
182
206
|
await runtime?.close();
|
|
207
|
+
await keptKv?.close();
|
|
183
208
|
};
|
|
184
209
|
const stopListening = closeOnSignals({ close });
|
|
185
210
|
return {
|
|
@@ -28,6 +28,8 @@ export type Admission = {
|
|
|
28
28
|
/** The grant each accepted topic of a `grant: true` channel was opened with. */
|
|
29
29
|
granted: Map<string, string>;
|
|
30
30
|
refused: RealtimeRefusal[];
|
|
31
|
+
/** It hears and never speaks: the page asked to watch (`OBSERVER_COOKIE_NAME`). */
|
|
32
|
+
observer: boolean;
|
|
31
33
|
};
|
|
32
34
|
/**
|
|
33
35
|
* Which of the topics a page asked for (`?topics=a,b`) it may open: each checked against the channels the space
|
|
@@ -44,10 +46,11 @@ export declare const connectionFor: (admission: Extract<Admission, {
|
|
|
44
46
|
* may send on, within its size and its rate — and stamped by the server with who sent it and when, whatever the
|
|
45
47
|
* message said.
|
|
46
48
|
*/
|
|
47
|
-
export declare const publishFrom: (hub: RealtimeHub, connection: RealtimeConnection, { topic, type, data }: {
|
|
49
|
+
export declare const publishFrom: (hub: RealtimeHub, connection: RealtimeConnection, { topic, type, data, echo }: {
|
|
48
50
|
topic?: unknown;
|
|
49
51
|
type?: unknown;
|
|
50
52
|
data?: unknown;
|
|
53
|
+
echo?: unknown;
|
|
51
54
|
}) => Promise<RealtimeAnswer>;
|
|
52
55
|
export type SubscribeDeps = {
|
|
53
56
|
req: SSRRequest;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import onAbort from "../../helpers/onAbort.js";
|
|
2
2
|
import { openEventStream } from "../../core/http/sse.js";
|
|
3
3
|
import { warnRealtime } from "./failure.js";
|
|
4
|
+
import { readCookies } from "../../core/auth/credentials.js";
|
|
4
5
|
import { accessRefusal } from "@plitzi/sdk-shared/actions";
|
|
5
6
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
-
import { PRESENCE_TYPE, channelLimits, matchChannel } from "@plitzi/sdk-shared/realtime";
|
|
7
|
+
import { OBSERVER_COOKIE_NAME, PRESENCE_TYPE, channelLimits, matchChannel } from "@plitzi/sdk-shared/realtime";
|
|
7
8
|
//#region src/modules/realtime/handlers.ts
|
|
8
9
|
/** Topics one connection may hold. A page shows a handful; past this it is somebody enumerating. */
|
|
9
10
|
var MAX_TOPICS = 8;
|
|
@@ -91,7 +92,8 @@ var admit = async (req, resolveChannels, grants) => {
|
|
|
91
92
|
} } : {},
|
|
92
93
|
accepted,
|
|
93
94
|
granted,
|
|
94
|
-
refused
|
|
95
|
+
refused,
|
|
96
|
+
observer: Object.hasOwn(readCookies(req), OBSERVER_COOKIE_NAME)
|
|
95
97
|
};
|
|
96
98
|
};
|
|
97
99
|
/** A new connection for what was admitted, speaking through `send`. */
|
|
@@ -103,6 +105,7 @@ var connectionFor = (admission, send, end) => ({
|
|
|
103
105
|
topics: admission.accepted,
|
|
104
106
|
grants: admission.granted,
|
|
105
107
|
announced: /* @__PURE__ */ new Set(),
|
|
108
|
+
observer: admission.observer,
|
|
106
109
|
sent: /* @__PURE__ */ new Map(),
|
|
107
110
|
send,
|
|
108
111
|
end
|
|
@@ -123,7 +126,7 @@ var withinRate = (connection, topic, perSecond) => {
|
|
|
123
126
|
* may send on, within its size and its rate — and stamped by the server with who sent it and when, whatever the
|
|
124
127
|
* message said.
|
|
125
128
|
*/
|
|
126
|
-
var publishFrom = async (hub, connection, { topic, type, data }) => {
|
|
129
|
+
var publishFrom = async (hub, connection, { topic, type, data, echo }) => {
|
|
127
130
|
const name = typeof topic === "string" ? topic : "";
|
|
128
131
|
const declaration = connection.topics.get(name);
|
|
129
132
|
if (!declaration) return {
|
|
@@ -136,6 +139,7 @@ var publishFrom = async (hub, connection, { topic, type, data }) => {
|
|
|
136
139
|
error: `Only the server publishes on "${name}"`,
|
|
137
140
|
reason: "server_only"
|
|
138
141
|
};
|
|
142
|
+
if (connection.observer) return { status: 204 };
|
|
139
143
|
const kind = typeof type === "string" ? type : "";
|
|
140
144
|
const presence = kind === PRESENCE_TYPE && declaration.presence === true;
|
|
141
145
|
if (!presence && !TYPE.test(kind)) return {
|
|
@@ -157,7 +161,7 @@ var publishFrom = async (hub, connection, { topic, type, data }) => {
|
|
|
157
161
|
};
|
|
158
162
|
if (presence) connection.announced.add(name);
|
|
159
163
|
try {
|
|
160
|
-
await hub.publish(connection.space, hub.from(connection, name, kind, value));
|
|
164
|
+
await hub.publish(connection.space, hub.from(connection, name, kind, value, echo === true && !presence));
|
|
161
165
|
} catch (error) {
|
|
162
166
|
warnRealtime("a message was not delivered")(error);
|
|
163
167
|
return {
|
|
@@ -18,6 +18,8 @@ export type RealtimeConnection = {
|
|
|
18
18
|
grants: Map<string, string>;
|
|
19
19
|
/** The topics it announced itself on, which hear `$leave` when it goes. */
|
|
20
20
|
announced: Set<string>;
|
|
21
|
+
/** A page that watches (`page check`, `page shot`): it hears its topics, arrives on none and says nothing on any. */
|
|
22
|
+
observer: boolean;
|
|
21
23
|
/** Messages sent this second, by topic — the rate a channel allows is per connection, and a connection is here. */
|
|
22
24
|
sent: Map<string, {
|
|
23
25
|
second: number;
|
|
@@ -38,7 +40,7 @@ export type RealtimeHub = ReturnType<typeof createRealtimeHub>;
|
|
|
38
40
|
*/
|
|
39
41
|
export declare const createRealtimeHub: (pubsub: PubSubAdapter) => {
|
|
40
42
|
publish: (space: RealtimeSpace, message: RealtimeMessage) => Promise<void>;
|
|
41
|
-
from: (connection: RealtimeConnection, topic: string, type: string, data: unknown) => RealtimeMessage;
|
|
43
|
+
from: (connection: RealtimeConnection, topic: string, type: string, data: unknown, echo?: boolean) => RealtimeMessage;
|
|
42
44
|
find: (token: string) => RealtimeConnection | undefined;
|
|
43
45
|
/**
|
|
44
46
|
* Lets go of `topic` every connection, on every replica, that opened it with `grant` — or with any grant, naming
|
|
@@ -65,14 +65,18 @@ var createRealtimeHub = (pubsub) => {
|
|
|
65
65
|
await (await entry.release)();
|
|
66
66
|
}
|
|
67
67
|
};
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* A stamped message from this connection: `from` and `user` are the server's, whatever was sent. `echo` carries the
|
|
70
|
+
* sender's asking to hear it too, which its page reads.
|
|
71
|
+
*/
|
|
72
|
+
const from = (connection, topic, type, data, echo = false) => ({
|
|
70
73
|
topic,
|
|
71
74
|
type,
|
|
72
75
|
data,
|
|
73
76
|
from: connection.id,
|
|
74
77
|
...connection.user ? { user: connection.user } : {},
|
|
75
|
-
at: Date.now()
|
|
78
|
+
at: Date.now(),
|
|
79
|
+
...echo ? { echo: true } : {}
|
|
76
80
|
});
|
|
77
81
|
/**
|
|
78
82
|
* Lets `connection` go of `topic`: it is told, the others on a presence channel hear it leave, and it no longer
|
|
@@ -111,7 +115,7 @@ var createRealtimeHub = (pubsub) => {
|
|
|
111
115
|
byToken.set(connection.token, connection);
|
|
112
116
|
for (const [topic, declaration] of connection.topics) {
|
|
113
117
|
await join(connection, topic);
|
|
114
|
-
if (declaration.presence) await publish(connection.space, from(connection, topic, JOIN_TYPE, null));
|
|
118
|
+
if (declaration.presence && !connection.observer) await publish(connection.space, from(connection, topic, JOIN_TYPE, null));
|
|
115
119
|
}
|
|
116
120
|
},
|
|
117
121
|
/** Unsubscribes a connection that went away, and tells the channels it announced itself on. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
|
|
2
2
|
import { authorizesDebugging } from "../../helpers/debugAuthorization.js";
|
|
3
|
+
import { SPACE_TOKEN_PARAM } from "../../core/auth/credentials.js";
|
|
3
4
|
import { realtimeModuleFor } from "../realtime/index.js";
|
|
4
5
|
import { matchRscPage } from "../rsc/matchRscPage.js";
|
|
5
6
|
import { ssrPaintedCookieName } from "../../helpers/paintedCookie.js";
|
|
@@ -14,13 +15,12 @@ import { publishSpaceDocument } from "./spaceDocument.js";
|
|
|
14
15
|
import { registerExternalPlugins } from "./registerExternalPlugins.js";
|
|
15
16
|
import { reportMissingPlugins } from "./reportMissingPlugins.js";
|
|
16
17
|
import { resolvePageSeo } from "./resolvePageSeo.js";
|
|
17
|
-
import { SPACE_TOKEN_PARAM } from "../../core/auth/credentials.js";
|
|
18
18
|
import { imagesPathOf } from "../../core/http/stages/images.js";
|
|
19
19
|
import { sdkAssetVersion } from "../../core/sdkAssets.js";
|
|
20
20
|
import { buildServerInfo } from "../../helpers/buildServerInfo.js";
|
|
21
21
|
import { resolveDebugMode } from "../../helpers/resolveDebugMode.js";
|
|
22
22
|
import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
|
|
23
|
-
import { collectServerElements, notFoundProvider } from "@plitzi/sdk-shared/schema/serverElements";
|
|
23
|
+
import { collectServerElements, notFoundProvider, pageNotFound, pageServerContext } from "@plitzi/sdk-shared/schema/serverElements";
|
|
24
24
|
import { debugCookieName } from "@plitzi/sdk-shared/devTools";
|
|
25
25
|
import { themeFromCookies } from "@plitzi/sdk-shared/theme";
|
|
26
26
|
import { paintedKeys, paintedStateFor } from "@plitzi/sdk-shared/state/paintedState";
|
|
@@ -84,11 +84,13 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
|
|
|
84
84
|
loadOfflineData,
|
|
85
85
|
flagOverrides
|
|
86
86
|
})) : rscPath ? { serverData: {} } : void 0;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
routeParams: pageMatch.routeParams,
|
|
87
|
+
const navigation = {
|
|
88
|
+
routeParams: pageMatch?.routeParams ?? {},
|
|
90
89
|
queryParams: req.query
|
|
91
|
-
}
|
|
90
|
+
};
|
|
91
|
+
const pageContext = pageServerContext(serverElements, rscData?.serverData ?? {}, navigation);
|
|
92
|
+
const pageSeo = resolvePageSeo(schema, pageMatch?.pageId, pageContext);
|
|
93
|
+
const notFound = pageMatch !== void 0 && rscData?.serverData !== void 0 ? notFoundProvider(serverElements, rscData.serverData, navigation) !== void 0 || pageNotFound(schema?.flat[pageMatch.pageId]?.attributes.notFound, pageContext) : false;
|
|
92
94
|
const server = buildServerInfo(req, config, {
|
|
93
95
|
rscPath,
|
|
94
96
|
rscData,
|
|
@@ -14,5 +14,8 @@ export type PageSeo = {
|
|
|
14
14
|
* `seoEnabled: false` returns nothing rather than a default, so the deployment's own title stays in charge of a
|
|
15
15
|
* page that opted out. Blank strings are treated as absent for the same reason — the builder writes one for a
|
|
16
16
|
* field the author cleared, and an empty <title> is worse than a generic one.
|
|
17
|
+
*
|
|
18
|
+
* Either may be a template over the page's server providers and the address (`pageServerContext`): a detail page says
|
|
19
|
+
* the title of the record it shows, in the HTML a crawler reads. One that does not evaluate is nothing, as a blank is.
|
|
17
20
|
*/
|
|
18
|
-
export declare const resolvePageSeo: (schema: Schema | undefined, pageId: string | undefined) => PageSeo;
|
|
21
|
+
export declare const resolvePageSeo: (schema: Schema | undefined, pageId: string | undefined, context?: Record<string, unknown>) => PageSeo;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pageSeoText } from "@plitzi/sdk-shared/schema/serverElements";
|
|
1
2
|
//#region src/modules/ssr/resolvePageSeo.ts
|
|
2
3
|
/**
|
|
3
4
|
* The title and description the addressed page declares for itself.
|
|
@@ -10,13 +11,16 @@
|
|
|
10
11
|
* `seoEnabled: false` returns nothing rather than a default, so the deployment's own title stays in charge of a
|
|
11
12
|
* page that opted out. Blank strings are treated as absent for the same reason — the builder writes one for a
|
|
12
13
|
* field the author cleared, and an empty <title> is worse than a generic one.
|
|
14
|
+
*
|
|
15
|
+
* Either may be a template over the page's server providers and the address (`pageServerContext`): a detail page says
|
|
16
|
+
* the title of the record it shows, in the HTML a crawler reads. One that does not evaluate is nothing, as a blank is.
|
|
13
17
|
*/
|
|
14
|
-
var resolvePageSeo = (schema, pageId) => {
|
|
18
|
+
var resolvePageSeo = (schema, pageId, context = {}) => {
|
|
15
19
|
if (!schema || !pageId) return {};
|
|
16
20
|
const page = schema.flat[pageId];
|
|
17
21
|
if (!page?.attributes.seoEnabled) return {};
|
|
18
|
-
const title = page.attributes.seoPageTitle
|
|
19
|
-
const description = page.attributes.seoPageDescription
|
|
22
|
+
const title = pageSeoText(page.attributes.seoPageTitle, context);
|
|
23
|
+
const description = pageSeoText(page.attributes.seoPageDescription, context);
|
|
20
24
|
return {
|
|
21
25
|
...title ? { title } : {},
|
|
22
26
|
...description ? { description } : {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.11",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -96,8 +96,8 @@
|
|
|
96
96
|
"./dist/env.js"
|
|
97
97
|
],
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"@plitzi/plitzi-sdk": "0.38.
|
|
100
|
-
"@plitzi/sdk-shared": "0.38.
|
|
99
|
+
"@plitzi/plitzi-sdk": "0.38.11",
|
|
100
|
+
"@plitzi/sdk-shared": "0.38.11",
|
|
101
101
|
"ejs": "^6.0.1",
|
|
102
102
|
"esbuild": "^0.28.2",
|
|
103
103
|
"jsonwebtoken": "^9.0.3",
|
|
@@ -19,6 +19,7 @@ const space: SpaceSpec = {
|
|
|
19
19
|
pages: [{ id, name, slug: '', layout: { id: 'shell', slot: 'main' }, body: [] }] // slot: where the body goes
|
|
20
20
|
}; // slug '' is home; 'products/:slug' takes a param
|
|
21
21
|
pageFamily({ folder, layout, seoTitle, body: entry => [...] }, ENTRIES) // pages of one shape, from data; ids scoped per page
|
|
22
|
+
pageFolders: [{ id: 'docs', name: 'Docs' }] // on the space: a `folder` is declared here, /docs/…
|
|
22
23
|
const { schema, style, warnings, suggestions } = authorSpace(space, { plugins: [declaration] });
|
|
23
24
|
```
|
|
24
25
|
|
|
@@ -43,6 +44,7 @@ svg(svgFile(new URL('./logo.svg', import.meta.url))) // a file, compacted: svgF
|
|
|
43
44
|
fontAwesome({ icon: 'fa-solid fa-xmark' })
|
|
44
45
|
list({ id: 'rows', items: 'p.data.rows', row: 'row-card' }) // a <ul>; a component per row, its root an <li>
|
|
45
46
|
list({ id: 'rows', items: 'p.data.rows', row: r => listItem({ children: [text({ from: `${r.item}.title` })] }) })
|
|
47
|
+
// typed items (site.data.rows): r.item is a path — from: r.item.title
|
|
46
48
|
carousel({ id: 'hero', items: 'p.data.slides', autoplay: 5000, row: 'slide-card', children: [/* carouselNext('hero') arrows */] })
|
|
47
49
|
// rows follow their item's `id`, or `itemKey: 'slug'`
|
|
48
50
|
apiContainer({ id: 'p', query: '/data/x.json', cache: true, children }) // publishes p.data; no box of its own
|
|
@@ -50,7 +52,7 @@ apiContainer({ id: 'p', query: '/data/x.json', runtime: 'server', children }) //
|
|
|
50
52
|
apiContainer({ id: 'p', action: 'report', runtime: 'server', children }) // the action's output at p's root: p.rows
|
|
51
53
|
// loadingSlot: 'p-skeleton' — a child shown until it answers
|
|
52
54
|
form({ id, managedByInteractions: true, flows, children })
|
|
53
|
-
formControl({ name: 'email', label: 'Email', subType: 'email' })
|
|
55
|
+
formControl({ name: 'email', label: 'Email', subType: 'email', required: true }) // optional without required; select: options: [{ label, value }]
|
|
54
56
|
modalContainer({ id, visible: false, title, children })
|
|
55
57
|
component('card', { props: { title }, children: { body: [ … ] } })
|
|
56
58
|
custom({ renderType: 'myPlugin' }); defineElement<Props>(declaration) // a plugin
|
|
@@ -63,7 +65,7 @@ const card = styles('card', { padding: 24, borderRadius: 16 }); // shorth
|
|
|
63
65
|
styles('title', { fontSize: { desktop: '48px', compact: '32px' }, fontWeight: 700 }); // one property per breakpoint
|
|
64
66
|
styles('card', { css: { desktop: { … }, compact: { … } }, states: { hover: { … } }, variants: { active: { … } } });
|
|
65
67
|
container({ class: card }); // a shared class…
|
|
66
|
-
container({
|
|
68
|
+
container({ class: [card, { opacity: '0.5' }] }); // …plus one thing
|
|
67
69
|
container({ css: { padding: '8px' } }); // rules of its own — never with `class`
|
|
68
70
|
```
|
|
69
71
|
|
|
@@ -31,8 +31,6 @@ export const recipe: SpaceSpec = {
|
|
|
31
31
|
{ label: 'Graphics cards', value: 'gpu' }
|
|
32
32
|
],
|
|
33
33
|
defaultValue: 'all',
|
|
34
|
-
// A filter always has an answer: the select is not a question that must be filled in.
|
|
35
|
-
required: false,
|
|
36
34
|
flows: [
|
|
37
35
|
[named('picked', on('onChange')), setState({ key: 'category', type: 'text', value: '{{ picked.value }}' })]
|
|
38
36
|
]
|
|
@@ -38,7 +38,8 @@ export const recipe: SpaceSpec = {
|
|
|
38
38
|
[named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]
|
|
39
39
|
],
|
|
40
40
|
children: [
|
|
41
|
-
|
|
41
|
+
// Optional unless it says so, as an HTML field is: a sign-up without an address is no sign-up.
|
|
42
|
+
formControl({ name: 'email', label: 'Email', subType: 'email', required: true }),
|
|
42
43
|
button({ content: 'Sign up', subType: 'submit' })
|
|
43
44
|
]
|
|
44
45
|
}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Style without copies: colours are tokens with a light and a dark value, a look used twice is a class, "this class
|
|
3
|
-
* plus one thing" is rules on top of it (`class: [card, { … }]
|
|
3
|
+
* plus one thing" is rules on top of it (`class: [card, { … }]`), and a rule for tablet AND phone
|
|
4
4
|
* is written once under `compact`.
|
|
5
5
|
*/
|
|
6
6
|
import { container, heading, styles, text } from '@plitzi/sdk-authoring';
|
|
@@ -38,6 +38,7 @@ image({ src: '/grain.png', decorative: true }) // an orname
|
|
|
38
38
|
image({ decorative: true, bind: { src: 'post.record.cover' } }) // a cover under its own headline adds nothing
|
|
39
39
|
image({ bind: { src: 'row.item.cover', alt: 'row.item.title' } }) // …unless it is alone in a link: then it names it
|
|
40
40
|
fontAwesome({ icon: 'fa-solid fa-triangle-exclamation', label: 'Overdue' }) // an icon that says something alone
|
|
41
|
+
svg(MARK, { class: logoMark }) // no `label`: an svg or an icon decorates
|
|
41
42
|
text('福', { decorative: true }) // a seal or a mark drawn for the look alone
|
|
42
43
|
list({ label: 'Kinds of capsule', … }) // a group of options is named, as a group
|
|
43
44
|
```
|
|
@@ -47,7 +47,7 @@ The space is not written until these are fixed.
|
|
|
47
47
|
| `channel-grant` | a topic of a private channel opened with no grant to it | the grant the message names, or a public channel |
|
|
48
48
|
| `channel-topic` | a `channel` element with no topic | a topic a channel of the space covers |
|
|
49
49
|
| `children-in-leaf` | children on a type that renders only its own attributes (`heading`, `text`, `image`…) | a `container` — for a heading made of parts, `container({ subType: 'h1', children })` |
|
|
50
|
-
| `class-and-css` | an element wearing a shared class AND `css` or `states` of its own — it has one base selector | the rules on top of the class: `class: [card, { opacity: '0.5' }]`
|
|
50
|
+
| `class-and-css` | an element wearing a shared class AND `css` or `states` of its own — it has one base selector | the rules on top of the class: `class: [card, { opacity: '0.5' }]` — or into the class |
|
|
51
51
|
| `class-and-selector` | an element with a shared class and a `selector` of its own | drop one: a shared class IS its selector |
|
|
52
52
|
| `class-conflict` | two different declarations for one class name | rename one, or make them agree |
|
|
53
53
|
| `class-listed-under-other-name` | a `styles()` declaration listed in `classes` under another name | list it under its own name |
|
|
@@ -64,6 +64,7 @@ The space is not written until these are fixed.
|
|
|
64
64
|
| `css-property-twice` | one property written twice in a rule set — `paddingTop` beside `'padding-top'` — so one would silently win | keep one |
|
|
65
65
|
| `css-property-unknown` | a CSS property that does not exist | the property it suggests; a custom property starts with `--` |
|
|
66
66
|
| `css-value` | an empty CSS value, or one with `;` or `{}` | one value per property; leave a property out instead of writing it empty |
|
|
67
|
+
| `declared-callback-target` | a `declaredCallback` target with a key beside `on` and `params` — read nowhere, so the step runs without it | the value in `params`: `{ on: 'calendar', params: { date } }` |
|
|
67
68
|
| `element-ids-target` | a step refreshing containers by id (`invalidateElements`, `invalidateQueries({ elements })`) naming one the space does not have, or one that is not an `apiContainer` | the id of the `apiContainer` whose data the step changes: `invalidateElements: ['posts']` |
|
|
68
69
|
| `element-load-strategy` | a `loadStrategy` the element does not take | one of the values the message lists |
|
|
69
70
|
| `element-rejected` | an element the schema refused to hold | the problem listed with it |
|
|
@@ -79,7 +80,7 @@ The space is not written until these are fixed.
|
|
|
79
80
|
| `flow-empty` | a flow with no steps | `[onClick(), setState({ … })]` |
|
|
80
81
|
| `flow-without-trigger` | a flow whose first step is not the event that runs it | `[onClick(), setState(…)]` |
|
|
81
82
|
| `folder-cycle` | a page folder inside itself | a `parent` that leads to the top |
|
|
82
|
-
| `folder-undeclared` | a page, a layout or a folder filed in a folder the space does not declare | declare the
|
|
83
|
+
| `folder-undeclared` | a page, a layout or a folder filed in a folder the space does not declare | declare it on the space — `pageFolders: [{ id: 'docs', name: 'Docs' }]`, its slug starting its pages' addresses — or use the name it suggests |
|
|
83
84
|
| `font-invalid` | a font the space cannot load as declared | the field the message names |
|
|
84
85
|
| `format-unknown` | an `as` that names no format of the space's `formats` | declare it once — `formats: { price: "{{ source\|currency('USD') }}" }` — or write the template in its place |
|
|
85
86
|
| `from-and-bind` | the main attribute bound twice, with `from` and in `bind` | keep `from`, and give `bind` the other attributes |
|
|
@@ -97,12 +98,11 @@ The space is not written until these are fixed.
|
|
|
97
98
|
| `list-without-items` | a controlled list with nothing to render | `items: [ … ]` or `bind: { items: 'provider.data.rows' }` |
|
|
98
99
|
| `loading-slot-unknown` | an `apiContainer`'s `loadingSlot` that is not the id of one of its children | the id of the child to show until the first answer — `loadingSlot: 'catalog-skeleton'` beside that child |
|
|
99
100
|
| `modifier-count` | more than one set of rules in a class list | one set, after the classes: `class: [card, { opacity: '0.5', 'margin-top': '8px' }]` |
|
|
100
|
-
| `modifier-without-id` | rules on top of a class on an element with no `id` — the id names the class they become | `id: 'hero-bg'` |
|
|
101
101
|
| `motion-invalid` | a `motion` with a preset, a trigger or a timing the page cannot play | `motion: { enter: 'fade-up', on: 'view' }` — the message lists the presets |
|
|
102
102
|
| `motion-no-tag` | a `motion` on an element with no tag of its own — nothing to move | the motion on what it wraps, or a `subType` on it |
|
|
103
103
|
| `no-pages` | a space with no pages | `pages: [{ id: 'home', name: 'Home', slug: '', body: [] }]` |
|
|
104
104
|
| `not-found-in-browser` | `notFound` on a provider asked from the browser — its answer arrives after the page was sent with its status | `runtime: 'server'` on the provider, or `visible` on the page's "not found" part instead |
|
|
105
|
-
| `not-found-not-a-template` | `notFound
|
|
105
|
+
| `not-found-not-a-template` | `notFound`, a provider's or a page's, that is not one `{{ expression }}` — never `true`, so never a 404 | `notFound: '{{ source.found == false }}'` |
|
|
106
106
|
| `notifications-shape` | `notifications` with a field it does not have, or a value that is not one CSS value | the field it suggests, or one it lists; one CSS value, like 'var(--card)', '12px' or '500' |
|
|
107
107
|
| `outside-ancestor` | an element that reads the state of an element it is not inside | nest it in the element the message names |
|
|
108
108
|
| `page-access-level` | an `accessLevel` that is not one the router reads | one of the values the message lists |
|
|
@@ -110,6 +110,7 @@ The space is not written until these are fixed.
|
|
|
110
110
|
| `page-route-taken` | two pages at one address for the same visitors | another slug — or `accessLevel` `'public'` on one and `'authenticated'` on the other |
|
|
111
111
|
| `page-target-unknown` | a link or `navigate` to a page id that does not exist | an existing page id, or a path with its slash (`'/about'`) |
|
|
112
112
|
| `page-target-url` | a URL, `mailto:` or `tel:` in a link left in page mode | `mode: 'external'` |
|
|
113
|
+
| `page-template` | a template in a page's `seoTitle`, `seoDescription` or `notFound` that reads what is not there when the server answers — anything but the page's `runtime: 'server'` providers and `navigation` — or cannot be read | read the record from a provider with `runtime: 'server'` on the page or its layout — `'{{ apiContainer_post.title }} — Blog'` — or words of its own |
|
|
113
114
|
| `page-without-slug` | a page with no `slug` | `slug: ''` for the home page, its path for any other: `'about'`, `'blog/:slug'` |
|
|
114
115
|
| `part-missing` | a compound element — a carousel, a tab container, a dropdown — without a part it renders through | the part the message names, inside it (`carousel()` writes its own track) |
|
|
115
116
|
| `prop-missing` | an instance without a prop its component requires | `component('card', { props: { name: … } })`, or bind it |
|
|
@@ -131,6 +132,7 @@ The space is not written until these are fixed.
|
|
|
131
132
|
| `server-provider-in-component` | a `runtime: 'server'` element inside a component: the page server resolves a page's and its layouts', never a component's | put the provider on the page, around the instance, and hand the component its rows as a prop |
|
|
132
133
|
| `setting-misplaced` | `settings.computed` or `settings.channels` written inside `settings` | `computed` and `channels` at the top of the space |
|
|
133
134
|
| `slot-children` | children handed to an instance outside its slots | `children: { slotName: [ … ] }`; a component with no slots takes no children |
|
|
135
|
+
| `slot-not-class` | a `slots` entry that is not a class — rules written in place, or nothing — which a slot cannot wear | the rules as a `styles()` declaration, handed to the slot beside its other classes |
|
|
134
136
|
| `slot-unknown` | a component slot that is not an element of its tree | the id of an element inside the component — usually an empty container |
|
|
135
137
|
| `source-field-unknown` | a path into a typed source that its sample does not have | read a field the sample has, or add the field to the sample |
|
|
136
138
|
| `state-key-list` | `settings.transientState` / `paintedState` that is not a list of top-level state keys | `transientState: ['demoStep']` — a dotted path names its top key |
|
|
@@ -145,6 +147,7 @@ The space is not written until these are fixed.
|
|
|
145
147
|
| `style-pseudo-unknown` | a pseudo-element a class cannot dress | one of `before`, `after`, `marker`, `placeholder`, `first-letter`, `first-line`, `selection` — without the `::` |
|
|
146
148
|
| `style-state-unknown` | a state a selector does not react to | one of the states it lists (`hover`, `focus`, `active`…) |
|
|
147
149
|
| `svg-not-svg` | an `svg` whose `content` is not one `<svg>…</svg>` — it draws nothing | the SVG markup alone; HTML around it goes in a `blockHtml` |
|
|
150
|
+
| `template-in-value` | a `{{ }}` inside an attribute that is an object or a list — a provider's `input`, a list's `items`, a plugin's settings: only an attribute that is text is evaluated | a binding on the attribute (`bindTemplate(name, source, template, { returns: 'value' })`); for a provider's `input`, nothing for a route or query param — the action is already handed them as `input.<name>` |
|
|
148
151
|
| `template-short-source` | a source named without its prefix inside a template or a step | the full name: `apiContainer_stats`, `list_rows` |
|
|
149
152
|
| `template-source-out-of-scope` | a template reading the source of an element it is not inside | move the element inside it, or share the value through `state` |
|
|
150
153
|
| `template-text-into-value` | a template, which renders text, feeding an attribute that holds a list or an object | `bindTemplate('items', source, '{{ … }}', { returns: 'value' })` |
|
|
@@ -185,6 +188,7 @@ The space renders, and renders something you probably did not mean. Fix every on
|
|
|
185
188
|
| `embed-without-title` | an `embed` with no `title` — a frame a screen reader cannot describe | say what it shows in `title` |
|
|
186
189
|
| `flag-rule-empty` | a flag rule with no conditions — skipped, never read as "always" | give it a condition, or set the flag's `value` instead |
|
|
187
190
|
| `flag-unused` | a declared flag nothing gates on and no template reads | gate what it switches (`flag: 'x'`), or remove it once the feature has shipped |
|
|
191
|
+
| `focus-on-field-box` | a `focus` or `focus-visible` state on a text field's or a select's `input` slot — the box the field is drawn in, which never takes focus, so no ring is ever shown | `'focus-within'` on the same class, or the state on the `field` slot |
|
|
188
192
|
| `FORM_SUBMIT_UNMANAGED` | a form the browser would submit itself, reloading the page | `managedByInteractions: true` |
|
|
189
193
|
| `form-control-name-taken` | two controls in one form with one name — one overwrites the other | a name each |
|
|
190
194
|
| `form-control-unnamed` | a control in a form with no `name` — its value never reaches `values` | `formControl({ name: 'email', … })` |
|
|
@@ -202,6 +206,7 @@ The space renders, and renders something you probably did not mean. Fix every on
|
|
|
202
206
|
| `path-not-in-data` | a binding onto a provider whose answer the author could read (`data`), through a path that answer does not have | the path the message lists the keys for — `p.data.plans`, not `p.data.landing.plans` |
|
|
203
207
|
| `plugin-attribute-reserved` | a plugin attribute named as one of the element’s own fields — a factory never hands it to the plugin | rename it in the plugin (`variant` → `kind`) |
|
|
204
208
|
| `provider-without-source` | an `apiContainer` that asks nothing | a `query` (or `action`, `connector`, `resource`) |
|
|
209
|
+
| `required-message-unused` | a `requiredMessage` on a field nothing requires — fields are optional unless `required: true`, so it is never shown and an empty answer is sent | `required: true` on the field, or no message |
|
|
205
210
|
| `route-param-undeclared` | `navigation.routeParams.x` read on a page whose slug has no `:x` — always empty | add `:x` to the slug, or read `navigation.queryParams.x` |
|
|
206
211
|
| `server-data-without-rsc` | a `runtime: 'server'` provider — `connector`, `action` or `query` — in a space that turns server data off (`rsc: { enabled: false }`) | drop `rsc: { enabled: false }`: server data is on unless a space turns it off |
|
|
207
212
|
| `span-holds-block` | a `container` with `subType: 'span'` or `'p'` holding a heading, a paragraph, a list, a form or prose | a `div` (leave `subType` out), or words and inline elements: a `text`, a `link` |
|
|
@@ -18,6 +18,8 @@ the value), or a format the space names once in `formats: { price: "{{ source|cu
|
|
|
18
18
|
`bindTemplate(to, source, template, { category?, returns? })` is the computed attribute — a template over the value
|
|
19
19
|
at `source`. `returns: 'value'` hands over what a single `{{ expression }}` evaluates to instead of its text: a list's
|
|
20
20
|
`items`, a number, a flag (see [lists](lists.md)).
|
|
21
|
+
A colour from data binds a custom property — `[{ to: '--who', source, category: 'style' }]` — that the class reads
|
|
22
|
+
(`var(--who, var(--muted))`); a value with `;` or braces is not written.
|
|
21
23
|
|
|
22
24
|
**A source names the id you gave the element** — `'posts.title'`, `'postList.item.cover'` — and the prefix is
|
|
23
25
|
completed from what the element publishes (`apiContainer_posts`, `list_postList`; a `form` publishes under
|
|
@@ -72,6 +74,8 @@ apiContainer({ id: 'board', runtime: 'server', action: 'queue-board' })
|
|
|
72
74
|
`"{{ source ? apiUrl ~ '/workspaces/' ~ source ~ '/stats' : '' }}"` with `source: 'state.workspace.id'`. A server
|
|
73
75
|
provider's bound `input` (`bindTemplate('input', 'state.c', '{{ { country: source } }}', { returns: 'value' })`)
|
|
74
76
|
asks again on every change; `reloadApi('p', { … })` asks once.
|
|
77
|
+
- An action is handed the page's route and query params as input (`r/:room` → `input.room`). A written `input` is
|
|
78
|
+
sent as is: a `{{ }}` in it is refused (`template-in-value`) — bind it.
|
|
75
79
|
- `isLoading` is true while it is asked again, in either runtime. A newer ask drops the older one; `cancelApi('p')` is a
|
|
76
80
|
STOP: what is shown stays.
|
|
77
81
|
- `runtime: 'server'` is resolved by the page server — server data is on unless a space says `rsc: { enabled: false }`.
|
|
@@ -106,9 +110,8 @@ server action answering only what is shown, its `access` checking who asks.
|
|
|
106
110
|
- **Refreshed by writes:** a `webHook` that is not a GET refreshes every request to its own site by default
|
|
107
111
|
(`invalidateQueries: 'origin'`); a `runServerAction` refreshes everything (`'all'`). Narrow it with
|
|
108
112
|
`'elements'` + `invalidateElements: ['orders']`, or stop it with `'none'`.
|
|
109
|
-
- **Say `'none'` when a refresh would move the page on.** A page that
|
|
110
|
-
|
|
111
|
-
goes, and skip the last step.
|
|
113
|
+
- **Say `'none'` when a refresh would move the page on.** A page that leaves on a provider's answer ("profile complete
|
|
114
|
+
→ leave") would leave in the middle of a multi-step form that saves as it goes.
|
|
112
115
|
|
|
113
116
|
## Visibility
|
|
114
117
|
|
|
@@ -156,10 +159,9 @@ Rules for a condition's template:
|
|
|
156
159
|
`'true'` or `'false'` is written as it says. A plain source, or a template that answers nothing (`''`), writes
|
|
157
160
|
nothing: the element stays as it started — hidden for `visible`, shown for a binding written by hand.
|
|
158
161
|
- **So a hiding flag is `visible: '!state.folded'`** (or a binding with no `visible`): on screen before anybody has set
|
|
159
|
-
it.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
visible wrapper still takes a slot in its parent's `gap`, and leaves a hole in the page.
|
|
162
|
+
it.
|
|
163
|
+
- **Hide the element itself**, not inside a wrapper left shown: an empty wrapper still takes a slot in its parent's
|
|
164
|
+
`gap`, a hole in the page.
|
|
163
165
|
|
|
164
166
|
### Loading, empty, error
|
|
165
167
|
|
|
@@ -183,9 +185,8 @@ shows on every load. Test for "arrived and empty" (`is defined and … is empty`
|
|
|
183
185
|
|
|
184
186
|
## Never ask the data for an opposite
|
|
185
187
|
|
|
186
|
-
Both sides of one question are `visible: 'x'` and `visible: '!x'`,
|
|
187
|
-
|
|
188
|
-
(`Boolean(post) && !canEdit`) still belongs where the data is made.
|
|
188
|
+
Both sides of one question are `visible: 'x'` and `visible: '!x'`, never a `notX` in the answer — `!` reads `"false"`
|
|
189
|
+
and `"0"` as false. A three-state condition still belongs where the data is made.
|
|
189
190
|
|
|
190
191
|
## State that outlives a reload
|
|
191
192
|
|
|
@@ -70,11 +70,11 @@ a row's button posts `{ jobId: '{{ list_jobRows.item.id }}' }` — the row that
|
|
|
70
70
|
|
|
71
71
|
**Pass objects, not JSON text.** `input: { title: '{{ form.values.title }}' }` rather than `input: '{"title": …}'`:
|
|
72
72
|
each value keeps its own type, and nothing depends on the quoting. (JSON text works — a value inside one of its strings
|
|
73
|
-
is escaped for it — but an object
|
|
73
|
+
is escaped for it — but an object needs no punctuation.)
|
|
74
74
|
|
|
75
75
|
**A field left empty is not in `values`.** A form sends what was typed, and a field nobody typed in sends nothing —
|
|
76
76
|
so `when({ field: 'sent.values.code', operator: '=', value: '' }, …)` never holds for it. Ask
|
|
77
|
-
`operator: 'empty'` / `'notEmpty'
|
|
77
|
+
`operator: 'empty'` / `'notEmpty'` (no `value`), which treat missing and `''` alike.
|
|
78
78
|
|
|
79
79
|
## Writes and what they refresh
|
|
80
80
|
|
|
@@ -25,6 +25,7 @@ export const docsLayout: LayoutSpec = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// in the space
|
|
28
|
+
pageFolders: [{ id: 'docs', name: 'Docs' }], // declared once: its slug (the id) starts its pages' addresses — /docs/quickstart
|
|
28
29
|
layouts: [appLayout, docsLayout],
|
|
29
30
|
pages: [{ name: 'Quickstart', slug: 'quickstart', folder: 'docs', layout: { id: 'docs-layout', slot: 'docs-slot' }, body: [ … ] }]
|
|
30
31
|
```
|
|
@@ -139,8 +140,10 @@ Every page is now its content and nothing else, and every page is guaranteed the
|
|
|
139
140
|
suggests writing your own (`not-found-page`): say it in the space's words.
|
|
140
141
|
- **A page that exists but finds nothing** — `/products/:slug` for a slug no product has — is sent with 404 too when
|
|
141
142
|
its server provider says so: `notFound: "{{ not (source.data.products|find('slug', navigation.routeParams.slug)) }}"`,
|
|
142
|
-
one expression against its answer, on a `runtime: 'server'` provider of the page
|
|
143
|
-
page shares
|
|
143
|
+
one expression against its answer, on a `runtime: 'server'` provider of the page. When the record comes from a
|
|
144
|
+
provider of its layout, which every page shares, the page says it: `notFound` on the page reads its server providers
|
|
145
|
+
by name — `"{{ not (apiContainer_feed.products|find('slug', navigation.routeParams.slug)) }}"`. The page renders as
|
|
146
|
+
written: show its "not found" part with `visible`.
|
|
144
147
|
|
|
145
148
|
## Styles follow the same rule
|
|
146
149
|
|
|
@@ -9,6 +9,7 @@ list bound to its records (see the provider in data-and-visibility).
|
|
|
9
9
|
```ts
|
|
10
10
|
list({ id: 'games', class: gameGrid, items: 'catalog.data.games', row: 'game-card' }) // a component per game
|
|
11
11
|
list({ id: 'games', items: 'catalog.data.games', row: r => listItem({ children: [text({ from: `${r.item}.title` })] }) })
|
|
12
|
+
// typed by a sample (`items: catalog.data.games`): `r.item` is a path — `from: r.item.title`, never `${r.item}.title`
|
|
12
13
|
list({ id: 'plans', items: ['Free', 'Pro'], children: [listItem({ children: [text({ from: 'plans.item' })] })] }) // fixed
|
|
13
14
|
```
|
|
14
15
|
|
|
@@ -83,8 +83,8 @@ import { elementChildren } from '@plitzi/plitzi-sdk';
|
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
Never write `style` onto an element the plugin does not render: nothing promises to keep it. Its boxes share one
|
|
86
|
-
stacking context, the plugin's. `useDisplayMode()` names the breakpoint showing
|
|
87
|
-
|
|
86
|
+
stacking context, the plugin's. `useDisplayMode()` names the breakpoint showing (`desktop`, `tablet`, `mobile`) at the
|
|
87
|
+
space's widths; `useElementSize(ref)`, its own box (`undefined` until measured).
|
|
88
88
|
|
|
89
89
|
## Its own server code
|
|
90
90
|
|
|
@@ -108,6 +108,7 @@ import { useChannel } from '@plitzi/plitzi-sdk';
|
|
|
108
108
|
|
|
109
109
|
const room = useChannel(roomTopic, { onMessage: message => move(message.from, message.data) });
|
|
110
110
|
room.publish('pointer', { x, y }); // throttle it: ~20 a second, the latest position each time
|
|
111
|
+
room.publish('react', { emoji }, { echo: true }); // and heard here too
|
|
111
112
|
room.members; // who is here, with the state each announced
|
|
112
113
|
```
|
|
113
114
|
|
|
@@ -14,7 +14,10 @@ channel({ id: 'room', topic: 'room:{{ id }}', keep: 0, bind: { presence: 'comput
|
|
|
14
14
|
|
|
15
15
|
Its descendants bind `room.members`, `room.connected`, `room.last`; flows use `on('onMessage')` (`type`, `data`,
|
|
16
16
|
`from`), `onJoin`/`onLeave` (`from`, `user` and the `state` the member announced — `onJoin` only for who came after
|
|
17
|
-
this page), `publishOn('room', 'reaction', data)` and `announceOn('room', state)`.
|
|
17
|
+
this page), `publishOn('room', 'reaction', data)` and `announceOn('room', state)`. What a page publishes reaches
|
|
18
|
+
every OTHER page on the topic: one that shows it the moment it sends — a reaction, a chat line — says
|
|
19
|
+
`publishOn('room', 'reaction', data, { echo: true })` and hears it back once the server took it (its `from` is
|
|
20
|
+
`room.me`). What a server action publishes reaches every page, the one that ran it included. State everyone must
|
|
18
21
|
agree on goes through a server action whose last step is `realtime.publish` — validated and saved first, announced
|
|
19
22
|
after.
|
|
20
23
|
|