@jigs.design/host 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/dist/catalog.d.ts +143 -0
- package/dist/catalog.js +99 -0
- package/dist/guidance.d.ts +44 -0
- package/dist/guidance.js +52 -0
- package/dist/host.d.ts +88 -0
- package/dist/host.js +104 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/procedures.d.ts +148 -0
- package/dist/procedures.js +61 -0
- package/dist/recipes.d.ts +24 -0
- package/dist/recipes.js +79 -0
- package/package.json +33 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Bates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jigs.design/host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Defines a host: the contract between your product and the model. A host lists the components a spec may draw, the recipes built from them, the procedures it may call, the image hosts it may load from, and the design guidance and situations the model designs by. It holds schemas, not the code that runs a procedure.
|
|
4
|
+
|
|
5
|
+
Import it from server code and config. No browser package imports it; the browser imports `ActionEvent` as a type at most.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { catalog } from "@jigs.design/catalog";
|
|
9
|
+
import { defineHost, mutation, query } from "@jigs.design/host";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
export const host = defineHost({
|
|
13
|
+
components: catalog,
|
|
14
|
+
procedures: {
|
|
15
|
+
orders: query({
|
|
16
|
+
input: z.object({ status: z.enum(["open", "archived"]) }),
|
|
17
|
+
output: z.array(z.object({ id: z.string(), total: z.number() })),
|
|
18
|
+
description: "Lists the caller's orders with the given status.",
|
|
19
|
+
}),
|
|
20
|
+
archiveOrder: mutation({
|
|
21
|
+
input: z.object({ id: z.string() }),
|
|
22
|
+
invalidates: ["orders"],
|
|
23
|
+
description: "Archives one of the caller's orders.",
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Exports
|
|
30
|
+
|
|
31
|
+
| Export | What it is |
|
|
32
|
+
| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `defineHost` | Builds a host from `components`, `procedures`, `recipes`, `imageHosts`, `guidance` and `situations` |
|
|
34
|
+
| `query`, `mutation`, `action` | The three kinds of procedure. An action is handled by the embedding page |
|
|
35
|
+
| `recipe` | A compound component written in Pickle from catalog components |
|
|
36
|
+
| `situation` | Design guidance for when the reader has a given job, with a checked example |
|
|
37
|
+
| `defineCatalog`, `defineComponent` | Build your own catalog |
|
|
38
|
+
| `string`, `number`, `boolean`, `oneOf`, `handler`, `image`, `link`, `list`, `block`, `inline`, `none` | Prop and children builders for components |
|
|
39
|
+
| `contentSecurityPolicy` | The page's `frame-src` and `img-src` directives, merged into yours |
|
|
40
|
+
| `CatalogProps`, `PropsOf`, `ActionEvent` | Types for a registry and for a page's `onAction` |
|
|
41
|
+
|
|
42
|
+
## Source
|
|
43
|
+
|
|
44
|
+
- `src/host.ts`: `defineHost`, `contentSecurityPolicy` and `ActionEvent`.
|
|
45
|
+
- `src/catalog.ts`: catalogs, components and the prop builders.
|
|
46
|
+
- `src/procedures.ts`: `query`, `mutation`, `action` and invalidation scopes.
|
|
47
|
+
- `src/recipes.ts`: `recipe`, compiled against the host's catalog.
|
|
48
|
+
- `src/guidance.ts`: `situation` and the design guidance types; the host's replace the catalog's.
|
|
49
|
+
|
|
50
|
+
## Docs
|
|
51
|
+
|
|
52
|
+
[Host](../../apps/docs/docs/host/index.mdx), [Catalog](../../apps/docs/docs/host/catalog.mdx), [Recipes](../../apps/docs/docs/host/recipes.mdx), [Design guidance](../../apps/docs/docs/host/guidance.mdx), [Queries](../../apps/docs/docs/host/queries.mdx), [Mutations](../../apps/docs/docs/host/mutations.mdx), [Actions](../../apps/docs/docs/host/actions.mdx).
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { DesignGuidance, GuidanceInput, SituationDefinition } from "./guidance.js";
|
|
2
|
+
import { ChildrenKind, ComponentSchema, PropSchema, Row } from "@jigs.design/runtime";
|
|
3
|
+
//#region src/catalog.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* One prop of a catalog component. `schema` is what the browser checks;
|
|
6
|
+
* `value` exists only in types, so `PropsOf` can type a registry component.
|
|
7
|
+
*/
|
|
8
|
+
export interface PropDefinition<TValue> {
|
|
9
|
+
/** For a handler: the props the component calls it with, in order, which the prompt prints as its signature. */
|
|
10
|
+
args?: ReadonlyArray<PropDefinition<unknown>>;
|
|
11
|
+
/** Pairs a value prop with its change handler, e.g. `value` with `onChange`. */
|
|
12
|
+
controlled?: string;
|
|
13
|
+
default?: boolean | number | string;
|
|
14
|
+
description?: string;
|
|
15
|
+
schema: PropSchema;
|
|
16
|
+
/**
|
|
17
|
+
* What the prompt calls an enum's values when it declares them once
|
|
18
|
+
* under Types, e.g. `ChartColor`. Without it, the prompt names them.
|
|
19
|
+
*/
|
|
20
|
+
typeName?: string;
|
|
21
|
+
readonly value?: TValue;
|
|
22
|
+
}
|
|
23
|
+
interface PropOptions<TDefault> {
|
|
24
|
+
default?: TDefault;
|
|
25
|
+
description?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function string(options?: PropOptions<string> & {
|
|
28
|
+
controlled?: string;
|
|
29
|
+
}): PropDefinition<string>;
|
|
30
|
+
export declare function number(options?: PropOptions<number> & {
|
|
31
|
+
controlled?: string;
|
|
32
|
+
max?: number;
|
|
33
|
+
min?: number;
|
|
34
|
+
}): PropDefinition<number>;
|
|
35
|
+
export declare function boolean(options?: PropOptions<boolean> & {
|
|
36
|
+
controlled?: string;
|
|
37
|
+
}): PropDefinition<boolean>;
|
|
38
|
+
export declare function oneOf<const TValues extends ReadonlyArray<string>>(values: TValues, options?: PropOptions<TValues[number]> & {
|
|
39
|
+
controlled?: string;
|
|
40
|
+
typeName?: string;
|
|
41
|
+
}): PropDefinition<TValues[number]>;
|
|
42
|
+
/** An `https` URL on one of the host's image hosts. */
|
|
43
|
+
export declare function image(options?: PropOptions<string>): PropDefinition<string>;
|
|
44
|
+
/** An `http` or `https` URL that loads nothing until the user clicks it. */
|
|
45
|
+
export declare function link(options?: PropOptions<string>): PropDefinition<string>;
|
|
46
|
+
/** A list of flat objects, such as chart rows or select options. */
|
|
47
|
+
export declare function list(options?: PropOptions<never>): PropDefinition<ReadonlyArray<Row>>;
|
|
48
|
+
type ValueOfDefinition<D> = D extends PropDefinition<infer V> ? V : never;
|
|
49
|
+
type HandlerArgs<TArgs extends ReadonlyArray<PropDefinition<unknown>>> = { [K in keyof TArgs]: ValueOfDefinition<TArgs[K]>; };
|
|
50
|
+
/**
|
|
51
|
+
* A function prop. `args` are what the component passes back to the
|
|
52
|
+
* program: they type the registry component's prop, and the prompt prints
|
|
53
|
+
* them as the handler's signature, `(value: number) => void`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function handler<const TArgs extends ReadonlyArray<PropDefinition<unknown>> = []>(args?: TArgs, options?: PropOptions<never>): PropDefinition<(...values: HandlerArgs<TArgs>) => void>;
|
|
56
|
+
export declare const none: () => ChildrenKind;
|
|
57
|
+
export declare const inline: () => ChildrenKind;
|
|
58
|
+
export declare const block: () => ChildrenKind;
|
|
59
|
+
export type PropDefinitions = Readonly<Record<string, PropDefinition<unknown>>>;
|
|
60
|
+
/**
|
|
61
|
+
* What a component is to the server's design checks. Only the catalog knows
|
|
62
|
+
* which of its components is a card or a button, and which props make a
|
|
63
|
+
* button the primary one, so it says so here.
|
|
64
|
+
*/
|
|
65
|
+
export type ComponentDesign = {
|
|
66
|
+
/**
|
|
67
|
+
* The prop values that make it the primary action, with the props'
|
|
68
|
+
* defaults filled in, e.g. `{ variant: "solid", color: "primary" }`.
|
|
69
|
+
* A view has at most one.
|
|
70
|
+
*/
|
|
71
|
+
primary?: Readonly<Record<string, boolean | number | string>>;
|
|
72
|
+
/**
|
|
73
|
+
* A control the reader acts with. Each counts toward a chat block's
|
|
74
|
+
* limit of two; one with no children needs its `ariaLabel`.
|
|
75
|
+
*/
|
|
76
|
+
role: "action";
|
|
77
|
+
} | {
|
|
78
|
+
/**
|
|
79
|
+
* `chart` draws data and needs its `ariaLabel`; `field` takes input
|
|
80
|
+
* and needs a `label` or an `ariaLabel`; `surface` is a card, and
|
|
81
|
+
* one never sits inside another.
|
|
82
|
+
*/
|
|
83
|
+
role: "chart" | "field" | "surface";
|
|
84
|
+
};
|
|
85
|
+
export interface ComponentDefinition<TName extends string = string, TProps extends PropDefinitions = PropDefinitions> {
|
|
86
|
+
/** Plausible wrong uses, and the concrete thing to reach for instead. */
|
|
87
|
+
avoid: ReadonlyArray<{
|
|
88
|
+
useInstead: string;
|
|
89
|
+
when: string;
|
|
90
|
+
}>;
|
|
91
|
+
children: ChildrenKind;
|
|
92
|
+
description: string;
|
|
93
|
+
/** What the design checks take it for; a component without one is none of those. */
|
|
94
|
+
design?: ComponentDesign;
|
|
95
|
+
name: TName;
|
|
96
|
+
props: TProps;
|
|
97
|
+
}
|
|
98
|
+
export declare function defineComponent<const TName extends string, const TProps extends PropDefinitions>(name: TName, definition: {
|
|
99
|
+
avoid?: ReadonlyArray<{
|
|
100
|
+
useInstead: string;
|
|
101
|
+
when: string;
|
|
102
|
+
}>;
|
|
103
|
+
children: ChildrenKind;
|
|
104
|
+
description: string;
|
|
105
|
+
design?: ComponentDesign;
|
|
106
|
+
props: TProps;
|
|
107
|
+
}): ComponentDefinition<TName, TProps>;
|
|
108
|
+
export interface Catalog<TComponents extends ReadonlyArray<ComponentDefinition> = ReadonlyArray<ComponentDefinition>> {
|
|
109
|
+
components: TComponents;
|
|
110
|
+
/**
|
|
111
|
+
* Design guidance for every UI drawn with this catalog. Not part of
|
|
112
|
+
* `version`: it changes no compiled program.
|
|
113
|
+
*/
|
|
114
|
+
guidance: DesignGuidance;
|
|
115
|
+
/** Guidance for what the reader will do with a UI; the model picks by `when`. */
|
|
116
|
+
situations: ReadonlyArray<SituationDefinition>;
|
|
117
|
+
/** Raise it when a component or a prop changes. */
|
|
118
|
+
version: number;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A catalog. Throws on two components with one name, a version that is not
|
|
122
|
+
* a non-negative integer, or a situation name that is not kebab-case or is
|
|
123
|
+
* listed twice.
|
|
124
|
+
*/
|
|
125
|
+
export declare function defineCatalog<const TComponents extends ReadonlyArray<ComponentDefinition>>(definition: {
|
|
126
|
+
components: TComponents;
|
|
127
|
+
/** A string for every placement, or `{ shared, chat, document }`. */
|
|
128
|
+
guidance?: GuidanceInput;
|
|
129
|
+
situations?: ReadonlyArray<SituationDefinition>;
|
|
130
|
+
version: number;
|
|
131
|
+
}): Catalog<TComponents>;
|
|
132
|
+
/** The props a registry component receives for a catalog component. */
|
|
133
|
+
export type PropsOf<C extends ComponentDefinition> = { [K in keyof C["props"]]?: ValueOfDefinition<C["props"][K]>; };
|
|
134
|
+
/** The props of every component in a catalog, by tag name. Type a registry with it. */
|
|
135
|
+
export type CatalogProps<TCatalog extends Catalog> = { [K in TCatalog["components"][number]["name"]]: PropsOf<ComponentNamed<TCatalog, K>>; };
|
|
136
|
+
/** A catalog component by name. */
|
|
137
|
+
export type ComponentNamed<TCatalog extends Catalog, TName extends string> = Extract<TCatalog["components"][number], {
|
|
138
|
+
name: TName;
|
|
139
|
+
}>;
|
|
140
|
+
/** What the browser checks for a component. */
|
|
141
|
+
export declare function componentSchema(component: ComponentDefinition): ComponentSchema;
|
|
142
|
+
//#endregion
|
|
143
|
+
export {};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { checkSituations, normalizeGuidance } from "./guidance.js";
|
|
2
|
+
//#region src/catalog.ts
|
|
3
|
+
function prop(schema, options) {
|
|
4
|
+
const definition = { schema };
|
|
5
|
+
if (options.controlled !== void 0) definition.controlled = options.controlled;
|
|
6
|
+
if (options.default !== void 0) definition.default = options.default;
|
|
7
|
+
if (options.description !== void 0) definition.description = options.description;
|
|
8
|
+
if (options.typeName !== void 0) definition.typeName = options.typeName;
|
|
9
|
+
return definition;
|
|
10
|
+
}
|
|
11
|
+
function string(options = {}) {
|
|
12
|
+
return prop({ kind: "string" }, options);
|
|
13
|
+
}
|
|
14
|
+
function number(options = {}) {
|
|
15
|
+
const schema = { kind: "number" };
|
|
16
|
+
if (options.min !== void 0) schema.min = options.min;
|
|
17
|
+
if (options.max !== void 0) schema.max = options.max;
|
|
18
|
+
return prop(schema, options);
|
|
19
|
+
}
|
|
20
|
+
function boolean(options = {}) {
|
|
21
|
+
return prop({ kind: "boolean" }, options);
|
|
22
|
+
}
|
|
23
|
+
function oneOf(values, options = {}) {
|
|
24
|
+
return prop({
|
|
25
|
+
kind: "enum",
|
|
26
|
+
values
|
|
27
|
+
}, options);
|
|
28
|
+
}
|
|
29
|
+
/** An `https` URL on one of the host's image hosts. */
|
|
30
|
+
function image(options = {}) {
|
|
31
|
+
return prop({ kind: "image" }, options);
|
|
32
|
+
}
|
|
33
|
+
/** An `http` or `https` URL that loads nothing until the user clicks it. */
|
|
34
|
+
function link(options = {}) {
|
|
35
|
+
return prop({ kind: "link" }, options);
|
|
36
|
+
}
|
|
37
|
+
/** A list of flat objects, such as chart rows or select options. */
|
|
38
|
+
function list(options = {}) {
|
|
39
|
+
return prop({ kind: "list" }, options);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A function prop. `args` are what the component passes back to the
|
|
43
|
+
* program: they type the registry component's prop, and the prompt prints
|
|
44
|
+
* them as the handler's signature, `(value: number) => void`.
|
|
45
|
+
*/
|
|
46
|
+
function handler(args, options = {}) {
|
|
47
|
+
const definition = prop({ kind: "handler" }, options);
|
|
48
|
+
return args === void 0 || args.length === 0 ? definition : {
|
|
49
|
+
...definition,
|
|
50
|
+
args
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const none = () => "none";
|
|
54
|
+
const inline = () => "inline";
|
|
55
|
+
const block = () => "block";
|
|
56
|
+
function defineComponent(name, definition) {
|
|
57
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(name)) throw new Error(`Component name "${name}" must start with a lowercase letter and use letters and digits only`);
|
|
58
|
+
const component = {
|
|
59
|
+
avoid: definition.avoid ?? [],
|
|
60
|
+
children: definition.children,
|
|
61
|
+
description: definition.description,
|
|
62
|
+
name,
|
|
63
|
+
props: definition.props
|
|
64
|
+
};
|
|
65
|
+
if (definition.design !== void 0) component.design = definition.design;
|
|
66
|
+
return component;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A catalog. Throws on two components with one name, a version that is not
|
|
70
|
+
* a non-negative integer, or a situation name that is not kebab-case or is
|
|
71
|
+
* listed twice.
|
|
72
|
+
*/
|
|
73
|
+
function defineCatalog(definition) {
|
|
74
|
+
if (!Number.isInteger(definition.version) || definition.version < 0) throw new Error("Catalog version must be a non-negative integer");
|
|
75
|
+
const seen = /* @__PURE__ */ new Set();
|
|
76
|
+
for (const component of definition.components) {
|
|
77
|
+
if (seen.has(component.name)) throw new Error(`Catalog lists <${component.name}> twice`);
|
|
78
|
+
seen.add(component.name);
|
|
79
|
+
}
|
|
80
|
+
const situations = definition.situations ?? [];
|
|
81
|
+
checkSituations(situations);
|
|
82
|
+
return {
|
|
83
|
+
components: definition.components,
|
|
84
|
+
guidance: normalizeGuidance(definition.guidance),
|
|
85
|
+
situations,
|
|
86
|
+
version: definition.version
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** What the browser checks for a component. */
|
|
90
|
+
function componentSchema(component) {
|
|
91
|
+
const props = {};
|
|
92
|
+
for (const [name, definition] of Object.entries(component.props)) props[name] = definition.schema;
|
|
93
|
+
return {
|
|
94
|
+
children: component.children,
|
|
95
|
+
props
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
export { block, boolean, componentSchema, defineCatalog, defineComponent, handler, image, inline, link, list, none, number, oneOf, string };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/guidance.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Design guidance: how to design a good UI with a catalog, not only a valid
|
|
4
|
+
* one. Foundations apply to every UI; a situation applies when its `when`
|
|
5
|
+
* matches what the reader will do with the UI. See
|
|
6
|
+
* `docs/design-guidance/rfc.md`.
|
|
7
|
+
*/
|
|
8
|
+
/** Guidance for every placement (`shared`), and for one prompt mode each. */
|
|
9
|
+
export interface DesignGuidance {
|
|
10
|
+
/** A UI in a chat reply. */
|
|
11
|
+
chat?: string;
|
|
12
|
+
/** A UI that is the whole reply. */
|
|
13
|
+
document?: string;
|
|
14
|
+
/** Every UI, in either mode. */
|
|
15
|
+
shared?: string;
|
|
16
|
+
}
|
|
17
|
+
/** What `guidance` takes: a string, for every placement, or the three parts. */
|
|
18
|
+
export type GuidanceInput = DesignGuidance | string;
|
|
19
|
+
/**
|
|
20
|
+
* Conditional design guidance, keyed on what the reader does with the UI.
|
|
21
|
+
* The prompt lists every situation; the model follows the one whose `when`
|
|
22
|
+
* matches.
|
|
23
|
+
*/
|
|
24
|
+
export interface SituationDefinition {
|
|
25
|
+
/** Pickle the server compiles against the host at startup. */
|
|
26
|
+
example?: string;
|
|
27
|
+
/** How to design for this situation, stating where it differs from the foundations. */
|
|
28
|
+
guidance: string;
|
|
29
|
+
/** Kebab-case label for apps, logs and evals. The model selects on `when`. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** What the reader will do with the UI, as a sentence. */
|
|
32
|
+
when: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A situation. Throws when `name` is not kebab-case, or `when` or `guidance`
|
|
36
|
+
* is empty. The server compiles `example` at startup.
|
|
37
|
+
*/
|
|
38
|
+
export declare function situation(definition: {
|
|
39
|
+
example?: string;
|
|
40
|
+
guidance: string;
|
|
41
|
+
name: string;
|
|
42
|
+
when: string;
|
|
43
|
+
}): SituationDefinition;
|
|
44
|
+
//#endregion
|
package/dist/guidance.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/guidance.ts
|
|
2
|
+
const SITUATION_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3
|
+
/** Throws unless `name` is kebab-case: lower-case letters and digits, single hyphens. */
|
|
4
|
+
function checkSituationName(name) {
|
|
5
|
+
if (!SITUATION_NAME.test(name)) throw new Error(`Situation name "${name}" must be kebab-case: lower-case letters and digits, joined by single hyphens`);
|
|
6
|
+
}
|
|
7
|
+
function isSharedOnly(input) {
|
|
8
|
+
return typeof input === "string";
|
|
9
|
+
}
|
|
10
|
+
/** Guidance with each part trimmed and empty parts left out. */
|
|
11
|
+
function normalizeGuidance(input) {
|
|
12
|
+
const given = input ?? {};
|
|
13
|
+
const parts = isSharedOnly(given) ? { shared: given } : given;
|
|
14
|
+
const out = {};
|
|
15
|
+
const chat = parts.chat?.trim() ?? "";
|
|
16
|
+
const document = parts.document?.trim() ?? "";
|
|
17
|
+
const shared = parts.shared?.trim() ?? "";
|
|
18
|
+
if (chat !== "") out.chat = chat;
|
|
19
|
+
if (document !== "") out.document = document;
|
|
20
|
+
if (shared !== "") out.shared = shared;
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A situation. Throws when `name` is not kebab-case, or `when` or `guidance`
|
|
25
|
+
* is empty. The server compiles `example` at startup.
|
|
26
|
+
*/
|
|
27
|
+
function situation(definition) {
|
|
28
|
+
checkSituationName(definition.name);
|
|
29
|
+
const when = definition.when.trim();
|
|
30
|
+
const guidance = definition.guidance.trim();
|
|
31
|
+
if (when === "") throw new Error(`Situation "${definition.name}" needs a \`when\``);
|
|
32
|
+
if (guidance === "") throw new Error(`Situation "${definition.name}" needs \`guidance\``);
|
|
33
|
+
const built = {
|
|
34
|
+
guidance,
|
|
35
|
+
name: definition.name,
|
|
36
|
+
when
|
|
37
|
+
};
|
|
38
|
+
const example = definition.example?.trim() ?? "";
|
|
39
|
+
if (example !== "") built.example = example;
|
|
40
|
+
return built;
|
|
41
|
+
}
|
|
42
|
+
/** Throws on a situation name that is not kebab-case or is listed twice. */
|
|
43
|
+
function checkSituations(situations) {
|
|
44
|
+
const seen = /* @__PURE__ */ new Set();
|
|
45
|
+
for (const entry of situations) {
|
|
46
|
+
checkSituationName(entry.name);
|
|
47
|
+
if (seen.has(entry.name)) throw new Error(`Situation "${entry.name}" is listed twice`);
|
|
48
|
+
seen.add(entry.name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { checkSituations, normalizeGuidance, situation };
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { DesignGuidance, GuidanceInput, SituationDefinition } from "./guidance.js";
|
|
2
|
+
import { Catalog } from "./catalog.js";
|
|
3
|
+
import { ActionDefinition, MutationDefinition, Parsed, Procedures, QueryDefinition } from "./procedures.js";
|
|
4
|
+
import { RecipeDefinition } from "./recipes.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/host.d.ts
|
|
7
|
+
export interface Host<TProcedures extends Procedures = Procedures, TCatalog extends Catalog = Catalog> {
|
|
8
|
+
components: TCatalog;
|
|
9
|
+
/** The host's design guidance, or the catalog's when the host sets none. */
|
|
10
|
+
guidance: DesignGuidance;
|
|
11
|
+
imageHosts: ReadonlyArray<string>;
|
|
12
|
+
procedures: TProcedures;
|
|
13
|
+
/** Compound components written in Pickle; the server checks them at startup. */
|
|
14
|
+
recipes: ReadonlyArray<RecipeDefinition>;
|
|
15
|
+
/** The host's situations, or the catalog's when the host sets none. */
|
|
16
|
+
situations: ReadonlyArray<SituationDefinition>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What a page's `onAction` receives for a host: one member per action, with
|
|
20
|
+
* the input as its schema parsed it. Import it as a type in the browser:
|
|
21
|
+
* `onAction={(action: ActionEvent<typeof host>) => …}`.
|
|
22
|
+
*/
|
|
23
|
+
export type ActionEvent<THost extends {
|
|
24
|
+
procedures: Procedures;
|
|
25
|
+
}> = { [K in keyof THost["procedures"] & string]: THost["procedures"][K] extends {
|
|
26
|
+
input: infer I extends z.ZodType | undefined;
|
|
27
|
+
kind: ActionDefinition["kind"];
|
|
28
|
+
} ? {
|
|
29
|
+
input: Parsed<I>;
|
|
30
|
+
name: K;
|
|
31
|
+
} : never; }[keyof THost["procedures"] & string];
|
|
32
|
+
/**
|
|
33
|
+
* What a scope may return for query `Q`: an input as the view would send it
|
|
34
|
+
* (the view's queries whose input contains it refresh), an array of those,
|
|
35
|
+
* or `true`.
|
|
36
|
+
*/
|
|
37
|
+
type ScopeResult<Q extends QueryDefinition> = true | QueryInput<Q> | ReadonlyArray<QueryInput<Q>>;
|
|
38
|
+
type QueryInput<Q extends QueryDefinition> = Q["input"] extends z.ZodType ? z.input<Q["input"]> : undefined;
|
|
39
|
+
type CheckedScopes<TProcedures extends Procedures, I extends z.ZodType | undefined, O extends z.ZodType | undefined, S> = { readonly [Q in keyof S]: Q extends keyof TProcedures ? TProcedures[Q] extends QueryDefinition ? true | ((input: Parsed<I>, output: Parsed<O>) => ScopeResult<TProcedures[Q]>) : never : never; };
|
|
40
|
+
/** Each mutation's scopes, with returns checked against their query's input. */
|
|
41
|
+
type CheckedProcedures<TProcedures extends Procedures> = { readonly [K in keyof TProcedures]: TProcedures[K] extends MutationDefinition<infer I, infer O, infer S> ? {
|
|
42
|
+
scopes: CheckedScopes<TProcedures, I, O, S>;
|
|
43
|
+
} : TProcedures[K]; };
|
|
44
|
+
/**
|
|
45
|
+
* The product's boundary with the spec. Throws at startup when a schema has
|
|
46
|
+
* no JSON form, `invalidates` names something that is not a query, a
|
|
47
|
+
* procedure name is not an identifier, an image host is malformed, or a
|
|
48
|
+
* situation name is not kebab-case or is listed twice.
|
|
49
|
+
*
|
|
50
|
+
* `guidance` and `situations` replace the catalog's when set; to extend the
|
|
51
|
+
* catalog's, spread them in.
|
|
52
|
+
*/
|
|
53
|
+
export declare function defineHost<const TProcedures extends Procedures, const TCatalog extends Catalog>(definition: {
|
|
54
|
+
components: TCatalog;
|
|
55
|
+
/** A string for every placement, or `{ shared, chat, document }`. */
|
|
56
|
+
guidance?: GuidanceInput;
|
|
57
|
+
imageHosts?: ReadonlyArray<string>;
|
|
58
|
+
procedures: TProcedures & CheckedProcedures<TProcedures>;
|
|
59
|
+
recipes?: ReadonlyArray<RecipeDefinition>;
|
|
60
|
+
situations?: ReadonlyArray<SituationDefinition>;
|
|
61
|
+
}): Host<TProcedures, TCatalog>;
|
|
62
|
+
/** A policy as directive names and their sources, e.g. `{ "img-src": ["'self'"] }`. */
|
|
63
|
+
export type PolicyDirectives = Readonly<Record<string, ReadonlyArray<string>>>;
|
|
64
|
+
/** The embedding page's policy, and where it must not be sent. */
|
|
65
|
+
export interface PagePolicy {
|
|
66
|
+
/**
|
|
67
|
+
* False for the endpoint, true for every other URL. The endpoint serves the
|
|
68
|
+
* sandbox frame with its own policy; sending the page's there replaces it,
|
|
69
|
+
* and the frame then refuses to run.
|
|
70
|
+
*/
|
|
71
|
+
appliesTo: (url: string | URL) => boolean;
|
|
72
|
+
/** The page's directives with the ones Pickle needs merged in. */
|
|
73
|
+
directives: PolicyDirectives;
|
|
74
|
+
/** `directives` as one `Content-Security-Policy` header value. */
|
|
75
|
+
header: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The embedding page's policy: the page's own `directives` plus what Pickle
|
|
79
|
+
* needs, `frame-src` for the sandbox frame and `img-src` for the host's image
|
|
80
|
+
* hosts. Send `header` on every response `appliesTo` accepts; the endpoint's
|
|
81
|
+
* frame carries its own policy, and a page policy sent there replaces it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function contentSecurityPolicy(host: Host, options: {
|
|
84
|
+
directives?: PolicyDirectives;
|
|
85
|
+
endpoint: string;
|
|
86
|
+
}): PagePolicy;
|
|
87
|
+
//#endregion
|
|
88
|
+
export {};
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { checkSituations, normalizeGuidance } from "./guidance.js";
|
|
2
|
+
import { isCallPolicy } from "./procedures.js";
|
|
3
|
+
import { isImageHostEntry, normalizeImageHost } from "@jigs.design/runtime";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/host.ts
|
|
6
|
+
const PROCEDURE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
7
|
+
function checkJsonSchema(label, schema) {
|
|
8
|
+
try {
|
|
9
|
+
z.toJSONSchema(schema);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
throw new Error(`${label} has no JSON Schema form, so it cannot travel as JSON. Use plain values, such as an ISO string for a date.`, { cause: error });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The product's boundary with the spec. Throws at startup when a schema has
|
|
16
|
+
* no JSON form, `invalidates` names something that is not a query, a
|
|
17
|
+
* procedure name is not an identifier, an image host is malformed, or a
|
|
18
|
+
* situation name is not kebab-case or is listed twice.
|
|
19
|
+
*
|
|
20
|
+
* `guidance` and `situations` replace the catalog's when set; to extend the
|
|
21
|
+
* catalog's, spread them in.
|
|
22
|
+
*/
|
|
23
|
+
function defineHost(definition) {
|
|
24
|
+
const { procedures } = definition;
|
|
25
|
+
const recipes = definition.recipes ?? [];
|
|
26
|
+
const situations = definition.situations ?? definition.components.situations;
|
|
27
|
+
checkSituations(situations);
|
|
28
|
+
const components = new Set(definition.components.components.map((component) => component.name));
|
|
29
|
+
const recipeNames = /* @__PURE__ */ new Set();
|
|
30
|
+
for (const entry of recipes) {
|
|
31
|
+
if (components.has(entry.name)) throw new Error(`Recipe "${entry.name}" has the name of a catalog component`);
|
|
32
|
+
if (recipeNames.has(entry.name)) throw new Error(`Recipe "${entry.name}" is listed twice`);
|
|
33
|
+
recipeNames.add(entry.name);
|
|
34
|
+
}
|
|
35
|
+
for (const [name, procedure] of Object.entries(procedures)) {
|
|
36
|
+
if (!PROCEDURE_NAME.test(name)) throw new Error(`Procedure name "${name}" must be an identifier: letters, digits and underscores, starting with a letter`);
|
|
37
|
+
if (procedure.input !== void 0) checkJsonSchema(`The input of "${name}"`, procedure.input);
|
|
38
|
+
if (procedure.output !== void 0) checkJsonSchema(`The output of "${name}"`, procedure.output);
|
|
39
|
+
if (procedure.kind !== "action" && procedure.projection !== void 0) checkJsonSchema(`The projection of "${name}"`, procedure.projection.schema);
|
|
40
|
+
if (procedure.kind === "mutation") {
|
|
41
|
+
if (!isCallPolicy(procedure.policy)) throw new Error(`"${name}" has policy ${JSON.stringify(procedure.policy)}: write "allow", "confirm" or "deny"`);
|
|
42
|
+
if (procedure.policy === "confirm" && procedure.confirm === void 0) throw new Error(`"${name}" asks for confirmation but has no confirm message: give it confirm: (input) => text`);
|
|
43
|
+
for (const target of procedure.invalidates) if ((Object.hasOwn(procedures, target) ? procedures[target] : void 0)?.kind !== "query") throw new Error(`"${name}" invalidates "${target}", which is not a query on this host`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const imageHosts = (definition.imageHosts ?? []).map((entry) => {
|
|
47
|
+
if (!isImageHostEntry(entry)) throw new Error(`Invalid image host "${entry}"; use a hostname such as "cdn.example.com" or "*.example.com", optionally with a path prefix ending in "/"`);
|
|
48
|
+
return normalizeImageHost(entry);
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
components: definition.components,
|
|
52
|
+
guidance: definition.guidance === void 0 ? definition.components.guidance : normalizeGuidance(definition.guidance),
|
|
53
|
+
imageHosts,
|
|
54
|
+
procedures,
|
|
55
|
+
recipes,
|
|
56
|
+
situations
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The source a CSP needs for `endpoint`: `'self'` for a path, the origin for a URL. */
|
|
60
|
+
function endpointSource(endpoint) {
|
|
61
|
+
if (endpoint.startsWith("/")) return "'self'";
|
|
62
|
+
return new URL(endpoint).origin;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Where a directive Pickle adds takes its sources from when the page has not
|
|
66
|
+
* set it: once the directive exists, the browser stops falling back.
|
|
67
|
+
*/
|
|
68
|
+
const FALLBACKS = /* @__PURE__ */ new Map([["frame-src", ["child-src", "default-src"]], ["img-src", ["default-src"]]]);
|
|
69
|
+
/** Adds `extra` to `directives`, keeping each source once and dropping a `'none'` that now has company. */
|
|
70
|
+
function mergeDirectives(directives, extra) {
|
|
71
|
+
const merged = new Map(Object.entries(directives).map(([name, sources]) => [name, [...sources]]));
|
|
72
|
+
for (const [name, sources] of Object.entries(extra)) {
|
|
73
|
+
const fallback = (FALLBACKS.get(name) ?? []).map((other) => directives[other]).find((found) => found !== void 0);
|
|
74
|
+
const list = merged.get(name) ?? [...fallback ?? []];
|
|
75
|
+
for (const source of sources) if (!list.includes(source)) list.push(source);
|
|
76
|
+
merged.set(name, list.length > 1 ? list.filter((source) => source !== "'none'") : list);
|
|
77
|
+
}
|
|
78
|
+
return Object.fromEntries(merged);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The embedding page's policy: the page's own `directives` plus what Pickle
|
|
82
|
+
* needs, `frame-src` for the sandbox frame and `img-src` for the host's image
|
|
83
|
+
* hosts. Send `header` on every response `appliesTo` accepts; the endpoint's
|
|
84
|
+
* frame carries its own policy, and a page policy sent there replaces it.
|
|
85
|
+
*/
|
|
86
|
+
function contentSecurityPolicy(host, options) {
|
|
87
|
+
const directives = mergeDirectives(options.directives ?? {}, {
|
|
88
|
+
"frame-src": [endpointSource(options.endpoint)],
|
|
89
|
+
"img-src": ["'self'", ...host.imageHosts.map((entry) => `https://${entry}`)]
|
|
90
|
+
});
|
|
91
|
+
const absolute = !options.endpoint.startsWith("/");
|
|
92
|
+
const endpoint = new URL(options.endpoint, "https://page.invalid");
|
|
93
|
+
const path = endpoint.pathname.replace(/\/$/, "");
|
|
94
|
+
return {
|
|
95
|
+
appliesTo: (url) => {
|
|
96
|
+
const target = new URL(url, endpoint);
|
|
97
|
+
return !((!absolute || target.origin === endpoint.origin) && (target.pathname === path || target.pathname.startsWith(`${path}/`)));
|
|
98
|
+
},
|
|
99
|
+
directives,
|
|
100
|
+
header: Object.entries(directives).map(([name, sources]) => [name, ...sources].join(" ")).join("; ")
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { contentSecurityPolicy, defineHost };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { DesignGuidance, GuidanceInput, SituationDefinition, situation } from "./guidance.js";
|
|
2
|
+
import { Catalog, CatalogProps, ComponentDefinition, ComponentDesign, ComponentNamed, PropDefinition, PropDefinitions, PropsOf, block, boolean, componentSchema, defineCatalog, defineComponent, handler, image, inline, link, list, none, number, oneOf, string } from "./catalog.js";
|
|
3
|
+
import { ActionDefinition, CallPolicy, InputOf, InvalidationScope, InvalidationScopes, MutationDefinition, OutputOf, ProcedureDefinition, Procedures, Projection, QueryDefinition, action, dataSchema, isCallPolicy, mutation, query } from "./procedures.js";
|
|
4
|
+
import { RecipeDefinition, recipe } from "./recipes.js";
|
|
5
|
+
import { ActionEvent, Host, PagePolicy, PolicyDirectives, contentSecurityPolicy, defineHost } from "./host.js";
|
|
6
|
+
export { type ActionDefinition, type ActionEvent, type CallPolicy, type Catalog, type CatalogProps, type ComponentDefinition, type ComponentDesign, type ComponentNamed, type DesignGuidance, type GuidanceInput, type Host, type InputOf, type InvalidationScope, type InvalidationScopes, type MutationDefinition, type OutputOf, type PagePolicy, type PolicyDirectives, type ProcedureDefinition, type Procedures, type Projection, type PropDefinition, type PropDefinitions, type PropsOf, type QueryDefinition, type RecipeDefinition, type SituationDefinition, action, block, boolean, componentSchema, contentSecurityPolicy, dataSchema, defineCatalog, defineComponent, defineHost, handler, image, inline, isCallPolicy, link, list, mutation, none, number, oneOf, query, recipe, situation, string };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { situation } from "./guidance.js";
|
|
2
|
+
import { block, boolean, componentSchema, defineCatalog, defineComponent, handler, image, inline, link, list, none, number, oneOf, string } from "./catalog.js";
|
|
3
|
+
import { action, dataSchema, isCallPolicy, mutation, query } from "./procedures.js";
|
|
4
|
+
import { contentSecurityPolicy, defineHost } from "./host.js";
|
|
5
|
+
import { recipe } from "./recipes.js";
|
|
6
|
+
export { action, block, boolean, componentSchema, contentSecurityPolicy, dataSchema, defineCatalog, defineComponent, defineHost, handler, image, inline, isCallPolicy, link, list, mutation, none, number, oneOf, query, recipe, situation, string };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { JsonValue } from "@jigs.design/runtime";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/procedures.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The part of a procedure's output its views read, and how to make it. The
|
|
6
|
+
* server parses the whole output, as the contract with `run`, then calls
|
|
7
|
+
* `project` on it and parses the result with `schema`; that is what the
|
|
8
|
+
* view receives. The prompt shows `schema` in the output's place, so a
|
|
9
|
+
* generated API's large records reach the model as the fields a UI shows.
|
|
10
|
+
*/
|
|
11
|
+
export interface Projection<TOutput = unknown> {
|
|
12
|
+
project(output: TOutput): Unparsed;
|
|
13
|
+
schema: z.ZodType;
|
|
14
|
+
}
|
|
15
|
+
/** What a projection's `project` returns, before its `schema` parses it. */
|
|
16
|
+
type Unparsed = z.input<z.ZodType>;
|
|
17
|
+
/** A procedure the spec reads with `useQuery`. */
|
|
18
|
+
export interface QueryDefinition<TInput extends z.ZodType | undefined = z.ZodType | undefined, TOutput extends z.ZodType = z.ZodType> {
|
|
19
|
+
description: string;
|
|
20
|
+
input: TInput;
|
|
21
|
+
kind: "query";
|
|
22
|
+
output: TOutput;
|
|
23
|
+
/** What views read instead of the whole output; see `Projection`. */
|
|
24
|
+
projection?: Projection<z.output<TOutput>>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Which inputs of one query a mutation refreshes. `true` refreshes every
|
|
28
|
+
* input. A function receives the mutation's parsed input and output, and
|
|
29
|
+
* returns the query input to refresh, an array of them, or `true`, all of
|
|
30
|
+
* which travel as JSON. `defineHost` narrows the return to the query's input.
|
|
31
|
+
*/
|
|
32
|
+
export type InvalidationScope<TInput = never, TOutput = never> = true | ((input: TInput, output: TOutput) => JsonValue);
|
|
33
|
+
/** Per query name, which of its inputs a mutation refreshes. */
|
|
34
|
+
export type InvalidationScopes<TInput = never, TOutput = never> = Readonly<Record<string, InvalidationScope<TInput, TOutput>>>;
|
|
35
|
+
/**
|
|
36
|
+
* What the server does with a call to a mutation: run it (`"allow"`), ask
|
|
37
|
+
* the user through the page first (`"confirm"`), or refuse it with
|
|
38
|
+
* `FORBIDDEN` (`"deny"`). A denied mutation stays in the prompt; the refusal
|
|
39
|
+
* happens at call time, never by hiding the procedure.
|
|
40
|
+
*/
|
|
41
|
+
export type CallPolicy = "allow" | "confirm" | "deny";
|
|
42
|
+
/**
|
|
43
|
+
* Whether `policy` is one of the three. A plain-JavaScript host, or a server
|
|
44
|
+
* override that returns a Promise, can hand over anything; the server denies
|
|
45
|
+
* a call whose policy is not one of these rather than running it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCallPolicy(policy: CallPolicy): boolean;
|
|
48
|
+
/** A procedure the spec calls with `useMutation`. */
|
|
49
|
+
export interface MutationDefinition<TInput extends z.ZodType | undefined = z.ZodType | undefined, TOutput extends z.ZodType | undefined = z.ZodType | undefined, TScopes extends InvalidationScopes = InvalidationScopes> {
|
|
50
|
+
/**
|
|
51
|
+
* The text the page shows when it asks the user to confirm a call, from
|
|
52
|
+
* the parsed input. The host writes it, never the model.
|
|
53
|
+
*/
|
|
54
|
+
confirm?(input: Parsed<TInput>): string;
|
|
55
|
+
description: string;
|
|
56
|
+
input: TInput;
|
|
57
|
+
/** Every query a success refreshes. */
|
|
58
|
+
invalidates: ReadonlyArray<string>;
|
|
59
|
+
kind: "mutation";
|
|
60
|
+
output: TOutput;
|
|
61
|
+
/** Whether a call runs; the server's `policy` option may override it. */
|
|
62
|
+
policy: CallPolicy;
|
|
63
|
+
/** What views read instead of the whole output; see `Projection`. */
|
|
64
|
+
projection?: Projection<Parsed<TOutput>>;
|
|
65
|
+
/** For each name in `invalidates`, which of that query's inputs refresh. */
|
|
66
|
+
scopes: TScopes;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A procedure the spec calls with `useMutation` that the embedding page
|
|
70
|
+
* handles instead of the server: the server checks the input and the view
|
|
71
|
+
* hands it to `onAction`. It has no output and refreshes nothing.
|
|
72
|
+
*/
|
|
73
|
+
export interface ActionDefinition<TInput extends z.ZodType | undefined = z.ZodType | undefined> {
|
|
74
|
+
description: string;
|
|
75
|
+
input: TInput;
|
|
76
|
+
kind: "action";
|
|
77
|
+
output: undefined;
|
|
78
|
+
}
|
|
79
|
+
export type ProcedureDefinition = ActionDefinition | MutationDefinition | QueryDefinition;
|
|
80
|
+
export type Procedures = Readonly<Record<string, ProcedureDefinition>>;
|
|
81
|
+
/**
|
|
82
|
+
* The schema of what a procedure's views read: its projection's when it has
|
|
83
|
+
* one, else its output; `undefined` for an action or a procedure with no
|
|
84
|
+
* output. The prompt shows it, and the member check reads against it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function dataSchema(procedure: ProcedureDefinition): z.ZodType | undefined;
|
|
87
|
+
/** What `run` receives for a procedure: the parsed input, or `undefined` when it takes none. */
|
|
88
|
+
export type InputOf<P extends ProcedureDefinition> = P["input"] extends z.ZodType ? z.output<P["input"]> : undefined;
|
|
89
|
+
/** What `run` returns for a procedure, before the server parses it. */
|
|
90
|
+
export type OutputOf<P extends ProcedureDefinition> = P["output"] extends z.ZodType ? z.input<P["output"]> : unknown;
|
|
91
|
+
/** How `query` and `mutation` take a projection: `project` returns what `schema` parses. */
|
|
92
|
+
interface ProjectionOption<TOutput, TProjection extends z.ZodType> {
|
|
93
|
+
project: (output: TOutput) => z.input<TProjection>;
|
|
94
|
+
schema: TProjection;
|
|
95
|
+
}
|
|
96
|
+
export declare function query<TOutput extends z.ZodType, TInput extends z.ZodType | undefined = undefined, TProjection extends z.ZodType = z.ZodType>(definition: {
|
|
97
|
+
description: string;
|
|
98
|
+
input?: TInput;
|
|
99
|
+
output: TOutput;
|
|
100
|
+
/** What views read instead of the whole output; see `Projection`. */
|
|
101
|
+
projection?: ProjectionOption<z.output<TOutput>, TProjection>;
|
|
102
|
+
}): QueryDefinition<TInput, TOutput>;
|
|
103
|
+
/** A schema's parsed value, or `undefined` when there is no schema. */
|
|
104
|
+
export type Parsed<T extends z.ZodType | undefined> = T extends z.ZodType ? z.output<T> : undefined;
|
|
105
|
+
/**
|
|
106
|
+
* The scopes an `invalidates` value means. A list or nothing leaves `TScopes`
|
|
107
|
+
* at its default, with a `string` key, and refreshes every input of each name.
|
|
108
|
+
*/
|
|
109
|
+
type ScopesOf<TScopes, TNames extends ReadonlyArray<string>> = string extends keyof TScopes ? { readonly [K in TNames[number]]: true; } : { readonly [K in keyof TScopes]: Extract<TScopes[K], InvalidationScope>; };
|
|
110
|
+
/**
|
|
111
|
+
* Any function, by a member every function has. It has no call signature on
|
|
112
|
+
* purpose: see `mutation`'s `TScopes`.
|
|
113
|
+
*/
|
|
114
|
+
interface ScopeFunction {
|
|
115
|
+
readonly name: string;
|
|
116
|
+
}
|
|
117
|
+
/** What `mutation` infers scopes from: `true` or a function, per name. */
|
|
118
|
+
type WrittenScope = ScopeFunction | true;
|
|
119
|
+
type WrittenScopes = Readonly<Record<string, WrittenScope>>;
|
|
120
|
+
export declare function mutation<TInput extends z.ZodType | undefined = undefined, TOutput extends z.ZodType | undefined = undefined, TProjection extends z.ZodType = z.ZodType, const TScopes extends WrittenScopes = WrittenScopes, const TNames extends ReadonlyArray<string> = readonly []>(definition: {
|
|
121
|
+
/**
|
|
122
|
+
* What the page asks the user before a call runs, written from the parsed
|
|
123
|
+
* input. With it, `policy` defaults to `"confirm"`.
|
|
124
|
+
*/
|
|
125
|
+
confirm?: (input: Parsed<TInput>) => string;
|
|
126
|
+
description: string;
|
|
127
|
+
input?: TInput;
|
|
128
|
+
/**
|
|
129
|
+
* The queries a success refreshes. A list of names refreshes every input
|
|
130
|
+
* of each; an object keyed by name scopes each one (`InvalidationScope`).
|
|
131
|
+
*/
|
|
132
|
+
invalidates?: TNames | { readonly [K in keyof TScopes]: TScopes[K] & InvalidationScope<Parsed<TInput>, Parsed<TOutput>>; };
|
|
133
|
+
output?: TOutput;
|
|
134
|
+
/**
|
|
135
|
+
* Whether a call runs; see `CallPolicy`. Default `"confirm"` with a
|
|
136
|
+
* `confirm` message and `"allow"` without one. `"confirm"` needs the
|
|
137
|
+
* message: the model never writes what the user is asked.
|
|
138
|
+
*/
|
|
139
|
+
policy?: CallPolicy;
|
|
140
|
+
/** What views read instead of the whole output; see `Projection`. */
|
|
141
|
+
projection?: ProjectionOption<Parsed<TOutput>, TProjection>;
|
|
142
|
+
}): MutationDefinition<TInput, TOutput, NoInfer<ScopesOf<TScopes, TNames>>>;
|
|
143
|
+
export declare function action<TInput extends z.ZodType | undefined = undefined>(definition: {
|
|
144
|
+
description: string;
|
|
145
|
+
input?: TInput;
|
|
146
|
+
}): ActionDefinition<NoInfer<TInput>>;
|
|
147
|
+
//#endregion
|
|
148
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import "zod";
|
|
2
|
+
//#region src/procedures.ts
|
|
3
|
+
/**
|
|
4
|
+
* Whether `policy` is one of the three. A plain-JavaScript host, or a server
|
|
5
|
+
* override that returns a Promise, can hand over anything; the server denies
|
|
6
|
+
* a call whose policy is not one of these rather than running it.
|
|
7
|
+
*/
|
|
8
|
+
function isCallPolicy(policy) {
|
|
9
|
+
return policy === "allow" || policy === "confirm" || policy === "deny";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The schema of what a procedure's views read: its projection's when it has
|
|
13
|
+
* one, else its output; `undefined` for an action or a procedure with no
|
|
14
|
+
* output. The prompt shows it, and the member check reads against it.
|
|
15
|
+
*/
|
|
16
|
+
function dataSchema(procedure) {
|
|
17
|
+
return procedure.kind === "action" ? void 0 : procedure.projection?.schema ?? procedure.output;
|
|
18
|
+
}
|
|
19
|
+
function query(definition) {
|
|
20
|
+
const input = definition.input;
|
|
21
|
+
const created = {
|
|
22
|
+
description: definition.description,
|
|
23
|
+
input,
|
|
24
|
+
kind: "query",
|
|
25
|
+
output: definition.output
|
|
26
|
+
};
|
|
27
|
+
if (definition.projection !== void 0) created.projection = definition.projection;
|
|
28
|
+
return created;
|
|
29
|
+
}
|
|
30
|
+
function isNameList(value) {
|
|
31
|
+
return Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function mutation(definition) {
|
|
34
|
+
const input = definition.input;
|
|
35
|
+
const output = definition.output;
|
|
36
|
+
const invalidates = definition.invalidates ?? [];
|
|
37
|
+
const scopes = isNameList(invalidates) ? Object.fromEntries(invalidates.map((name) => [name, true])) : invalidates;
|
|
38
|
+
const created = {
|
|
39
|
+
description: definition.description,
|
|
40
|
+
input,
|
|
41
|
+
invalidates: Object.keys(scopes),
|
|
42
|
+
kind: "mutation",
|
|
43
|
+
output,
|
|
44
|
+
policy: definition.policy ?? (definition.confirm === void 0 ? "allow" : "confirm"),
|
|
45
|
+
scopes
|
|
46
|
+
};
|
|
47
|
+
if (definition.confirm !== void 0) created.confirm = definition.confirm;
|
|
48
|
+
if (definition.projection !== void 0) created.projection = definition.projection;
|
|
49
|
+
return created;
|
|
50
|
+
}
|
|
51
|
+
function action(definition) {
|
|
52
|
+
const input = definition.input;
|
|
53
|
+
return {
|
|
54
|
+
description: definition.description,
|
|
55
|
+
input,
|
|
56
|
+
kind: "action",
|
|
57
|
+
output: void 0
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { action, dataSchema, isCallPolicy, mutation, query };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/recipes.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A recipe: a compound component the product writes in Pickle, from catalog
|
|
4
|
+
* components and other recipes. The model uses it like any tag. Because it is
|
|
5
|
+
* written in catalog tags, it runs on every renderer whose catalog has those
|
|
6
|
+
* tags, and the server checks it against the host at startup.
|
|
7
|
+
*/
|
|
8
|
+
export interface RecipeDefinition {
|
|
9
|
+
/** One line: when the model should reach for this recipe. */
|
|
10
|
+
description: string;
|
|
11
|
+
/** A one-line use, shown to the model. */
|
|
12
|
+
example?: string;
|
|
13
|
+
/** The name of the `<define>` in `source`. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Pickle source with exactly one `<define name="…">`. */
|
|
16
|
+
source: string;
|
|
17
|
+
}
|
|
18
|
+
/** A recipe named by the `<define>` in its source. */
|
|
19
|
+
export declare function recipe(definition: {
|
|
20
|
+
description: string;
|
|
21
|
+
example?: string;
|
|
22
|
+
source: string;
|
|
23
|
+
}): RecipeDefinition;
|
|
24
|
+
//#endregion
|
package/dist/recipes.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/recipes.ts
|
|
2
|
+
const RECIPE_NAME = /^[a-z][A-Za-z0-9]*$/;
|
|
3
|
+
const DEFINE_TAG = /<define(?=[\s/>])/g;
|
|
4
|
+
const ATTR_NAME = /[A-Za-z_][\w-]*/y;
|
|
5
|
+
/** Index just past the `}` matching the `{` at `start`, or -1 if unbalanced. */
|
|
6
|
+
function skipBraced(source, start) {
|
|
7
|
+
let depth = 0;
|
|
8
|
+
let quote = null;
|
|
9
|
+
for (let index = start; index < source.length; index++) {
|
|
10
|
+
const char = source.charAt(index);
|
|
11
|
+
if (quote !== null) {
|
|
12
|
+
if (char === "\\") index++;
|
|
13
|
+
else if (char === quote) quote = null;
|
|
14
|
+
} else if (char === "\"" || char === "'" || char === "`") quote = char;
|
|
15
|
+
else if (char === "{") depth++;
|
|
16
|
+
else if (char === "}") {
|
|
17
|
+
depth--;
|
|
18
|
+
if (depth === 0) return index + 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return -1;
|
|
22
|
+
}
|
|
23
|
+
/** A literal attribute value: `"x"`, `'x'` or `{"x"}`. */
|
|
24
|
+
function literal(value) {
|
|
25
|
+
const quoted = /^\s*\{?\s*(?:"([^"\\]*)"|'([^'\\]*)')\s*\}?\s*$/.exec(value);
|
|
26
|
+
return quoted === null ? null : quoted[1] ?? quoted[2] ?? null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The `name` attribute of the opening tag at `start`, read the way the
|
|
30
|
+
* compiler reads it. The compiler checks the whole source again at startup.
|
|
31
|
+
*/
|
|
32
|
+
function nameAttribute(source, start) {
|
|
33
|
+
let cursor = start + 7;
|
|
34
|
+
while (cursor < source.length) {
|
|
35
|
+
const char = source.charAt(cursor);
|
|
36
|
+
if (/\s/.test(char)) {
|
|
37
|
+
cursor++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (char === ">" || char === "/") return null;
|
|
41
|
+
ATTR_NAME.lastIndex = cursor;
|
|
42
|
+
const attr = ATTR_NAME.exec(source)?.[0];
|
|
43
|
+
if (attr === void 0) return null;
|
|
44
|
+
cursor += attr.length;
|
|
45
|
+
if (source.charAt(cursor) !== "=") continue;
|
|
46
|
+
cursor++;
|
|
47
|
+
const opener = source.charAt(cursor);
|
|
48
|
+
let end;
|
|
49
|
+
if (opener === "\"" || opener === "'") end = source.indexOf(opener, cursor + 1) + 1;
|
|
50
|
+
else if (opener === "{") end = skipBraced(source, cursor);
|
|
51
|
+
else return null;
|
|
52
|
+
if (end <= 0) return null;
|
|
53
|
+
if (attr === "name") return literal(source.slice(cursor, end));
|
|
54
|
+
cursor = end;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** The name of the one `<define>` in a recipe's source. */
|
|
59
|
+
function defineName(source) {
|
|
60
|
+
const starts = [...source.matchAll(DEFINE_TAG)].map((match) => match.index);
|
|
61
|
+
const [start] = starts;
|
|
62
|
+
if (start === void 0 || starts.length > 1) throw new Error(`A recipe's source must contain exactly one <define>; found ${starts.length}`);
|
|
63
|
+
const name = nameAttribute(source, start);
|
|
64
|
+
if (name === null) throw new Error("A recipe's <define> needs a literal name, e.g. <define name=\"orderRow\">");
|
|
65
|
+
if (!RECIPE_NAME.test(name)) throw new Error(`Recipe name "${name}" must start with a lowercase letter and use letters and digits only`);
|
|
66
|
+
return name;
|
|
67
|
+
}
|
|
68
|
+
/** A recipe named by the `<define>` in its source. */
|
|
69
|
+
function recipe(definition) {
|
|
70
|
+
const built = {
|
|
71
|
+
description: definition.description,
|
|
72
|
+
name: defineName(definition.source),
|
|
73
|
+
source: definition.source
|
|
74
|
+
};
|
|
75
|
+
if (definition.example !== void 0) built.example = definition.example;
|
|
76
|
+
return built;
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { recipe };
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigs.design/host",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Define a host: the catalog a spec may draw, the procedures it may call, and the image hosts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/yoamomonstruos/pickle.git",
|
|
9
|
+
"directory": "packages/host"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@jigs.design/runtime": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"zod": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"zod": "4.6.7"
|
|
35
|
+
}
|
|
6
36
|
}
|