@dolphy-app/extension-sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @dolphy-app/extension-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SDK автора расширений: defineExtension, defineAnswerElement, помощники тестирования
4
+
5
+ Версия пакетов равна версии приложения Dolphy, из релиза которого они опубликованы (0.2.0).
6
+
7
+ `@dolphy-app/extension-sdk` — код расширения (`defineExtension`,
8
+ `defineExerciseType`), элемент ответа (`defineAnswerElement`) и помощники
9
+ тестов (`@dolphy-app/extension-sdk/testing`).
10
+
11
+ ```ts
12
+ import { defineExtension } from '@dolphy-app/extension-sdk';
13
+ import { loadExerciseType } from '@dolphy-app/extension-sdk/testing';
14
+ ```
15
+
16
+ ## Установка
17
+
18
+ ```sh
19
+ npm install @dolphy-app/extension-sdk
20
+ ```
21
+
22
+ ## Документация
23
+
24
+ [Расширения Dolphy](https://github.com/dolphy-app/dolphy/blob/main/docs/design/extensions.md).
@@ -0,0 +1,36 @@
1
+ import { AnswerElementProps, ExerciseTypeHandler, ExtensionContext, ExtensionModule, GradePolicyHandler, MarkdownRenderContext, MarkdownRendererModule } from "@dolphy-app/extension-api";
2
+ export * from "@dolphy-app/extension-api";
3
+ //#region packages/extension-sdk/src/define-extension.d.ts
4
+ interface ExtensionDefinition {
5
+ exerciseTypes?: Readonly<Record<string, ExerciseTypeHandler>>;
6
+ gradePolicies?: Readonly<Record<string, GradePolicyHandler>>;
7
+ /** Вызывается после регистрации `exerciseTypes` и `gradePolicies`. */
8
+ activate?(context: ExtensionContext): void | Promise<void>;
9
+ deactivate?(): void | Promise<void>;
10
+ }
11
+ export declare const defineExerciseType: <Spec, Answer, View>(handler: ExerciseTypeHandler<Spec, Answer, View>) => ExerciseTypeHandler<Spec, Answer, View>;
12
+ export declare const defineExtension: (definition: ExtensionDefinition) => ExtensionModule;
13
+ //#endregion
14
+ //#region packages/extension-sdk/src/answer-element.d.ts
15
+ interface AnswerElementApi {
16
+ readonly root: ShadowRoot;
17
+ /** `aria-label` хост-элемента, выставленный приложением; `null`, если нет. */
18
+ readonly label: string | null;
19
+ /** Сообщает приложению текущий ответ: событие `dolphy-answer-change`. */
20
+ setAnswer(value: unknown, complete: boolean): void;
21
+ /** Просит приложение отправить ответ: событие `dolphy-answer-submit`. */
22
+ submit(): void;
23
+ }
24
+ interface AnswerElementInstance {
25
+ /** Вызывается при изменении `view`/`value`/`disabled`/`verdict`. */
26
+ update(props: AnswerElementProps): void;
27
+ destroy?(): void;
28
+ }
29
+ type MountAnswerElement = (api: AnswerElementApi, props: AnswerElementProps) => AnswerElementInstance;
30
+ export declare const defineAnswerElement: (tag: string, mount: MountAnswerElement) => void;
31
+ //#endregion
32
+ //#region packages/extension-sdk/src/markdown-renderer.d.ts
33
+ /** `export default defineMarkdownRenderer(...)` в модуле рендерера содержимого. */
34
+ export declare const defineMarkdownRenderer: (render: (source: string, container: HTMLElement, context: MarkdownRenderContext) => void | Promise<void>) => MarkdownRendererModule<HTMLElement>;
35
+ //#endregion
36
+ export type { AnswerElementApi, AnswerElementInstance, ExtensionDefinition, MountAnswerElement };
package/dist/index.js ADDED
@@ -0,0 +1,166 @@
1
+ import { ANSWER_EVENT, ELEMENT_NAME_PATTERN } from "@dolphy-app/extension-api";
2
+
3
+ export * from "@dolphy-app/extension-api"
4
+
5
+ //#region packages/extension-sdk/src/define-extension.ts
6
+ const defineExerciseType = (handler) => handler;
7
+ const disposeInReverse = async (registrations) => {
8
+ const errors = [];
9
+ for (const registration of registrations.splice(0).reverse()) try {
10
+ await registration.dispose();
11
+ } catch (error) {
12
+ errors.push(error);
13
+ }
14
+ return errors;
15
+ };
16
+ const defineExtension = (definition) => {
17
+ const registrations = [];
18
+ const register = (context) => {
19
+ const entries = Object.entries(definition.exerciseTypes ?? {});
20
+ for (const [type, handler] of entries) registrations.push(context.registerExerciseType(type, handler));
21
+ for (const [id, handler] of Object.entries(definition.gradePolicies ?? {})) registrations.push(context.registerGradePolicy(id, handler));
22
+ };
23
+ const activate = async (context) => {
24
+ try {
25
+ register(context);
26
+ await definition.activate?.(context);
27
+ } catch (error) {
28
+ const disposalErrors = await disposeInReverse(registrations);
29
+ if (disposalErrors.length === 0) throw error;
30
+ throw new AggregateError([error, ...disposalErrors], "activation failed and rollback was incomplete");
31
+ }
32
+ };
33
+ const deactivate = async () => {
34
+ const failures = [];
35
+ try {
36
+ await definition.deactivate?.();
37
+ } catch (error) {
38
+ failures.push(error);
39
+ }
40
+ const disposalErrors = await disposeInReverse(registrations);
41
+ if (disposalErrors.length > 0) failures.push(new AggregateError(disposalErrors, "failed to dispose contributions"));
42
+ if (failures.length === 1) throw failures[0];
43
+ if (failures.length > 1) throw new AggregateError(failures, "failed to deactivate extension");
44
+ };
45
+ return {
46
+ activate,
47
+ deactivate
48
+ };
49
+ };
50
+
51
+ //#endregion
52
+ //#region packages/extension-sdk/src/answer-element.ts
53
+ const logFailure = (tag, message, error) => {
54
+ console.error({
55
+ error,
56
+ tag
57
+ }, message);
58
+ };
59
+ const createAnswerElementClass = (tag, mount) => class AnswerElement extends HTMLElement {
60
+ #root = this.attachShadow({ mode: "open" });
61
+ #props = {
62
+ view: void 0,
63
+ value: void 0,
64
+ disabled: false,
65
+ verdict: null
66
+ };
67
+ #instance = null;
68
+ #isFlushScheduled = false;
69
+ get view() {
70
+ return this.#props.view;
71
+ }
72
+ set view(view) {
73
+ this.#change({ view });
74
+ }
75
+ get value() {
76
+ return this.#props.value;
77
+ }
78
+ set value(value) {
79
+ this.#change({ value });
80
+ }
81
+ get disabled() {
82
+ return this.#props.disabled;
83
+ }
84
+ set disabled(disabled) {
85
+ this.#change({ disabled });
86
+ }
87
+ get verdict() {
88
+ return this.#props.verdict;
89
+ }
90
+ set verdict(verdict) {
91
+ this.#change({ verdict });
92
+ }
93
+ connectedCallback() {
94
+ if (this.#instance !== null) return;
95
+ const readLabel = () => this.getAttribute("aria-label");
96
+ const api = {
97
+ root: this.#root,
98
+ get label() {
99
+ return readLabel();
100
+ },
101
+ setAnswer: (value, complete) => {
102
+ const detail = {
103
+ value,
104
+ complete
105
+ };
106
+ this.#emit(ANSWER_EVENT.change, detail);
107
+ },
108
+ submit: () => this.#emit(ANSWER_EVENT.submit, void 0)
109
+ };
110
+ try {
111
+ this.#instance = mount(api, Object.freeze({ ...this.#props }));
112
+ } catch (error) {
113
+ logFailure(tag, "answer element failed to mount", error);
114
+ }
115
+ }
116
+ disconnectedCallback() {
117
+ const instance = this.#instance;
118
+ this.#instance = null;
119
+ if (instance === null) return;
120
+ try {
121
+ instance.destroy?.();
122
+ } catch (error) {
123
+ logFailure(tag, "answer element failed to destroy", error);
124
+ }
125
+ this.#root.replaceChildren();
126
+ }
127
+ #change(patch) {
128
+ this.#props = {
129
+ ...this.#props,
130
+ ...patch
131
+ };
132
+ if (this.#instance === null || this.#isFlushScheduled) return;
133
+ this.#isFlushScheduled = true;
134
+ queueMicrotask(() => this.#flush());
135
+ }
136
+ #flush() {
137
+ this.#isFlushScheduled = false;
138
+ const instance = this.#instance;
139
+ if (instance === null) return;
140
+ try {
141
+ instance.update(Object.freeze({ ...this.#props }));
142
+ } catch (error) {
143
+ logFailure(tag, "answer element failed to update", error);
144
+ }
145
+ }
146
+ #emit(name, detail) {
147
+ this.dispatchEvent(new CustomEvent(name, {
148
+ detail,
149
+ bubbles: true,
150
+ composed: true
151
+ }));
152
+ }
153
+ };
154
+ const defineAnswerElement = (tag, mount) => {
155
+ if (!ELEMENT_NAME_PATTERN.test(tag)) throw new TypeError(`invalid custom element name '${tag}'`);
156
+ if (customElements.get(tag) !== void 0) return;
157
+ customElements.define(tag, createAnswerElementClass(tag, mount));
158
+ };
159
+
160
+ //#endregion
161
+ //#region packages/extension-sdk/src/markdown-renderer.ts
162
+ /** `export default defineMarkdownRenderer(...)` в модуле рендерера содержимого. */
163
+ const defineMarkdownRenderer = (render) => ({ render });
164
+
165
+ //#endregion
166
+ export { defineAnswerElement, defineExerciseType, defineExtension, defineMarkdownRenderer };
@@ -0,0 +1,40 @@
1
+ import { ExtensionLogger, ExtensionModule, GradePolicyInput, GradeResult, GradeValue, JsonSchema, LibraryReader } from "@dolphy-app/extension-api";
2
+ //#region packages/extension-sdk/src/testing.d.ts
3
+ export declare const createMemoryLibrary: (files: Readonly<Record<string, string>>) => LibraryReader;
4
+ export declare const createSchemaValidator: (schema: JsonSchema) => (value: unknown) => string[];
5
+ export interface LoadedExerciseType {
6
+ project(spec: unknown, options?: {
7
+ exerciseId?: string;
8
+ }): Promise<unknown>;
9
+ grade(input: {
10
+ spec: unknown;
11
+ answer: unknown;
12
+ exerciseId?: string;
13
+ timeoutMs?: number;
14
+ authorMode?: boolean;
15
+ }): Promise<GradeResult>;
16
+ referenceAnswer(spec: unknown, options?: {
17
+ exerciseId?: string;
18
+ }): Promise<{
19
+ found: true;
20
+ answer: unknown;
21
+ } | {
22
+ found: false;
23
+ }>;
24
+ /** Деактивирует модуль расширения. */
25
+ dispose(): Promise<void>;
26
+ }
27
+ export declare const loadExerciseType: (module: ExtensionModule, type: string, options?: {
28
+ library?: LibraryReader;
29
+ logger?: ExtensionLogger;
30
+ }) => Promise<LoadedExerciseType>;
31
+ export interface LoadedGradePolicy {
32
+ evaluate(input: GradePolicyInput): Promise<GradeValue | null>;
33
+ /** Деактивирует модуль расширения. */
34
+ dispose(): Promise<void>;
35
+ }
36
+ export declare const loadGradePolicy: (module: ExtensionModule, id: string, options?: {
37
+ library?: LibraryReader;
38
+ logger?: ExtensionLogger;
39
+ }) => Promise<LoadedGradePolicy>;
40
+ //#endregion
@@ -0,0 +1,163 @@
1
+ import { Ajv2020 } from "ajv/dist/2020.js";
2
+
3
+ //#region packages/extension-sdk/src/testing.ts
4
+ const MAX_MESSAGES = 6;
5
+ const MAX_REASON_CHARS = 100;
6
+ const MAX_TEXT_CHARS = 4e3;
7
+ const DEFAULT_EXERCISE_ID = "test::lesson::exercise";
8
+ const DEFAULT_TIMEOUT_MS = 2e3;
9
+ const silentLogger = {
10
+ debug: () => void 0,
11
+ info: () => void 0,
12
+ warn: () => void 0,
13
+ error: () => void 0
14
+ };
15
+ const createMemoryLibrary = (files) => {
16
+ const entries = new Map(Object.entries(files));
17
+ return {
18
+ readText: async (path) => {
19
+ const text = entries.get(path);
20
+ if (text === void 0) throw new Error(`ENOENT: ${path}`);
21
+ return text;
22
+ },
23
+ stat: async (path) => {
24
+ const text = entries.get(path);
25
+ if (text === void 0) return null;
26
+ return {
27
+ kind: "file",
28
+ bytes: Buffer.byteLength(text),
29
+ mtimeMs: 0
30
+ };
31
+ }
32
+ };
33
+ };
34
+ const createSchemaValidator = (schema) => {
35
+ const validate = new Ajv2020({
36
+ allErrors: true,
37
+ strict: false
38
+ }).compile(schema);
39
+ return (value) => {
40
+ if (validate(value)) return [];
41
+ return (validate.errors ?? []).slice(0, MAX_MESSAGES).map((error) => `${error.instancePath || "/"} ${error.message}`);
42
+ };
43
+ };
44
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
45
+ const OUTCOME_KEYS = {
46
+ passed: [
47
+ "outcome",
48
+ "feedback",
49
+ "data"
50
+ ],
51
+ failed: [
52
+ "outcome",
53
+ "reason",
54
+ "feedback",
55
+ "detail",
56
+ "data"
57
+ ],
58
+ error: [
59
+ "outcome",
60
+ "reason",
61
+ "feedback",
62
+ "data"
63
+ ]
64
+ };
65
+ const textProblem = (result, key) => {
66
+ const value = result[key];
67
+ if (value === void 0) return null;
68
+ if (typeof value !== "string") return `'${key}' must be a string`;
69
+ if (value.length > MAX_TEXT_CHARS) return `'${key}' is longer than ${MAX_TEXT_CHARS} characters`;
70
+ return null;
71
+ };
72
+ const reasonProblem = (result) => {
73
+ const { reason } = result;
74
+ if (typeof reason !== "string" || reason.length === 0) return `'reason' must be a non-empty string`;
75
+ if (reason.length > MAX_REASON_CHARS) return `'reason' is longer than ${MAX_REASON_CHARS} characters`;
76
+ return null;
77
+ };
78
+ const findGradeResultProblem = (result) => {
79
+ if (!isRecord(result)) return "result must be an object";
80
+ const { outcome } = result;
81
+ if (outcome !== "passed" && outcome !== "failed" && outcome !== "error") return `unknown outcome ${JSON.stringify(outcome)}`;
82
+ const allowed = OUTCOME_KEYS[outcome];
83
+ const extra = Object.keys(result).find((key) => !allowed.includes(key));
84
+ if (extra !== void 0) return `unexpected key '${extra}'`;
85
+ return (outcome === "passed" ? null : reasonProblem(result)) ?? textProblem(result, "feedback") ?? textProblem(result, "detail");
86
+ };
87
+ const loadExerciseType = async (module, type, options = {}) => {
88
+ const handlers = /* @__PURE__ */ new Map();
89
+ const context = {
90
+ extensionId: "test",
91
+ logger: options.logger ?? silentLogger,
92
+ library: options.library ?? createMemoryLibrary({}),
93
+ registerExerciseType: (registeredType, handler) => {
94
+ handlers.set(registeredType, handler);
95
+ return { dispose: () => void handlers.delete(registeredType) };
96
+ },
97
+ registerGradePolicy: () => ({ dispose: () => void 0 })
98
+ };
99
+ await module.activate(context);
100
+ const handler = handlers.get(type);
101
+ if (handler === void 0) throw new Error(`exercise type '${type}' was not registered`);
102
+ return {
103
+ project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => handler.project({
104
+ exerciseId,
105
+ spec
106
+ }),
107
+ grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
108
+ const result = await handler.grade({
109
+ exerciseId,
110
+ spec,
111
+ answer,
112
+ timeoutMs,
113
+ authorMode
114
+ });
115
+ const problem = findGradeResultProblem(result);
116
+ if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
117
+ return result;
118
+ },
119
+ referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
120
+ const answer = await handler.referenceAnswer?.({
121
+ exerciseId,
122
+ spec
123
+ });
124
+ return answer === void 0 ? { found: false } : {
125
+ found: true,
126
+ answer
127
+ };
128
+ },
129
+ dispose: async () => {
130
+ await module.deactivate?.();
131
+ }
132
+ };
133
+ };
134
+ const isGradeValue = (value) => Number.isInteger(value) && value >= 1 && value <= 5;
135
+ const loadGradePolicy = async (module, id, options = {}) => {
136
+ const handlers = /* @__PURE__ */ new Map();
137
+ const context = {
138
+ extensionId: "test",
139
+ logger: options.logger ?? silentLogger,
140
+ library: options.library ?? createMemoryLibrary({}),
141
+ registerExerciseType: () => ({ dispose: () => void 0 }),
142
+ registerGradePolicy: (registeredId, handler) => {
143
+ handlers.set(registeredId, handler);
144
+ return { dispose: () => void handlers.delete(registeredId) };
145
+ }
146
+ };
147
+ await module.activate(context);
148
+ const handler = handlers.get(id);
149
+ if (handler === void 0) throw new Error(`grade policy '${id}' was not registered`);
150
+ return {
151
+ evaluate: async (input) => {
152
+ const result = await handler(input);
153
+ if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
154
+ return result;
155
+ },
156
+ dispose: async () => {
157
+ await module.deactivate?.();
158
+ }
159
+ };
160
+ };
161
+
162
+ //#endregion
163
+ export { createMemoryLibrary, createSchemaValidator, loadExerciseType, loadGradePolicy };
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@dolphy-app/extension-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "SDK автора расширений: defineExtension, defineAnswerElement, помощники тестирования",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./testing": {
12
+ "types": "./dist/testing.d.ts",
13
+ "default": "./dist/testing.js"
14
+ }
15
+ },
16
+ "types": "./dist/index.d.ts",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "dependencies": {
21
+ "@dolphy-app/extension-api": "0.2.0",
22
+ "ajv": "^8"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.12"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/dolphy-app/dolphy.git",
30
+ "directory": "packages/extension-sdk"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org"
35
+ }
36
+ }