@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.
@@ -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
+ });
@@ -0,0 +1,8 @@
1
+ import { createRoot } from "react-dom/client";
2
+
3
+ import App from "./App.tsx";
4
+ import "./lib/i18n.ts";
5
+
6
+ import "./index.css";
7
+
8
+ createRoot(document.getElementById("root")!).render(<App />);