@drhomet/mithaq 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/README.md +161 -0
  3. package/dist/asas/build.d.ts +10 -2
  4. package/dist/asas/build.js +69 -22
  5. package/dist/asas/cli/mithaq.js +35 -3
  6. package/dist/asas/cli/upgrade.d.ts +22 -9
  7. package/dist/asas/cli/upgrade.js +187 -78
  8. package/dist/asas/http/fetch.d.ts +21 -3
  9. package/dist/asas/http/fetch.js +123 -60
  10. package/dist/asas/http/orpc.d.ts +5 -3
  11. package/dist/asas/http/orpc.js +126 -108
  12. package/dist/asas/runner/bulk.d.ts +1 -1
  13. package/dist/asas/runner/bulk.js +16 -11
  14. package/dist/asas/runner/errors.d.ts +5 -2
  15. package/dist/asas/runner/errors.js +18 -2
  16. package/dist/asas/runner/read.d.ts +6 -2
  17. package/dist/asas/runner/read.js +28 -6
  18. package/dist/asas/runner/steps.d.ts +22 -4
  19. package/dist/asas/runner/steps.js +15 -9
  20. package/dist/asas/runner/store.d.ts +4 -0
  21. package/dist/asas/runner/store.js +21 -0
  22. package/dist/asas/runner/tenant.d.ts +2 -2
  23. package/dist/asas/runner/tenant.js +78 -16
  24. package/dist/asas/runner/write.d.ts +173 -11
  25. package/dist/asas/runner/write.js +274 -46
  26. package/dist/auth/better-auth/access.d.ts +14 -0
  27. package/dist/auth/better-auth/access.js +32 -0
  28. package/dist/auth/better-auth/generate.d.ts +3 -1
  29. package/dist/auth/better-auth/generate.js +26 -8
  30. package/dist/auth/better-auth/index.d.ts +2 -0
  31. package/dist/auth/better-auth/tenant.d.ts +11 -3
  32. package/dist/auth/better-auth/tenant.js +10 -2
  33. package/dist/auth/local/index.js +5 -3
  34. package/dist/base/fields.d.ts +3 -2
  35. package/dist/base/fields.js +3 -2
  36. package/dist/base/index.d.ts +2 -1
  37. package/dist/base/json-schema.d.ts +7 -0
  38. package/dist/base/json-schema.js +35 -0
  39. package/dist/base/kinds.d.ts +21 -0
  40. package/dist/base/kinds.js +46 -1
  41. package/dist/base/rules.d.ts +4 -4
  42. package/dist/base/rules.js +4 -3
  43. package/dist/drizzle/index.d.ts +9 -139
  44. package/dist/drizzle/index.js +143 -24
  45. package/dist/drizzle/kinds.d.ts +1 -0
  46. package/dist/drizzle/kinds.js +3 -1
  47. package/dist/drizzle/migrate.d.ts +26 -0
  48. package/dist/drizzle/migrate.js +69 -26
  49. package/dist/drizzle/rules.d.ts +1 -1
  50. package/dist/index.d.ts +11 -21
  51. package/dist/index.js +8 -18
  52. package/dist/mithaq/define.d.ts +62 -15
  53. package/dist/mithaq/define.js +23 -9
  54. package/dist/mithaq/generate-messages.js +5 -4
  55. package/dist/mithaq/language.d.ts +43 -4
  56. package/dist/mithaq/message-file.d.ts +13 -0
  57. package/dist/mithaq/message-file.js +29 -0
  58. package/dist/mithaq/messages.js +5 -0
  59. package/dist/mithaq/resolve.js +70 -10
  60. package/dist/mithaq/types.d.ts +29 -1
  61. package/dist/rbac/index.d.ts +2 -2
  62. package/dist/rbac/index.js +1 -1
  63. package/dist/rich-text/index.d.ts +1 -0
  64. package/dist/rich-text/index.js +1 -0
  65. package/dist/rich-text/rich-text.d.ts +2 -55
  66. package/dist/rich-text/rich-text.js +2 -125
  67. package/dist/rich-text/value.d.ts +55 -0
  68. package/dist/rich-text/value.js +126 -0
  69. package/dist/screen/client.d.ts +5 -2
  70. package/dist/screen/client.js +5 -2
  71. package/dist/screen/generate.js +48 -6
  72. package/dist/screen/index.d.ts +18 -1
  73. package/dist/screen/index.js +24 -6
  74. package/dist/screen/react.d.ts +2 -0
  75. package/dist/storage/index.d.ts +2 -3
  76. package/dist/storage/index.js +1 -2
  77. package/dist/storage/storage.d.ts +2 -0
  78. package/dist/storage/storage.js +9 -2
  79. package/dist/targets.d.ts +14 -0
  80. package/dist/targets.js +16 -0
  81. package/dist/vite.d.ts +7 -0
  82. package/dist/vite.js +81 -0
  83. package/dist/zod/index.d.ts +12 -3
  84. package/dist/zod/index.js +6 -5
  85. package/dist/zod/kinds.d.ts +3 -1
  86. package/dist/zod/kinds.js +11 -2
  87. package/dist/zod/rules.d.ts +1 -1
  88. package/docs/packages/mithaq/README.md +974 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +283 -0
  90. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  91. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  92. package/docs/packages/mithaq-admin/README.md +312 -0
  93. package/docs/templates/asas/README.md +1094 -0
  94. package/package.json +37 -8
  95. package/skills/add-field/SKILL.md +27 -0
  96. package/skills/add-kayan/SKILL.md +26 -0
  97. package/skills/eject-admin-piece/SKILL.md +28 -0
  98. package/skills/upgrade-mithaq/SKILL.md +35 -0
  99. package/r/mithaq-cells.json +0 -20
  100. package/r/mithaq-controls.json +0 -37
  101. package/r/mithaq-directional-icon.json +0 -15
  102. package/r/mithaq-expected-updated-at.json +0 -15
  103. package/r/mithaq-field-shell.json +0 -18
  104. package/r/mithaq-filter-operators.json +0 -15
  105. package/r/mithaq-form.json +0 -33
  106. package/r/mithaq-list-columns.json +0 -23
  107. package/r/mithaq-list-filters.json +0 -24
  108. package/r/mithaq-list-pagination.json +0 -24
  109. package/r/mithaq-list-query.json +0 -18
  110. package/r/mithaq-list-selection.json +0 -19
  111. package/r/mithaq-list.json +0 -33
  112. package/r/mithaq-nav.json +0 -24
  113. package/r/mithaq-nodes.json +0 -34
  114. package/r/mithaq-relationship-cell.json +0 -20
  115. package/r/mithaq-relationship-control.json +0 -20
  116. package/r/mithaq-rich-text.json +0 -40
  117. package/r/mithaq-screens-host.json +0 -96
  118. package/r/mithaq-storage.json +0 -44
  119. package/r/mithaq-upload.json +0 -29
  120. package/r/registry.json +0 -550
