@plitzi/sdk-server 0.38.5 → 0.38.7

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +100 -14
  3. package/dist/adapters/jsonAdapters.d.ts +8 -4
  4. package/dist/adapters/jsonAdapters.js +5 -9
  5. package/dist/core/auth/api.d.ts +4 -1
  6. package/dist/core/auth/api.js +14 -5
  7. package/dist/core/auth/createAuth.d.ts +7 -1
  8. package/dist/core/auth/createAuth.js +14 -0
  9. package/dist/core/auth/credentials.d.ts +2 -0
  10. package/dist/core/auth/credentials.js +4 -2
  11. package/dist/core/auth/session.d.ts +2 -0
  12. package/dist/core/auth/session.js +6 -5
  13. package/dist/core/createServer.js +25 -4
  14. package/dist/core/http/stages/devReload.d.ts +2 -2
  15. package/dist/core/http/stages/devReload.js +2 -2
  16. package/dist/env.d.ts +1 -0
  17. package/dist/env.js +19 -0
  18. package/dist/env.test.d.ts +1 -0
  19. package/dist/functions-api.d.ts +5 -0
  20. package/dist/helpers/buildServerInfo.js +6 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/modules/actions/runtime/projectData.d.ts +18 -0
  23. package/dist/modules/actions/runtime/projectData.js +69 -0
  24. package/dist/modules/actions/runtime/taskContext.d.ts +1 -1
  25. package/dist/modules/actions/runtime/taskContext.js +6 -3
  26. package/dist/modules/actions/types.d.ts +7 -1
  27. package/dist/modules/functions/build.js +10 -0
  28. package/dist/modules/functions/capabilities.js +5 -0
  29. package/dist/modules/functions/context.js +10 -2
  30. package/dist/modules/functions/contract.d.ts +5 -0
  31. package/dist/modules/functions/driver.js +4 -0
  32. package/dist/modules/functions/local.d.ts +3 -1
  33. package/dist/modules/functions/local.js +4 -2
  34. package/dist/modules/functions/protocol.d.ts +3 -0
  35. package/dist/modules/functions/scope.d.ts +1 -1
  36. package/dist/modules/images/imageProxy.js +1 -1
  37. package/dist/modules/mysql/accounts.js +1 -5
  38. package/dist/modules/project/plugins.d.ts +31 -0
  39. package/dist/modules/project/plugins.js +162 -0
  40. package/dist/modules/project/serveProject.d.ts +78 -0
  41. package/dist/modules/project/serveProject.js +200 -0
  42. package/dist/modules/project/serveProject.test.d.ts +1 -0
  43. package/dist/modules/project/watchSpace.d.ts +14 -0
  44. package/dist/modules/project/watchSpace.js +77 -0
  45. package/dist/modules/realtime/failure.d.ts +6 -0
  46. package/dist/modules/realtime/failure.js +11 -0
  47. package/dist/modules/realtime/handlers.js +13 -3
  48. package/dist/modules/realtime/hub.js +4 -1
  49. package/dist/modules/realtime/socket.js +3 -2
  50. package/dist/modules/rsc/connectorRscData.d.ts +7 -3
  51. package/dist/modules/rsc/connectorRscData.js +3 -4
  52. package/dist/modules/rsc/handler.d.ts +1 -0
  53. package/dist/modules/rsc/handler.js +11 -1
  54. package/dist/modules/rsc/publicFileResolver.d.ts +4 -8
  55. package/dist/modules/rsc/publicFileResolver.js +6 -10
  56. package/dist/modules/runtime/bundle.js +1 -1
  57. package/dist/modules/ssr/Component.d.ts +2 -1
  58. package/dist/modules/ssr/Component.js +2 -1
  59. package/dist/modules/ssr/prepareRender.js +14 -3
  60. package/dist/modules/ssr/registerExternalPlugins.js +1 -1
  61. package/dist/modules/ssr/vendor.test.d.ts +1 -0
  62. package/dist/modules/ssr/views/template.ejs +7 -3
  63. package/dist/plugins/compile.js +1 -1
  64. package/dist/plugins/copy.js +1 -1
  65. package/dist/plugins/manager.js +1 -1
  66. package/dist/project.d.ts +9 -0
  67. package/dist/project.js +2 -0
  68. package/package.json +16 -4
  69. package/skills/plitzi-authoring/CHEATSHEET.md +2 -2
  70. package/skills/plitzi-authoring/SKILL.md +3 -2
  71. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  72. package/skills/plitzi-authoring/reference/auth.md +75 -0
  73. package/skills/plitzi-authoring/reference/authoring-errors.md +18 -4
  74. package/skills/plitzi-authoring/reference/colours-and-motion.md +3 -2
  75. package/skills/plitzi-authoring/reference/efficiency.md +5 -2
  76. package/skills/plitzi-authoring/reference/elements-and-styles.md +23 -23
  77. package/skills/plitzi-authoring/reference/flows.md +11 -6
  78. package/skills/plitzi-authoring/reference/layouts.md +15 -18
  79. package/skills/plitzi-authoring/reference/plugins.md +1 -1
  80. package/skills/plitzi-authoring/reference/review-checklist.md +2 -1
  81. package/skills/plitzi-authoring/reference/structure.md +3 -2
@@ -1,6 +1,6 @@
1
1
  import { createActionResolver } from "../actions/runtime/renderResolver.js";
2
2
  import { createConnectorResolver } from "../connectors/resolver.js";
3
- import { dataFileResolver, dataLookupResolver, publicFileResolver } from "./publicFileResolver.js";
3
+ import { dataLookupResolver, publicFileResolver } from "./publicFileResolver.js";
4
4
  import { resolveRscData } from "./resolveRscData.js";
5
5
  //#region src/modules/rsc/connectorRscData.ts
6
6
  /**
@@ -18,12 +18,11 @@ import { resolveRscData } from "./resolveRscData.js";
18
18
  *
19
19
  * A deployment that resolves server elements some other way still supplies its own `getRscData`, and that one wins.
20
20
  */
