@examplary/cli 1.13.1 → 1.15.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 CHANGED
@@ -25,6 +25,10 @@ You'll be required to pass a `--key` argument with your API key, which you can
25
25
  obtain from your Examplary account settings. You can also set the `EXAMPLARY_API_KEY`
26
26
  environment variable to avoid passing the key every time.
27
27
 
28
+ A `README.md` and `preview.png` in the same directory are uploaded as the question type's
29
+ documentation and preview image, along with any translations named by language code, like
30
+ `README.nl.md` or `preview.nl.png`.
31
+
28
32
  ## Validating
29
33
 
30
34
  To run all of the pre-upload checks without uploading anything, use:
@@ -1,10 +1,29 @@
1
1
  #! /usr/bin/env node
2
- import { readFile } from "fs/promises";
2
+ import { readdir, readFile } from "fs/promises";
3
3
  import { join } from "path";
4
4
  import { cwd, exit } from "process";
5
5
  import { setApiHost, setApiKey, uploadFile } from "../lib/api.js";
6
6
  import { readJsonOrYamlFile, readMetaFile } from "../lib/read-meta.js";
7
7
  import { isValid, printValidationResult, validateDefinition, } from "../lib/validate.js";
8
+ /**
9
+ * Uploads e.g. `README.md` (English) and its translations like `README.nl.md`,
10
+ * returning their URLs keyed by language code.
11
+ */
12
+ const uploadLocalizedFiles = async (dir, baseName, extension, contentType) => {
13
+ const pattern = new RegExp(`^${baseName}(?:\\.([a-z]{2}))?\\.${extension}$`);
14
+ const urls = {};
15
+ for (const file of await readdir(dir)) {
16
+ const match = file.match(pattern);
17
+ if (!match)
18
+ continue;
19
+ const contents = await readFile(join(dir, file));
20
+ if (!contents.length)
21
+ continue;
22
+ urls[match[1] || "en"] = await uploadFile(file, contents, contentType);
23
+ console.log(`• Uploaded ${file}`);
24
+ }
25
+ return Object.keys(urls).length ? urls : undefined;
26
+ };
8
27
  export const uploadCommand = async (argv) => {
9
28
  setApiHost(argv.host);
10
29
  setApiKey(argv.key);
@@ -38,6 +57,19 @@ export const uploadCommand = async (argv) => {
38
57
  console.log(`• Uploaded component ${componentType}`);
39
58
  }
40
59
  definition.components = components;
60
+ // Upload the bundled functions
61
+ const functions = {};
62
+ for (const [functionName, output] of Object.entries(result.functions)) {
63
+ // Upload source map first if it exists
64
+ let sourceMapStatement = "";
65
+ if (output.sourcemap) {
66
+ const url = await uploadFile(`function-${functionName}.js.map`, output.sourcemap, "application/json");
67
+ sourceMapStatement = `\n//# sourceMappingURL=${url}`;
68
+ }
69
+ functions[functionName] = await uploadFile(`function-${functionName}.js`, output.js + sourceMapStatement, "application/javascript");
70
+ console.log(`• Uploaded function ${functionName}`);
71
+ }
72
+ definition.functions = functions;
41
73
  // Upload styles if present
42
74
  if (result.styles) {
43
75
  definition.stylesheet = await uploadFile("styles.css", result.styles, "text/css");
@@ -49,18 +81,10 @@ export const uploadCommand = async (argv) => {
49
81
  definition.icon = await uploadFile("icon.svg", result.iconContents, "image/svg+xml");
50
82
  console.log(`• Uploaded icon`);
51
83
  }
52
- // Upload the README if present
53
- const readme = await readFile(join(dir, "README.md"), "utf-8").catch(() => null);
54
- if (readme) {
55
- definition.readme = await uploadFile("README.md", readme, "text/markdown");
56
- console.log(`• Uploaded README`);
57
- }
58
- // Upload the preview image if present
59
- const previewImage = await readFile(join(dir, "preview.png")).catch(() => null);
60
- if (previewImage) {
61
- definition.previewImage = await uploadFile("preview.png", previewImage, "image/png");
62
- console.log(`• Uploaded preview image`);
63
- }
84
+ definition.readmeUrls = await uploadLocalizedFiles(dir, "README", "md", "text/markdown");
85
+ definition.readme = definition.readmeUrls?.en;
86
+ definition.previewImageUrls = await uploadLocalizedFiles(dir, "preview", "png", "image/png");
87
+ definition.previewImage = definition.previewImageUrls?.en;
64
88
  // Upload the preview data if present, always converted to JSON
65
89
  try {
66
90
  const preview = await readJsonOrYamlFile(dir, "preview");
@@ -1,4 +1,10 @@
1
1
  export declare const buildComponent: (file: string) => Promise<{
2
2
  js: string;
3
3
  sourcemap: string;
4
+ sha256: string;
5
+ }>;
6
+ export declare const buildFunction: (file: string) => Promise<{
7
+ js: string;
8
+ sourcemap: string;
9
+ sha256: string;
4
10
  }>;
@@ -1,4 +1,8 @@
1
+ import { createHash } from "crypto";
1
2
  import { build } from "esbuild";
3
+ const calculateHash = (compiled) => {
4
+ return createHash("sha256").update(compiled).digest("hex");
5
+ };
2
6
  export const buildComponent = async (file) => {
3
7
  const res = await build({
4
8
  entryPoints: [file],
@@ -23,5 +27,28 @@ export const buildComponent = async (file) => {
23
27
  sourcemap = Buffer.from(js.split(sourceMapStatement)[1].trim(), "base64").toString("utf-8");
24
28
  js = js.split(sourceMapStatement)[0].trim();
25
29
  }
26
- return { js, sourcemap };
30
+ const sha256 = calculateHash(js);
31
+ return { js, sourcemap, sha256 };
32
+ };
33
+ export const buildFunction = async (file) => {
34
+ const res = await build({
35
+ entryPoints: [file],
36
+ bundle: true,
37
+ write: false,
38
+ minify: true,
39
+ sourcemap: "inline",
40
+ platform: "neutral",
41
+ mainFields: ["module", "main"],
42
+ format: "iife",
43
+ globalName: "userFunction",
44
+ });
45
+ let js = res.outputFiles[0].text;
46
+ let sourcemap = "";
47
+ const sourceMapStatement = "\n//# sourceMappingURL=data:application/json;base64,";
48
+ if (js.includes(sourceMapStatement)) {
49
+ sourcemap = Buffer.from(js.split(sourceMapStatement)[1].trim(), "base64").toString("utf-8");
50
+ js = js.split(sourceMapStatement)[0].trim();
51
+ }
52
+ const sha256 = calculateHash(js);
53
+ return { js, sourcemap, sha256 };
27
54
  };
@@ -18,6 +18,8 @@ export type ValidationResult = {
18
18
  styles?: string;
19
19
  /** Raw icon file contents, present only when an icon is configured. */
20
20
  iconContents?: string;
21
+ /** Bundled function output, keyed by function name, ready to upload. */
22
+ functions: Record<string, BuiltComponent>;
21
23
  };
22
24
  /**
23
25
  * Runs every check the `upload` command performs, short of actually uploading:
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import { resolve } from "path";
3
- import { buildComponent } from "./bundle.js";
3
+ import { buildComponent, buildFunction } from "./bundle.js";
4
4
  import { deepCompileJsonata } from "./jsonata.js";
5
5
  import { runResponseProcessingTestCases, } from "./response-processing.js";
6
6
  import { buildStyles } from "./styles.js";
@@ -20,6 +20,7 @@ export const validateDefinition = async (definition, dir) => {
20
20
  const errors = [];
21
21
  const warnings = [];
22
22
  const bundles = {};
23
+ const functions = {};
23
24
  let styles;
24
25
  let iconContents;
25
26
  if (!definition.id)
@@ -89,6 +90,21 @@ export const validateDefinition = async (definition, dir) => {
89
90
  errors.push(`Error bundling component ${componentType} from ${componentPath}: ${error.message}`);
90
91
  }
91
92
  }
93
+ // Bundle functions to confirm they build and to capture their output
94
+ for (const [functionName, functionPath] of Object.entries(definition.functions || {})) {
95
+ const realPath = resolve(dir, functionPath);
96
+ try {
97
+ const output = await buildFunction(realPath);
98
+ functions[functionName] = output;
99
+ const sizeInKb = output.js.length / 1000;
100
+ if (sizeInKb > 100) {
101
+ errors.push(`Function ${functionName} is larger than the 100 kB limit (${sizeInKb.toFixed(2)} kB)`);
102
+ }
103
+ }
104
+ catch (error) {
105
+ errors.push(`Error bundling function ${functionName} from ${functionPath}: ${error.message}`);
106
+ }
107
+ }
92
108
  // Build styles if any components are present
93
109
  if (Object.keys(bundles).length) {
94
110
  styles = (await buildStyles()) ?? undefined;
@@ -106,7 +122,15 @@ export const validateDefinition = async (definition, dir) => {
106
122
  errors.push(`Icon file is empty: ${iconPath}`);
107
123
  }
108
124
  }
109
- return { errors, warnings, testCaseResults, bundles, styles, iconContents };
125
+ return {
126
+ errors,
127
+ warnings,
128
+ testCaseResults,
129
+ bundles,
130
+ functions,
131
+ styles,
132
+ iconContents,
133
+ };
110
134
  };
111
135
  /** Whether the definition is safe to upload. */
112
136
  export const isValid = (result) => result.errors.length === 0 && result.testCaseResults.every((r) => r.passed);
@@ -116,6 +140,10 @@ export const printValidationResult = (result) => {
116
140
  const sizeInKb = (output.js.length / 1000).toFixed(2);
117
141
  console.log(`• Bundled component ${componentType} (${sizeInKb} kB)`);
118
142
  }
143
+ for (const [functionName, output] of Object.entries(result.functions)) {
144
+ const sizeInKb = (output.js.length / 1000).toFixed(2);
145
+ console.log(`• Bundled function ${functionName} (${sizeInKb} kB)`);
146
+ }
119
147
  for (const warning of result.warnings) {
120
148
  console.warn(` ⚠️ ${warning.message}`);
121
149
  if (warning.file && process.env.GITHUB_ACTIONS) {