@examplary/cli 1.0.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/.turbo/turbo-release.log +69 -0
- package/README.md +26 -0
- package/bin/index.js +37 -0
- package/package.json +52 -0
- package/src/commands/preview.js +210 -0
- package/src/commands/upload.js +178 -0
- package/src/lib/api.js +53 -0
- package/src/lib/bundle.js +35 -0
- package/src/lib/middleware.js +16 -0
- package/src/lib/styles.js +32 -0
- package/src/preview-ui/App.tsx +164 -0
- package/src/preview-ui/components/json-editor.tsx +41 -0
- package/src/preview-ui/components/preview-assessment.tsx +41 -0
- package/src/preview-ui/components/preview-print.tsx +37 -0
- package/src/preview-ui/components/preview-results.tsx +35 -0
- package/src/preview-ui/components/preview-settings-area.tsx +91 -0
- package/src/preview-ui/components/question-component.tsx +70 -0
- package/src/preview-ui/index.css +11 -0
- package/src/preview-ui/index.html +13 -0
- package/src/preview-ui/lib/i18n.ts +15 -0
- package/src/preview-ui/main.tsx +8 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[4:07:09 PM] [semantic-release] › ℹ Running semantic-release version 24.2.7
|
|
2
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "verifyConditions" from "@semantic-release/npm"
|
|
3
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "verifyConditions" from "@semantic-release/github"
|
|
4
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "prepare" from "@semantic-release/npm"
|
|
5
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "publish" from "@semantic-release/npm"
|
|
6
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "publish" from "@semantic-release/github"
|
|
7
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "addChannel" from "@semantic-release/npm"
|
|
8
|
+
[4:07:10 PM] [semantic-release] › ✔ Loaded plugin "addChannel" from "@semantic-release/github"
|
|
9
|
+
[4:07:14 PM] [semantic-release] › ✔ Run automated release from branch main on repository https://github.com/examplary-ai/examplary
|
|
10
|
+
[4:07:14 PM] [semantic-release] › ✔ Allowed to push to the Git repository
|
|
11
|
+
[4:07:14 PM] [semantic-release] › ℹ Start step "verifyConditions" of plugin "@semantic-release/npm"
|
|
12
|
+
[4:07:14 PM] [semantic-release] [@semantic-release/npm] › ℹ Verify authentication for registry https://registry.npmjs.org/
|
|
13
|
+
[4:07:14 PM] [semantic-release] [@semantic-release/npm] › ℹ Reading npm config from /home/runner/work/examplary/examplary/.npmrc
|
|
14
|
+
[4:07:14 PM] [semantic-release] [@semantic-release/npm] › ℹ Wrote NPM_TOKEN to /tmp/87543065a99722d5233a87b2f2b107ba/.npmrc
|
|
15
|
+
tschoffelen
|
|
16
|
+
[4:07:15 PM] [semantic-release] › ✔ Completed step "verifyConditions" of plugin "@semantic-release/npm"
|
|
17
|
+
[4:07:15 PM] [semantic-release] › ℹ Start step "verifyConditions" of plugin "@semantic-release/github"
|
|
18
|
+
[4:07:15 PM] [semantic-release] [@semantic-release/github] › ℹ Verify GitHub authentication (https://api.github.com)
|
|
19
|
+
[4:07:15 PM] [semantic-release] › ✔ Completed step "verifyConditions" of plugin "@semantic-release/github"
|
|
20
|
+
[4:07:15 PM] [semantic-release] › ℹ No git tag version found on branch main
|
|
21
|
+
[4:07:15 PM] [semantic-release] › ℹ No previous release found, retrieving all commits
|
|
22
|
+
[4:07:15 PM] [semantic-release] › ℹ Found 948 commits since last release
|
|
23
|
+
[4:07:15 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
24
|
+
[4:07:15 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Start step "analyzeCommits" of plugin "@semantic-release/commit-analyzer"
|
|
25
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Found 4 commits for package @examplary/cli since last release
|
|
26
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Analyzing commit: feat: rename to @examplary/cli
|
|
27
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ The release type for the commit is minor
|
|
28
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Analyzing commit: feat: rename to @examplary/cli
|
|
29
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ The release type for the commit is minor
|
|
30
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Analyzing commit: feat: rename to @examplary/cli
|
|
31
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ The release type for the commit is minor
|
|
32
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Analyzing commit: feat: rename bundler to cli
|
|
33
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ The release type for the commit is minor
|
|
34
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Analysis of 4 commits complete: minor release
|
|
35
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Completed step "analyzeCommits" of plugin "@semantic-release/commit-analyzer"
|
|
36
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
37
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
38
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Plugin "@semantic-release/release-notes-generator" does not provide step "analyzeCommits"
|
|
39
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
40
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
41
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Plugin "@semantic-release/npm" does not provide step "analyzeCommits"
|
|
42
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
43
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
44
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Plugin "@semantic-release/github" does not provide step "analyzeCommits"
|
|
45
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
46
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
47
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ No more plugins
|
|
48
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
49
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
50
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ No more plugins
|
|
51
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
52
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
53
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ No more plugins
|
|
54
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
55
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
56
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ No more plugins
|
|
57
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
58
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
59
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ No more plugins
|
|
60
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
61
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
62
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ No more plugins
|
|
63
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "analyzeCommits" of plugin "[Function: semantic-release-monorepo]"
|
|
64
|
+
[4:07:19 PM] [semantic-release] › ℹ There is no previous release, the next release version is 1.0.0
|
|
65
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "generateNotes" of plugin "[Function: semantic-release-monorepo]"
|
|
66
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Plugin "@semantic-release/commit-analyzer" does not provide step "generateNotes"
|
|
67
|
+
[4:07:19 PM] [semantic-release] › ✔ Completed step "generateNotes" of plugin "[Function: semantic-release-monorepo]"
|
|
68
|
+
[4:07:19 PM] [semantic-release] › ℹ Start step "generateNotes" of plugin "[Function: semantic-release-monorepo]"
|
|
69
|
+
[4:07:19 PM] [semantic-release] [[Function: semantic-release-monorepo]] › ℹ Start step "generateNotes" of plugin "@semantic-release/release-notes-generator"
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Examplary CLI
|
|
2
|
+
|
|
3
|
+
This package provides a command-line tool to develop and upload custom question types for
|
|
4
|
+
the Examplary platform.
|
|
5
|
+
|
|
6
|
+
Read more in the [📘 Developer Documentation](https://developers.examplary.ai/question-types).
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
You can locally develop your custom question types using the Examplary CLI, by
|
|
11
|
+
running the following command in your project directory:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @examplary/cli preview
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To upload your custom question type, run the following command in a directory
|
|
18
|
+
that contains a `question-type.json` file:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @examplary/cli upload
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
You'll be required to pass a `--key` argument with your API key, which you can
|
|
25
|
+
obtain from your Examplary account settings. You can also set the `EXAMPLARY_API_KEY`
|
|
26
|
+
environment variable to avoid passing the key every time.
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#! /usr/bin/env node
|
|
2
|
+
import yargs from "yargs";
|
|
3
|
+
import { hideBin } from "yargs/helpers";
|
|
4
|
+
|
|
5
|
+
import { previewCommand } from "../src/commands/preview.js";
|
|
6
|
+
import { uploadCommand } from "../src/commands/upload.js";
|
|
7
|
+
|
|
8
|
+
yargs()
|
|
9
|
+
.usage("examplary <cmd> [args]")
|
|
10
|
+
|
|
11
|
+
.command(
|
|
12
|
+
"upload",
|
|
13
|
+
"Bundle and upload a question type definition",
|
|
14
|
+
(yargs) => {
|
|
15
|
+
yargs
|
|
16
|
+
.option("key", {
|
|
17
|
+
type: "string",
|
|
18
|
+
description:
|
|
19
|
+
"API key for authentication (uses EXAMPLARY_API_KEY env var if not provided)",
|
|
20
|
+
default: process.env.EXAMPLARY_API_KEY || "",
|
|
21
|
+
})
|
|
22
|
+
.option("host", {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "API host to upload to",
|
|
25
|
+
default: "https://api.examplary.ai",
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
uploadCommand,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
.command("preview", "Run local development server", previewCommand)
|
|
32
|
+
|
|
33
|
+
.help()
|
|
34
|
+
.recommendCommands()
|
|
35
|
+
.demandCommand()
|
|
36
|
+
|
|
37
|
+
.parse(hideBin(process.argv));
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@examplary/cli",
|
|
3
|
+
"description": "A bundler for Examplary question types.",
|
|
4
|
+
"packageManager": "yarn@4.8.1",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": "./bin/index.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"release": "semantic-release -e semantic-release-monorepo"
|
|
10
|
+
},
|
|
11
|
+
"installConfig": {
|
|
12
|
+
"hoistingLimits": "dependencies"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@examplary/schemas": "*",
|
|
19
|
+
"@examplary/ui": "*",
|
|
20
|
+
"@hono/node-server": "^1.19.0",
|
|
21
|
+
"@monaco-editor/react": "^4.7.0",
|
|
22
|
+
"@tailwindcss/postcss": "^4.1.11",
|
|
23
|
+
"@tailwindcss/vite": "^4.1.11",
|
|
24
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
25
|
+
"esbuild": "^0.25.8",
|
|
26
|
+
"hono": "^4.8.9",
|
|
27
|
+
"i18next": "^25.3.2",
|
|
28
|
+
"i18next-browser-languagedetector": "^8.2.0",
|
|
29
|
+
"lucide-react": "^0.535.0",
|
|
30
|
+
"open": "^10.2.0",
|
|
31
|
+
"postcss": "^8.5.6",
|
|
32
|
+
"react": "^19.1.0",
|
|
33
|
+
"react-dom": "^19.1.0",
|
|
34
|
+
"react-error-boundary": "^6.0.0",
|
|
35
|
+
"react-i18next": "^15.6.1",
|
|
36
|
+
"swr": "^2.3.4",
|
|
37
|
+
"tailwindcss": "^4.1.11",
|
|
38
|
+
"use-local-storage": "^3.0.0",
|
|
39
|
+
"vite": "^7.0.6",
|
|
40
|
+
"ws": "^8.18.3",
|
|
41
|
+
"yargs": "^18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.1.0",
|
|
45
|
+
"typescript": "^5.8.3"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://developers.examplary.ai/",
|
|
48
|
+
"author": {
|
|
49
|
+
"name": "Examplary AI",
|
|
50
|
+
"email": "hi@examplary.ai"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#! /usr/bin/env node
|
|
2
|
+
import { watch } from "fs";
|
|
3
|
+
import { stat, readFile } from "fs/promises";
|
|
4
|
+
import { createServer as createHttpServer } from "http";
|
|
5
|
+
import { join, resolve } from "path";
|
|
6
|
+
import { cwd, exit } from "process";
|
|
7
|
+
|
|
8
|
+
import { serve } from "@hono/node-server";
|
|
9
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
10
|
+
import react from "@vitejs/plugin-react";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import open from "open";
|
|
13
|
+
import { createServer } from "vite";
|
|
14
|
+
import { WebSocketServer } from "ws";
|
|
15
|
+
|
|
16
|
+
import { setApiHost, setApiKey } from "../lib/api.js";
|
|
17
|
+
import { buildComponent } from "../lib/bundle.js";
|
|
18
|
+
import { adaptExpressMiddleware } from "../lib/middleware.js";
|
|
19
|
+
import { buildStyles } from "../lib/styles.js";
|
|
20
|
+
|
|
21
|
+
export const previewCommand = async (argv) => {
|
|
22
|
+
setApiHost(argv.host);
|
|
23
|
+
setApiKey(argv.key);
|
|
24
|
+
|
|
25
|
+
// Let's check a question-type.json file exists
|
|
26
|
+
const dir = cwd();
|
|
27
|
+
const metaFilePath = join(dir, "question-type.json");
|
|
28
|
+
try {
|
|
29
|
+
await stat(metaFilePath);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error(`🚫 Error reading question-type.json: ${e.message}`);
|
|
32
|
+
exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Let's start a Vite server to preview the question type
|
|
36
|
+
const root = resolve(import.meta.dirname, "..", "preview-ui");
|
|
37
|
+
const app = new Hono();
|
|
38
|
+
const vite = await createServer({
|
|
39
|
+
server: { middlewareMode: true },
|
|
40
|
+
mode: "development",
|
|
41
|
+
appType: "custom",
|
|
42
|
+
root,
|
|
43
|
+
plugins: [react(), tailwindcss()],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
app.get("/api/bundle", async (c) => {
|
|
47
|
+
const path = c.req.query("path");
|
|
48
|
+
const sourcemap = c.req.query("sourcemap") === "1";
|
|
49
|
+
const componentName = c.req.query("componentName");
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const componentOutput = await buildComponent(path);
|
|
53
|
+
|
|
54
|
+
if (sourcemap) {
|
|
55
|
+
return c.body(componentOutput.sourcemap, 200, {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
const sizeInKb = componentOutput.length / 1000;
|
|
60
|
+
if (sizeInKb > 100) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`⚠️ Component ${componentName} is larger than 100 kB! Consider optimizing it.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let output = componentOutput.js;
|
|
67
|
+
if (componentOutput.sourcemap) {
|
|
68
|
+
output += `\n//# sourceMappingURL=/api/bundle?componentName=${componentName}&path=${encodeURIComponent(path || "")}&sourcemap=1`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return c.body(output, 200, {
|
|
72
|
+
"Content-Type": "application/javascript",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(
|
|
77
|
+
`🚫 Error bundling component ${componentName} from ${path}: ${error.message}`,
|
|
78
|
+
);
|
|
79
|
+
return c.text(
|
|
80
|
+
`Error bundling component ${componentName}: ${error.message}`,
|
|
81
|
+
500,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
app.get("/api/icon", async (c) => {
|
|
86
|
+
const path = c.req.query("path") || "";
|
|
87
|
+
const icon = await readFile(resolve(dir, path), "utf-8");
|
|
88
|
+
|
|
89
|
+
return c.body(icon, 200, {
|
|
90
|
+
"Content-Type": "image/svg+xml",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
app.get("/api/stylesheet", async (c) => {
|
|
95
|
+
const componentName = c.req.query("componentName");
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const componentOutput = await buildStyles();
|
|
99
|
+
return c.body(componentOutput || "", 200, {
|
|
100
|
+
"Content-Type": "text/css",
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error(
|
|
104
|
+
`🚫 Error building CSS for component ${componentName}: ${error.message}`,
|
|
105
|
+
);
|
|
106
|
+
return c.text(
|
|
107
|
+
`Error building CSS for component ${componentName}: ${error.message}`,
|
|
108
|
+
500,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let wsPort; // WebSocket port will be set when server starts
|
|
114
|
+
|
|
115
|
+
app.get("/", async (c) => {
|
|
116
|
+
let questionType;
|
|
117
|
+
try {
|
|
118
|
+
questionType = JSON.parse(await readFile(metaFilePath, "utf-8"));
|
|
119
|
+
questionType.components = questionType.components
|
|
120
|
+
? Object.entries(questionType.components).reduce(
|
|
121
|
+
(acc, [componentName, componentPath]) => ({
|
|
122
|
+
...acc,
|
|
123
|
+
[componentName]: `/api/bundle?componentName=${componentName}&path=${encodeURIComponent(componentPath)}`,
|
|
124
|
+
}),
|
|
125
|
+
{},
|
|
126
|
+
)
|
|
127
|
+
: questionType.components;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return c.text(`Error reading question-type.json: ${e.message}`, 500);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let template = await readFile(resolve(root, "index.html"), "utf-8");
|
|
133
|
+
template = await vite.transformIndexHtml("/", template);
|
|
134
|
+
|
|
135
|
+
// Inject hot reload script
|
|
136
|
+
const hotReloadScript = `
|
|
137
|
+
<script>
|
|
138
|
+
(function() {
|
|
139
|
+
const wsPort = ${wsPort || "null"};
|
|
140
|
+
if (wsPort) {
|
|
141
|
+
const ws = new WebSocket('ws://localhost:' + wsPort);
|
|
142
|
+
ws.onmessage = function(event) {
|
|
143
|
+
const data = JSON.parse(event.data);
|
|
144
|
+
if (data.type === 'reload') {
|
|
145
|
+
console.log('🔄 Reloading page due to file change...');
|
|
146
|
+
window.location.reload();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
ws.onopen = function() {
|
|
150
|
+
console.log('🔗 Hot reload connected');
|
|
151
|
+
};
|
|
152
|
+
ws.onclose = function() {
|
|
153
|
+
console.log('🔌 Hot reload disconnected');
|
|
154
|
+
};
|
|
155
|
+
ws.onerror = function(error) {
|
|
156
|
+
console.log('❌ Hot reload error:', error);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
</script>
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
template =
|
|
164
|
+
"<script>window._questionType = " +
|
|
165
|
+
JSON.stringify(questionType) +
|
|
166
|
+
`;</script>${template}${hotReloadScript}`;
|
|
167
|
+
return c.html(template);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
app.use("*", adaptExpressMiddleware(vite.middlewares));
|
|
171
|
+
|
|
172
|
+
// Set up file watcher
|
|
173
|
+
const watchDir = cwd();
|
|
174
|
+
console.log(`📁 Watching ${watchDir} for changes...`);
|
|
175
|
+
|
|
176
|
+
let wss; // Will be initialized after server starts
|
|
177
|
+
|
|
178
|
+
const watcher = watch(watchDir, { recursive: true }, (filename) => {
|
|
179
|
+
if (filename && !filename.startsWith(".")) {
|
|
180
|
+
// Broadcast reload message to all connected clients
|
|
181
|
+
if (wss) {
|
|
182
|
+
wss.clients.forEach((client) => {
|
|
183
|
+
if (client.readyState === 1) {
|
|
184
|
+
// WebSocket.OPEN
|
|
185
|
+
client.send(JSON.stringify({ type: "reload" }));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Clean up watcher on process exit
|
|
193
|
+
process.on("SIGINT", () => {
|
|
194
|
+
watcher.close();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
serve({ fetch: app.fetch }, (address) => {
|
|
199
|
+
const port = address.port;
|
|
200
|
+
wsPort = port + 1234;
|
|
201
|
+
|
|
202
|
+
const wsServer = createHttpServer();
|
|
203
|
+
wss = new WebSocketServer({ server: wsServer });
|
|
204
|
+
wsServer.listen(wsPort);
|
|
205
|
+
|
|
206
|
+
const url = `http://localhost:${port}`;
|
|
207
|
+
console.log(`🚀 Preview server running at ${url}`);
|
|
208
|
+
open(url);
|
|
209
|
+
});
|
|
210
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#! /usr/bin/env node
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { cwd, exit } from "process";
|
|
5
|
+
|
|
6
|
+
import { setApiHost, setApiKey, uploadFile } from "../lib/api.js";
|
|
7
|
+
import { buildComponent } from "../lib/bundle.js";
|
|
8
|
+
import { buildStyles } from "../lib/styles.js";
|
|
9
|
+
|
|
10
|
+
export const uploadCommand = async (argv) => {
|
|
11
|
+
setApiHost(argv.host);
|
|
12
|
+
setApiKey(argv.key);
|
|
13
|
+
|
|
14
|
+
// Try to read the question-type.json files in the current directory
|
|
15
|
+
const dir = cwd();
|
|
16
|
+
const metaFilePath = join(dir, "question-type.json");
|
|
17
|
+
let metaFileContents;
|
|
18
|
+
let definition = {};
|
|
19
|
+
try {
|
|
20
|
+
metaFileContents = await readFile(metaFilePath, "utf-8");
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(`🚫 Error reading question-type.json: ${e.message}`);
|
|
23
|
+
exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Read JSON
|
|
27
|
+
try {
|
|
28
|
+
const json = JSON.parse(metaFileContents);
|
|
29
|
+
definition = json;
|
|
30
|
+
if (definition.$schema) delete definition.$schema;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`🚫 Invalid JSON file: ${error.message}`);
|
|
33
|
+
exit(1);
|
|
34
|
+
}
|
|
35
|
+
console.log(`Uploading question type: ${definition.id}...`);
|
|
36
|
+
if (!definition.id) {
|
|
37
|
+
console.error(`🚫 Missing required field: id`);
|
|
38
|
+
exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (!definition.name) {
|
|
41
|
+
console.error(`🚫 Missing required field: name`);
|
|
42
|
+
exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Bundle and upload components
|
|
46
|
+
const components = {};
|
|
47
|
+
const validComponentTypes = [
|
|
48
|
+
"settings-area",
|
|
49
|
+
"assessment",
|
|
50
|
+
"print",
|
|
51
|
+
"results",
|
|
52
|
+
];
|
|
53
|
+
for (const [componentType, componentPath] of Object.entries(
|
|
54
|
+
definition.components || {},
|
|
55
|
+
)) {
|
|
56
|
+
// Validation
|
|
57
|
+
if (!validComponentTypes.includes(componentType)) {
|
|
58
|
+
console.error(`🚫 Invalid component type specified: ${componentType}`);
|
|
59
|
+
exit(1);
|
|
60
|
+
}
|
|
61
|
+
const realPath = resolve(dir, componentPath);
|
|
62
|
+
if (!realPath) {
|
|
63
|
+
console.error(`🚫 Missing required file: ${componentPath}`);
|
|
64
|
+
exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Bundle component
|
|
68
|
+
let componentOutput = {};
|
|
69
|
+
try {
|
|
70
|
+
componentOutput = await buildComponent(realPath);
|
|
71
|
+
const sizeInKb = componentOutput.js.length / 1000;
|
|
72
|
+
const sizeHint = `(${sizeInKb.toFixed(2)} kB)`;
|
|
73
|
+
console.log(
|
|
74
|
+
`• Bundled component ${componentType} from ${componentPath} ${sizeHint}`,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (sizeInKb > 100) {
|
|
78
|
+
console.warn(
|
|
79
|
+
` ⚠️ Component ${componentType} is larger than 100 kB! Consider optimizing it.`,
|
|
80
|
+
);
|
|
81
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
82
|
+
console.log(
|
|
83
|
+
`::warning file=${realPath},title=Large component size::Component ${
|
|
84
|
+
componentType
|
|
85
|
+
} is larger than 100 kB ${sizeHint}! Consider optimizing it.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(
|
|
91
|
+
`🚫 Error bundling component ${componentType} from ${componentPath}: ${error.message}`,
|
|
92
|
+
);
|
|
93
|
+
exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Upload source map first if it exists
|
|
97
|
+
let sourceMapStatement = "";
|
|
98
|
+
if (componentOutput.sourcemap) {
|
|
99
|
+
const url = await uploadFile(
|
|
100
|
+
`component-${componentType}.js.map`,
|
|
101
|
+
componentOutput.sourcemap,
|
|
102
|
+
"application/json",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
sourceMapStatement = `\n//# sourceMappingURL=${url}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Upload component
|
|
109
|
+
components[componentType] = await uploadFile(
|
|
110
|
+
`component-${componentType}.js`,
|
|
111
|
+
componentOutput.js + sourceMapStatement,
|
|
112
|
+
"application/javascript",
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
console.log(`• Uploaded component ${componentType}`);
|
|
116
|
+
}
|
|
117
|
+
definition.components = components;
|
|
118
|
+
|
|
119
|
+
// Upload styles if present
|
|
120
|
+
if (Object.keys(definition.components).length) {
|
|
121
|
+
const styles = await buildStyles();
|
|
122
|
+
if (styles) {
|
|
123
|
+
definition.stylesheet = await uploadFile(
|
|
124
|
+
"styles.css",
|
|
125
|
+
styles,
|
|
126
|
+
"text/css",
|
|
127
|
+
);
|
|
128
|
+
const sizeInKb = styles.length / 1000;
|
|
129
|
+
const sizeHint = `(${sizeInKb.toFixed(2)} kB)`;
|
|
130
|
+
console.log(`• Uploaded styles ${sizeHint}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Upload icon if specified
|
|
135
|
+
if (definition.icon) {
|
|
136
|
+
const iconPath = resolve(dir, definition.icon);
|
|
137
|
+
let iconContents;
|
|
138
|
+
try {
|
|
139
|
+
iconContents = await readFile(iconPath, "utf-8");
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error(`🚫 Error reading icon file: ${error.message}`);
|
|
142
|
+
exit(1);
|
|
143
|
+
}
|
|
144
|
+
if (!iconContents) {
|
|
145
|
+
console.error(`🚫 Icon file is empty: ${iconPath}`);
|
|
146
|
+
exit(1);
|
|
147
|
+
}
|
|
148
|
+
definition.icon = await uploadFile(
|
|
149
|
+
"icon.svg",
|
|
150
|
+
iconContents,
|
|
151
|
+
"image/svg+xml",
|
|
152
|
+
);
|
|
153
|
+
console.log(`• Uploaded icon`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Upload the main question type definition
|
|
157
|
+
const res = await fetch(`${argv.host}/question-types`, {
|
|
158
|
+
headers: {
|
|
159
|
+
Authorization: argv.key,
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
},
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: JSON.stringify(definition),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
console.error(`🚫 Uploading question type failed`);
|
|
168
|
+
try {
|
|
169
|
+
const errorResponse = await res.json();
|
|
170
|
+
console.log(errorResponse);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.log(e);
|
|
173
|
+
}
|
|
174
|
+
exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`• Finished uploading definition\n`);
|
|
178
|
+
};
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
import { exit } from "process";
|
|
3
|
+
|
|
4
|
+
// These get overwritten by the CLI options
|
|
5
|
+
export let API_HOST = "https://api.examplary.ai";
|
|
6
|
+
export let API_KEY = process.env.EXAMPLARY_API_KEY || "";
|
|
7
|
+
|
|
8
|
+
export const setApiKey = (key) => {
|
|
9
|
+
if (!key) return;
|
|
10
|
+
API_KEY = key;
|
|
11
|
+
};
|
|
12
|
+
export const setApiHost = (host) => {
|
|
13
|
+
if (!host) return;
|
|
14
|
+
API_HOST = host;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const uploadFile = async (fileName, fileContents, contentType) => {
|
|
18
|
+
const params = new URLSearchParams();
|
|
19
|
+
params.append("filename", basename(fileName));
|
|
20
|
+
params.append("contentType", contentType);
|
|
21
|
+
params.append("type", "question-type");
|
|
22
|
+
|
|
23
|
+
// Request the upload URL
|
|
24
|
+
const res = await fetch(`${API_HOST}/media/upload?${params.toString()}`, {
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: API_KEY,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const errorText = await res.text();
|
|
32
|
+
console.error(`🚫 Upload failed: ${errorText}`);
|
|
33
|
+
exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Actually upload the file
|
|
37
|
+
const { uploadUrl, publicUrl } = await res.json();
|
|
38
|
+
const uploadRes = await fetch(uploadUrl, {
|
|
39
|
+
method: "PUT",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": contentType,
|
|
42
|
+
},
|
|
43
|
+
body: fileContents,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!uploadRes.ok) {
|
|
47
|
+
const errorText = await uploadRes.text();
|
|
48
|
+
console.error(`🚫 Upload failed: ${errorText}`);
|
|
49
|
+
exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return publicUrl;
|
|
53
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { build } from "esbuild";
|
|
2
|
+
|
|
3
|
+
export const buildComponent = async (file) => {
|
|
4
|
+
const res = await build({
|
|
5
|
+
entryPoints: [file],
|
|
6
|
+
bundle: true,
|
|
7
|
+
write: false,
|
|
8
|
+
minify: true,
|
|
9
|
+
sourcemap: "inline",
|
|
10
|
+
platform: "browser",
|
|
11
|
+
format: "cjs",
|
|
12
|
+
external: [
|
|
13
|
+
"@examplary/ui",
|
|
14
|
+
"react",
|
|
15
|
+
"react-dom",
|
|
16
|
+
"react/jsx-runtime",
|
|
17
|
+
"react-dom/client",
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let js = res.outputFiles[0].text;
|
|
22
|
+
let sourcemap = "";
|
|
23
|
+
|
|
24
|
+
const sourceMapStatement =
|
|
25
|
+
"\n//# sourceMappingURL=data:application/json;base64,";
|
|
26
|
+
if (js.includes(sourceMapStatement)) {
|
|
27
|
+
sourcemap = Buffer.from(
|
|
28
|
+
js.split(sourceMapStatement)[1].trim(),
|
|
29
|
+
"base64",
|
|
30
|
+
).toString("utf-8");
|
|
31
|
+
js = js.split(sourceMapStatement)[0].trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { js, sourcemap };
|
|
35
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
|
|
3
|
+
export const adaptExpressMiddleware = (middleware) =>
|
|
4
|
+
createMiddleware(async (c, next) => {
|
|
5
|
+
const req = c.env.incoming;
|
|
6
|
+
const res = c.env.outgoing;
|
|
7
|
+
|
|
8
|
+
await new Promise((resolve, reject) => {
|
|
9
|
+
middleware(req, res, (err) => {
|
|
10
|
+
if (err) reject(err);
|
|
11
|
+
else resolve(c.res);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await next();
|
|
16
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import tailwindcss from "@tailwindcss/postcss";
|
|
5
|
+
import postcss from "postcss";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const tailwindDir = path.dirname(require.resolve("tailwindcss/package.json"));
|
|
9
|
+
|
|
10
|
+
export const buildStyles = async () => {
|
|
11
|
+
const absolutePath = path.resolve(tailwindDir);
|
|
12
|
+
|
|
13
|
+
const inputCSS = `
|
|
14
|
+
@import "${absolutePath}/theme.css" layer(theme);
|
|
15
|
+
@import "${absolutePath}/utilities.css" layer(utilities);
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const result = await postcss([
|
|
19
|
+
tailwindcss({
|
|
20
|
+
optimize: { minify: true },
|
|
21
|
+
}),
|
|
22
|
+
]).process(inputCSS, { from: "module-css.css" });
|
|
23
|
+
|
|
24
|
+
const utilitiesStyles = result.css.split("@layer utilities")[1].trim();
|
|
25
|
+
if (utilitiesStyles.length < 5) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const css = result.css.replace(/@layer /g, "@layer modules_");
|
|
30
|
+
|
|
31
|
+
return css;
|
|
32
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
import type { QuestionType } from "@examplary/schemas";
|
|
5
|
+
import {
|
|
6
|
+
formatQuestionType,
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from "@examplary/ui";
|
|
13
|
+
import useLocalStorage from "use-local-storage";
|
|
14
|
+
|
|
15
|
+
import { JsonEditor } from "./components/json-editor";
|
|
16
|
+
import { PreviewAssessment } from "./components/preview-assessment";
|
|
17
|
+
import { PreviewPrint } from "./components/preview-print";
|
|
18
|
+
import { PreviewResults } from "./components/preview-results";
|
|
19
|
+
import { PreviewSettingsArea } from "./components/preview-settings-area";
|
|
20
|
+
|
|
21
|
+
function App() {
|
|
22
|
+
const { i18n } = useTranslation();
|
|
23
|
+
|
|
24
|
+
const rawQuestionType = globalThis._questionType as unknown as QuestionType;
|
|
25
|
+
const questionType = formatQuestionType(rawQuestionType, i18n);
|
|
26
|
+
|
|
27
|
+
const [componentName, setComponentName] = useLocalStorage(
|
|
28
|
+
`component-name-${questionType.id}`,
|
|
29
|
+
Object.keys(questionType.components || {})[0] || "assessment",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const availableComponents = Object.keys(questionType.components || {});
|
|
33
|
+
|
|
34
|
+
const [answer, setAnswer] = useState(undefined);
|
|
35
|
+
|
|
36
|
+
const defaultSettings = (questionType.settings || []).reduce(
|
|
37
|
+
(acc, setting) => {
|
|
38
|
+
if ("default" in setting) {
|
|
39
|
+
acc[setting.id] = setting.default;
|
|
40
|
+
}
|
|
41
|
+
return acc;
|
|
42
|
+
},
|
|
43
|
+
{},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const [question, setQuestion] = useLocalStorage(
|
|
47
|
+
`payload-question-${questionType.id}`,
|
|
48
|
+
{
|
|
49
|
+
id: "q_qQjXQasmUfpWRV5KO5mzkxoy",
|
|
50
|
+
title: "Napoleon's role in the French Revolution",
|
|
51
|
+
type: questionType.id,
|
|
52
|
+
description:
|
|
53
|
+
"When Napoleon Bonaparte rose to power, he was seen as a hero of the French Revolution. However, his actions and policies during his rule have been debated by historians. What was Napoleon's role in the French Revolution?",
|
|
54
|
+
settings: {
|
|
55
|
+
...defaultSettings,
|
|
56
|
+
scoringCriteria: [
|
|
57
|
+
"The answer must correctly identify Napoleon's role in the French Revolution and provide relevant historical context.",
|
|
58
|
+
],
|
|
59
|
+
options: [
|
|
60
|
+
{
|
|
61
|
+
correct: true,
|
|
62
|
+
value:
|
|
63
|
+
"To establish a centralized legal system and promote the principles of the Revolution.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
correct: false,
|
|
67
|
+
value:
|
|
68
|
+
"To overthrow the revolutionary government and establish a dictatorship.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
correct: false,
|
|
72
|
+
value:
|
|
73
|
+
"To maintain the status quo and prevent further revolutionary changes.",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
correct: false,
|
|
77
|
+
value: "To support the monarchy and restore the old regime.",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="min-h-screen flex">
|
|
86
|
+
<aside className="bg-nav w-[360px] text-black border-r-2 border-border p-6 overflow-auto">
|
|
87
|
+
<h1 className="text-xl font-semibold">{questionType.name}</h1>
|
|
88
|
+
<h2 className="text-sm opacity-50">Question type preview</h2>
|
|
89
|
+
|
|
90
|
+
<h3 className="text-sm font-medium mb-1.5 mt-8">Preview options</h3>
|
|
91
|
+
<div className="grid grid-cols-3 items-center gap-3 border-2 rounded-md bg-white p-4">
|
|
92
|
+
<p className="text-gray-800 text-sm">Component</p>
|
|
93
|
+
<Select value={componentName} onValueChange={setComponentName}>
|
|
94
|
+
<SelectTrigger className="text-sm col-span-2">
|
|
95
|
+
<SelectValue />
|
|
96
|
+
</SelectTrigger>
|
|
97
|
+
<SelectContent>
|
|
98
|
+
{availableComponents.map((component) => (
|
|
99
|
+
<SelectItem key={component} value={component}>
|
|
100
|
+
{component}
|
|
101
|
+
</SelectItem>
|
|
102
|
+
))}
|
|
103
|
+
</SelectContent>
|
|
104
|
+
</Select>
|
|
105
|
+
<p className="text-gray-800 text-sm">Locale</p>
|
|
106
|
+
<Select
|
|
107
|
+
value={i18n.language}
|
|
108
|
+
onValueChange={(value) => i18n.changeLanguage(value)}
|
|
109
|
+
>
|
|
110
|
+
<SelectTrigger className="text-sm col-span-2">
|
|
111
|
+
<SelectValue />
|
|
112
|
+
</SelectTrigger>
|
|
113
|
+
<SelectContent>
|
|
114
|
+
<SelectItem value="en">en</SelectItem>
|
|
115
|
+
<SelectItem value="nl">nl</SelectItem>
|
|
116
|
+
</SelectContent>
|
|
117
|
+
</Select>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<h3 className="text-sm font-medium mb-1.5 mt-8">Question</h3>
|
|
121
|
+
<div className="border-2 rounded-md bg-white overflow-hidden">
|
|
122
|
+
<JsonEditor value={question} setValue={setQuestion} />
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<h3 className="text-sm font-medium mb-1.5 mt-8">Answer</h3>
|
|
126
|
+
<div className="border-2 rounded-md bg-white overflow-hidden">
|
|
127
|
+
<JsonEditor value={answer} setValue={setAnswer} />
|
|
128
|
+
</div>
|
|
129
|
+
</aside>
|
|
130
|
+
|
|
131
|
+
<div className="flex-1 flex items-center justify-center">
|
|
132
|
+
<div className="max-w-3xl mx-auto py-10" key={i18n.language}>
|
|
133
|
+
{componentName === "assessment" && (
|
|
134
|
+
<PreviewAssessment
|
|
135
|
+
questionType={questionType}
|
|
136
|
+
question={question}
|
|
137
|
+
answer={answer}
|
|
138
|
+
saveAnswer={setAnswer}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
{componentName === "results" && (
|
|
142
|
+
<PreviewResults
|
|
143
|
+
questionType={questionType}
|
|
144
|
+
question={question}
|
|
145
|
+
answer={answer}
|
|
146
|
+
/>
|
|
147
|
+
)}
|
|
148
|
+
{componentName === "settings-area" && (
|
|
149
|
+
<PreviewSettingsArea
|
|
150
|
+
questionType={questionType}
|
|
151
|
+
question={question}
|
|
152
|
+
setQuestion={setQuestion}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
{componentName === "print" && (
|
|
156
|
+
<PreviewPrint questionType={questionType} question={question} />
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default App;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { Editor } from "@monaco-editor/react";
|
|
4
|
+
|
|
5
|
+
export const JsonEditor = ({ value, setValue }) => {
|
|
6
|
+
const [stringValue, setStringValue] = useState(() =>
|
|
7
|
+
JSON.stringify(value, null, 2),
|
|
8
|
+
);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
setStringValue(JSON.stringify(value, null, 2));
|
|
11
|
+
}, [value]);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Editor
|
|
15
|
+
height={160}
|
|
16
|
+
defaultLanguage="json"
|
|
17
|
+
value={stringValue}
|
|
18
|
+
onChange={(newStringValue) => {
|
|
19
|
+
setStringValue(newStringValue || "");
|
|
20
|
+
try {
|
|
21
|
+
const parsedValue = JSON.parse(newStringValue || "");
|
|
22
|
+
setValue(parsedValue);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error("Invalid JSON:", error);
|
|
25
|
+
}
|
|
26
|
+
}}
|
|
27
|
+
options={{
|
|
28
|
+
minimap: { enabled: false },
|
|
29
|
+
stickyScroll: { enabled: false },
|
|
30
|
+
tabSize: 3,
|
|
31
|
+
fontSize: 11,
|
|
32
|
+
lineNumbers: "off",
|
|
33
|
+
scrollBeyondLastLine: false,
|
|
34
|
+
padding: {
|
|
35
|
+
top: 8,
|
|
36
|
+
bottom: 8,
|
|
37
|
+
},
|
|
38
|
+
}}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RichTextDisplay } from "@examplary/ui";
|
|
2
|
+
|
|
3
|
+
import { QuestionComponent } from "./question-component";
|
|
4
|
+
|
|
5
|
+
export const PreviewAssessment = ({
|
|
6
|
+
questionType,
|
|
7
|
+
question,
|
|
8
|
+
answer,
|
|
9
|
+
saveAnswer,
|
|
10
|
+
}) => {
|
|
11
|
+
return (
|
|
12
|
+
<div className="relative flex flex-col min-h-[calc(100vh-17.5rem)]">
|
|
13
|
+
<div className="rounded-base shadow-light border-2 border-border bg-main text-black hidden md:block right-0 top-5 bottom-5 left-5 absolute" />
|
|
14
|
+
<div className="rounded-base shadow-light border-2 border-border bg-main text-black flex-1 bg-white py-7 px-10 md:mr-5 relative z-10">
|
|
15
|
+
<div className="flex justify-between gap-2 mb-3">
|
|
16
|
+
<div className="size-6 bg-bright border-2 text-sm font-bold rounded-full flex items-center justify-center shrink-0">
|
|
17
|
+
1
|
|
18
|
+
</div>
|
|
19
|
+
<div className="flex-1">
|
|
20
|
+
<h3 className="font-semibold text-lg mb-1">
|
|
21
|
+
<RichTextDisplay as="span">{question.title}</RichTextDisplay>
|
|
22
|
+
</h3>
|
|
23
|
+
<RichTextDisplay as="p" className="text-zinc-900 mb-6">
|
|
24
|
+
{question.description}
|
|
25
|
+
</RichTextDisplay>
|
|
26
|
+
|
|
27
|
+
<QuestionComponent
|
|
28
|
+
type={questionType}
|
|
29
|
+
componentName="assessment"
|
|
30
|
+
props={{
|
|
31
|
+
question,
|
|
32
|
+
answer,
|
|
33
|
+
saveAnswer,
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RichTextDisplay } from "@examplary/ui";
|
|
2
|
+
|
|
3
|
+
import { QuestionComponent } from "./question-component";
|
|
4
|
+
|
|
5
|
+
export const PreviewPrint = ({ questionType, question }) => {
|
|
6
|
+
return (
|
|
7
|
+
<div className="relative">
|
|
8
|
+
<div className="border-2 border-border border-t-0 border-b-0 bg-main text-black flex-1 bg-white">
|
|
9
|
+
<div className="flex justify-between gap-2 mb-3 py-16 px-12">
|
|
10
|
+
<div className="size-6 border-2 text-sm font-bold rounded-full flex items-center justify-center shrink-0">
|
|
11
|
+
1
|
|
12
|
+
</div>
|
|
13
|
+
<div className="flex-1">
|
|
14
|
+
<h3 className="font-semibold text-lg mb-1">
|
|
15
|
+
<RichTextDisplay as="span">{question.title}</RichTextDisplay>
|
|
16
|
+
</h3>
|
|
17
|
+
<RichTextDisplay as="p" className="text-zinc-900 mb-6">
|
|
18
|
+
{question.description}
|
|
19
|
+
</RichTextDisplay>
|
|
20
|
+
|
|
21
|
+
<QuestionComponent
|
|
22
|
+
type={questionType}
|
|
23
|
+
componentName="print"
|
|
24
|
+
props={{
|
|
25
|
+
question,
|
|
26
|
+
answerBoxes: true,
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div className="absolute inset-0 bottom-auto h-10 bg-gradient-to-b from-bg to-transparent z-10" />
|
|
34
|
+
<div className="absolute inset-0 top-auto h-10 bg-gradient-to-b to-bg from-transparent z-10" />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RichTextDisplay } from "@examplary/ui";
|
|
2
|
+
|
|
3
|
+
import { QuestionComponent } from "./question-component";
|
|
4
|
+
|
|
5
|
+
export const PreviewResults = ({ questionType, question, answer }) => {
|
|
6
|
+
return (
|
|
7
|
+
<div className="rounded-base border-2 border-border bg-main text-black flex-1 bg-white py-7 px-10 md:mr-5 relative z-10">
|
|
8
|
+
<div className="flex justify-between gap-2 mb-3">
|
|
9
|
+
<div className="size-6 bg-bright border-2 text-sm font-bold rounded-full flex items-center justify-center shrink-0">
|
|
10
|
+
1
|
|
11
|
+
</div>
|
|
12
|
+
<div className="flex-1">
|
|
13
|
+
<h3 className="font-semibold text-lg mb-2.5">
|
|
14
|
+
<RichTextDisplay as="span">{question.title}</RichTextDisplay>
|
|
15
|
+
</h3>
|
|
16
|
+
|
|
17
|
+
{answer ? (
|
|
18
|
+
<QuestionComponent
|
|
19
|
+
type={questionType}
|
|
20
|
+
componentName="results"
|
|
21
|
+
props={{
|
|
22
|
+
question,
|
|
23
|
+
answer,
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
) : (
|
|
27
|
+
<span className="text-zinc-500 bg-zinc-100 rounded p-1 px-2 font-medium text-sm">
|
|
28
|
+
Empty answer
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { cn, MinimalRichTextField } from "@examplary/ui";
|
|
2
|
+
import { FileQuestionIcon } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { QuestionComponent } from "./question-component";
|
|
5
|
+
|
|
6
|
+
export const PreviewSettingsArea = ({
|
|
7
|
+
questionType,
|
|
8
|
+
question,
|
|
9
|
+
setQuestion,
|
|
10
|
+
}) => {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
data-type="question"
|
|
14
|
+
data-question-id={question.id}
|
|
15
|
+
className={cn(
|
|
16
|
+
"rounded-base shadow-light border-2 border-border bg-main text-black",
|
|
17
|
+
"mb-3 relative bg-white transition-[border,shadow,opacity]",
|
|
18
|
+
"shadow-light",
|
|
19
|
+
)}
|
|
20
|
+
>
|
|
21
|
+
<div className="p-4 flex gap-3">
|
|
22
|
+
<div
|
|
23
|
+
className={cn(
|
|
24
|
+
"size-6 rounded-full border-2 items-center justify-center flex transition",
|
|
25
|
+
questionType?.iconBackgroundColor ? "" : "bg-bright",
|
|
26
|
+
)}
|
|
27
|
+
style={{
|
|
28
|
+
backgroundColor: questionType?.iconBackgroundColor || undefined,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
{questionType?.icon ? (
|
|
32
|
+
<img
|
|
33
|
+
src={`/api/icon?path=${questionType.icon}&questionType=${questionType.id}`}
|
|
34
|
+
alt={questionType.name}
|
|
35
|
+
className="size-3.5 object-contain"
|
|
36
|
+
/>
|
|
37
|
+
) : (
|
|
38
|
+
<FileQuestionIcon className="size-3.5" strokeWidth={2.6} />
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
<div className="flex flex-1">
|
|
42
|
+
<div className="flex-1 flex flex-col">
|
|
43
|
+
<MinimalRichTextField
|
|
44
|
+
singleLine
|
|
45
|
+
readOnly
|
|
46
|
+
className={cn(
|
|
47
|
+
"w-full font-medium",
|
|
48
|
+
"cursor-not-allowed pointer-events-none",
|
|
49
|
+
)}
|
|
50
|
+
value={question.title}
|
|
51
|
+
onChange={(value) => question.update("title", value)}
|
|
52
|
+
placeholder={questionType?.titlePlaceholder}
|
|
53
|
+
/>
|
|
54
|
+
<MinimalRichTextField
|
|
55
|
+
data-type="description"
|
|
56
|
+
className={cn(
|
|
57
|
+
"w-full text-sm mt-1",
|
|
58
|
+
"cursor-not-allowed pointer-events-none",
|
|
59
|
+
)}
|
|
60
|
+
value={question.description}
|
|
61
|
+
readOnly
|
|
62
|
+
showFormattingMenu
|
|
63
|
+
placeholder={questionType?.descriptionPlaceholder}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="border-t-2 pt-5">
|
|
69
|
+
<div className="pb-5 px-6">
|
|
70
|
+
<QuestionComponent
|
|
71
|
+
type={questionType}
|
|
72
|
+
componentName="settings-area"
|
|
73
|
+
props={{
|
|
74
|
+
question,
|
|
75
|
+
settings: question.settings,
|
|
76
|
+
setSetting: (settingId, value) => {
|
|
77
|
+
setQuestion({
|
|
78
|
+
...question,
|
|
79
|
+
settings: {
|
|
80
|
+
...question.settings,
|
|
81
|
+
[settingId]: value,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ComponentType } from "react";
|
|
2
|
+
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
|
|
3
|
+
import { useTranslation } from "react-i18next";
|
|
4
|
+
|
|
5
|
+
import { fetchComponent, FormattedQuestionType } from "@examplary/ui";
|
|
6
|
+
import { Loader2Icon } from "lucide-react";
|
|
7
|
+
import useSWR from "swr";
|
|
8
|
+
|
|
9
|
+
type QuestionComponentProps = {
|
|
10
|
+
type: FormattedQuestionType;
|
|
11
|
+
componentName: "assessment" | "print" | "results" | "settings-area";
|
|
12
|
+
props: Record<string, any>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const QuestionComponentContent = ({
|
|
16
|
+
type,
|
|
17
|
+
componentName,
|
|
18
|
+
props,
|
|
19
|
+
}: QuestionComponentProps) => {
|
|
20
|
+
const componentUrl = type?.components?.[componentName];
|
|
21
|
+
|
|
22
|
+
const { t, i18n } = useTranslation(type?.translationNs);
|
|
23
|
+
const { data: Component, error } = useSWR<ComponentType<any>>(
|
|
24
|
+
componentUrl,
|
|
25
|
+
fetchComponent,
|
|
26
|
+
{
|
|
27
|
+
revalidateOnFocus: false,
|
|
28
|
+
revalidateOnReconnect: false,
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (!componentUrl) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (error) {
|
|
37
|
+
return <FallbackComponent error={error} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!Component) {
|
|
41
|
+
return <Loader2Icon className="animate-spin size-5" />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const finalProps = {
|
|
45
|
+
t,
|
|
46
|
+
i18n,
|
|
47
|
+
...props,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return <Component {...finalProps} />;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const FallbackComponent = ({ error }: Partial<FallbackProps>) => {
|
|
54
|
+
return (
|
|
55
|
+
<div className="rounded-md bg-gray-100 p-5">
|
|
56
|
+
<p className="text-red-500 text-sm font-semibold">
|
|
57
|
+
Failed loading question component
|
|
58
|
+
</p>
|
|
59
|
+
<pre className="text-sm font-mono">{error?.message}</pre>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const QuestionComponent = (props: QuestionComponentProps) => {
|
|
65
|
+
return (
|
|
66
|
+
<ErrorBoundary FallbackComponent={FallbackComponent}>
|
|
67
|
+
<QuestionComponentContent {...props} />
|
|
68
|
+
</ErrorBoundary>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
@layer modules_theme, theme, modules_base, base, modules_components, components, modules_utilities, utilities;
|
|
2
|
+
|
|
3
|
+
@import "tailwindcss";
|
|
4
|
+
@import "@examplary/ui/src/global.css";
|
|
5
|
+
|
|
6
|
+
@layer base {
|
|
7
|
+
:root {
|
|
8
|
+
--radius: 0.5rem;
|
|
9
|
+
--font-sans: "DM Sans", "Inter", "sans-serif";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Examplary - Question type preview</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body class="bg-app-bg font-sans">
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/main.tsx"></script>
|
|
11
|
+
<link href="/api/stylesheet" rel="stylesheet" />
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { initReactI18next } from "react-i18next";
|
|
2
|
+
|
|
3
|
+
import I18n from "i18next";
|
|
4
|
+
import LanguageDetector from "i18next-browser-languagedetector";
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
7
|
+
export const i18n = I18n.use(initReactI18next)
|
|
8
|
+
.use(LanguageDetector)
|
|
9
|
+
.init({
|
|
10
|
+
interpolation: {
|
|
11
|
+
escapeValue: false,
|
|
12
|
+
},
|
|
13
|
+
supportedLngs: ["en", "nl"],
|
|
14
|
+
fallbackLng: "en",
|
|
15
|
+
});
|