21
- var connectorRscData = ({ connectors, actions, publicDir, dataDir, elementTimeoutMs }) => {
21
+ var connectorRscData = ({ connectors, actions, publicDir, data, elementTimeoutMs }) => {
22
22
  const resolveConnector = connectors ? createConnectorResolver(connectors) : void 0;
23
23
  const resolveAction = actions ? createActionResolver(actions.lookups, actions.module) : void 0;
24
24
  const resolvePublicFile = publicDir ? publicFileResolver(publicDir) : void 0;
25
- const dataLookup = actions?.lookups.getData;
26
- const resolveDataFile = dataDir ? dataFileResolver(dataDir) : dataLookup ? dataLookupResolver((spaceId, at) => dataLookup(spaceId, at)) : void 0;
25
+ const resolveDataFile = data ? dataLookupResolver(data) : void 0;
27
26
  /**
28
27
  * An element names ONE producer, and which one decides how its data is fetched.
29
28
  *
@@ -14,6 +14,7 @@ export declare const withPageLocation: (req: SSRRequest) => SSRRequest;
14
14
  * - main environment: no-store (development, always fresh)
15
15
  * - Authenticated requests: Cache-Control: private, max-age=<ttl>
16
16
  * - Unauthenticated requests: Cache-Control: public, max-age=<ttl>
17
+ * - A request carrying `Cache-Control: no-cache` is resolved again, and its answer replaces the one cached.
17
18
  */
18
19
  export declare const handleRsc: (req: SSRRequest, res: SSRResponseHelpers, config: SSRPageServerConfig, _pluginManager: PluginManager, cache?: TtlCache<string>,
19
20
  /** The request's own: a browser that stops waiting stops the elements it asked about. */
@@ -22,6 +22,15 @@ var meterRsc = async (req, config, spaceId, environment, revision, cached) => {
22
22
  });
23
23
  } catch {}
24
24
  };
25
+ /**
26
+ * Whether the browser asked for the server's answer as of now — a refresh that exists because something changed (a
27
+ * write, an explicit reload), which the answer kept for the last `cacheTtlMs` would undo.
28
+ *
29
+ * It skips reading the cache, never writing it: the answer it gets is the newest there is, and the next visitor is
30
+ * served it. Nobody gains anything they could not already have by changing the query string, which misses the cache
31
+ * as well.
32
+ */
33
+ var asksFresh = (req) => (req.headers["cache-control"] ?? "").split(",").some((directive) => directive.trim().toLowerCase() === "no-cache");
25
34
  /** The endpoint's own parameters: what it is asked about, never input to what it resolves. */
26
35
  var ENDPOINT_PARAMS = /* @__PURE__ */ new Set(["location", "ids"]);
27
36
  /**
@@ -70,6 +79,7 @@ var withPageLocation = (req) => {
70
79
  * - main environment: no-store (development, always fresh)
71
80
  * - Authenticated requests: Cache-Control: private, max-age=<ttl>
72
81
  * - Unauthenticated requests: Cache-Control: public, max-age=<ttl>
82
+ * - A request carrying `Cache-Control: no-cache` is resolved again, and its answer replaces the one cached.
73
83
  */
