@monosmith/plugin-sdk 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/README.md +89 -2
- package/build-dir/build-dir.algorithm.d.ts +16 -0
- package/build-dir/build-dir.algorithm.js +26 -0
- package/build-dir/index.d.ts +1 -0
- package/build-dir/index.js +1 -0
- package/ci/ci-contribution.algorithm.d.ts +28 -0
- package/ci/ci-contribution.algorithm.js +19 -0
- package/ci/index.d.ts +2 -0
- package/ci/index.js +1 -0
- package/codec/encode-by-schema.algorithm.d.ts +12 -0
- package/codec/encode-by-schema.algorithm.js +105 -0
- package/codec/encode-error.error.d.ts +4 -0
- package/codec/encode-error.error.js +7 -0
- package/codec/encode-json-value.algorithm.d.ts +13 -0
- package/codec/encode-json-value.algorithm.js +51 -0
- package/codec/index.d.ts +6 -0
- package/codec/index.js +5 -0
- package/codec/message-parse.error.d.ts +4 -0
- package/codec/message-parse.error.js +7 -0
- package/codec/method-codec.algorithm.d.ts +8 -0
- package/codec/method-codec.algorithm.js +31 -0
- package/codec/normalize-by-schema.algorithm.d.ts +14 -0
- package/codec/normalize-by-schema.algorithm.js +103 -0
- package/codec/params-error.error.d.ts +4 -0
- package/codec/params-error.error.js +7 -0
- package/codec/schema-lookup.algorithm.d.ts +13 -0
- package/codec/schema-lookup.algorithm.js +56 -0
- package/codec/wire-message.mapper.d.ts +25 -0
- package/codec/wire-message.mapper.js +58 -0
- package/conformance/check-generation.use-case.d.ts +12 -0
- package/conformance/check-generation.use-case.js +91 -0
- package/conformance/check-plugin.use-case.d.ts +24 -0
- package/conformance/check-plugin.use-case.js +78 -0
- package/conformance/conformance-report.model.d.ts +22 -0
- package/conformance/conformance-report.model.js +23 -0
- package/conformance/find-manifests.algorithm.d.ts +7 -0
- package/conformance/find-manifests.algorithm.js +34 -0
- package/conformance/go-list.mapper.d.ts +4 -0
- package/conformance/go-list.mapper.js +8 -0
- package/conformance/index.d.ts +5 -0
- package/conformance/index.js +3 -0
- package/framing/frame-message.algorithm.d.ts +5 -0
- package/framing/frame-message.algorithm.js +8 -0
- package/framing/frame-reader.store.d.ts +15 -0
- package/framing/frame-reader.store.js +59 -0
- package/framing/framing.error.d.ts +4 -0
- package/framing/framing.error.js +7 -0
- package/framing/index.d.ts +3 -0
- package/framing/index.js +3 -0
- package/host/connect-plugin.use-case.d.ts +26 -0
- package/host/connect-plugin.use-case.js +44 -0
- package/host/host-options.contract.d.ts +28 -0
- package/host/host-options.contract.js +2 -0
- package/host/index.d.ts +7 -0
- package/host/index.js +4 -0
- package/host/plugin-client.client.d.ts +42 -0
- package/host/plugin-client.client.js +166 -0
- package/host/start-plugin.use-case.d.ts +10 -0
- package/host/start-plugin.use-case.js +57 -0
- package/index.d.ts +15 -0
- package/index.js +20 -0
- package/jsondoc/indent-raw-json.algorithm.d.ts +8 -0
- package/jsondoc/indent-raw-json.algorithm.js +60 -0
- package/jsondoc/index.d.ts +2 -0
- package/jsondoc/index.js +2 -0
- package/jsondoc/json-document.error.d.ts +4 -0
- package/jsondoc/json-document.error.js +7 -0
- package/jsondoc/json-document.model.d.ts +38 -0
- package/jsondoc/json-document.model.js +113 -0
- package/jsondoc/scan-json-object.algorithm.d.ts +17 -0
- package/jsondoc/scan-json-object.algorithm.js +132 -0
- package/package.json +34 -4
- package/plugin-refs/index.d.ts +1 -0
- package/plugin-refs/index.js +1 -0
- package/plugin-refs/plugin-ref.policy.d.ts +9 -0
- package/plugin-refs/plugin-ref.policy.js +22 -0
- package/plugin-roles/index.d.ts +1 -0
- package/plugin-roles/index.js +1 -0
- package/plugin-roles/plugin-role.policy.d.ts +16 -0
- package/plugin-roles/plugin-role.policy.js +30 -0
- package/projects/audit-settings.policy.d.ts +5 -0
- package/projects/audit-settings.policy.js +8 -0
- package/projects/index.d.ts +6 -0
- package/projects/index.js +6 -0
- package/projects/manifest-match.algorithm.d.ts +7 -0
- package/projects/manifest-match.algorithm.js +38 -0
- package/projects/project-kind.policy.d.ts +9 -0
- package/projects/project-kind.policy.js +28 -0
- package/projects/publishes.policy.d.ts +7 -0
- package/projects/publishes.policy.js +8 -0
- package/projects/set-toolchain.algorithm.d.ts +7 -0
- package/projects/set-toolchain.algorithm.js +13 -0
- package/projects/stack-plugins.algorithm.d.ts +3 -0
- package/projects/stack-plugins.algorithm.js +4 -0
- package/protocol/index.d.ts +4 -0
- package/protocol/index.js +3 -0
- package/protocol/protocol-schema.contract.d.ts +3 -0
- package/protocol/protocol-schema.contract.js +1925 -0
- package/protocol/protocol-types.contract.d.ts +1017 -0
- package/protocol/protocol-types.contract.js +36 -0
- package/protocol/rpc-error.error.d.ts +9 -0
- package/protocol/rpc-error.error.js +13 -0
- package/protocol/schema-document.contract.d.ts +55 -0
- package/protocol/schema-document.contract.js +1 -0
- package/scaffold/index.d.ts +3 -0
- package/scaffold/index.js +2 -0
- package/scaffold/scaffold-data.mapper.d.ts +15 -0
- package/scaffold/scaffold-data.mapper.js +24 -0
- package/scaffold/scaffold-project.use-case.d.ts +20 -0
- package/scaffold/scaffold-project.use-case.js +13 -0
- package/serve/capabilities-of.algorithm.d.ts +13 -0
- package/serve/capabilities-of.algorithm.js +28 -0
- package/serve/claim-stdout.use-case.d.ts +10 -0
- package/serve/claim-stdout.use-case.js +14 -0
- package/serve/dispatch-request.use-case.d.ts +14 -0
- package/serve/dispatch-request.use-case.js +56 -0
- package/serve/index.d.ts +4 -0
- package/serve/index.js +2 -0
- package/serve/initialize-plugin.use-case.d.ts +9 -0
- package/serve/initialize-plugin.use-case.js +16 -0
- package/serve/plugin-members.config.d.ts +16 -0
- package/serve/plugin-members.config.js +25 -0
- package/serve/plugin.contract.d.ts +65 -0
- package/serve/plugin.contract.js +1 -0
- package/serve/protocol-streams.contract.d.ts +13 -0
- package/serve/protocol-streams.contract.js +1 -0
- package/serve/request-outcome.contract.d.ts +9 -0
- package/serve/request-outcome.contract.js +1 -0
- package/serve/serve.use-case.d.ts +27 -0
- package/serve/serve.use-case.js +93 -0
- package/tasks/contribute-tasks.algorithm.d.ts +15 -0
- package/tasks/contribute-tasks.algorithm.js +22 -0
- package/tasks/index.d.ts +2 -0
- package/tasks/index.js +2 -0
- package/tasks/task.algorithm.d.ts +18 -0
- package/tasks/task.algorithm.js +23 -0
- package/templating/file-exists.error.d.ts +6 -0
- package/templating/file-exists.error.js +10 -0
- package/templating/index.d.ts +4 -0
- package/templating/index.js +4 -0
- package/templating/parse-template.algorithm.d.ts +11 -0
- package/templating/parse-template.algorithm.js +197 -0
- package/templating/render-template.algorithm.d.ts +7 -0
- package/templating/render-template.algorithm.js +87 -0
- package/templating/template-functions.algorithm.d.ts +10 -0
- package/templating/template-functions.algorithm.js +104 -0
- package/templating/template-syntax.contract.d.ts +33 -0
- package/templating/template-syntax.contract.js +1 -0
- package/templating/template.error.d.ts +4 -0
- package/templating/template.error.js +7 -0
- package/templating/tokenize-action.algorithm.d.ts +20 -0
- package/templating/tokenize-action.algorithm.js +57 -0
- package/templating/write-templates.use-case.d.ts +11 -0
- package/templating/write-templates.use-case.js +44 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { MessageParseError, parseRequest, writeFailure, writeResult } from '../codec/index.js';
|
|
2
|
+
import { FrameReader, frameMessage } from '../framing/index.js';
|
|
3
|
+
import { ERROR_CODES } from '../protocol/index.js';
|
|
4
|
+
import { halfImplemented } from './capabilities-of.algorithm.js';
|
|
5
|
+
import { claimStdout } from './claim-stdout.use-case.js';
|
|
6
|
+
import { dispatchRequest } from './dispatch-request.use-case.js';
|
|
7
|
+
/**
|
|
8
|
+
* Runs a plugin on the process's standard input and output, and exits when the
|
|
9
|
+
* host sends `shutdown` or closes the stream. Use it as the whole body of the
|
|
10
|
+
* plugin's entry point:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* await serve({ describe: () => ({ ... }), discover: (params) => ({ ... }) })
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Standard output carries only the protocol's frames: whatever else the plugin
|
|
17
|
+
* or a library writes there goes to standard error instead. A failure of the
|
|
18
|
+
* stream exits with status 1 after the reason is printed.
|
|
19
|
+
*/
|
|
20
|
+
export async function serve(plugin) {
|
|
21
|
+
const output = claimStdout();
|
|
22
|
+
try {
|
|
23
|
+
await serveStreams(plugin, { input: process.stdin, output });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
27
|
+
// serve is the plugin's main, like the Go SDK's serve.Main: it ends the process, whatever handles the plugin left open.
|
|
28
|
+
// eslint-disable-next-line unicorn/no-process-exit
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
// eslint-disable-next-line unicorn/no-process-exit
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Answers requests from `input` on `output` until the host sends `shutdown`
|
|
36
|
+
* or the input ends, then resolves. This is the loop {@link serve} runs, on
|
|
37
|
+
* streams you choose: a test serves a plugin in-process over pipes with it.
|
|
38
|
+
*
|
|
39
|
+
* Semantics of the Go SDK's `serve.Serve`: requests are answered one at a time,
|
|
40
|
+
* in order; a request before `initialize` is refused (InvalidRequest);
|
|
41
|
+
* a notification (no `id`) gets no answer; an unreadable frame body is answered
|
|
42
|
+
* with ParseError and the loop goes on; an unreadable header ends it with an error.
|
|
43
|
+
*/
|
|
44
|
+
export async function serveStreams(plugin, streams) {
|
|
45
|
+
const log = streams.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
46
|
+
for (const member of halfImplemented(plugin)) {
|
|
47
|
+
log(`plugin: ${member} needs its pair (install with uninstall, declared with rewrite) to be advertised; it will not be called`);
|
|
48
|
+
}
|
|
49
|
+
const reader = new FrameReader();
|
|
50
|
+
const session = { initialized: false };
|
|
51
|
+
for await (const chunk of streams.input) {
|
|
52
|
+
for (const body of reader.push(chunk)) {
|
|
53
|
+
const shutdown = await answer(plugin, streams.output, session, body);
|
|
54
|
+
if (shutdown)
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Answers one frame. Returns whether it was `shutdown`, after which the loop ends. */
|
|
60
|
+
async function answer(plugin, output, session, body) {
|
|
61
|
+
let request;
|
|
62
|
+
try {
|
|
63
|
+
request = parseRequest(body);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!(error instanceof MessageParseError))
|
|
67
|
+
throw error;
|
|
68
|
+
await send(output, writeFailure(null, ERROR_CODES.ParseError, error.message));
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// A notification has no reply; version 1 defines none for plugins.
|
|
72
|
+
if (request.id === null)
|
|
73
|
+
return false;
|
|
74
|
+
if (!session.initialized && request.method !== 'initialize') {
|
|
75
|
+
await send(output, writeFailure(request.id, ERROR_CODES.InvalidRequest, 'initialize must come first'));
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const outcome = await dispatchRequest(plugin, request);
|
|
79
|
+
if (request.method === 'initialize' && outcome.ok)
|
|
80
|
+
session.initialized = true;
|
|
81
|
+
await send(output, outcome.ok ? writeResult(request.id, outcome.resultJson) : writeFailure(request.id, outcome.code, outcome.message));
|
|
82
|
+
return request.method === 'shutdown';
|
|
83
|
+
}
|
|
84
|
+
function send(output, body) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
output.write(frameMessage(body), (error) => {
|
|
87
|
+
if (error === null || error === undefined)
|
|
88
|
+
resolve();
|
|
89
|
+
else
|
|
90
|
+
reject(error);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ContributeResult, Contribution, Project } from '../protocol/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The answer to `contribute` when every project gets the same kind of
|
|
4
|
+
* addition: calls `contribution` for each project and keys the result by
|
|
5
|
+
* project name. Return `undefined` for a project the plugin has nothing to add
|
|
6
|
+
* to; it is left out.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* contribute: ({ projects }) => contributeTo(projects, () => ({
|
|
10
|
+
* tasks: { 'layout-check': task(['node', checker, 'layout-check'], { cache: true }) },
|
|
11
|
+
* dependsOn: { lint: ['layout-check'] },
|
|
12
|
+
* }))
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export declare function contributeTo(projects: readonly Project[], contribution: (project: Project) => Contribution | undefined): ContributeResult;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answer to `contribute` when every project gets the same kind of
|
|
3
|
+
* addition: calls `contribution` for each project and keys the result by
|
|
4
|
+
* project name. Return `undefined` for a project the plugin has nothing to add
|
|
5
|
+
* to; it is left out.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* contribute: ({ projects }) => contributeTo(projects, () => ({
|
|
9
|
+
* tasks: { 'layout-check': task(['node', checker, 'layout-check'], { cache: true }) },
|
|
10
|
+
* dependsOn: { lint: ['layout-check'] },
|
|
11
|
+
* }))
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function contributeTo(projects, contribution) {
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const project of projects) {
|
|
17
|
+
const added = contribution(project);
|
|
18
|
+
if (added !== undefined)
|
|
19
|
+
result[project.name] = added;
|
|
20
|
+
}
|
|
21
|
+
return { projects: result };
|
|
22
|
+
}
|
package/tasks/index.d.ts
ADDED
package/tasks/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Task } from '../protocol/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A task the host can run, hash and cache. The command is an argv array and is
|
|
4
|
+
* never passed through a shell. Other members are the options.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* task(['node', 'check.js'], { inputs: ['widget.json'], cache: true, description: 'check the manifest' })
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export declare function task(command: string[], options?: Omit<Task, 'command'>): Task;
|
|
11
|
+
/**
|
|
12
|
+
* The `build` task of a project: cached, built into the project's directory
|
|
13
|
+
* under `dist/` (which is also its `outputs`, restored on a cache hit), and run
|
|
14
|
+
* after the build of every project this one imports (`^build`). `outputDir` is
|
|
15
|
+
* the directory the command is told to write into, `../../dist/libs/core` for
|
|
16
|
+
* `libs/core`, to pass in its argv.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildTask(root: string, command: (outputDir: string) => string[], options?: Omit<Task, 'command'>): Task;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildDirFromProject } from '../build-dir/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* A task the host can run, hash and cache. The command is an argv array and is
|
|
4
|
+
* never passed through a shell. Other members are the options.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* task(['node', 'check.js'], { inputs: ['widget.json'], cache: true, description: 'check the manifest' })
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export function task(command, options = {}) {
|
|
11
|
+
return { command, ...options };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The `build` task of a project: cached, built into the project's directory
|
|
15
|
+
* under `dist/` (which is also its `outputs`, restored on a cache hit), and run
|
|
16
|
+
* after the build of every project this one imports (`^build`). `outputDir` is
|
|
17
|
+
* the directory the command is told to write into, `../../dist/libs/core` for
|
|
18
|
+
* `libs/core`, to pass in its argv.
|
|
19
|
+
*/
|
|
20
|
+
export function buildTask(root, command, options = {}) {
|
|
21
|
+
const outputDir = buildDirFromProject(root);
|
|
22
|
+
return { command: command(outputDir), outputs: [outputDir], dependsOn: ['^build'], cache: true, ...options };
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** A target file already exists: templates are written for a new project and never overwrite what is there. */
|
|
2
|
+
export declare class FileExistsError extends Error {
|
|
3
|
+
/** The existing file, relative to the root. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
constructor(path: string);
|
|
6
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A target file already exists: templates are written for a new project and never overwrite what is there. */
|
|
2
|
+
export class FileExistsError extends Error {
|
|
3
|
+
/** The existing file, relative to the root. */
|
|
4
|
+
path;
|
|
5
|
+
constructor(path) {
|
|
6
|
+
super(`${path}: file already exists`);
|
|
7
|
+
this.name = 'FileExistsError';
|
|
8
|
+
this.path = path;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TemplateNode } from './template-syntax.contract.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Parses a template with `[[ ]]` delimiters. The language is the subset of Go's
|
|
4
|
+
* `text/template` that project templates use: `[[.Field]]` and `[[.A.B]]`,
|
|
5
|
+
* `[[if]]`, `[[else if]]`, `[[else]]`, `[[with]]`, `[[range]]` and `[[end]]`, the
|
|
6
|
+
* functions `eq ne lt le gt ge not and or len index print printf`, string,
|
|
7
|
+
* number and boolean literals, parentheses, pipes, comments, and the `-`
|
|
8
|
+
* trim markers (`[[- x -]]`). Variables, `define`, `template` and `block`
|
|
9
|
+
* are not supported and are reported as such.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseTemplate(text: string, name: string): TemplateNode[];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { tokenizeAction } from './tokenize-action.algorithm.js';
|
|
2
|
+
import { TemplateError } from './template.error.js';
|
|
3
|
+
const OPEN = '[[';
|
|
4
|
+
const CLOSE = ']]';
|
|
5
|
+
const UNSUPPORTED = new Set(['define', 'template', 'block', 'break', 'continue']);
|
|
6
|
+
/**
|
|
7
|
+
* Parses a template with `[[ ]]` delimiters. The language is the subset of Go's
|
|
8
|
+
* `text/template` that project templates use: `[[.Field]]` and `[[.A.B]]`,
|
|
9
|
+
* `[[if]]`, `[[else if]]`, `[[else]]`, `[[with]]`, `[[range]]` and `[[end]]`, the
|
|
10
|
+
* functions `eq ne lt le gt ge not and or len index print printf`, string,
|
|
11
|
+
* number and boolean literals, parentheses, pipes, comments, and the `-`
|
|
12
|
+
* trim markers (`[[- x -]]`). Variables, `define`, `template` and `block`
|
|
13
|
+
* are not supported and are reported as such.
|
|
14
|
+
*/
|
|
15
|
+
export function parseTemplate(text, name) {
|
|
16
|
+
const fail = (message) => {
|
|
17
|
+
throw new TemplateError(name, message);
|
|
18
|
+
};
|
|
19
|
+
const root = [];
|
|
20
|
+
const stack = [];
|
|
21
|
+
const target = () => {
|
|
22
|
+
const top = stack.at(-1);
|
|
23
|
+
if (top === undefined)
|
|
24
|
+
return root;
|
|
25
|
+
return top.inElse ? top.otherwise : top.body;
|
|
26
|
+
};
|
|
27
|
+
for (const piece of splitPieces(text, fail)) {
|
|
28
|
+
if ('literal' in piece) {
|
|
29
|
+
if (piece.literal !== '')
|
|
30
|
+
target().push({ type: 'text', text: piece.literal });
|
|
31
|
+
}
|
|
32
|
+
else if (piece.action.startsWith('/*')) {
|
|
33
|
+
if (!piece.action.endsWith('*/'))
|
|
34
|
+
fail('unclosed comment');
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
applyAction(piece.action, stack, target, fail);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const open = stack.at(-1);
|
|
41
|
+
if (open !== undefined)
|
|
42
|
+
fail(`unclosed ${open.type}: missing [[end]]`);
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
/** The literal text and the actions of a template, in order, with the `-` trim markers applied. */
|
|
46
|
+
function splitPieces(text, fail) {
|
|
47
|
+
const pieces = [];
|
|
48
|
+
let position = 0;
|
|
49
|
+
let trimNext = false;
|
|
50
|
+
while (position < text.length) {
|
|
51
|
+
const open = text.indexOf(OPEN, position);
|
|
52
|
+
let literal = text.slice(position, open === -1 ? text.length : open);
|
|
53
|
+
if (trimNext)
|
|
54
|
+
literal = literal.trimStart();
|
|
55
|
+
trimNext = false;
|
|
56
|
+
if (open === -1) {
|
|
57
|
+
pieces.push({ literal });
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const close = findClose(text, open + OPEN.length);
|
|
61
|
+
if (close === -1)
|
|
62
|
+
fail(`unclosed action at offset ${open}`);
|
|
63
|
+
let action = text.slice(open + OPEN.length, close);
|
|
64
|
+
if (/^-\s/.test(action)) {
|
|
65
|
+
literal = literal.trimEnd();
|
|
66
|
+
action = action.slice(1);
|
|
67
|
+
}
|
|
68
|
+
if (/\s-$/.test(action)) {
|
|
69
|
+
trimNext = true;
|
|
70
|
+
action = action.slice(0, -1);
|
|
71
|
+
}
|
|
72
|
+
pieces.push({ literal }, { action: action.trim() });
|
|
73
|
+
position = close + CLOSE.length;
|
|
74
|
+
}
|
|
75
|
+
return pieces;
|
|
76
|
+
}
|
|
77
|
+
/** The offset of the `]]` that ends the action, skipping string literals. */
|
|
78
|
+
function findClose(text, from) {
|
|
79
|
+
for (let i = from; i < text.length; i++) {
|
|
80
|
+
const c = text[i];
|
|
81
|
+
if (c === '"') {
|
|
82
|
+
i++;
|
|
83
|
+
while (i < text.length && text[i] !== '"')
|
|
84
|
+
i += text[i] === '\\' ? 2 : 1;
|
|
85
|
+
}
|
|
86
|
+
else if (c === '`') {
|
|
87
|
+
i = text.indexOf('`', i + 1);
|
|
88
|
+
if (i === -1)
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
else if (text.startsWith(CLOSE, i)) {
|
|
92
|
+
return i;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return -1;
|
|
96
|
+
}
|
|
97
|
+
function applyAction(action, stack, target, fail) {
|
|
98
|
+
const tokens = tokenizeAction(action, fail);
|
|
99
|
+
const first = tokens[0];
|
|
100
|
+
const keyword = first?.t === 'word' ? first.value : '';
|
|
101
|
+
switch (keyword) {
|
|
102
|
+
case 'if':
|
|
103
|
+
case 'with':
|
|
104
|
+
case 'range': {
|
|
105
|
+
stack.push({ type: keyword, pipeline: parsePipeline(tokens.slice(1), fail), body: [], otherwise: [], inElse: false, chained: false });
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'else': {
|
|
109
|
+
startElse(tokens, stack, fail);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'end': {
|
|
113
|
+
if (tokens.length > 1)
|
|
114
|
+
fail('unexpected tokens after end');
|
|
115
|
+
closeFrames(stack, target, fail);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
default: {
|
|
119
|
+
if (UNSUPPORTED.has(keyword)) {
|
|
120
|
+
fail(`"${keyword}" is not supported by the SDK's templates (the supported subset: fields, if, else, with, range, end, and the functions eq ne lt le gt ge not and or len index print printf)`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (tokens.length === 0)
|
|
124
|
+
fail('empty action');
|
|
125
|
+
target().push({ type: 'action', pipeline: parsePipeline(tokens, fail) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function startElse(tokens, stack, fail) {
|
|
131
|
+
const top = stack.at(-1) ?? fail('unexpected else');
|
|
132
|
+
if (top.inElse)
|
|
133
|
+
fail('multiple else in one block');
|
|
134
|
+
top.inElse = true;
|
|
135
|
+
const next = tokens[1];
|
|
136
|
+
if (next?.t === 'word' && next.value === 'if') {
|
|
137
|
+
stack.push({ type: 'if', pipeline: parsePipeline(tokens.slice(2), fail), body: [], otherwise: [], inElse: false, chained: true });
|
|
138
|
+
}
|
|
139
|
+
else if (tokens.length > 1) {
|
|
140
|
+
fail('unexpected tokens after else');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Ends the innermost block, and the `else if` blocks chained to it. */
|
|
144
|
+
function closeFrames(stack, target, fail) {
|
|
145
|
+
let frame;
|
|
146
|
+
do {
|
|
147
|
+
frame = stack.pop() ?? fail('unexpected end');
|
|
148
|
+
target().push({ type: frame.type, pipeline: frame.pipeline, body: frame.body, otherwise: frame.otherwise });
|
|
149
|
+
} while (frame.chained);
|
|
150
|
+
}
|
|
151
|
+
function parsePipeline(tokens, fail) {
|
|
152
|
+
const [pipeline, used] = pipelineFrom(tokens, 0, fail);
|
|
153
|
+
if (used < tokens.length)
|
|
154
|
+
fail('unexpected ")" in action');
|
|
155
|
+
return pipeline;
|
|
156
|
+
}
|
|
157
|
+
function pipelineFrom(tokens, start, fail) {
|
|
158
|
+
const pipeline = [];
|
|
159
|
+
let command = [];
|
|
160
|
+
let i = start;
|
|
161
|
+
for (; i < tokens.length; i++) {
|
|
162
|
+
const token = tokens[i];
|
|
163
|
+
if (token.t === 'close')
|
|
164
|
+
break;
|
|
165
|
+
if (token.t === 'pipe') {
|
|
166
|
+
if (command.length === 0)
|
|
167
|
+
fail('missing value before |');
|
|
168
|
+
pipeline.push(command);
|
|
169
|
+
command = [];
|
|
170
|
+
}
|
|
171
|
+
else if (token.t === 'open') {
|
|
172
|
+
const [inner, next] = pipelineFrom(tokens, i + 1, fail);
|
|
173
|
+
if (tokens[next]?.t !== 'close')
|
|
174
|
+
fail('unclosed (');
|
|
175
|
+
command.push({ kind: 'pipeline', pipeline: inner });
|
|
176
|
+
i = next;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
command.push(operandOf(token));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (command.length === 0)
|
|
183
|
+
fail(pipeline.length > 0 ? 'missing command after |' : 'missing value for the action');
|
|
184
|
+
pipeline.push(command);
|
|
185
|
+
return [pipeline, i];
|
|
186
|
+
}
|
|
187
|
+
function operandOf(token) {
|
|
188
|
+
if (token.t === 'field')
|
|
189
|
+
return { kind: 'field', root: token.root, path: token.path };
|
|
190
|
+
if (token.t === 'string' || token.t === 'number')
|
|
191
|
+
return { kind: token.t, value: token.value };
|
|
192
|
+
if (token.t !== 'word')
|
|
193
|
+
throw new Error('unreachable');
|
|
194
|
+
if (token.value === 'true' || token.value === 'false')
|
|
195
|
+
return { kind: 'bool', value: token.value === 'true' };
|
|
196
|
+
return token.value === 'nil' ? { kind: 'nil' } : { kind: 'name', name: token.value };
|
|
197
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a `[[ ]]` template with the data, the way the Go SDK's `templating`
|
|
3
|
+
* does with `text/template` and `missingkey=error`: a field the data does not
|
|
4
|
+
* have is an error, not an empty string. See {@link parseTemplate} for the
|
|
5
|
+
* language. `name` is only used in error messages.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderTemplate(text: string, data: unknown, name?: string): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseTemplate } from './parse-template.algorithm.js';
|
|
2
|
+
import { TEMPLATE_FUNCTIONS, byKey, format, isObject, isTruthy } from './template-functions.algorithm.js';
|
|
3
|
+
import { TemplateError } from './template.error.js';
|
|
4
|
+
/**
|
|
5
|
+
* Renders a `[[ ]]` template with the data, the way the Go SDK's `templating`
|
|
6
|
+
* does with `text/template` and `missingkey=error`: a field the data does not
|
|
7
|
+
* have is an error, not an empty string. See {@link parseTemplate} for the
|
|
8
|
+
* language. `name` is only used in error messages.
|
|
9
|
+
*/
|
|
10
|
+
export function renderTemplate(text, data, name = 'template') {
|
|
11
|
+
const fail = (message) => {
|
|
12
|
+
throw new TemplateError(name, message);
|
|
13
|
+
};
|
|
14
|
+
return render(parseTemplate(text, name), { dot: data, root: data }, fail);
|
|
15
|
+
}
|
|
16
|
+
function render(nodes, scope, fail) {
|
|
17
|
+
return nodes.map(node => renderNode(node, scope, fail)).join('');
|
|
18
|
+
}
|
|
19
|
+
function renderNode(node, scope, fail) {
|
|
20
|
+
if (node.type === 'text')
|
|
21
|
+
return node.text;
|
|
22
|
+
const value = evaluatePipeline(node.pipeline, scope, fail);
|
|
23
|
+
if (node.type === 'action')
|
|
24
|
+
return format(value);
|
|
25
|
+
if (node.type === 'if')
|
|
26
|
+
return render(isTruthy(value) ? node.body : node.otherwise, scope, fail);
|
|
27
|
+
if (node.type === 'with')
|
|
28
|
+
return isTruthy(value) ? render(node.body, { dot: value, root: scope.root }, fail) : render(node.otherwise, scope, fail);
|
|
29
|
+
const items = rangeItems(value, fail);
|
|
30
|
+
return items.length === 0 ? render(node.otherwise, scope, fail) : items.map(item => render(node.body, { dot: item, root: scope.root }, fail)).join('');
|
|
31
|
+
}
|
|
32
|
+
/** What `range` loops over: the elements of an array, or the values of a map in key order. */
|
|
33
|
+
function rangeItems(value, fail) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value;
|
|
36
|
+
if (value === null || value === undefined)
|
|
37
|
+
return [];
|
|
38
|
+
return isObject(value) ? Object.keys(value).sort(byKey).map(key => value[key]) : fail(`range can't iterate over ${format(value)}`);
|
|
39
|
+
}
|
|
40
|
+
const NONE = Symbol('nothing piped');
|
|
41
|
+
function evaluatePipeline(pipeline, scope, fail) {
|
|
42
|
+
let piped = NONE;
|
|
43
|
+
for (const command of pipeline)
|
|
44
|
+
piped = evaluateCommand(command, scope, piped, fail);
|
|
45
|
+
return piped;
|
|
46
|
+
}
|
|
47
|
+
function evaluateCommand(command, scope, piped, fail) {
|
|
48
|
+
const [head, ...rest] = command;
|
|
49
|
+
if (head.kind === 'name')
|
|
50
|
+
return callFunction(head.name, rest, scope, piped, fail);
|
|
51
|
+
if (rest.length > 0)
|
|
52
|
+
return fail('can\'t give an argument to a value that is not a function');
|
|
53
|
+
return piped === NONE ? evaluateOperand(head, scope, fail) : fail('can\'t pipe into a value that is not a function');
|
|
54
|
+
}
|
|
55
|
+
function callFunction(name, operands, scope, piped, fail) {
|
|
56
|
+
const fn = Object.hasOwn(TEMPLATE_FUNCTIONS, name) ? TEMPLATE_FUNCTIONS[name] : undefined;
|
|
57
|
+
if (fn === undefined)
|
|
58
|
+
return fail(`function "${name}" not defined`);
|
|
59
|
+
const args = operands.map(operand => evaluateOperand(operand, scope, fail));
|
|
60
|
+
if (piped !== NONE)
|
|
61
|
+
args.push(piped);
|
|
62
|
+
try {
|
|
63
|
+
return fn(args);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return fail(`error calling ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function evaluateOperand(operand, scope, fail) {
|
|
70
|
+
if (operand.kind === 'nil')
|
|
71
|
+
return null;
|
|
72
|
+
if ('value' in operand)
|
|
73
|
+
return operand.value;
|
|
74
|
+
if (operand.kind === 'pipeline')
|
|
75
|
+
return evaluatePipeline(operand.pipeline, scope, fail);
|
|
76
|
+
if (operand.kind === 'name')
|
|
77
|
+
return evaluateCommand([operand], scope, NONE, fail);
|
|
78
|
+
let value = operand.root ? scope.root : scope.dot;
|
|
79
|
+
for (const key of operand.path) {
|
|
80
|
+
if (!isObject(value))
|
|
81
|
+
return fail(`can't evaluate field ${key} in ${value === null || value === undefined ? 'nil' : typeof value}`);
|
|
82
|
+
if (!Object.hasOwn(value, key))
|
|
83
|
+
return fail(`map has no entry for key "${key}"`);
|
|
84
|
+
value = value[key];
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TemplateFunction = (args: unknown[]) => unknown;
|
|
2
|
+
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
3
|
+
/** Keys in the order Go sorts the keys of a map in. */
|
|
4
|
+
export declare function byKey(a: string, b: string): number;
|
|
5
|
+
/** Go's truth: false, 0, nil, and an empty string, array or map are false; everything else is true. */
|
|
6
|
+
export declare function isTruthy(value: unknown): boolean;
|
|
7
|
+
/** A value as `text/template` prints it. */
|
|
8
|
+
export declare function format(value: unknown): string;
|
|
9
|
+
/** The functions of `text/template` the subset has. */
|
|
10
|
+
export declare const TEMPLATE_FUNCTIONS: Readonly<Record<string, TemplateFunction>>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export function isObject(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
/** Keys in the order Go sorts the keys of a map in. */
|
|
5
|
+
export function byKey(a, b) {
|
|
6
|
+
return Number(a > b) - Number(a < b);
|
|
7
|
+
}
|
|
8
|
+
/** Go's truth: false, 0, nil, and an empty string, array or map are false; everything else is true. */
|
|
9
|
+
export function isTruthy(value) {
|
|
10
|
+
if ([undefined, null, false, 0, ''].includes(value))
|
|
11
|
+
return false;
|
|
12
|
+
return Array.isArray(value) ? value.length > 0 : !isObject(value) || Object.keys(value).length > 0;
|
|
13
|
+
}
|
|
14
|
+
/** A value as `text/template` prints it. */
|
|
15
|
+
export function format(value) {
|
|
16
|
+
if (value === undefined || value === null)
|
|
17
|
+
return '<no value>';
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return `[${value.map(item => format(item)).join(' ')}]`;
|
|
20
|
+
if (!isObject(value))
|
|
21
|
+
return String(value);
|
|
22
|
+
const entries = Object.keys(value).sort(byKey).map(key => `${key}:${format(value[key])}`);
|
|
23
|
+
return `map[${entries.join(' ')}]`;
|
|
24
|
+
}
|
|
25
|
+
function comparable(a, b) {
|
|
26
|
+
const kind = typeof a;
|
|
27
|
+
if (typeof b !== kind || !['string', 'number', 'boolean'].includes(kind))
|
|
28
|
+
throw new TypeError('incompatible types for comparison');
|
|
29
|
+
return kind;
|
|
30
|
+
}
|
|
31
|
+
function ordered(test) {
|
|
32
|
+
return ([a, b]) => {
|
|
33
|
+
if (comparable(a, b) === 'boolean')
|
|
34
|
+
throw new TypeError('invalid type for comparison');
|
|
35
|
+
return test(a === b ? 0 : (Number(a > b) || -1));
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function indexInto(current, key) {
|
|
39
|
+
if (Array.isArray(current)) {
|
|
40
|
+
if (typeof key !== 'number' || key < 0 || key >= current.length)
|
|
41
|
+
throw new RangeError(`index out of range: ${format(key)}`);
|
|
42
|
+
return current[key];
|
|
43
|
+
}
|
|
44
|
+
if (isObject(current))
|
|
45
|
+
return Object.hasOwn(current, String(key)) ? current[String(key)] : undefined;
|
|
46
|
+
throw new TypeError(`can't index ${format(current)}`);
|
|
47
|
+
}
|
|
48
|
+
/** What Sprint puts between operands: a space when neither side is a string. */
|
|
49
|
+
function sprint(args) {
|
|
50
|
+
return args.reduce((out, arg, i) => {
|
|
51
|
+
const space = i > 0 && typeof arg !== 'string' && typeof args[i - 1] !== 'string';
|
|
52
|
+
return `${out}${space ? ' ' : ''}${format(arg)}`;
|
|
53
|
+
}, '');
|
|
54
|
+
}
|
|
55
|
+
/** The functions of `text/template` the subset has. */
|
|
56
|
+
export const TEMPLATE_FUNCTIONS = {
|
|
57
|
+
eq: ([first, ...others]) => {
|
|
58
|
+
if (others.length === 0)
|
|
59
|
+
throw new TypeError('missing argument for comparison');
|
|
60
|
+
return others.some((other) => {
|
|
61
|
+
if (first === null || other === null)
|
|
62
|
+
return first === other;
|
|
63
|
+
comparable(first, other);
|
|
64
|
+
return first === other;
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
ne: ([a, b]) => {
|
|
68
|
+
if (a === null || b === null)
|
|
69
|
+
return a !== b;
|
|
70
|
+
comparable(a, b);
|
|
71
|
+
return a !== b;
|
|
72
|
+
},
|
|
73
|
+
lt: ordered(difference => difference < 0),
|
|
74
|
+
le: ordered(difference => difference <= 0),
|
|
75
|
+
gt: ordered(difference => difference > 0),
|
|
76
|
+
ge: ordered(difference => difference >= 0),
|
|
77
|
+
not: ([value]) => !isTruthy(value),
|
|
78
|
+
and: args => args.find(arg => !isTruthy(arg)) ?? args.at(-1),
|
|
79
|
+
or: args => args.find(arg => isTruthy(arg)) ?? args.at(-1),
|
|
80
|
+
len: ([value]) => {
|
|
81
|
+
if (typeof value === 'string')
|
|
82
|
+
return Buffer.byteLength(value);
|
|
83
|
+
if (Array.isArray(value))
|
|
84
|
+
return value.length;
|
|
85
|
+
if (isObject(value))
|
|
86
|
+
return Object.keys(value).length;
|
|
87
|
+
throw new TypeError(`len of ${format(value)}`);
|
|
88
|
+
},
|
|
89
|
+
index: ([value, ...keys]) => keys.reduce((current, key) => indexInto(current, key), value),
|
|
90
|
+
print: args => sprint(args),
|
|
91
|
+
printf: ([pattern, ...args]) => {
|
|
92
|
+
if (typeof pattern !== 'string')
|
|
93
|
+
throw new TypeError('printf needs a format string');
|
|
94
|
+
let next = 0;
|
|
95
|
+
return pattern.replaceAll(/%([%sdvqt])/g, (_match, verb) => {
|
|
96
|
+
if (verb === '%')
|
|
97
|
+
return '%';
|
|
98
|
+
if (next >= args.length)
|
|
99
|
+
throw new RangeError(`missing argument for %${verb}`);
|
|
100
|
+
const arg = args[next++];
|
|
101
|
+
return verb === 'q' ? JSON.stringify(String(arg)) : format(arg);
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** One operand of a command: a field of the data, a literal, a parenthesised pipeline or a function name. */
|
|
2
|
+
export type Operand = {
|
|
3
|
+
kind: 'field';
|
|
4
|
+
root: boolean;
|
|
5
|
+
path: string[];
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'string' | 'number' | 'bool';
|
|
8
|
+
value: string | number | boolean;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'nil';
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'pipeline';
|
|
13
|
+
pipeline: Pipeline;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'name';
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
/** Operands in a row: a function call (`eq .Kind "app"`) or a single value. */
|
|
19
|
+
export type Command = Operand[];
|
|
20
|
+
/** Commands joined by `|`, each receiving the previous one's value as its last argument. */
|
|
21
|
+
export type Pipeline = Command[];
|
|
22
|
+
export type TemplateNode = {
|
|
23
|
+
type: 'text';
|
|
24
|
+
text: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'action';
|
|
27
|
+
pipeline: Pipeline;
|
|
28
|
+
} | {
|
|
29
|
+
type: 'if' | 'with' | 'range';
|
|
30
|
+
pipeline: Pipeline;
|
|
31
|
+
body: TemplateNode[];
|
|
32
|
+
otherwise: TemplateNode[];
|
|
33
|
+
};
|