@@ -6,7 +6,7 @@ import type { StandardSchemaV1 } from './standard-schema.ts';
6
6
  import type { Rules } from '../base/rules.ts';
7
7
  import type { Rule, RuleValue } from '../base/rules.ts';
8
8
  import type { Kind } from '../base/kinds.ts';
9
- import type { RichTextRead } from '../rich-text/rich-text.ts';
9
+ import type { RichTextRead } from '../rich-text/value.ts';
10
10
  import type { ResolvedStorage } from '../storage/storage.ts';
11
11
  import type { RefOf, ScreenField, ScreenKeys } from '../screen/index.ts';
12
12
  /** What a field access callback is handed. */
@@ -54,6 +54,16 @@ export type ServiceOf<L> = L extends {
54
54
  } ? Extract<Targets[keyof Targets], {
55
55
  capability: 'service';
56
56
  }> : never;
57
+ /**
58
+ * Payload's `jsonSchema`: `schema` is what a value is checked against. `uri` and `fileMatch` name it
59
+ * for an editor; it is never fetched from its `uri`, which would be a request the server makes on a
60
+ * writer's behalf.
61
+ */
62
+ export type JsonSchema = {
63
+ schema: Record<string, unknown> | boolean;
64
+ uri?: string;
65
+ fileMatch?: readonly string[];
66
+ };
57
67
  type Named<Locale extends string = string> = {
58
68
  access?: {
59
69
  create?: StoredFieldAccess;
@@ -73,6 +83,10 @@ type Named<Locale extends string = string> = {
73
83
  readonly value: string;
74
84
  readonly label: Localized<Locale>;
75
85
  }[];
86
+ /** A `json` field only: the JSON Schema its value is checked against, as Payload's field takes it. */
87
+ jsonSchema?: JsonSchema;
88
+ /** A `json` field only, and every one names it: the longest JSON text its value is, `defaultJsonMaxBytes` or its own. */
89
+ maxBytes?: number;
76
90
  /** The column name. A relationship without one gets `<snake_case name>_id`. */
77
91
  dbName?: string;
78
92
  /**
@@ -100,6 +114,7 @@ type FieldKind<KindName extends string> = KindName extends 'join' ? {
100
114
  relationTo: string | (() => Kayan);
101
115
  /** Whether this field holds a set of rows rather than one, kept in a join table and read back as an array of ids. */
102
116
  hasMany?: true;
117
+ filterOptions?: FilterOptions;
103
118
  on?: never;
104
119
  defaultLimit?: never;
105
120
  defaultSort?: never;
@@ -107,6 +122,7 @@ type FieldKind<KindName extends string> = KindName extends 'join' ? {
107
122
  kind: KindName;
108
123
  relationTo?: never;
109
124
  hasMany?: never;
125
+ filterOptions?: never;
110
126
  on?: never;
111
127
  defaultLimit?: never;
112
128
  defaultSort?: never;
@@ -121,6 +137,7 @@ export type RuntimeField = Named & {
121
137
  rules?: Record<string, unknown>;
122
138
  relationTo?: string | (() => Kayan);
123
139
  hasMany?: true;
140
+ filterOptions?: FilterOptions;
124
141
  /** A `join` field only: the child's relationship that points back at this kayan. */
125
142
  on?: string;
126
143
  /** A `join` field only: how many of the child's rows one populated read carries. */
@@ -158,6 +175,7 @@ type BoundField<L extends LanguageShape, K extends keyof L['kinds'] & string> =
158
175
  } ? {
159
176
  relationTo: string | (() => Kayan);
160
177
  hasMany?: true;
178
+ filterOptions?: FilterOptions;
161
179
  on?: never;
162
180
  defaultLimit?: never;
163
181
  defaultSort?: never;
@@ -168,6 +186,16 @@ type BoundField<L extends LanguageShape, K extends keyof L['kinds'] & string> =
168
186
  defaultLimit?: never;
169
187
  defaultSort?: never;
170
188
  });
189
+ /**
190
+ * Which rows of its target a relationship's picker offers this actor, as Payload's field of the same
191
+ * name: a `Where`, `true` for every row the actor reads, `false` for none. A picker's read is ANDed
192
+ * with it, so it narrows what the target's own read answers and never widens it.
193
+ */
194
+ export type FilterOptions = (args: {
195
+ user: unknown;
196
+ /** The transaction of the write it holds, where there is one, so a read it makes joins it as Payload's `req` carries one. */
197
+ tx?: unknown;
198
+ }) => Where | boolean | Promise<Where | boolean>;
171
199
  /** Field union derived once from a concrete language's kind and rule registries. */
172
200
  export type FieldOf<L extends LanguageShape> = {
173
201
  [K in keyof L['kinds'] & string]: BoundField<L, K>;
@@ -17,8 +17,8 @@ export type Assigned = {
17
17
  /** The access target: it names the system roles, reads which an actor is assigned, and answers what they hold. */
18
18
  export type AccessTarget<Roles extends SystemRoles = SystemRoles, Table extends RowTable = RowTable, Actor = unknown> = {
19
19
  readonly capability: 'access';
20
- /** `'rbac'` from `rbacTarget`, `'betterAuth'` from `betterAuthTarget`. */
21
- readonly type?: 'rbac' | 'betterAuth';
20
+ /** `'rbac'` from `rbacTarget`, `'betterAuth'` from `betterAuthTarget`, `'memberRole'` from `memberRoleTarget`. */
21
+ readonly type?: 'rbac' | 'betterAuth' | 'memberRole';
22
22
  /** The system roles, which are also the names a row table may be keyed on. */
23
23
  readonly roles: Roles;
24
24
  /** The slugs this actor is assigned, read from the trusted snapshot, so an actor naming its own roles is a claim. Absent is `user.roles`. */
@@ -105,7 +105,7 @@ export const rbacTarget = ({ roles, rolesOf, access, kayan, }) => {
105
105
  if (!Object.hasOwn(roles, role))
106
106
  throw new Refusal(`Role "${role}" is not declared on the access target.`);
107
107
  if (role === 'super_admin')
108
- throw new Refusal('Role "super_admin" is the platform role and cannot be assigned in a tenant.');
108
+ throw new Refusal('Role "super_admin" is the backoffice role and cannot be assigned in a tenant.');
109
109
  const project = asas;
110
110
  const declarations = resolvedKayans.get(project) ?? {};
111
111
  const assignment = declarations[memberRoles];
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import type { StandardSchemaV1 } from '../mithaq/standard-schema.ts';
4
4
  import type { ResolvedField } from '../mithaq/types.ts';
5
5
  import { type RichTextValue } from './rich-text.ts';
6
+ export * from './rich-text.ts';
6
7
  /**
7
8
  * The rukn a project lays down for rich text, over its own node list:
8
9
  * `arkan: [base, richText({ nodes: richTextNodes })]`.
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { kind } from '../base/kinds.js';
4
4
  import { defineRukn } from '../mithaq/rukn.js';
5
5
  import { isRichText, richTextReader, storedRichText } from './rich-text.js';
6
+ export * from './rich-text.js';
6
7
  // An emptied editor still sends a root and one empty paragraph, so a blank text
7
8
  // with no decorator is missing for `required`.
8
9
  const refusalOf = ({ field, read, value }) => {
@@ -1,50 +1,10 @@
1
1
  import { DecoratorNode, type ElementFormatType, type Klass, type LexicalNode, type LexicalUpdateJSON, type SerializedLexicalNode } from 'lexical';
2
- /** One node of Lexical's serialized editor state: a `type`, and `children` where it holds any. */
3
- export type RichTextNode = {
4
- type: string;
5
- children?: RichTextNode[];
6
- text?: string;
7
- [key: string]: unknown;
8
- };
9
- /** What a `richText` field holds: Lexical's `editorState.toJSON()`, a root of `type: 'root'`. */
10
- export type RichTextValue = {
11
- root: RichTextNode & {
12
- type: 'root';
13
- children: RichTextNode[];
14
- };
15
- [key: string]: unknown;
16
- };
17
- /**
18
- * How deep a rich text value may nest, how large it may be and how many nodes it may hold,
19
- * all checked before any Lexical code reads it.
20
- */
21
- export declare const richTextBounds: {
22
- depth: number;
23
- bytes: number;
24
- nodes: number;
25
- };
26
- /** Whether a link may point here: `http`, `https`, `mailto`, `tel`, a fragment or a path. */
27
- export declare const safeUrl: (url: unknown) => boolean;
28
- /**
29
- * Why a value is no Lexical editor state a `richText` column may hold, or nothing. Runs before
30
- * any Lexical code reads the value; a `style` is refused, since no editor of the project writes one.
31
- */
32
- export declare const richTextGuard: (value: unknown) => string | undefined;
33
- /** Whether a value passes `richTextGuard`, which is all the kind itself asks of one. */
34
- export declare const isRichText: (value: unknown) => value is RichTextValue;
2
+ import { type RichTextRead, type RichTextValue } from './value.ts';
3
+ export * from './value.ts';
35
4
  /** The node classes the registry's editor draws. A project's list spreads it: `[...defaultNodes, CalloutNode]`. */
36
5
  export declare const defaultNodes: readonly Klass<LexicalNode>[];
37
6
  /** Every node type a list of classes can read, the core ones included. */
38
7
  export declare const typesOf: (nodes: readonly Klass<LexicalNode>[]) => ReadonlySet<string>;
39
- /**
40
- * The first node type under a value's `root` that `types` does not hold, or nothing, and `''`
41
- * past the depth bound. Read off the JSON, since a production Lexical minifies its errors.
42
- */
43
- export declare const unknownType: ({ node, types, depth }: {
44
- node: unknown;
45
- types: ReadonlySet<string>;
46
- depth?: number;
47
- }) => string | undefined;
48
8
  /** An upload node as Payload's lexical editor writes it, v3. */
49
9
  export type SerializedUploadNode = SerializedLexicalNode & {
50
10
  type: 'upload';
@@ -113,24 +73,11 @@ export declare class UploadNode extends DecoratorNode<unknown> {
113
73
  isInline(): false;
114
74
  decorate(): unknown;
115
75
  }
116
- /** What a write's value reads as: its text and whether it holds a decorator, or why it is refused. */
117
- export type RichTextRead = {
118
- text: string;
119
- holdsDecorator?: boolean;
120
- } | {
121
- refused: string;
122
- type?: string;
123
- };
124
76
  /**
125
77
  * A reader of `richText` values over a node list: the guard walk, then a headless parse.
126
78
  * `uploads` names the kayan an `UploadNode` may point at.
127
79
  */
128
80
  export declare const richTextReader: (nodes: readonly Klass<LexicalNode>[], uploads?: readonly string[]) => (value: unknown) => RichTextRead;
129
- /**
130
- * How a stored `richText` value is read back: a root with children and nothing more,
131
- * so a value holding a node the project has since dropped still reads.
132
- */
133
- export declare const storedRichText: (value: unknown) => RichTextRead;
134
81
  /** What a converter left out of a value: the node's path in what it was handed, its type, and why. */
135
82
  export type RichTextDropped = {
136
83
  path: string;
@@ -4,129 +4,14 @@ import { LinkNode } from '@lexical/link';
4
4
  import { ListItemNode, ListNode } from '@lexical/list';
5
5
  import { HeadingNode, QuoteNode } from '@lexical/rich-text';
6
6
  import { $getDocument, $getRoot, $isDecoratorNode, $isElementNode, $getState, $setState, createEditor, createState, DecoratorNode, LineBreakNode, ParagraphNode, RootNode, TabNode, TextNode, } from 'lexical';
7
- /**
8
- * How deep a rich text value may nest, how large it may be and how many nodes it may hold,
9
- * all checked before any Lexical code reads it.
10
- */
11
- export const richTextBounds = { depth: 64, bytes: 512 * 1024, nodes: 10_000 };
12
- // Depth is checked before `JSON.stringify` walks a hostile value. An object met
13
- // twice is refused, so one reachable many ways is not walked once per way.
14
- const within = (value, depth, seen = new WeakSet()) => {
15
- if (value === null || typeof value !== 'object')
16
- return undefined;
17
- if (depth <= 0)
18
- return `The value nests deeper than ${richTextBounds.depth} levels.`;
19
- if (seen.has(value))
20
- return 'The value holds one object in two places.';
21
- seen.add(value);
22
- for (const one of Object.values(value)) {
23
- const refused = within(one, depth - 1, seen);
24
- if (refused)
25
- return refused;
26
- }
27
- return undefined;
28
- };
29
- /** Whether a link may point here: `http`, `https`, `mailto`, `tel`, a fragment or a path. */
30
- export const safeUrl = (url) => {
31
- if (typeof url !== 'string')
32
- return false;
33
- // eslint-disable-next-line no-control-regex
34
- const cleaned = url.trim().replace(/[\x00-\x1f\x7f]/g, '');
35
- if (cleaned.startsWith('#') || cleaned.startsWith('/') || cleaned.startsWith('./') || cleaned.startsWith('../'))
36
- return true;
37
- const protocol = cleaned.match(/^([a-z][a-z0-9+\-.]*):(?=.)/i)?.[1]?.toLowerCase();
38
- // A web address must name a host: refuses `https://`, `http:///path` (read as
39
- // a host named `path`), `https://@` and the like.
40
- if (protocol === 'http' || protocol === 'https') {
41
- try {
42
- return /^https?:\/\/[^/?#\\\s]/i.test(cleaned) && /[\p{L}\p{N}]/u.test(new URL(cleaned).hostname);
43
- }
44
- catch {
45
- return false;
46
- }
47
- }
48
- return protocol === 'mailto' || protocol === 'tel';
49
- };
50
- const held = (node, key) => node[key];
51
- /**
52
- * Why a value is no Lexical editor state a `richText` column may hold, or nothing. Runs before
53
- * any Lexical code reads the value; a `style` is refused, since no editor of the project writes one.
54
- */
55
- export const richTextGuard = (value) => {
56
- if (!value || typeof value !== 'object' || Array.isArray(value))
57
- return 'The value is no object.';
58
- const nested = within(value, richTextBounds.depth);
59
- if (nested)
60
- return nested;
61
- if (new TextEncoder().encode(JSON.stringify(value)).length > richTextBounds.bytes)
62
- return `The value is larger than ${richTextBounds.bytes} bytes.`;
63
- const root = held(value, 'root');
64
- if (!root || typeof root !== 'object' || Array.isArray(root) || held(root, 'type') !== 'root')
65
- return 'The value has no root of type "root".';
66
- if (!Array.isArray(held(root, 'children')))
67
- return 'The root holds no list of children.';
68
- let count = 0;
69
- const walk = (node) => {
70
- if (!node || typeof node !== 'object' || Array.isArray(node) || typeof held(node, 'type') !== 'string')
71
- return 'A node is no object with a string type.';
72
- if (++count > richTextBounds.nodes)
73
- return `The value holds more than ${richTextBounds.nodes} nodes.`;
74
- const type = held(node, 'type');
75
- if (Object.hasOwn(node, 'text') && typeof held(node, 'text') !== 'string')
76
- return `A node of type "${type}" holds text that is no string.`;
77
- if (typeof held(node, 'text') === 'string') {
78
- for (const key of ['format', 'detail']) {
79
- if (Object.hasOwn(node, key) && !Number.isInteger(held(node, key)))
80
- return `A node of type "${type}" holds a ${key} that is no whole number.`;
81
- }
82
- }
83
- for (const key of ['style', 'textStyle']) {
84
- if (Object.hasOwn(node, key) && held(node, key) !== '')
85
- return `A node of type "${type}" holds a ${key}, and this editor writes none.`;
86
- }
87
- if ((type === 'link' || type === 'autolink') && !safeUrl(held(node, 'url')))
88
- return `A link points at an address that is not http, https, mailto, tel or a path.`;
89
- if (!Object.hasOwn(node, 'children'))
90
- return undefined;
91
- const children = held(node, 'children');
92
- if (!Array.isArray(children))
93
- return `A node of type "${type}" holds children that are no list.`;
94
- for (const child of children) {
95
- const refused = walk(child);
96
- if (refused)
97
- return refused;
98
- }
99
- return undefined;
100
- };
101
- return walk(root);
102
- };
103
- /** Whether a value passes `richTextGuard`, which is all the kind itself asks of one. */
104
- export const isRichText = (value) => richTextGuard(value) === undefined;
7
+ import { richTextBounds, richTextGuard, safeUrl, unknownType } from './value.js';
8
+ export * from './value.js';
105
9
  /** The node classes the registry's editor draws. A project's list spreads it: `[...defaultNodes, CalloutNode]`. */
106
10
  export const defaultNodes = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, HorizontalRuleNode];
107
11
  /** The nodes every Lexical editor registers whatever it is given. */
108
12
  const coreNodes = [RootNode, ParagraphNode, TextNode, LineBreakNode, TabNode];
109
13
  /** Every node type a list of classes can read, the core ones included. */
110
14
  export const typesOf = (nodes) => new Set([...coreNodes, ...nodes].map((node) => node.getType()));
111
- /**
112
- * The first node type under a value's `root` that `types` does not hold, or nothing, and `''`
113
- * past the depth bound. Read off the JSON, since a production Lexical minifies its errors.
114
- */
115
- export const unknownType = ({ node, types, depth = richTextBounds.depth }) => {
116
- if (!node || typeof node !== 'object')
117
- return undefined;
118
- if (depth <= 0)
119
- return '';
120
- const held = node;
121
- if (typeof held.type === 'string' && !types.has(held.type))
122
- return held.type;
123
- for (const child of Array.isArray(held.children) ? held.children : []) {
124
- const type = unknownType({ node: child, types, depth: depth - 1 });
125
- if (type !== undefined)
126
- return type;
127
- }
128
- return undefined;
129
- };
130
15
  // Set by `richTextReader` around its own synchronous parse alone, so a value is
131
16
  // never checked against another build's list.
132
17
  let holdingFiles;
@@ -257,14 +142,6 @@ export const richTextReader = (nodes, uploads) => {
257
142
  }
258
143
  };
259
144
  };
260
- /**
261
- * How a stored `richText` value is read back: a root with children and nothing more,
262
- * so a value holding a node the project has since dropped still reads.
263
- */
264
- export const storedRichText = (value) => {
265
- const root = value && typeof value === 'object' && !Array.isArray(value) ? value.root : undefined;
266
- return root && typeof root === 'object' && root.type === 'root' && Array.isArray(root.children) ? { text: '' } : { refused: 'The stored value is no editor state.' };
267
- };
268
145
  // Canonical through Lexical's own `toJSON`, so converting again changes nothing,
269
146
  // and read once more, since the canonical value is larger.
270
147
  const settle = (convert, nodes) => {
@@ -0,0 +1,55 @@
1
+ /** One node of Lexical's serialized editor state: a `type`, and `children` where it holds any. */
2
+ export type RichTextNode = {
3
+ type: string;
4
+ children?: RichTextNode[];
5
+ text?: string;
6
+ [key: string]: unknown;
7
+ };
8
+ /** What a `richText` field holds: Lexical's `editorState.toJSON()`, a root of `type: 'root'`. */
9
+ export type RichTextValue = {
10
+ root: RichTextNode & {
11
+ type: 'root';
12
+ children: RichTextNode[];
13
+ };
14
+ [key: string]: unknown;
15
+ };
16
+ /**
17
+ * How deep a rich text value may nest, how large it may be and how many nodes it may hold,
18
+ * all checked before any Lexical code reads it.
19
+ */
20
+ export declare const richTextBounds: {
21
+ depth: number;
22
+ bytes: number;
23
+ nodes: number;
24
+ };
25
+ /** Whether a link may point here: `http`, `https`, `mailto`, `tel`, a fragment or a path. */
26
+ export declare const safeUrl: (url: unknown) => boolean;
27
+ /**
28
+ * Why a value is no Lexical editor state a `richText` column may hold, or nothing. Runs before
29
+ * any Lexical code reads the value; a `style` is refused, since no editor of the project writes one.
30
+ */
31
+ export declare const richTextGuard: (value: unknown) => string | undefined;
32
+ /** Whether a value passes `richTextGuard`, which is all the kind itself asks of one. */
33
+ export declare const isRichText: (value: unknown) => value is RichTextValue;
34
+ /**
35
+ * The first node type under a value's `root` that `types` does not hold, or nothing, and `''`
36
+ * past the depth bound. Read off the JSON, since a production Lexical minifies its errors.
37
+ */
38
+ export declare const unknownType: ({ node, types, depth }: {
39
+ node: unknown;
40
+ types: ReadonlySet<string>;
41
+ depth?: number;
42
+ }) => string | undefined;
43
+ /** What a write's value reads as: its text and whether it holds a decorator, or why it is refused. */
44
+ export type RichTextRead = {
45
+ text: string;
46
+ holdsDecorator?: boolean;
47
+ } | {
48
+ refused: string;
49
+ type?: string;
50
+ };
51
+ /**
52
+ * How a stored `richText` value is read back: a root with children and nothing more,
53
+ * so a value holding a node the project has since dropped still reads.
54
+ */
55
+ export declare const storedRichText: (value: unknown) => RichTextRead;
@@ -0,0 +1,126 @@
1
+ // No Lexical import: core reads and checks a stored value with this alone, so a project without rich text installs no Lexical.
2
+ /**
3
+ * How deep a rich text value may nest, how large it may be and how many nodes it may hold,
4
+ * all checked before any Lexical code reads it.
5
+ */
6
+ export const richTextBounds = { depth: 64, bytes: 512 * 1024, nodes: 10_000 };
7
+ // Depth is checked before `JSON.stringify` walks a hostile value. An object met
8
+ // twice is refused, so one reachable many ways is not walked once per way.
9
+ const within = (value, depth, seen = new WeakSet()) => {
10
+ if (value === null || typeof value !== 'object')
11
+ return undefined;
12
+ if (depth <= 0)
13
+ return `The value nests deeper than ${richTextBounds.depth} levels.`;
14
+ if (seen.has(value))
15
+ return 'The value holds one object in two places.';
16
+ seen.add(value);
17
+ for (const one of Object.values(value)) {
18
+ const refused = within(one, depth - 1, seen);
19
+ if (refused)
20
+ return refused;
21
+ }
22
+ return undefined;
23
+ };
24
+ /** Whether a link may point here: `http`, `https`, `mailto`, `tel`, a fragment or a path. */
25
+ export const safeUrl = (url) => {
26
+ if (typeof url !== 'string')
27
+ return false;
28
+ // eslint-disable-next-line no-control-regex
29
+ const cleaned = url.trim().replace(/[\x00-\x1f\x7f]/g, '');
30
+ if (cleaned.startsWith('#') || cleaned.startsWith('/') || cleaned.startsWith('./') || cleaned.startsWith('../'))
31
+ return true;
32
+ const protocol = cleaned.match(/^([a-z][a-z0-9+\-.]*):(?=.)/i)?.[1]?.toLowerCase();
33
+ // A web address must name a host: refuses `https://`, `http:///path` (read as
34
+ // a host named `path`), `https://@` and the like.
35
+ if (protocol === 'http' || protocol === 'https') {
36
+ try {
37
+ return /^https?:\/\/[^/?#\\\s]/i.test(cleaned) && /[\p{L}\p{N}]/u.test(new URL(cleaned).hostname);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ return protocol === 'mailto' || protocol === 'tel';
44
+ };
45
+ const held = (node, key) => node[key];
46
+ /**
47
+ * Why a value is no Lexical editor state a `richText` column may hold, or nothing. Runs before
48
+ * any Lexical code reads the value; a `style` is refused, since no editor of the project writes one.
49
+ */
50
+ export const richTextGuard = (value) => {
51
+ if (!value || typeof value !== 'object' || Array.isArray(value))
52
+ return 'The value is no object.';
53
+ const nested = within(value, richTextBounds.depth);
54
+ if (nested)
55
+ return nested;
56
+ if (new TextEncoder().encode(JSON.stringify(value)).length > richTextBounds.bytes)
57
+ return `The value is larger than ${richTextBounds.bytes} bytes.`;
58
+ const root = held(value, 'root');
59
+ if (!root || typeof root !== 'object' || Array.isArray(root) || held(root, 'type') !== 'root')
60
+ return 'The value has no root of type "root".';
61
+ if (!Array.isArray(held(root, 'children')))
62
+ return 'The root holds no list of children.';
63
+ let count = 0;
64
+ const walk = (node) => {
65
+ if (!node || typeof node !== 'object' || Array.isArray(node) || typeof held(node, 'type') !== 'string')
66
+ return 'A node is no object with a string type.';
67
+ if (++count > richTextBounds.nodes)
68
+ return `The value holds more than ${richTextBounds.nodes} nodes.`;
69
+ const type = held(node, 'type');
70
+ if (Object.hasOwn(node, 'text') && typeof held(node, 'text') !== 'string')
71
+ return `A node of type "${type}" holds text that is no string.`;
72
+ if (typeof held(node, 'text') === 'string') {
73
+ for (const key of ['format', 'detail']) {
74
+ if (Object.hasOwn(node, key) && !Number.isInteger(held(node, key)))
75
+ return `A node of type "${type}" holds a ${key} that is no whole number.`;
76
+ }
77
+ }
78
+ for (const key of ['style', 'textStyle']) {
79
+ if (Object.hasOwn(node, key) && held(node, key) !== '')
80
+ return `A node of type "${type}" holds a ${key}, and this editor writes none.`;
81
+ }
82
+ if ((type === 'link' || type === 'autolink') && !safeUrl(held(node, 'url')))
83
+ return `A link points at an address that is not http, https, mailto, tel or a path.`;
84
+ if (!Object.hasOwn(node, 'children'))
85
+ return undefined;
86
+ const children = held(node, 'children');
87
+ if (!Array.isArray(children))
88
+ return `A node of type "${type}" holds children that are no list.`;
89
+ for (const child of children) {
90
+ const refused = walk(child);
91
+ if (refused)
92
+ return refused;
93
+ }
94
+ return undefined;
95
+ };
96
+ return walk(root);
97
+ };
98
+ /** Whether a value passes `richTextGuard`, which is all the kind itself asks of one. */
99
+ export const isRichText = (value) => richTextGuard(value) === undefined;
100
+ /**
101
+ * The first node type under a value's `root` that `types` does not hold, or nothing, and `''`
102
+ * past the depth bound. Read off the JSON, since a production Lexical minifies its errors.
103
+ */
104
+ export const unknownType = ({ node, types, depth = richTextBounds.depth }) => {
105
+ if (!node || typeof node !== 'object')
106
+ return undefined;
107
+ if (depth <= 0)
108
+ return '';
109
+ const held = node;
110
+ if (typeof held.type === 'string' && !types.has(held.type))
111
+ return held.type;
112
+ for (const child of Array.isArray(held.children) ? held.children : []) {
113
+ const type = unknownType({ node: child, types, depth: depth - 1 });
114
+ if (type !== undefined)
115
+ return type;
116
+ }
117
+ return undefined;
118
+ };
119
+ /**
120
+ * How a stored `richText` value is read back: a root with children and nothing more,
121
+ * so a value holding a node the project has since dropped still reads.
122
+ */
123
+ export const storedRichText = (value) => {
124
+ const root = value && typeof value === 'object' && !Array.isArray(value) ? value.root : undefined;
125
+ return root && typeof root === 'object' && root.type === 'root' && Array.isArray(root.children) ? { text: '' } : { refused: 'The stored value is no editor state.' };
126
+ };
@@ -1,6 +1,8 @@
1
+ export { flattenMessageFile } from '../mithaq/message-file.ts';
1
2
  export { afterCommitHeader } from '../asas/runner/errors.ts';
2
3
  export { allowedType } from '../storage/allowed-type.ts';
3
4
  export { compareDecimals, decimalDigits, isDecimal } from '../base/decimal.ts';
5
+ export { isJson } from '../base/kinds.ts';
4
6
  export type { ButtonClient, ClientField, ClientKayan, FrameClient, LeafClient, NavEntry, NodeClient, PageClient, RegionsClient, ScreenClient, ScreenPageClient, } from './index.ts';
5
7
  import type { ClientField, NavEntry, PageClient, ScreenClient } from './index.ts';
6
8
  export { compareValues, matchesWhere, same } from '../mithaq/where.ts';
@@ -130,6 +132,7 @@ export declare const builtInScreenKinds: {
130
132
  readonly date: readonly ["date"];
131
133
  readonly textArray: readonly ["textArray"];
132
134
  readonly uuid: readonly ["uuid"];
135
+ readonly json: readonly ["json"];
133
136
  readonly relationship: readonly ["relationship", "upload"];
134
137
  readonly join: readonly ["join"];
135
138
  readonly switch: readonly ["checkbox"];
@@ -147,7 +150,7 @@ export declare const builtInScreenKinds: {
147
150
  readonly calendar: readonly ["date"];
148
151
  };
149
152
  /** Every control and cell the copied registry ships, by the name it looks each up by. */
150
- export declare const registryControls: readonly [...("buttonGroup" | "calendar" | "checkbox" | "checkboxGroup" | "combobox" | "date" | "datetime" | "decimal" | "email" | "integer" | "nativeSelect" | "number" | "otp" | "radio" | "relationship" | "select" | "slider" | "switch" | "text" | "textArray" | "textarea" | "toggle" | "toggleGroup" | "uuid")[], "richText", "upload"];
151
- export declare const registryCells: readonly ['text', 'email', 'number', 'integer', 'decimal', 'checkbox', 'datetime', 'date', 'textArray', 'uuid', 'relationship', 'join', 'richText', 'upload'];
153
+ export declare const registryControls: readonly [...("buttonGroup" | "calendar" | "checkbox" | "checkboxGroup" | "combobox" | "date" | "datetime" | "decimal" | "email" | "integer" | "json" | "nativeSelect" | "number" | "otp" | "radio" | "relationship" | "select" | "slider" | "switch" | "text" | "textArray" | "textarea" | "toggle" | "toggleGroup" | "uuid")[], "richText", "upload"];
154
+ export declare const registryCells: readonly ['text', 'email', 'number', 'integer', 'decimal', 'checkbox', 'datetime', 'date', 'textArray', 'uuid', 'json', 'relationship', 'join', 'richText', 'upload'];
152
155
  /** The built-in controls that draw a field's `options`; the build refuses one over a field that declares none. */
153
156
  export declare const choiceControls: string[];
@@ -1,8 +1,10 @@
1
1
  // Every runtime import of this entry imports nothing but types, so a browser
2
2
  // receives no server code and no framework.
3
+ export { flattenMessageFile } from '../mithaq/message-file.js';
3
4
  export { afterCommitHeader } from '../asas/runner/errors.js';
4
5
  export { allowedType } from '../storage/allowed-type.js';
5
6
  export { compareDecimals, decimalDigits, isDecimal } from '../base/decimal.js';
7
+ export { isJson } from '../base/kinds.js';
6
8
  export { compareValues, matchesWhere, same } from '../mithaq/where.js';
7
9
  /** Whether the access report lets its actor hold one catalog key: a screen's, an operation's, or any other key. */
8
10
  export const holds = (report, key) => {
@@ -15,7 +17,7 @@ export const holds = (report, key) => {
15
17
  return operations[action] === true;
16
18
  return report.permissions === '*' || report.permissions?.includes(key) === true;
17
19
  };
18
- // A reference prints the package name into a glob's base, `/node_modules/<name>/`,
20
+ // `mithaq generate screens` looks a reference's package up as `node_modules/<name>/`,
19
21
  // so no path up, absolute path or space may cross.
20
22
  const npmName = '(?:@[a-z0-9~-][a-z0-9._~-]*/)?[a-z0-9~-][a-z0-9._~-]*';
21
23
  export const packageName = new RegExp(`^${npmName}$`, 'i');
@@ -40,6 +42,7 @@ export const builtInScreenKinds = {
40
42
  date: ['date'],
41
43
  textArray: ['textArray'],
42
44
  uuid: ['uuid'],
45
+ json: ['json'],
43
46
  // A file is a row of a storage kayan, so the picker of rows draws it too.
44
47
  relationship: ['relationship', 'upload'],
45
48
  join: ['join'],
@@ -63,6 +66,6 @@ export const registryControls = [
63
66
  'richText',
64
67
  'upload',
65
68
  ];
66
- export const registryCells = ['text', 'email', 'number', 'integer', 'decimal', 'checkbox', 'datetime', 'date', 'textArray', 'uuid', 'relationship', 'join', 'richText', 'upload'];
69
+ export const registryCells = ['text', 'email', 'number', 'integer', 'decimal', 'checkbox', 'datetime', 'date', 'textArray', 'uuid', 'json', 'relationship', 'join', 'richText', 'upload'];
67
70
  /** The built-in controls that draw a field's `options`; the build refuses one over a field that declares none. */
68
71
  export const choiceControls = ['radio', 'toggleGroup', 'select', 'nativeSelect', 'combobox', 'buttonGroup', 'checkboxGroup'];