74
84
  var handleRsc = async (req, res, config, _pluginManager, cache, signal) => {
75
85
  if (!config.adapters.getRscData) {
@@ -110,7 +120,7 @@ var handleRsc = async (req, res, config, _pluginManager, cache, signal) => {
110
120
  const isAuthenticated = !!req.ctx.user;
111
121
  const cacheControl = environment === "main" || uncached ? "no-store" : isAuthenticated ? `private, max-age=${Math.floor(ttlMs / 1e3)}` : `public, max-age=${Math.floor(ttlMs / 1e3)}`;
112
122
  const cacheKey = environment !== "main" && !uncached ? buildRscCacheKey(spaceId, environment, revision, req.ctx.user?.id, idsParam, req) : void 0;
113
- const cached = cacheKey ? cache?.get(cacheKey) : void 0;
123
+ const cached = cacheKey && !asksFresh(req) ? cache?.get(cacheKey) : void 0;
114
124
  if (!previewing) await meterRsc(req, config, spaceId, environment, revision, !!cached);
115
125
  if (cached) {
116
126
  res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -7,13 +7,9 @@ import { SpaceRevision } from '@plitzi/sdk-shared';
7
7
  */
8
8
  export declare const publicFileResolver: (publicDir: string) => RscElementResolver;
9
9
  /**
10
- * A server provider whose `query` is `/data/<file>`, read from `dataDir` — the project's own data, which is never
11
- * served as a file: nobody downloads the folder. What the provider reads is in the page it renders.
12
- */
13
- export declare const dataFileResolver: (dataDir: string) => RscElementResolver;
14
- /**
15
- * A server provider whose `query` is `/data/<file>`, answered from the space's own data as Plitzi keeps it — of the
16
- * version being rendered, read off the same deployment record an action's run is (`req.ctx.spaceDeployment`), so a
17
- * published page reads the data it was published with. What `dataFileResolver` is for a folder, for a platform.
10
+ * A server provider whose `query` is `/data/<file>`, answered from the space's own data — of the version being
11
+ * rendered, read off the same deployment record an action's run is (`req.ctx.spaceDeployment`), so a published page
12
+ * reads the data it was published with. The lookup is the platform's, or a self-hosted server's `dataDir`
13
+ * (`dataDirLookup`): the data is never served as a file, and what the provider reads is in the page it renders.
18
14
  */
19
15
  export declare const dataLookupResolver: (getData: (spaceId: number, at: SpaceRevision) => Promise<Record<string, string> | undefined>) => RscElementResolver;
@@ -1,5 +1,5 @@
1
- import path from "node:path";
2
1
  import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
3
  import { PROJECT_DATA_PREFIX } from "@plitzi/sdk-shared/server/rsc/projectData";
4
4
  //#region src/modules/rsc/publicFileResolver.ts
5
5
  /** Where a provider's `query` is a path: a URL, a protocol-relative one or one with `{{tokens}}` is not. */
@@ -44,14 +44,10 @@ var fileResolver = (folder, prefix) => {
44
44
  */
45
45
  var publicFileResolver = (publicDir) => fileResolver(publicDir, "/");
46
46
  /**
47
- * A server provider whose `query` is `/data/<file>`, read from `dataDir` — the project's own data, which is never
48
- * served as a file: nobody downloads the folder. What the provider reads is in the page it renders.
49
- */
50
- var dataFileResolver = (dataDir) => fileResolver(dataDir, PROJECT_DATA_PREFIX);
51
- /**
52
- * A server provider whose `query` is `/data/<file>`, answered from the space's own data as Plitzi keeps it — of the
53
- * version being rendered, read off the same deployment record an action's run is (`req.ctx.spaceDeployment`), so a
54
- * published page reads the data it was published with. What `dataFileResolver` is for a folder, for a platform.
47
+ * A server provider whose `query` is `/data/<file>`, answered from the space's own data — of the version being
48
+ * rendered, read off the same deployment record an action's run is (`req.ctx.spaceDeployment`), so a published page
49
+ * reads the data it was published with. The lookup is the platform's, or a self-hosted server's `dataDir`
50
+ * (`dataDirLookup`): the data is never served as a file, and what the provider reads is in the page it renders.
55
51
  */
56
52
  var dataLookupResolver = (getData) => {
57
53
  return async ({ element, spaceId, environment, req }) => {
@@ -74,4 +70,4 @@ var dataLookupResolver = (getData) => {
74
70
  };
75
71
  };
76
72
  //#endregion
77
- export { dataFileResolver, dataLookupResolver, publicFileResolver };
73
+ export { dataLookupResolver, publicFileResolver };
@@ -1,8 +1,8 @@
1
1
  import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
2
2
  import { createHash } from "node:crypto";
3
+ import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import esbuild from "esbuild";
5
- import fs from "node:fs/promises";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { gunzipSync, gzipSync } from "node:zlib";
8
8
  //#region src/modules/runtime/bundle.ts
@@ -8,6 +8,7 @@ export type ComponentProps = {
8
8
  plugins?: Record<string, SSRPlugin>;
9
9
  ssrResult?: SSRRenderResult;
10
10
  sdkDevToolsStylePath?: string;
11
+ sdkIconsStylePath?: string;
11
12
  debugMode?: boolean;
12
13
  /** Set when the metering adapter degrades this render: the account behind this space is over its quota. */
13
14
  overQuota?: boolean;
@@ -20,5 +21,5 @@ export type ComponentProps = {
20
21
  /** The flags a tester forced, for a page allowed to debug. See `prepareRender`. */
21
22
  forcedFlags?: Record<string, boolean>;
22
23
  };
23
- declare const Component: ({ server, renderMode, previewMode, offlineData, environment, plugins, ssrResult, sdkDevToolsStylePath, debugMode, overQuota, theme, state, serverFlags, forcedFlags }: ComponentProps) => import('react').JSX.Element;
24
+ declare const Component: ({ server, renderMode, previewMode, offlineData, environment, plugins, ssrResult, sdkDevToolsStylePath, sdkIconsStylePath, debugMode, overQuota, theme, state, serverFlags, forcedFlags }: ComponentProps) => import('react').JSX.Element;
24
25
  export default Component;
@@ -1,7 +1,7 @@
1
1
  import PlitziSdk from "@plitzi/plitzi-sdk";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  //#region src/modules/ssr/Component.tsx
4
- var Component = ({ server, renderMode = "raw", previewMode = true, offlineData, environment = "main", plugins, ssrResult, sdkDevToolsStylePath, debugMode = false, overQuota, theme, state, serverFlags, forcedFlags }) => {
4
+ var Component = ({ server, renderMode = "raw", previewMode = true, offlineData, environment = "main", plugins, ssrResult, sdkDevToolsStylePath, sdkIconsStylePath, debugMode = false, overQuota, theme, state, serverFlags, forcedFlags }) => {
5
5
  const serverWithResult = {
6
6
  ...server,
7
7
  ssr: {
@@ -17,6 +17,7 @@ var Component = ({ server, renderMode = "raw", previewMode = true, offlineData,
17
17
  offlineMode: !!offlineData && Object.keys(offlineData).length > 0,
18
18
  offlineData,
19
19
  sdkDevToolsStylePath,
20
+ sdkIconsStylePath,
20
21
  debugMode,
21
22
  ...overQuota === void 0 ? {} : { overQuota },
22
23
  ...theme === void 0 ? {} : { theme },
@@ -14,6 +14,7 @@ import { publishSpaceDocument } from "./spaceDocument.js";
14
14
  import { registerExternalPlugins } from "./registerExternalPlugins.js";
15
15
  import { reportMissingPlugins } from "./reportMissingPlugins.js";
16
16
  import { resolvePageSeo } from "./resolvePageSeo.js";
17
+ import { SPACE_TOKEN_PARAM } from "../../core/auth/credentials.js";
17
18
  import { imagesPathOf } from "../../core/http/stages/images.js";
18
19
  import { sdkAssetVersion } from "../../core/sdkAssets.js";
19
20
  import { buildServerInfo } from "../../helpers/buildServerInfo.js";
@@ -95,6 +96,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
95
96
  const version = config.assetVersion ?? sdkAssetVersion();
96
97
  const v = version ? `?v=${version}` : "";
97
98
  const sdkDevToolsStylePath = `/sdk-assets/plitzi-sdk-devtools.css${v}`;
99
+ const sdkIconsStylePath = `/sdk-assets/plitzi-sdk-icons.css${v}`;
98
100
  /**
99
101
  * Two facts, and they have to leave this server separately.
100
102
  *
@@ -164,13 +166,21 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
164
166
  * Not for a draft, which no other process could produce when the fetch reaches it, and not for a deployment's own
165
167
  * template, which was written for the space inline; a page with no script needs no space at all.
166
168
  */
167
- const spaceDocumentPath = offlineData !== void 0 && offlineDataOverride === void 0 && config.templateFn === void 0 && config.ssrOnly !== true ? publishSpaceDocument(spaceId, environment, spaceDocument(offlineData)) : void 0;
169
+ const publishedDocument = offlineData !== void 0 && offlineDataOverride === void 0 && config.templateFn === void 0 && config.ssrOnly !== true ? publishSpaceDocument(spaceId, environment, spaceDocument(offlineData)) : void 0;
170
+ /**
171
+ * A page that named its space with a token in its address (`?access-token=`, how a host serving many spaces is asked
172
+ * for one) has its document asked for the same way: fetched bare, the host's own space answered it — another space's
173
+ * document, hydrated over this one's page.
174
+ */
175
+ const spaceToken = req.query[SPACE_TOKEN_PARAM];
176
+ const spaceDocumentPath = publishedDocument && spaceToken ? `${publishedDocument}?${SPACE_TOKEN_PARAM}=${encodeURIComponent(spaceToken)}` : publishedDocument;
168
177
  const offlineDataStr = hydrationPayload(offlineData, {
169
178
  offlineMode: true,
170
179
  environment,
171
180
  renderMode: "raw",
172
181
  server,
173
182
  sdkDevToolsStylePath,
183
+ sdkIconsStylePath,
174
184
  ...theme ? { theme } : {},
175
185
  ...paintedState ? { state: paintedState } : {},
176
186
  ...clientAnalytics ? { analytics: clientAnalytics } : {},
@@ -219,7 +229,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
219
229
  ...deferred ? { deferred } : {}
220
230
  };
221
231
  });
222
- const vendorJs = (debugAuthorized ? "/sdk-assets/plitzi-sdk-dev-vendor.js" : "/sdk-assets/plitzi-sdk-vendor.js") + v;
232
+ const vendorJs = (debugRendered ? "/sdk-assets/plitzi-sdk-dev-vendor.js" : "/sdk-assets/plitzi-sdk-vendor.js") + v;
223
233
  return {
224
234
  componentProps: {
225
235
  plugins: Object.keys(pluginComponents).length > 0 ? pluginComponents : void 0,
@@ -228,6 +238,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
228
238
  environment: req.ctx.spaceDeployment?.environment ?? environment,
229
239
  debugMode: debugRendered,
230
240
  sdkDevToolsStylePath,
241
+ sdkIconsStylePath,
231
242
  overQuota,
232
243
  theme,
233
244
  ...paintedState ? { state: paintedState } : {},
@@ -239,7 +250,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
239
250
  title: DEFAULT_TITLE,
240
251
  jsPath: `/sdk-assets/plitzi-sdk.js${v}`,
241
252
  cssPath: `/sdk-assets/plitzi-sdk.css${v}`,
242
- iconsCssPath: `/sdk-assets/plitzi-sdk-icons.css${v}`,
253
+ iconsCssPath: sdkIconsStylePath,
243
254
  spaceDocumentPath,
244
255
  react: vendorJs,
245
256
  reactJsx: vendorJs,
@@ -2,8 +2,8 @@ import { serverLog } from "../../helpers/serverLog.js";
2
2
  import { fetchOutbound } from "../../helpers/outboundGuard.js";
3
3
  import { writeFileAtomic } from "../../helpers/atomicFile.js";
4
4
  import crypto from "node:crypto";
5
- import path from "node:path";
6
5
  import fs from "node:fs/promises";
6
+ import path from "node:path";
7
7
  //#region src/modules/ssr/registerExternalPlugins.ts
8
8
  var MANIFEST_TTL_MS = 6e5;
9
9
  var manifestCache = /* @__PURE__ */ new Map();
@@ -0,0 +1 @@
1
+ export {};
@@ -77,9 +77,10 @@
77
77
  <% } %>
78
78
 
79
79
  <% if (!locals.ssrOnly) { %>
80
- <link rel="modulepreload" href="<%= locals.react || '' %>" crossorigin />
81
- <link rel="modulepreload" href="<%= locals.reactDom || '' %>" crossorigin />
82
-
80
+ <%#
81
+ The import map before anything that loads a module: a browser that fetched one already (a modulepreload counts)
82
+ ignores a map that comes after it, and the SDK's `import "react"` then resolves to nothing.
83
+ %>
83
84
  <script type="importmap">
84
85
  {
85
86
  "imports": {
@@ -92,6 +93,9 @@
92
93
  }
93
94
  }
94
95
  </script>
96
+
97
+ <link rel="modulepreload" href="<%= locals.react || '' %>" crossorigin />
98
+ <link rel="modulepreload" href="<%= locals.reactDom || '' %>" crossorigin />
95
99
  <% } %>
96
100
 
97
101
  <%# The space, fetched beside the page while the scripts load, rather than carried in it. %>
@@ -1,7 +1,7 @@
1
1
  import { writeFileAtomic } from "../helpers/atomicFile.js";
2
+ import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import esbuild from "esbuild";
4
- import fs from "node:fs/promises";
5
5
  import { pluginAssetLoaders, pluginImportQueries } from "@plitzi/sdk-shared/plugins/bundle";
6
6
  import { inPluginLayer } from "@plitzi/sdk-shared/style/cssLayers";
7
7
  //#region src/plugins/compile.ts
@@ -1,6 +1,6 @@
1
1
  import { copyFileAtomic, writeFileAtomic } from "../helpers/atomicFile.js";
2
- import path from "node:path";
3
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
4
4
  import { inPluginLayer } from "@plitzi/sdk-shared/style/cssLayers";
5
5
  //#region src/plugins/copy.ts
6
6
  var copyPlugin = async (src, destDir, filename) => {
@@ -5,8 +5,8 @@ import { copyPlugin, copyPluginStylesheet } from "./copy.js";
5
5
  import { detectAction, isComponentSource } from "./detect.js";
6
6
  import { assertPluginSources } from "./validate.js";
7
7
  import { createHash } from "node:crypto";
8
- import path from "node:path";
9
8
  import fs from "node:fs/promises";
9
+ import path from "node:path";
10
10
  //#region src/plugins/manager.ts
11
11
  var META_FILE = "meta.json";
12
12
  var DEFAULT_TTL_MS = 6048e5;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The server of a project `@plitzi/cli` writes: its `src/main.ts` hands `serveProject` the space, its actions and its
3
+ * options, and the rest — the port, the plugins, the functions, the runtime, the files, the reloads while developing —
4
+ * comes from where the project keeps each part (`@plitzi/sdk-shared/project/paths`). A fix to it arrives with the
5
+ * package, never as a file to upgrade.
6
+ */
7
+ export { serveProject } from './modules/project/serveProject';
8
+ export type { ProjectServerOptions, ProjectSpace, ServedProject, ServeProjectOptions } from './modules/project/serveProject';
9
+ export type { AuthoredDocuments } from './modules/project/watchSpace';
@@ -0,0 +1,2 @@
1
+ import { serveProject } from "./modules/project/serveProject.js";
2
+ export { serveProject };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.38.5",
3
+ "version": "0.38.7",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -75,6 +75,16 @@
75
75
  "import": "./dist/runtime.js",
76
76
  "default": "./dist/runtime.js"
77
77
  },
78
+ "./project": {
79
+ "types": "./dist/project.d.ts",
80
+ "import": "./dist/project.js",
81
+ "default": "./dist/project.js"
82
+ },
83
+ "./env": {
84
+ "types": "./dist/env.d.ts",
85
+ "import": "./dist/env.js",
86
+ "default": "./dist/env.js"
87
+ },
78
88
  "./functions-api.d.ts": "./dist/functions-api.d.ts"
79
89
  },
80
90
  "repository": {
@@ -82,10 +92,12 @@
82
92
  "url": "https://github.com/plitzi/plitzi-workspace.git"
83
93
  },
84
94
  "type": "module",
85
- "sideEffects": false,
95
+ "sideEffects": [
96
+ "./dist/env.js"
97
+ ],
86
98
  "dependencies": {
87
- "@plitzi/plitzi-sdk": "0.38.5",
88
- "@plitzi/sdk-shared": "0.38.5",
99
+ "@plitzi/plitzi-sdk": "0.38.7",
100
+ "@plitzi/sdk-shared": "0.38.7",
89
101
  "ejs": "^6.0.1",
90
102
  "esbuild": "^0.28.2",
91
103
  "jsonwebtoken": "^9.0.3",
@@ -13,7 +13,7 @@ const space: SpaceSpec = {
13
13
  classes: { card }, // styles() declarations, by their own name; styles('card', tw('p-4 rounded-xl'))
14
14
  computed: { total: '{{ … }}' }, // values read anywhere as computed.total
15
15
  flags: { beta: { description, value: false, rules: [] } },
16
- customCss: '@keyframes …', // keyframes and what no property says
16
+ keyframes: { blink: { '50%': { opacity: 0 } } }, // a class plays them: animation: 'blink 1s infinite'
17
17
  layouts: [{ id: 'shell', body }], // chrome shared by pages
18
18
  components: [{ id, props, slots, root }], // a block placed many times with different content
19
19
  pages: [{ id, name, slug: '', layout: { id: 'shell', slot: 'main' }, body: [] }] // slot: where the body goes
@@ -81,7 +81,7 @@ bind: { alt: 'p.data.title' } // other attribut
81
81
  bind: [bindTemplate('content', 'p.data.items', '{{ source|length }} items')]
82
82
  bind: [bindTemplate('items', 'p.data.rows', '{{ source|filter(r => r.on) }}', { returns: 'value' })]
83
83
  bind: [variantFrom(pill, 'rows.item.status')] // a variant chosen by the data
84
- bind: [activeOn(navLink, 'about')] // the current page's link
84
+ current: 'section' // a link lit on its page and the pages under it
85
85
  bind: [activeWhen(dot, '{{ list_dots.index == state.slide }}')] // a variant while a condition holds
86
86
  visible: 'p.data.signedIn' // shown while true; '!p.data.signedIn' inverts
87
87
  visible: { source: 'p.data.rows', template: '{{ source is defined and source|length == 0 }}' }
@@ -153,6 +153,7 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
153
153
  | [components.md](reference/components.md) | One block placed many times — a card, a tier, a testimonial: props, slots, binding a row into one, why it is closed |
154
154
  | [data-and-visibility.md](reference/data-and-visibility.md) | Bindings, providers, offline data, loading/empty/error states, live data, caching, showing and hiding, kept state |
155
155
  | [kept-state.md](reference/kept-state.md) | State that outlives a reload: `keepState`, transient and painted keys |
156
+ | [auth.md](reference/auth.md) | Signing people in: providers, `authLogin`, `auth.*`, visitor roles |
156
157
  | [feature-flags.md](reference/feature-flags.md) | Switching a part of the space on or off — a beta, a rollout, the old version kept until the new one ships |
157
158
  | [lists.md](reference/lists.md) | Rendering rows, filtering and sorting them, a detail page for one record, carousels |
158
159
  | [typed-sources.md](reference/typed-sources.md) | Data typed by a sample of it: `source()`, typed rows, `twig` for templates |
@@ -162,9 +163,9 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
162
163
  | [flows.md](reference/flows.md) | Clicks, submits, page loads, every few seconds, server actions, modals, state |
163
164
  | [realtime.md](reference/realtime.md) | Pages that see each other: channels, presence, cursors, a shared board, who may hear a topic |
164
165
  | [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering, behaving in the builder |
165
- | [drawing.md](reference/drawing.md) | A plugin that draws or animates: a canvas sized to the device, WebGL shaders that say why they failed, a loop that stops when nobody sees it |
166
+ | [drawing.md](reference/drawing.md) | A plugin that draws or animates: a canvas sized to the device, WebGL, a loop that stops when unseen |
166
167
  | [structure.md](reference/structure.md) | A space bigger than one screen: files, helpers, naming, keeping it short |
167
- | [testing.md](reference/testing.md) | Any test: `inspectPage` (one call, every problem), handles, fixtures, catching a flash from the first frame, shortcuts, counting renders |
168
+ | [testing.md](reference/testing.md) | Any test: `inspectPage`, handles, fixtures, catching a flash from the first frame, shortcuts, counting renders |
168
169
  | [performance.md](reference/performance.md) | A page with many elements, a busy flow, something that feels slow: what renders, what it costs, how to measure it |
169
170
  | [snippets-and-export.md](reference/snippets-and-export.md) | Publishing a snippet; turning an exported JSON — or a server action — into code |
170
171
  | [accessibility.md](reference/accessibility.md) | Icon buttons, fields, images, clickable cards, toggles, headings, landmarks, a canvas — anything a screen reader or a browser agent has to use |
@@ -27,8 +27,8 @@ formControl({ name: 'q', label: 'Search the docs', hideLabel: true, placeholder:
27
27
  formControl({ name: 'tint', subType: 'color', label: 'Highlight colour', hideLabel: true })
28
28
  ```
29
29
 
30
- A `placeholder` names only a field you type into, and only until something is typed. A swatch, a date, a select or a
31
- checkbox always needs its `label`.
30
+ A `placeholder` names only a field you type into, and only until something is typed. A swatch, a date, a select, a
31
+ checkbox or a switch always needs its `label`.
32
32
 
33
33
  ## Pictures say what they show, or that they only decorate
34
34
 
@@ -0,0 +1,75 @@
1
+ # Signing people in
2
+
3
+ ## Where they sign in
4
+
5
+ A page signs in with one provider: the space's `settings.userProvider`, or — when it declares none — the one the server
6
+ that rendered it serves.
7
+
8
+ | The space runs… | Declare |
9
+ | --- | --- |
10
+ | on the Plitzi platform, visitors signing in with their Plitzi account | `userProvider: 'server'` — and `visitorRoles` |
11
+ | on a self-hosted server with `auth` (`createServer({ auth })`; in a project, `src/config/serverOptions.ts`) | nothing: the server tells its pages where they sign in |
12
+ | against an API of your own over HTTP+JSON | `userProvider: 'basic'`, `loginUrl`, `userUrl`, `refreshUrl`, `logoutUrl`, and `sessionHintCookie` when it writes one |
13
+
14
+ What the space declares wins, setting by setting; a space naming another provider than the server's ignores the
15
+ server's altogether. A page with no provider at all offers no auth actions, and an `authLogin` there says why in the
16
+ console (`[plitzi] auth.login: …`) — read it before suspecting the form.
17
+
18
+ On the platform, `userProvider: 'server'` signs in by leaving the page: a `link` with `mode: 'external'` to
19
+ `/auth/sign-in?return=/` sends the visitor to sign in and back, signed in on the space's own host.
20
+
21
+ ## The steps
22
+
23
+ ```ts
24
+ form({
25
+ id: 'signIn',
26
+ managedByInteractions: true,
27
+ flows: [
28
+ [
29
+ named('sent', onSubmit()),
30
+ named('signedIn', authLogin({ username: '{{ sent.values.username }}', password: '{{ sent.values.password }}' })),
31
+ when({ field: 'signedIn.ok', operator: '=', value: true }, navigate({ urlType: 'page', url: 'dashboard' })),
32
+ when(
33
+ { field: 'signedIn.reason', operator: '=', value: 'unverified' },
34
+ addNotification({ content: 'Confirm your address first — the link is in your inbox', appearance: 'warning' })
35
+ )
36
+ ]
37
+ ],
38
+ children: [
39
+ formControl({ name: 'username', label: 'Username' }),
40
+ formControl({ name: 'password', label: 'Password', subType: 'password' }),
41
+ button({ content: 'Sign in', subType: 'submit' })
42
+ ]
43
+ });
44
+ ```
45
+
46
+ - `authLogin(params)` — `{ username, password }`; `{ mode: 'token', token }` adopts a credential obtained elsewhere,
47
+ checked against `userUrl`; `{ mode: 'mfa', mfaToken, code }` completes a sign-in that owed a second factor.
48
+ - What it answers: `ok`; when not, `reason` (`unverified`, `inactive`, `network`, `missing`…) and, for `reason: 'mfa'`,
49
+ `mfaToken` — the challenge `mode: 'mfa'` completes. Test it on `<step>.ok` with `when`, never `whenSucceeded` /
50
+ `whenFailed`, which are a server action's ([flows](flows.md)).
51
+ - `authLogout()` ends the session; `authRefreshDetails()` reloads who is signed in.
52
+
53
+ ## Who is signed in
54
+
55
+ The `auth` source: `{{ auth.isAuthenticated }}`, `{{ auth.status }}` (`init` … `authenticated` | `guest`, while it is
56
+ being found out), `{{ auth.details.username }}` — `details` carries `id`, `username`, `email`, `verified`, `roles` and
57
+ `permissions`. Show a signed-in part with `visible: 'auth.isAuthenticated'`; never bind a token into the page.
58
+
59
+ ## Pages for one kind of visitor
60
+
61
+ A page's `accessLevel`: `'authenticated'` (signed in), `'public'` (signed OUT only — the sign-in page), or nothing
62
+ (everybody). A lone `public` page vanishes the moment anyone signs in, so leave it out unless the page has a signed-in
63
+ twin on the same slug. `unauthorizedRedirect` names where a visitor the page is not for goes — a page's id or slug
64
+ (`''` is the home page), or a URL; without it they are answered 403.
65
+
66
+ ## What a visitor may do
67
+
68
+ `settings.visitorRoles` — `{ author: ['postPublish'] }` — is what each role gives. A visitor holds exactly the
69
+ permissions of their roles in this space; who holds a role is given by email in the builder, never written here. A page
70
+ reads them in `auth.details.permissions`, a plugin asks `can('postPublish')` of the auth context.
71
+
72
+ A server action's trigger says who may run it: `access: { mode: 'public' }`, `{ mode: 'session' }` (signed in) or
73
+ `{ mode: 'role', permissions: ['postPublish'] }`. The page hiding a button is not the check — the trigger is. Inside,
74
+ its steps read who asked as `{{ user.id }}`, and a function as `ctx.user` (`id`, `username`, `email`, `verified`,
75
+ `roles`, `permissions`) — never their session.
@@ -27,7 +27,7 @@ The space is not written until these are fixed.
27
27
  | `action-without-entry` | an action with no way in, so nothing can start it | a `trigger` — `{ type: 'call', access }`, or `render`, `webhook`, `custom`, `schedule` |
28
28
  | `action-without-steps` | an action with no steps | the steps it runs |
29
29
  | `active-when-constant` | an `activeWhen` condition that reads nothing — always or never true | name what it depends on: `'{{ list_dots.index == state.slide }}'` |
30
- | `ancestor-not-class` | an ancestor condition keyed by something that is not a class name | `[card.name]` for a `styles()` declaration |
30
+ | `ancestor-not-class` | an ancestor condition keyed by something that is neither a class name nor `'>'` (the parent) | `[card.name]` for a `styles()` declaration, or `'>'` for the parent whatever it wears |
31
31
  | `anchor-duplicate` | one anchor on two elements of the same page (layouts included) | rename all but one |
32
32
  | `anchor-invalid` | an `anchor` or a link's `hash` that is not lowercase letters, digits and `-` | `anchor: 'plans'`, `hash: 'plans'` — no `#` |
33
33
  | `anchor-missing` | a link whose `hash` no element on the page it goes to carries — it would land at the top | the anchor the message lists, or give the section that `anchor` |
@@ -59,20 +59,23 @@ The space is not written until these are fixed.
59
59
  | `computed-not-template` | a computed value that is not a template | `'{{ state.x * 2 }}'` |
60
60
  | `computed-reads-element` | a computed value reading an element's source — no element is around the whole space | compute it from the globals and the variables, or bind it on the element |
61
61
  | `computed-unknown` | a computed value read before it is declared, or never declared | declare it in `computed`, above the one that reads it |
62
+ | `condition-field-unpublished` | a `when` asking a step of its flow for a key it never publishes — `whenSucceeded` / `whenFailed` on anything but `runServerAction` | a key the step publishes (`explain <step>`): after `authLogin`, `when({ field: 'signedIn.ok', operator: '=', value: true }, …)` |
62
63
  | `controls-unknown` | a button's `controls` naming no element of the space | the id of the element it shows and hides: `controls: 'faq-answer'` |
63
64
  | `css-property-twice` | one property written twice in a rule set — `paddingTop` beside `'padding-top'` — so one would silently win | keep one |
64
65
  | `css-property-unknown` | a CSS property that does not exist | the property it suggests; a custom property starts with `--` |
65
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
+ | `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']` |
66
68
  | `element-load-strategy` | a `loadStrategy` the element does not take | one of the values the message lists |
67
69
  | `element-rejected` | an element the schema refused to hold | the problem listed with it |
68
70
  | `element-runtime` | a `runtime` the element does not run in | one of the values the message lists |
69
71
  | `element-shape` | an element that is not an object with a `type`, `attributes` and a list of `children` | build elements with their factories — `text(…)`, `container(…)` |
72
+ | `element-slot-unknown` | a `slots` key the element type does not have — its class would dress nothing | one of the slots `plitzi explain <type>` lists; the element itself takes `class` |
70
73
  | `flag-gate` | an element's or a page's `flag` that is not a flag name | `flag: 'newCheckout'`, or `flag: '!newCheckout'` for "only while off" |
71
74
  | `flag-name` | a flag whose name a template cannot read as `flags.x` | letters, digits and `_` |
72
75
  | `flag-rule-shape` | a flag rule that is not `{ when, value }`, or whose `value` is not `true` or `false` | `{ when: { … }, value: true }` |
73
76
  | `flag-shape` | a flag whose `value` is not `true` or `false` | `{ value: false, rules: [] }` |
74
77
  | `flag-undeclared` | a gate on a flag the space does not declare — it reads as off, so the element never (or always) renders | declare it in `flags`, or remove the gate |
75
- | `flag-unknown` | a template reading a flag the space does not declare | the declared name it suggests, or declare it |
78
+ | `flag-unknown` | a template, a binding or a `when` reading a flag the space does not declare | the declared name it suggests, or declare it |
76
79
  | `flow-empty` | a flow with no steps | `[onClick(), setState({ … })]` |
77
80
  | `flow-without-trigger` | a flow whose first step is not the event that runs it | `[onClick(), setState(…)]` |
78
81
  | `folder-cycle` | a page folder inside itself | a `parent` that leads to the top |
@@ -83,9 +86,11 @@ The space is not written until these are fixed.
83
86
  | `from-without-attribute` | `from` on a type with no one attribute that shows its data (a container, a form) | bind the attribute you mean: `bind: { attribute: 'source' }` |
84
87
  | `global-callback-module` | a global callback sent to a module other than the one that registers it | the step builder, which knows the module |
85
88
  | `global-callback-undeclared` | a global-callback step builder naming an action no source declares | one of the actions it lists |
89
+ | `global-field-unknown` | a binding, a template or a `when` reading a field `navigation`, `auth` or `theme` never has — `auth.authenticated` | the field it suggests: `auth.isAuthenticated`, `auth.details.username`, `navigation.queryParams.next`, `theme.resolved` |
86
90
  | `id-invalid` | an id a binding, a template or a test cannot name | a letter first, then letters, digits, `-` and `_`: `'hero-title'` |
87
91
  | `id-shadows-global` | an element named like a global data source (`state`, `navigation`, `auth`…) | another id |
88
92
  | `id-taken` | two elements with one id — ids are one namespace for the whole space | a name taken by a page or a layout: a name of its own; a helper called twice: `scope('promos', ref => …)`, every id inside prefixed |
93
+ | `keyframes-shape` | keyframes with a name CSS does not accept or the SDK keeps for itself, or a frame that is not an offset | a plain name (`slide-in`), frames keyed `from`, `to` or a percentage (`'50%'`, `'0%, 100%'`) |
89
94
  | `layout-slot-unknown` | a layout `slot` that is not an element inside that layout | the id of the element in the shell where the body goes |
90
95
  | `layout-undeclared` | a page or a layout inside a layout the space does not declare | the layout's id, or declare it in `layouts` |
91
96
  | `list-items-ignored` | a list whose items nothing reads — `source: 'none'` renders its children once | `source: 'controlled'` |
@@ -96,7 +101,7 @@ The space is not written until these are fixed.
96
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 |
97
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 |
98
103
  | `no-pages` | a space with no pages | `pages: [{ id: 'home', name: 'Home', slug: '', body: [] }]` |
99
- | `notifications-shape` | `notifications` with a field it does not have, or a value that is not one CSS value | the fields it lists; a colour or a length, like 'var(--card)' or '12px' |
104
+ | `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' |
100
105
  | `outside-ancestor` | an element that reads the state of an element it is not inside | nest it in the element the message names |
101
106
  | `page-access-level` | an `accessLevel` that is not one the router reads | one of the values the message lists |
102
107
  | `page-route-reserved` | a page under `/fn`, where the space's functions answer | another slug |
@@ -131,6 +136,10 @@ The space is not written until these are fixed.
131
136
  | `step-name` | a step name a later step cannot read as `{{ name.field }}` | a letter first, then letters, digits, `-` and `_` |
132
137
  | `step-params` | a step param that does not exist, or a value outside its options | the params and values it lists |
133
138
  | `step-type` | a step `type` that does not exist | the step builders — `setState(…)`, `navigate(…)` — write it |
139
+ | `style-condition-unknown` | a condition a class's rules cannot hold under | `motion-reduce`, `motion-safe`, or a container width — `container (max-width: 30rem)`, `container card (min-width: 480px)` |
140
+ | `style-pseudo-content` | a `content` CSS cannot read — text without its quotes draws nothing — or a `before`/`after` with no `content`, which is not there at all | the text in quotes inside the string — `content: '"→"'`, `'""'` for an empty box — or a function such as `counter(step)` |
141
+ | `style-pseudo-property` | a property the pseudo-element ignores — the browser drops it without a word | one it honours (`::selection` paints, `::marker` and `::placeholder` take text and font), or style the element |
142
+ | `style-pseudo-unknown` | a pseudo-element a class cannot dress | one of `before`, `after`, `marker`, `placeholder`, `first-letter`, `first-line`, `selection` — without the `::` |
134
143
  | `style-state-unknown` | a state a selector does not react to | one of the states it lists (`hover`, `focus`, `active`…) |
135
144
  | `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` |
136
145
  | `template-short-source` | a source named without its prefix inside a template or a step | the full name: `apiContainer_stats`, `list_rows` |
@@ -161,6 +170,7 @@ The space renders, and renders something you probably did not mean. Fix every on
161
170
  | Code | What was wrong | Write instead |
162
171
  | --- | --- | --- |
163
172
  | `action-output-path` | a read of `.data` on a provider fed by a server action — it publishes the action’s output at its root, and `.data` is a `query` provider’s answer | the output’s own field — `apiContainer_feed.stories`, not `apiContainer_feed.data.stories` |
173
+ | `animation-name-unknown` | `animation-name` naming keyframes the space does not declare — nothing plays | declare them in the space's `keyframes`, or name one it has |
164
174
  | `click-on-static-element` | a click flow on a container, text, heading, image or list item — no keyboard reaches it | the flow on a `button` (it holds children) or a `link` |
165
175
  | `colour-without-dark` | a colour token with no dark value | `{ light, dark, default }` |
166
176
  | `condition-starts-visible` | a computed visibility that shows until its data answers | `visible: { source, template }` |
@@ -176,9 +186,11 @@ The space renders, and renders something you probably did not mean. Fix every on
176
186
  | `form-control-name-taken` | two controls in one form with one name — one overwrites the other | a name each |
177
187
  | `form-control-unnamed` | a control in a form with no `name` — its value never reaches `values` | `formControl({ name: 'email', … })` |
178
188
  | `form-value-compared-to-blank` | a `when` comparing a submitted field to `""` — a field nobody typed in is not sent | `operator: 'empty'` / `'notEmpty'` |
189
+ | `heading-level-overridden` | a markdown's or richText's `heading` slot and a level's (`heading3`) both setting one property, the general class written later in the stylesheet — the level's value is lost | set the property on one of the two: the level slot for that level alone, `heading` for every heading |
179
190
  | `heading-level-skipped` | an `h4` right after an `h2` — the outline misses a level | the next level down; size it with its class |
180
191
  | `image-without-alt` | an image that is not `decorative` and has no `alt` | say what it shows, or `decorative: true` |
181
192
  | `label-ignored` | a `label` on a container whose tag is named by what it holds (`li`, a heading) | the words inside, or a landmark tag (`nav`, `section`…) |
193
+ | `link-current-section` | `current: 'section'` on a link that is never current (an external one) or current everywhere (to `/`) | leave `current` out — `'section'` is for a link to a section's own page, `/automations/runs` |
182
194
  | `list-item-key-missing` | a list's `itemKey` that some of its items lack, or two of them share — the rows fall back to `id`, then position | a field every item has, once each |
183
195
  | `list-row-not-li` | a row of a list with `items` that is not an `<li>` — that list is a `<ul>` (or `<ol>`), so the row is a box inside a list, no item to a screen reader | `container({ subType: 'li' })` — or the component's root one, or a wrapper for a link or a button |
184
196
  | `overlay-never-opened` | a modal or a dialog that starts hidden and that no step opens | a flow with `openModal('id')` / `openDialog('id')` |
@@ -208,10 +220,12 @@ Nothing is wrong: the page renders as written. Each is a shorter way to the same
208
220
 
209
221
  | Code | Written the long way | The short way |
210
222
  | --- | --- | --- |
223
+ | `class-overrides-class` | one class's shorthand erasing the longhand another class on the same element writes out — `padding` over `padding-top` — because the stylesheet writes it later: classes in the order they are first met, a breakpoint's rules after the base, never the order of a class list | the longhands the shorthand means instead of it (`padding-left`, `padding-right`), so it leaves the other class's alone — or `quiet: ['class-overrides-class']` when the shorthand is meant to win |
211
224
  | `content-attribute` | a button or a link whose children are only its words and an icon — a `text`, a `fontAwesome` — elements more than it needs | its own `content` and `icon` (`link({ href, content: 'Docs', icon: 'fa-solid fa-arrow-right', iconPlacement: 'after' })`); the icon's class goes on the `icon` slot, and what the text's class adds on the box's class, never the class itself |
212
225
  | `custom-css-class` | a `customCss` rule a class can hold — `.card:hover`, `.panel .icon` | the class's own `states` or `ancestors`, where the style editor reads it back and a breakpoint can change it |
213
- | `custom-css-notifications` | the toasts dressed with `.Toastify__toast` rules in `customCss` | `notifications: { font, fontSize, border, shadow, padding, background, text, radius }` |
226
+ | `custom-css-notifications` | the toasts or their icon, close button or progress bar dressed with `.Toastify__*` rules in `customCss` | `notifications: { font, fontSize, fontWeight, minHeight, iconSize, closeColor, progressHeight, … }` |
214
227
  | `custom-css-sdk-default` | `customCss` repeating what the SDK already does for every space — less motion when asked, the theme toggle showing one icon | nothing: remove it |
228
+ | `custom-css-slot` | a part of a built-in element dressed in `customCss` by the SDK's markup — a form control's `<input>`, a pager's buttons, a heading inside a markdown | a class on the slot of that part — `slots: { field: input }`, `elements.<type>.slots` — with its states |
215
229
  | `heavy-animation` | keyframes animating what the browser repaints or lays out again on every frame — a size, a position, a blur, a shadow, or a colour in a loop — which stutters whenever the page is busy, most of all while it loads | `opacity` and `transform`: a size or a position is `translate`/`scale`, a blur or a shadow is the `opacity` of a layer carrying it; a loop that must animate anything else starts `paused` and runs under `[data-hydrated]` |
216
230
  | `literal-colour` | a colour written out in a class where the space has a token of that value — it stays put in the dark scheme | `var(--token)` when it should follow the scheme; a token of one value of its own when it must stay the same in both |
217
231
  | `repeated-on-pages` | the same block — a header, a footer, a side panel — written into several pages | a layout holding it once (`layouts`, and `layout: { id, slot }` on each page) — a component when only some pages of a layout carry it; a link marks its own page with the `current` state |
@@ -2,8 +2,9 @@
2
2
 
3
3
  ## Animations
4
4
 
5
- - **Keyframes go in the space's `customCss`** (`'@keyframes marquee { to { transform: translateX(-50%); } }'`) and
6
- the class names them: `animation: 'marquee 30s linear infinite'`.
5
+ - **Keyframes go in the space's `keyframes`** (`keyframes: { marquee: { to: { transform: 'translateX(-50%)' } } }`)
6
+ and the class names them: `animation: 'marquee 30s linear infinite'`. A name no keyframes declare is warned
7
+ (`animation-name-unknown`).
7
8
  - **An element hidden by `visible` is `display: none`**, so an animation in its class runs again every time it is
8
9
  shown — the way to animate a slide in (see recipes/every-few-seconds.ts).
9
10
  - **Pause on hover** from the element around it: `ancestors: { [band.name]: { states: { hover: