@mpeggroup/mpeg-sdl-editor 1.3.1

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.
Files changed (52) hide show
  1. package/.github/workflows/check-bun-dependencies.yml +13 -0
  2. package/.github/workflows/lint-pr-message.yml +13 -0
  3. package/.github/workflows/release-bun-webapp.yml +15 -0
  4. package/.github/workflows/validate-bun-webapp-pr.yml +9 -0
  5. package/LICENSE +21 -0
  6. package/README.md +59 -0
  7. package/build.ts +8 -0
  8. package/functional_tests/README.md +25 -0
  9. package/functional_tests/features/environment.py +50 -0
  10. package/functional_tests/features/steps/helper/selenium.py +15 -0
  11. package/functional_tests/features/steps/webapp.py +30 -0
  12. package/functional_tests/features/webapp.feature +5 -0
  13. package/functional_tests/pip-requirements.txt +2 -0
  14. package/html/favicon.svg +126 -0
  15. package/html/index.html +14 -0
  16. package/html/style.css +2 -0
  17. package/package.json +61 -0
  18. package/src/App.css +3 -0
  19. package/src/App.tsx +266 -0
  20. package/src/assets/mpeg.svg +143 -0
  21. package/src/codemirror/ruler.ts +58 -0
  22. package/src/components/DesktopPanels.tsx +65 -0
  23. package/src/components/Editor.tsx +384 -0
  24. package/src/components/MobileDrawer.tsx +57 -0
  25. package/src/components/Navbar.tsx +226 -0
  26. package/src/components/ResizableLayout.tsx +69 -0
  27. package/src/components/Settings.tsx +170 -0
  28. package/src/components/StatusBar.tsx +47 -0
  29. package/src/components/SubNav.tsx +58 -0
  30. package/src/hooks/useAutoDisplayCompletions.ts +26 -0
  31. package/src/hooks/useDragResize.ts +104 -0
  32. package/src/hooks/useEnableLinting.ts +21 -0
  33. package/src/hooks/useFileOperations.ts +102 -0
  34. package/src/hooks/useMobileDetection.ts +32 -0
  35. package/src/hooks/usePrettier.ts +40 -0
  36. package/src/hooks/useRulerWidth.ts +42 -0
  37. package/src/hooks/useShowSemanticWarnings.ts +23 -0
  38. package/src/hooks/useShowSymanticErrors.ts +23 -0
  39. package/src/hooks/useShowSyntaxErrors.ts +23 -0
  40. package/src/hooks/useTheme.ts +21 -0
  41. package/src/hooks/useToast.ts +37 -0
  42. package/src/logo/getLogoSvg.ts +5 -0
  43. package/src/logo/svg.module.d.ts +5 -0
  44. package/src/main.tsx +13 -0
  45. package/src/sdl/sdlComplete.ts +86 -0
  46. package/src/sdl/sdlLanguage.ts +18 -0
  47. package/src/sdl/sdlLinter.ts +157 -0
  48. package/tailwind.config.ts +16 -0
  49. package/tests/Editor_test.tsx +29 -0
  50. package/tests/happydom.ts +3 -0
  51. package/tests/sdlLanguage_test.ts +39 -0
  52. package/tsconfig.json +27 -0
@@ -0,0 +1,42 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export const RULER_WIDTH_OPTIONS = [
4
+ 60,
5
+ 80,
6
+ 100,
7
+ 120,
8
+ 140,
9
+ 160,
10
+ 180,
11
+ 200,
12
+ ] as const;
13
+ export type RulerWidth = (typeof RULER_WIDTH_OPTIONS)[number];
14
+
15
+ function isValidRulerWidth(value: number): value is RulerWidth {
16
+ return RULER_WIDTH_OPTIONS.includes(value as RulerWidth);
17
+ }
18
+
19
+ export function useRulerWidth(defaultWidth: RulerWidth = 80) {
20
+ const [rulerWidth, setRulerWidthInternal] = useState<RulerWidth>(() => {
21
+ const stored = localStorage.getItem("rulerWidth");
22
+ if (stored) {
23
+ const parsed = Number(stored);
24
+ if (isValidRulerWidth(parsed)) {
25
+ return parsed;
26
+ }
27
+ }
28
+ return defaultWidth;
29
+ });
30
+
31
+ useEffect(() => {
32
+ localStorage.setItem("rulerWidth", String(rulerWidth));
33
+ }, [rulerWidth]);
34
+
35
+ const setRulerWidth = useCallback((width: number) => {
36
+ if (isValidRulerWidth(width)) {
37
+ setRulerWidthInternal(width);
38
+ }
39
+ }, []);
40
+
41
+ return { rulerWidth, setRulerWidth };
42
+ }
@@ -0,0 +1,23 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export function useShowSemanticWarnings(defaultValue: boolean = true) {
4
+ const [showSemanticWarnings, setShowSemanticWarningsInternal] = useState<
5
+ boolean
6
+ >(() => {
7
+ const stored = localStorage.getItem("showSemanticWarnings");
8
+ if (stored !== null) {
9
+ return stored === "true";
10
+ }
11
+ return defaultValue;
12
+ });
13
+
14
+ useEffect(() => {
15
+ localStorage.setItem("showSemanticWarnings", String(showSemanticWarnings));
16
+ }, [showSemanticWarnings]);
17
+
18
+ const setShowSemanticWarnings = useCallback((value: boolean) => {
19
+ setShowSemanticWarningsInternal(value);
20
+ }, []);
21
+
22
+ return { showSemanticWarnings, setShowSemanticWarnings };
23
+ }
@@ -0,0 +1,23 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export function useShowSemanticErrors(defaultValue: boolean = true) {
4
+ const [showSemanticErrors, setShowSemanticErrorsInternal] = useState<boolean>(
5
+ () => {
6
+ const stored = localStorage.getItem("showSemanticErrors");
7
+ if (stored !== null) {
8
+ return stored === "true";
9
+ }
10
+ return defaultValue;
11
+ },
12
+ );
13
+
14
+ useEffect(() => {
15
+ localStorage.setItem("showSemanticErrors", String(showSemanticErrors));
16
+ }, [showSemanticErrors]);
17
+
18
+ const setShowSemanticErrors = useCallback((value: boolean) => {
19
+ setShowSemanticErrorsInternal(value);
20
+ }, []);
21
+
22
+ return { showSemanticErrors, setShowSemanticErrors };
23
+ }
@@ -0,0 +1,23 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export function useShowSyntaxErrors(defaultValue: boolean = true) {
4
+ const [showSyntaxErrors, setShowSyntaxErrorsInternal] = useState<boolean>(
5
+ () => {
6
+ const stored = localStorage.getItem("showSyntaxErrors");
7
+ if (stored !== null) {
8
+ return stored === "true";
9
+ }
10
+ return defaultValue;
11
+ },
12
+ );
13
+
14
+ useEffect(() => {
15
+ localStorage.setItem("showSyntaxErrors", String(showSyntaxErrors));
16
+ }, [showSyntaxErrors]);
17
+
18
+ const setShowSyntaxErrors = useCallback((value: boolean) => {
19
+ setShowSyntaxErrorsInternal(value);
20
+ }, []);
21
+
22
+ return { showSyntaxErrors, setShowSyntaxErrors };
23
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export type Theme = "light" | "dark";
4
+
5
+ export function useTheme(defaultTheme: Theme = "light") {
6
+ const [theme, setTheme] = useState<Theme>(() => {
7
+ const storedTheme = localStorage.getItem("theme") as Theme | null;
8
+ return storedTheme || defaultTheme;
9
+ });
10
+
11
+ useEffect(() => {
12
+ document.documentElement.setAttribute("data-theme", theme);
13
+ localStorage.setItem("theme", theme);
14
+ }, [theme]);
15
+
16
+ const toggleTheme = () => {
17
+ setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
18
+ };
19
+
20
+ return { theme, toggleTheme };
21
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export type ToastType = "success" | "warning" | "error";
4
+ export type ShowToastFunction = (
5
+ message: string,
6
+ type?: ToastType,
7
+ duration?: number,
8
+ ) => void;
9
+
10
+ interface ToastState {
11
+ message: string;
12
+ type: ToastType;
13
+ }
14
+
15
+ export function useToast(): [ToastState | null, ShowToastFunction] {
16
+ const [toastState, setToastState] = useState<ToastState | null>(null);
17
+ const [toastDuration, setToastDuration] = useState<number>(2000);
18
+
19
+ useEffect(() => {
20
+ if (toastState) {
21
+ const timer = setTimeout(() => {
22
+ setToastState(null);
23
+ }, toastDuration);
24
+ return () => clearTimeout(timer);
25
+ }
26
+ }, [toastState, toastDuration]);
27
+
28
+ const showToast = useCallback(
29
+ (message: string, type: ToastType = "success", duration: number = 2000) => {
30
+ setToastState({ message, type });
31
+ setToastDuration(duration);
32
+ },
33
+ [],
34
+ );
35
+
36
+ return [toastState, showToast];
37
+ }
@@ -0,0 +1,5 @@
1
+ import mpegSvg from "../assets/mpeg.svg" with { type: "text" };
2
+
3
+ export function getLogoSvg() {
4
+ return mpegSvg;
5
+ }
@@ -0,0 +1,5 @@
1
+ // This file declares types for SVG imports
2
+ declare module "*.svg" {
3
+ const content: string;
4
+ export default content;
5
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,13 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { App } from "./App.tsx";
3
+
4
+ function start() {
5
+ const root = createRoot(document.getElementById("root")!);
6
+ root.render(<App />);
7
+ }
8
+
9
+ if (document.readyState === "loading") {
10
+ document.addEventListener("DOMContentLoaded", start);
11
+ } else {
12
+ start();
13
+ }
@@ -0,0 +1,86 @@
1
+ import {
2
+ CompletionContext,
3
+ type CompletionResult,
4
+ } from "@codemirror/autocomplete";
5
+ import type { SyntaxNode } from "@lezer/common";
6
+ import { syntaxTree } from "@codemirror/language";
7
+ import { foldNodeProp } from "@codemirror/language";
8
+ import {
9
+ getPotentialSyntacticTokens,
10
+ TokenTypeId,
11
+ } from "@mpeggroup/mpeg-sdl-parser";
12
+
13
+ function isCommentCompletion(lastNode: SyntaxNode): boolean {
14
+ return lastNode.type.id === TokenTypeId.Comment;
15
+ }
16
+
17
+ function isGlobalScopeCompletion(lastNode: SyntaxNode): boolean {
18
+ // see if the last node is a specification
19
+ if (lastNode.type.id === TokenTypeId.Specification) {
20
+ return true;
21
+ }
22
+
23
+ // see if the parent of the last node is a specification
24
+ const parentNode = lastNode.parent;
25
+
26
+ return parentNode?.type.id === TokenTypeId.Specification;
27
+ }
28
+
29
+ function isBlockScopeCompletion(lastNode: SyntaxNode): boolean {
30
+ // see if the parent of the last node is a block scoped node
31
+ const parentNode = lastNode.parent;
32
+
33
+ if (!parentNode) {
34
+ return false;
35
+ }
36
+
37
+ return parentNode.type.prop(foldNodeProp) !== undefined;
38
+ }
39
+
40
+ function getCompletionResult(
41
+ completionOptions: string[],
42
+ from: number,
43
+ ): CompletionResult {
44
+ return {
45
+ from,
46
+ options: completionOptions.map((label) => ({ label })),
47
+ validFor: /^\w*$/,
48
+ };
49
+ }
50
+
51
+ function sdlComplete(context: CompletionContext): CompletionResult | null {
52
+ const parseTree = syntaxTree(context.state);
53
+ const lastNode = parseTree.resolveInner(context.pos, -1);
54
+
55
+ // Don't provide completions within a comment
56
+ if (isCommentCompletion(lastNode)) {
57
+ console.error("No completions in comments");
58
+ return null;
59
+ }
60
+
61
+ // only provide completions at the global scope if completion was explicitly requested
62
+ if (isGlobalScopeCompletion(lastNode) && !context.explicit) {
63
+ return null;
64
+ }
65
+
66
+ // only provide completions at the block scope if completion was explicitly requested
67
+ if (isBlockScopeCompletion(lastNode) && !context.explicit) {
68
+ return null;
69
+ }
70
+
71
+ const lastText = context.state.sliceDoc(lastNode.from, context.pos);
72
+ const lastTag = /^\w*$/.exec(lastText);
73
+
74
+ const completions = getPotentialSyntacticTokens(lastNode.cursor());
75
+
76
+ if (completions && (completions.length > 0)) {
77
+ return getCompletionResult(
78
+ completions,
79
+ lastTag ? lastNode.from + lastTag.index : context.pos,
80
+ );
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ export { sdlComplete };
@@ -0,0 +1,18 @@
1
+ import { createLenientSdlParser } from "@mpeggroup/mpeg-sdl-parser";
2
+ import { LanguageSupport, LRLanguage } from "@codemirror/language";
3
+ import { sdlComplete } from "./sdlComplete.ts";
4
+
5
+ export const sdlLanguage = LRLanguage.define({
6
+ parser: createLenientSdlParser(),
7
+ languageData: {
8
+ commentTokens: { line: "//" },
9
+ },
10
+ });
11
+
12
+ export const sdlCompletion = sdlLanguage.data.of({
13
+ autocomplete: sdlComplete,
14
+ });
15
+
16
+ export function sdl(): LanguageSupport {
17
+ return new LanguageSupport(sdlLanguage, [sdlCompletion]);
18
+ }
@@ -0,0 +1,157 @@
1
+ import { syntaxTree } from "@codemirror/language";
2
+ import {
3
+ type Diagnostic,
4
+ forEachDiagnostic,
5
+ linter,
6
+ setDiagnostics,
7
+ } from "@codemirror/lint";
8
+ import { type Extension } from "@codemirror/state";
9
+ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
10
+ import {
11
+ buildAst,
12
+ collateSyntaxErrors,
13
+ createLenientSdlAnalyser,
14
+ SdlStringInput,
15
+ SemanticError,
16
+ SemanticWarning,
17
+ Specification,
18
+ SyntaxError,
19
+ } from "@mpeggroup/mpeg-sdl-parser";
20
+
21
+ interface SdlLinterOptions {
22
+ onSyntaxErrorChange: (syntaxErrors: SyntaxError[]) => void;
23
+ onSemanticErrorChange: (semanticErrors: SemanticError[]) => void;
24
+ onSemanticWarningChange: (semanticWarnings: SemanticWarning[]) => void;
25
+ showSyntaxErrors: boolean;
26
+ showSemanticErrors: boolean;
27
+ showSemanticWarnings: boolean;
28
+ }
29
+
30
+ const DEBOUNCE_MS = 100;
31
+
32
+ export function sdlLinter(options: SdlLinterOptions): Extension {
33
+ // Dispatch clear then set as two separate transactions so CodeMirror destroys
34
+ // and recreates the widget DOM node rather than reusing/moving it. Safari's
35
+ // contenteditable engine silently ignores in-place widget moves, so without
36
+ // the clear the indicator never visually repositions.
37
+ function dispatchDiagnostics(view: EditorView, diagnostics: Diagnostic[]) {
38
+ view.dispatch(setDiagnostics(view.state, []));
39
+ view.dispatch(setDiagnostics(view.state, diagnostics));
40
+ }
41
+
42
+ function runLint(view: EditorView) {
43
+ const currentDoc = view.state.doc;
44
+ try {
45
+ const diagnostics: Diagnostic[] = [];
46
+ const sdlParseTree = syntaxTree(view.state);
47
+ const sdlStringInput = new SdlStringInput(view.state.doc.toString());
48
+ const syntaxErrors = collateSyntaxErrors(sdlParseTree, sdlStringInput);
49
+ const specification = buildAst(sdlParseTree, sdlStringInput, true);
50
+ const analyser = createLenientSdlAnalyser();
51
+ const analysisResult = analyser.analyse(specification as Specification);
52
+
53
+ if (options.showSyntaxErrors) {
54
+ for (const error of syntaxErrors) {
55
+ diagnostics.push({
56
+ from: error.location!.position,
57
+ to: error.location!.position,
58
+ severity: "error",
59
+ message: error.errorMessage,
60
+ });
61
+ }
62
+ }
63
+ if (options.showSemanticErrors) {
64
+ for (const error of analysisResult.semanticErrors) {
65
+ diagnostics.push({
66
+ from: error.location!.position,
67
+ to: error.location!.position,
68
+ severity: "error",
69
+ message: error.errorMessage,
70
+ });
71
+ }
72
+ }
73
+ if (options.showSemanticWarnings) {
74
+ for (const warning of analysisResult.semanticWarnings) {
75
+ diagnostics.push({
76
+ from: warning.location!.position,
77
+ to: warning.location!.position,
78
+ severity: "warning",
79
+ message: warning.errorMessage,
80
+ });
81
+ }
82
+ }
83
+
84
+ if (options.onSyntaxErrorChange) {
85
+ options.onSyntaxErrorChange(syntaxErrors);
86
+ }
87
+ if (options.onSemanticErrorChange) {
88
+ options.onSemanticErrorChange(analysisResult.semanticErrors);
89
+ }
90
+ if (options.onSemanticWarningChange) {
91
+ options.onSemanticWarningChange(analysisResult.semanticWarnings);
92
+ }
93
+
94
+ if (view.state.doc === currentDoc) {
95
+ dispatchDiagnostics(view, diagnostics);
96
+ }
97
+ } catch {
98
+ if (view.state.doc === currentDoc) {
99
+ view.dispatch(setDiagnostics(view.state, []));
100
+ }
101
+ }
102
+ }
103
+
104
+ const rafLintPlugin = ViewPlugin.fromClass(
105
+ class {
106
+ private lastChangeAt = 0;
107
+ private rafId = 0;
108
+
109
+ constructor(private readonly view: EditorView) {
110
+ requestAnimationFrame(() => runLint(this.view));
111
+ }
112
+
113
+ update(update: ViewUpdate) {
114
+ if (!update.docChanged) {
115
+ return;
116
+ }
117
+
118
+ // Immediately remap existing diagnostics with positive bias so
119
+ // insertions AT a zero-width marker push it forward right away.
120
+ const remapped: Diagnostic[] = [];
121
+ forEachDiagnostic(update.startState, (d, from, to) => {
122
+ remapped.push({
123
+ ...d,
124
+ from: update.changes.mapPos(from, 1),
125
+ to: update.changes.mapPos(to, 1),
126
+ });
127
+ });
128
+ const snapDoc = update.state.doc;
129
+ requestAnimationFrame(() => {
130
+ if (this.view.state.doc === snapDoc) {
131
+ dispatchDiagnostics(this.view, remapped);
132
+ }
133
+ });
134
+
135
+ this.lastChangeAt = Date.now();
136
+ cancelAnimationFrame(this.rafId);
137
+ this.rafId = requestAnimationFrame(() => this.tick());
138
+ }
139
+
140
+ private tick() {
141
+ if (Date.now() - this.lastChangeAt >= DEBOUNCE_MS) {
142
+ runLint(this.view);
143
+ } else {
144
+ this.rafId = requestAnimationFrame(() => this.tick());
145
+ }
146
+ }
147
+
148
+ destroy() {
149
+ cancelAnimationFrame(this.rafId);
150
+ }
151
+ },
152
+ );
153
+
154
+ // linter(null) registers the diagnostic state field, decorations and theme
155
+ // without attaching a lint source — we drive everything from rafLintPlugin.
156
+ return [linter(null), rafLintPlugin];
157
+ }
@@ -0,0 +1,16 @@
1
+ const tailwindConfig: import("tailwindcss").Config & { daisyui?: any } = {
2
+ content: [
3
+ "./src/**/*.{html,js,jsx,ts,tsx}",
4
+ ],
5
+ theme: {
6
+ extend: {},
7
+ },
8
+ plugins: [
9
+ require("daisyui"),
10
+ ],
11
+ daisyui: {
12
+ themes: ["light", "dark"],
13
+ },
14
+ };
15
+
16
+ export default tailwindConfig;
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "@testing-library/react";
3
+ import { Editor } from "../src/components/Editor.tsx";
4
+
5
+ describe("Editor component", () => {
6
+ test("renders with initial value", () => {
7
+ const { getAllByRole } = render(
8
+ <Editor
9
+ code="int a;"
10
+ onCodeChange={() => {}}
11
+ onCursorChange={() => {}}
12
+ onSyntaxErrorChange={() => {}}
13
+ onSemanticErrorChange={() => {}}
14
+ onSemanticWarningChange={() => {}}
15
+ theme="light"
16
+ rulerWidth={80}
17
+ autoDisplayCompletions
18
+ enableLinting
19
+ />,
20
+ );
21
+ const textboxes = getAllByRole("textbox");
22
+
23
+ // Check that at least one textbox contains the expected content
24
+ const found = textboxes.some(
25
+ (el) => el.textContent && el.textContent.includes("int a;"),
26
+ );
27
+ expect(found).toBe(true);
28
+ });
29
+ });
@@ -0,0 +1,3 @@
1
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
2
+
3
+ GlobalRegistrator.register();
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sdlLanguage } from "../src/sdl/sdlLanguage.ts";
3
+
4
+ describe("sdlLanguage", () => {
5
+ test("parses valid SDL without error", () => {
6
+ const parser = sdlLanguage.parser;
7
+ const input = "computed const int a = 1;";
8
+ const tree = parser.parse(input);
9
+
10
+ // The root node should not be an error
11
+ expect(tree.type.isError).toBe(false);
12
+
13
+ // There should be no error nodes in the tree
14
+ let hasError = false;
15
+ tree.iterate({
16
+ enter: (type) => {
17
+ if (type.type.isError) hasError = true;
18
+ },
19
+ });
20
+
21
+ expect(hasError).toBe(false);
22
+ });
23
+
24
+ test("parses invalid SDL with error node", () => {
25
+ const parser = sdlLanguage.parser;
26
+ const input = "invalid sdl $";
27
+ const tree = parser.parse(input);
28
+
29
+ // The tree should contain an error node
30
+ let hasError = false;
31
+ tree.iterate({
32
+ enter: (type) => {
33
+ if (type.type.isError) hasError = true;
34
+ },
35
+ });
36
+
37
+ expect(hasError).toBe(true);
38
+ });
39
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Enable latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+
22
+ // Some stricter flags (disabled by default)
23
+ "noUnusedLocals": false,
24
+ "noUnusedParameters": false,
25
+ "noPropertyAccessFromIndexSignature": false
26
+ }
27
+ }