diff --git a/.agents/skills/code-style/SKILL.md b/.agents/skills/code-style/SKILL.md index 3a2dcfb6d..b4c09e9a4 100644 --- a/.agents/skills/code-style/SKILL.md +++ b/.agents/skills/code-style/SKILL.md @@ -32,6 +32,7 @@ details only when needed. - Normally, only one component, model or store is defined per file. Multiple components per file are ok when they are small, closely related, not exported and don't have logic of their own (so no hooks, no state, no side effects). Otherwise split them into separate files and import them. - When a component needs to import dedicated subcomponents, put them in a folder named after the component, with an `index.tsx` and a `styles.module.scss` file. - When shared styles are needed, import them from the parent folder's `styles.module.scss` file. Avoid global CSS and inline styles. +- Declare class fields explicitly. Do not use TypeScript constructor parameter properties (public, protected, private, or readonly on constructor parameters). Declare each field in the class body and assign it explicitly in the constructor. - Use `clsx` for conditional class names. Avoid inline styles and global CSS. - private methods should be located after public methods at the bottom of the class. @@ -72,3 +73,17 @@ createOrUpdateUserPermission(documentRootId: string, user: User, access: Access) ## Readability over micro-optimizations Unless the code is on a hot path where performance matters, favor readability over micro-optimizations and clever one-liners. Iterating over a list several times with well-named steps is fine when the list is small. + +## TypeScript imports + +After editing `.ts` or `.tsx` files: +- Run TypeScript's Organize Imports operation on every changed file. +- Use the project's TypeScript version, tsconfig, and the import-organizing + preferences and formatting options used by VS Code. +- Prefer an existing repository command that invokes this operation. + Otherwise, use a helper backed by the TypeScript language service's + `organizeImports` API. +- Apply the returned edits; do not approximate the result by hand or + substitute another import sorter. +- Verify that running the organizer again produces no further edits. +- If the operation cannot be run, report that verification is incomplete. \ No newline at end of file diff --git a/.agents/skills/mdx-docs/SKILL.md b/.agents/skills/mdx-docs/SKILL.md new file mode 100644 index 000000000..a28017adf --- /dev/null +++ b/.agents/skills/mdx-docs/SKILL.md @@ -0,0 +1,86 @@ +--- +name: mdx-docs +description: Write or edit repository Markdown and MDX documentation using Teaching Dev conventions, components, and remark syntax. Read before changing .md or .mdx documentation. +--- + +# MDX Documentation + +Apply these rules to `.md` and `.mdx` documentation, not source code. Keep edits focused on the requested feature or page. + +## Language and content + +- Write for computer science teachers at upper secondary schools (Gymnasium). Assume programming knowledge, but explain Teaching Dev concepts and setup requirements. Use classroom-relevant examples and focus on what teachers need to create and use teaching materials. +- Write framework documentation in Swiss Standard German: use ss instead of ß and proper umlauts (ä, ö, ü). Comments in embedded code examples may also be German. Preserve identifiers, file paths, commands, and quoted output. +- Lead usage documentation with a minimal working example. Explain prerequisites, surprising behaviour, constraints, and consequences the example does not reveal. Do not narrate each line or repeat API definitions in surrounding prose. +- Use headings for topics, ordered lists for sequences, and unordered lists for parallel items. Keep workflows and narrative explanations in prose. Reserve tables for comparisons across several dimensions. + +## Location and identity + +- Keep package usage and API references in a root `README.mdx` or a `docs/` directory with `README.mdx` or `index.mdx` and optional subpages. Keep related assets alongside the documentation, following the package's existing layout. +- Edit package sources under `packages/`, not synchronized copies under `tdev-website/docs/packages/`. The synchronization logic is in [updateSync/packageDocsSync](../../../updateSync/packageDocsSync). +- Package docs are the reference. Teacher-facing pages under `tdev-website/docs/` demonstrate classroom use and link to the reference. Add or update a gallery page when relevant to the requested feature; keep overlapping examples consistent without duplicating the full API reference. +- Preserve existing frontmatter, including `page_id`, when editing or moving pages. Preserve persisted component IDs in existing examples. +- Whenever a component needs a new `id` attribute or an MDX page needs a new `page_id`, generate a fresh, random, valid UUIDv4 using a UUID generator, such as `crypto.randomUUID()` or Python's `uuid.uuid4()`. Do not invent UUIDs, copy them from other examples, or reuse an existing ID for a new component or page. + +## API definitions + +Use definition lists for attributes, props, options, parameters, API functions, and terminology. Include types, defaults, and constraints when useful. Put each term on its own line, followed by one or more `: ` definitions: + +```md +`option` +: Description. +: Standard: `false`. +`otherOption` +: Description of the next option. +``` + +For code blocks, lists, or other block content within a definition, use `:::dd`. See the [definition-list gallery](../../../tdev-website/docs/gallery/markdown/remark-deflist/index.mdx) for examples. + +## Examples and instructions + +Use `CodeShowcase` when readers benefit from seeing source alongside its rendered or interactive result. Use ordinary fenced blocks for commands, configuration, and standalone snippets. Keep existing runnable code-fence metadata when editing live examples. + +Import components explicitly. For Markdown demonstrations, set `lang="md"`. Use `imports` to include the imports readers need in the displayed source: + +```mdx +import CodeShowcase from '@tdev-components/CodeShowcase'; +import Steps from '@tdev-components/Steps'; + + + + +1. Öffne die Einstellungen. +2. Speichere die Änderungen. + + + +``` + +Wrap ordered lists describing user actions in `Steps`; use ordinary lists for other enumerations. See the [Steps gallery](../../../tdev-website/docs/gallery/display/steps/index.mdx). + +## Repository syntax + +Use the relevant gallery page for detailed syntax rather than inventing JSX or copying plugin setup into usage guides: + +- [Cards and flex](../../../tdev-website/docs/gallery/markdown/remark-flex-cards/index.mdx): `:::cards` or `:::flex`, with `::br` between items. When nesting directives, use longer outer fences, such as `::::flex` around `:::cards`. +- [Details](../../../tdev-website/docs/gallery/markdown/remark-details/index.mdx): use a container with a summary: + + ```md + :::details[Weitere Informationen] + Optionaler Inhalt. + ::: + ``` + +- [Admonitions](../../../tdev-website/docs/gallery/markdown/admonitions/index.mdx): use notes, tips, and warnings for information that needs attention, not routine prose. +- [Keyboard shortcuts](../../../tdev-website/docs/gallery/markdown/remark-kbd/index.mdx): `[[Ctrl]] + [[C]]`. +- [Inline icons](../../../tdev-website/docs/gallery/markdown/remark-mdi/index.mdx): `:mdi[skateboarding]`. +- [Boxed emphasis](../../../tdev-website/docs/gallery/markdown/remark-strong/index.mdx): `__path/to/file__` highlights a path using the repository's boxed style. Use it when highlighting a file or directory helps; use `**text**` for ordinary bold and backticks for identifiers and commands. +- [Images](../../../tdev-website/docs/gallery/markdown/remark-image/index.mdx): alt text becomes the caption. Consult the gallery for size options, attribution JSON, and inline images. +- Diagrams and formulas: consult [Mermaid](../../../tdev-website/docs/gallery/markdown/mermaid/index.mdx), [Graphviz](../../../tdev-website/docs/gallery/markdown/remark-graphviz/index.mdx), or [math](../../../tdev-website/docs/gallery/markdown/remark-math/index.mdx) when they clarify the content. + +## Validation + +- Check documented APIs, defaults, and constraints against the implementation. Verify local links and imports, including relative paths after moving a page. +- After structural MDX changes, compile the affected pages with the relevant remark plugins. Preview changes involving layout or interaction. +- Markdown and MDX are excluded from repository-wide Prettier checks; `format:check` does not validate them. Review their formatting by hand. + diff --git a/package.json b/package.json index 3976fd875..5e64afa3d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "start": "concurrently --raw --kill-others 'PACKAGE_SRC=packages PACKAGE_DEST=docs/Packages docusaurus start' 'sleep 1s && tsx updateSync/packageDocsSync/watch.ts --src packages --dest tdev-website/docs/packages'", + "start": "tsx updateSync/packageDocsSync/preBuild.ts --src packages --dest tdev-website/docs/packages && concurrently --raw --kill-others 'docusaurus start' 'tsx updateSync/packageDocsSync/watch.ts --src packages --dest tdev-website/docs/packages --skip-initial'", "prebuild": "yarn workspace @tdev/material-sync sync && tsx updateSync/packageDocsSync/preBuild.ts --src packages --dest tdev-website/docs/packages", "build": "docusaurus build", "swizzle": "docusaurus swizzle", diff --git a/packages/tdev/pyodide-code/README.mdx b/packages/tdev/pyodide-code/README.mdx deleted file mode 100644 index 465ab64f5..000000000 --- a/packages/tdev/pyodide-code/README.mdx +++ /dev/null @@ -1,309 +0,0 @@ ---- -page_id: e7bde0ab-c0fe-4225-8325-e4ebc22d414b -tags: - - '@tdev/' - - python - - code -sidebar_position: 50 ---- - -import CodeShowcase from '@tdev-components/CodeShowcase'; -import Clock from '@tdev/packages/pyodide-code/components/Clock'; -import Pyodide from '@tdev/pyodide-code/components/Pyodide'; - -import CodeBlock from '@theme/CodeBlock'; - -# pyodide-code - - -```py live_pyo id=b26cde2c-5b0b-4acb-814e-9f2b3bdd29a9 -print("Hello from Pyodide!") -``` - - -## Sleep - - -```py live_pyo id=82e559e8-db77-4efb-adcc-f6cd18085c9e title=Sleep_und_Ausgabe -from time import sleep -for i in range(10): - print(i, i*i, i**3) - sleep(1) -``` - -## Ausgabe löschen - -`os.system('clear')` und `os.system('cls')` löschen die bisherige Ausgabe des aktuellen Editors. Nachfolgende Ausgaben erscheinen wieder im leeren Logfenster. Beide Befehle geben `0` zurück. Diese Unterstützung gilt für die beiden Löschbefehle; eine allgemeine Betriebssystem-Shell steht im Browser nicht zur Verfügung. - -```py live_pyo hideWarning title=Ausgabe_löschen -from os import system -from time import sleep - -for i in range(5): - system('clear') - print(i) - sleep(1) -``` - -Es ist immer nur die aktuelle Ausgabe sichtbar. - -## Fibonacci -```py live_pyo id=d5d60431-5589-4d38-9c8c-9b563194f5cc title=Fibonacci_ohne_Rekursion -def fibonacci(n): - if n <= 0: - return [] - elif n == 1: - return [0] - elif n == 2: - return [0, 1] - else: - seq = [0, 1] - for i in range(2, n): - seq.append(seq[-1] + seq[-2]) - return seq -n = int(input("fib von? ")) -print(fibonacci(n)) -``` - -### Mit Memoization - -```py live_pyo id=22b0f649-9b77-4e97-9ccb-cfeb159bbde9 title=Fibonacci_mit_Memoization -from functools import lru_cache - -@lru_cache -def fibonacci(n): - if n in {0, 1}: - return n - return fibonacci(n-1) + fibonacci(n-2) - -for n in range(1, 200): - print(f"Fibonacci({n}) = {fibonacci(n)}") -``` - -## Minimierung mit SciPy - -```py live_pyo id=8e484282-faac-4955-b0a2-b0a2e270f765 title=Minimierung_mit_SciPy -import numpy as np -from scipy import optimize -# Function to minimize: f(x) = (x - 3)^2 -def func(x): - return (x - 3) ** 2 - -# Minimize using scipy.optimize -result = optimize.minimize(func, x0=0) -print("Minimum at x =", result.x[0]) -print("Function value at minimum =", result.fun) -``` - -## Python-Pakete laden - -Imports werden mit Pythons AST ausgewertet, auch innerhalb von Funktionen oder zusammen mit weiteren Anweisungen auf einer Zeile. Pakete aus der Pyodide-Distribution werden mit deren eingebautem Loader geladen. Bereits verfügbare Module, die Standardbibliothek und lokale JavaScript-Module lösen keine zusätzlichen PyPI-Installationen aus. Fehlgeschlagene Installationen werden im Editor angezeigt. - -Für zusätzliche oder versionierte Abhängigkeiten kann die `Pyodide`-Komponente eine Liste von Paketanforderungen erhalten: - - - -export const code = `from slugify import slugify\n -print(slugify("C'est déjà l'été.", algorithm='modern')) -print(slugify('影師嗎', backend='text-unidecode', algorithm='modern'))`; - - - - -Diese Pakete werden vor der Ausführung installiert. So funktioniert auch `import slugify`, obwohl die Distribution `python-slugify` heisst. Die Liste steht ebenfalls als `packages` in den `ModelMeta`-Optionen zur Verfügung. Dynamisch zusammengesetzte Importnamen benötigen solche expliziten Paketanforderungen. - -Explizite Paketanforderungen müssen erfolgreich installiert werden. Bei fehlgeschlagenen automatisch erkannten Abhängigkeiten wird eine Diagnose angezeigt und der Python-Code trotzdem ausgeführt. Dadurch können optionale Imports weiterhin mit `try`/`except ImportError` behandelt werden. - -Fortschrittsmeldungen beim Laden von Paketen erscheinen ausschliesslich in der Entwicklerkonsole des Browsers. Fehlgeschlagene Installationen werden weiterhin im Editor angezeigt. - -## Freie Worker wiederverwenden - -Nacheinander gestartete Skripte verwenden einen freien Worker desselben Browser-Tabs wieder. Dadurch bleiben bereits installierte Pakete und importierte Bibliotheken verfügbar. Gleichzeitig laufende Skripte erhalten jeweils einen eigenen Worker. Pro Tab bleiben höchstens zwei freie Worker erhalten; verschiedene Tabs teilen keinen Python-Interpreter. - -Python-Fehler behalten den geladenen Worker beim jeweiligen Editor. Beim nächsten Start wird derselbe Worker wiederverwendet. - -**Abbrechen** beendet den Worker sofort und initialisiert einen Ersatz. Das gilt auch beim Abbrechen einer Eingabe und stoppt Endlosschleifen zuverlässig. Andere gleichzeitig laufende Skripte behalten ihre Worker. Das abgebrochene Skript wird nicht automatisch erneut ausgeführt; seine Pakete müssen beim nächsten Start erneut geladen werden. - -Skriptvariablen werden bei jeder Ausführung neu angelegt. Importierte Module, Dateien, das Arbeitsverzeichnis und anderer Interpreterzustand können zwischen Editoren erhalten bleiben. Worker mit noch laufenden asyncio-Tasks bleiben ebenfalls beim jeweiligen Editor. Erweiterungen müssen geplante asyncio-Callbacks und JavaScript-Timer vor Ende des Skripts selbst beenden; solche Hintergrundaktivitäten werden durch die Task-Prüfung nicht erkannt. Ein ausgefallener Worker wird beim nächsten Start eines Skripts ersetzt. - -## Interaktive Komponenten hinzufügen - -Das `pyodide-code`-Package kann erweitert werden - sowohl mit neuen, lokalen Pythin-Bibliotheken als auch mit interaktiven Komponenten, die in React geschrieben sind und mit Python kommunizieren können. Ein Beispiel dafür ist die interaktive Uhr-Komponente: - -### Interaktive Uhr - - - - -```py live_pyo id=e63340b1-3821-4b70-99af-602272a48d8d title=Interaktive_Uhr -from clock import use_clock -from time import sleep -uhr = use_clock("sbb-uhr") - -while True: - uhr.set_seconds(uhr.seconds + 1) - if uhr.seconds % 360 == 0: - sleep(0.99) - uhr.set_minutes(uhr.minutes + 6) - if uhr.minutes % 360 == 0: - uhr.set_hours(uhr.hours + 30) - sleep(0.01) -``` - - -### Erweiterungen registrieren - -Eine Erweiterung hat zwei getrennte Einstiegspunkte: `worker.ts` stellt Python-Module bereit, `client.ts` verarbeitet deren Nachrichten im Browser. Beide verwenden denselben Payload-Typ aus `protocol.ts`. React-Komponenten und MobX-Stores bleiben normale Bestandteile der Website. - -Die Einstiegspunkte werden explizit im bestehenden Pyodide-Plugin konfiguriert: - -```ts title="siteConfig.ts" -import type { PluginOptions } from '@tdev/pyodide-code/plugin'; - -plugins: [ - [ - require.resolve('@tdev/pyodide-code/plugin'), - { - extensions: [ - { - name: 'clock', - worker: require.resolve('./website/pyodide/clock/worker'), - client: require.resolve('./website/pyodide/clock/client') - } - ] - } satisfies PluginOptions - ] -] -``` - -Die Namen der Erweiterungen müssen eindeutig sein. `require.resolve()` liefert absolute Dateipfade; relative Einstiegspfade in den Optionen werden relativ zum Docusaurus-Seitenverzeichnis aufgelöst. Die Konfiguration enthält nur Dateipfade und Namen, keine React-Komponenten oder Store-Instanzen. Nach Änderungen an der Erweiterungsliste muss der Entwicklungsserver neu gestartet werden. - -Das Plugin erzeugt getrennte Registries für Worker und Browser. Es braucht weder ein Alias-Override von Package-Dateien noch eine globale Registrierung beim App-Start. `worker.ts` darf keine React-, DOM- oder Store-Abhängigkeiten importieren; gemeinsame Payload-Typen werden mit `import type` eingebunden. - -Beim Laden der jeweiligen Registry wird einmalig geprüft, ob die Worker-Einträge `createModules()` und die Browser-Einträge `handleMessage()` bereitstellen. Ein ungültiger Export verhindert die Initialisierung dieser Registry; die Fehlermeldung nennt die Erweiterung und den betroffenen Einstiegspunkt. - -### Uhr: gemeinsamer Vertrag - -```ts title="website/pyodide/clock/protocol.ts" -export interface ClockTime { - hours: number; - minutes: number; - seconds: number; -} - -export type ClockUpdate = Partial; -``` - -Alle Winkel sind in Grad angegeben. Eine Nachricht kann einzelne Zeiger oder die ganze Uhr ändern. Die beiden Einstiegspunkte verwenden `ClockUpdate` als Typparameter. Die Typen prüfen den selbst geschriebenen Worker und Handler, es gibt keine automatische Laufzeitvalidierung des Payloads. - -### Uhr: Python-Modul im Worker - -import clockWorkerSource from '!!raw-loader!@tdev/pyodide/clock/worker.ts'; - - - {clockWorkerSource} - - -`defineWorkerExtension()` erhält eine Factory. Diese liefert ein Objekt, dessen Schlüssel die Python-Modulnamen sind, hier `clock`. Eine Erweiterung kann mehrere Module bereitstellen; Modulnamen und Nachrichtentypen sind unabhängig voneinander. Doppelte Modulnamen und der interne Name `browser_input` werden mit einer Fehlermeldung abgelehnt. - -Der Factory-Kontext bietet: - -- `editorId`: die ID des ausführenden Editors. -- `send(targetId, payload)`: sendet eine Nachricht an den Browser-Handler dieser Erweiterung. Das Framework ergänzt Editor-ID, Erweiterungsname und Zeitstempel. Der Payload muss durch strukturiertes Klonen übertragbar sein, beispielsweise ein Objekt aus Zahlen, Strings und Arrays. - -Die Factory wird bei **jeder Ausführung** aufgerufen und ihre Module werden vor dem Python-Code registriert. Damit funktionieren auch dynamische Imports wie `importlib.import_module('clock')`. Der Worker wartet vor Abschluss auf ausstehende Nachrichten an die Browser-Handler. Ein Handler-Fehler wird als Ausführungsfehler im Editor angezeigt. Bereits zugestellte Änderungen werden nicht zurückgerollt. - -Schlagen sowohl die Ausführung als auch deren Abschluss fehl, erscheint der ursprüngliche Ausführungsfehler zuerst. Zusätzliche Fehler beim Zustellen von Nachrichten oder beim Aufräumen werden darunter angezeigt. Die weiteren Aufräumschritte werden auch nach einem Fehler ausgeführt. - -### Uhr: Browser-Handler und React - -import clockClientSource from '!!raw-loader!@tdev/pyodide/clock/client.ts'; - - - {clockClientSource} - - -`defineClientExtension()` erhält einen synchronen Handler. Der Kontext enthält den aktuellen `root`-Store. Die Nachricht enthält `editorId`, `targetId`, `timeStamp` und `payload`. Der Handler läuft innerhalb einer MobX-Action; er aktualisiert die bestehenden Website-Stores. - -Die Uhr verwendet weiterhin den [ClockStore](https://github.com/GBSL-Informatik/teaching-dev/blob/main/tdev-website/website/stores/ClockStore.ts), das [Clock-Modell](https://github.com/GBSL-Informatik/teaching-dev/blob/main/tdev-website/website/packages/pyodide-code/models/Clock.ts) und die [React-Komponente](https://github.com/GBSL-Informatik/teaching-dev/blob/main/tdev-website/website/packages/pyodide-code/components/Clock/index.tsx). Nur der Store muss im `SiteStore` vorhanden sein; ein `handleMessage`-Override ist nicht nötig. Die Komponente kann wie bisher unabhängig vom Editor im MDX platziert werden: - -```mdx -import Clock from '@tdev/packages/pyodide-code/components/Clock'; - - -``` - -Python verwendet weiterhin `from clock import use_clock` und dieselbe Ziel-ID. Mehrere Komponenten oder Editoren können absichtlich dieselbe Ziel-ID verwenden; die Ziel-IDs gelten für den gemeinsamen Website-Store, nicht nur für einen Editor. - -### Zustand und Lebensdauer - -Die Browser-Stores gehören weiterhin der Website und leben mit deren Root-Store. Der Zustand der Uhr bleibt nach Ende oder Abbruch eines Skripts erhalten; ein Neustart setzt ihn nicht automatisch zurück. Ein unbekanntes Ziel wird beim ersten Update im `ClockStore` angelegt, auch wenn noch keine React-Komponente dafür angezeigt wird. - -Jeder Aufruf von `use_clock()` erzeugt einen eigenen Python-Handle mit zunächst lokalen Nullwerten. Seine Getter lesen die letzten Werte dieses Handles, nicht den Browser-Store und nicht andere Handles. `set_seconds()` ändert im Browser nur den Sekundenzeiger. `reset()` setzt alle Zeiger zurück. Diese Erweiterung bietet keine Rückfragen vom Browser an Python. - -Worker können zwischen Editoren wiederverwendet werden. Die registrierten JavaScript-Module erhalten pro Ausführung einen neuen Kontext. Bereits importierte Python-Hilfsmodule können jedoch alte Referenzen auf Module oder Handles behalten; deren Cache wird nicht rekursiv geleert. Solche Hilfsmodule sollen das Modul oder die Factory innerhalb der pro Ausführung aufgerufenen Funktion neu importieren und erst dann Handles anfordern. Ein auf Modulebene gespeichertes `from clock import use_clock` bleibt sonst an den alten, bereits geschlossenen Kontext gebunden. - -Erweiterungen müssen eigene Timer und Hintergrundaufgaben vor Ende des Skripts beenden. Nachrichten nach Abschluss werden verworfen. **Abbrechen** beendet den Worker; ausstehende Nachrichten einer abgebrochenen oder ersetzten Ausführung werden durch die bestehende Ausführungsprüfung ignoriert. Das Framework stellt keine automatische Ressourcenverwaltung oder Hintergrunddienste für Erweiterungen bereit. - - -## Installation - -:::info[`packages/tdev/pyodide-code`] -Kopiere des `packages/tdev/pyodide-code`-Verzeichnis in das `tdev-website/website/packages`-Verzeichnis oder über `updateTdev.config.yaml` hinzufügen. -::: - - -import pyodideSW from '!!raw-loader!@site/static/pyodide.sw.js'; - -1. Pyodide-Plugin und Dokument-Provider im `siteConfig.ts` registrieren: - ```ts title="siteConfig.ts" - const getSiteConfig: SiteConfigProvider = () => { - return { - plugins: [ - require.resolve('@tdev/pyodide-code/plugin'), - ], - apiDocumentProviders: [ - require.resolve('@tdev/pyodide-code/register'), - ] - }; - }; - ``` -2. ServiceWorker hinzufügen: - Damit Eingaben von Benutzer:innen abgewartet werden können, braucht es einen Servie-Worker. Dieser muss direkt in den statischen Ordner [`static/pyodide.sw.js`](https://github.com/GBSL-Informatik/teaching-dev/blob/main/static/pyodide.sw.js) kopiert werden. - Bei Paket-Updates muss auch diese Datei aktualisiert werden, damit Frontend und ServiceWorker dasselbe Eingabeprotokoll verwenden. - :::details[`static/pyodide.sw.js`] - - {pyodideSW} - - ::: - -Danach muss erneut installiert werden: - -```bash -yarn install -``` - -## Pyodide-Version konfigurieren - -Die exakte `pyodide`-Abhängigkeit in `package.json` legt die Laufzeitversion fest. -`config.ts` leitet daraus `PYODIDE_VERSION` und `PYODIDE_INDEX_URL` ab. Worker und -Main-Thread-Hook verwenden denselben ES-Modul-Loader und dieselbe CDN-Adresse. -Das Docusaurus-Plugin prüft beim Start, dass die Version exakt angegeben ist, -und konfiguriert den für Pyodide erforderlichen Modul-Worker. - -Zum Aktualisieren auf eine neue stabile Version: - -```bash -yarn workspace @tdev/pyodide-code add pyodide@ --exact -``` - -Die geänderten Dateien `package.json` und `yarn.lock` müssen gemeinsam übernommen werden. - -### Docusaurus- und Rspack-Updates prüfen - -Der `ModuleWorker`-Konstruktor, der Chunk-Name `pyodide-worker` und die Parser- und -SplitChunks-Konfiguration in `plugin.ts` gehören zusammen. Sie erhalten den -ES-Modul-Worker und verhindern gemeinsame Chunks, die Rspack sonst über -`importScripts()` laden würde. Nach Updates von Docusaurus, Rspack oder Pyodide -muss ein Produktions-Build im Browser geprüft werden: Der Worker muss als Modul -starten und Python einschliesslich Paketimporten und Eingaben ausführen können. diff --git a/packages/tdev/pyodide-code/components/Header/index.tsx b/packages/tdev/pyodide-code/components/Header/index.tsx index 8d5976af6..26339a421 100644 --- a/packages/tdev/pyodide-code/components/Header/index.tsx +++ b/packages/tdev/pyodide-code/components/Header/index.tsx @@ -20,7 +20,7 @@ const Header = observer((props: Props) => { return ( {!code.meta.slim && } - {code.canExecute && } + {code.canExecute && } {code.isExecuting && + {cookieId} + + {cookie.isConnected ? 'Klicke auf den Cookie.' : 'Starte zuerst das Python-Programm.'} + + + ); +}); + +export default CookieComponent; diff --git a/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/styles.module.scss b/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/styles.module.scss new file mode 100644 index 000000000..bbdbf6938 --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/styles.module.scss @@ -0,0 +1,55 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + margin-block: 1rem; +} + +.cookie { + width: 12rem; + max-width: 100%; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + cursor: pointer; + transition: scale 100ms; + + &:focus-visible { + outline: 3px solid var(--ifm-color-primary); + outline-offset: 4px; + } + + &:active:not(:disabled) { + scale: 0.95; + } + + &:disabled { + cursor: default; + opacity: 0.6; + } +} + +.image { + display: block; + width: 100%; +} + +.dough { + stroke: var(--ifm-color-warning-darkest); + stroke-width: 5px; +} + +.chip { + fill: var(--ifm-color-warning-darkest); +} + +.label { + font-weight: var(--ifm-font-weight-bold); +} + +.status { + color: var(--ifm-color-emphasis-700); + font-size: 0.875rem; +} diff --git a/tdev-website/website/pyodide/cookie-klicker/src/models/Cookie.ts b/tdev-website/website/pyodide/cookie-klicker/src/models/Cookie.ts new file mode 100644 index 000000000..f3cf86300 --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/src/models/Cookie.ts @@ -0,0 +1,49 @@ +import type { EventSource } from '@tdev/pyodide-code/events'; +import { action, computed, observable } from 'mobx'; +import { DEFAULT_COOKIE_COLOR, type CookieClick, type CookieKey } from '../../protocol'; +import type CookieStore from '../stores/CookieStore'; + +const CLICK_EVENT: CookieClick = { type: 'click', payload: null }; + +export default class Cookie { + @observable accessor rotation = 0; + @observable accessor color = DEFAULT_COOKIE_COLOR; + readonly eventSource: EventSource; + readonly id: string; + readonly store: CookieStore; + + constructor(id: string, store: CookieStore) { + this.id = id; + this.store = store; + this.eventSource = { extension: store.context.name, targetId: id }; + } + + @computed + get isConnected(): boolean { + return this.store.context.events.isConnected(this.eventSource); + } + + @computed + get rotationTransform(): string { + return `rotate(${this.rotation} 100 100)`; + } + + @action + setRotation(rotation: number) { + this.rotation = rotation; + } + + @action + setColor(color: string) { + this.color = color; + } + + handleClick = () => { + void this.store.context.events.emit(this.eventSource, CLICK_EVENT); + }; + + handleKey(key: string) { + const event: CookieKey = { type: 'key', payload: { key } }; + void this.store.context.events.emit(this.eventSource, event); + } +} diff --git a/tdev-website/website/pyodide/cookie-klicker/src/stores/CookieStore.ts b/tdev-website/website/pyodide/cookie-klicker/src/stores/CookieStore.ts new file mode 100644 index 000000000..65a5aa32d --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/src/stores/CookieStore.ts @@ -0,0 +1,22 @@ +import type { ClientContext } from '@tdev/pyodide-code/extensions'; +import { action, observable } from 'mobx'; +import Cookie from '../models/Cookie'; + +export default class CookieStore { + readonly context: ClientContext; + readonly cookies = observable.map([], { deep: false }); + + constructor(context: ClientContext) { + this.context = context; + } + + @action + useCookie(id: string): Cookie { + let cookie = this.cookies.get(id); + if (!cookie) { + cookie = new Cookie(id, this); + this.cookies.set(id, cookie); + } + return cookie; + } +} diff --git a/tdev-website/website/pyodide/cookie-klicker/worker.ts b/tdev-website/website/pyodide/cookie-klicker/worker.ts new file mode 100644 index 000000000..c88aeaf85 --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/worker.ts @@ -0,0 +1,48 @@ +import { defineWorkerExtension, type WorkerContext } from '@tdev/pyodide-code/extensions'; +import { DEFAULT_COOKIE_COLOR, type CookieUpdate } from './protocol'; + +export default defineWorkerExtension((context) => { + const cookies = new Map>(); + return { + cookie_klicker: { + use_cookie: (id: string) => { + const existingCookie = cookies.get(id); + if (existingCookie) { + return existingCookie; + } + const cookie = createCookie(context, id); + cookies.set(id, cookie); + return cookie; + } + } + }; +}); + +const createCookie = (context: WorkerContext, id: string) => { + const events = context.events.source(id); + let rotation = 0; + let color = DEFAULT_COOKIE_COLOR; + return { + events, + get rotation() { + return rotation; + }, + get color() { + return color; + }, + set_rotation: (value: number) => { + if (!Number.isFinite(value)) { + throw new Error('Cookie rotation must be a finite number.'); + } + rotation = ((value % 360) + 360) % 360; + context.send(id, { rotation }); + }, + set_color: (value: string) => { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Cookie color must be a non-empty CSS color string.'); + } + color = value; + context.send(id, { color }); + } + }; +}; diff --git a/tdev-website/website/pyodide/grid/README.md b/tdev-website/website/pyodide/grid/README.md new file mode 100644 index 000000000..25265a2d2 --- /dev/null +++ b/tdev-website/website/pyodide/grid/README.md @@ -0,0 +1,62 @@ +# Grid + +Voraussetzung ist eine Teaching-Dev-Website mit installiertem `pyodide-code`-Package. Kopiere den vollständigen Ordner nach `website/pyodide/grid` und ergänze im bestehenden Pyodide-Plugin die `extensions`-Liste: + +```ts +extensions: [ + './website/pyodide/grid' +] +``` + +Starte den Entwicklungsserver neu. + +## Im Unterrichtsmaterial verwenden + +```mdx +import Grid from '@tdev/pyodide/grid/src/components/Grid'; + + +``` + +```py +from grid import Grid + +board = Grid(10, 10, 'white', grid_id='board') +board[2][3] = 'navy' +board[2][3].text = 'A' +board[2][3].text_color = 'white' +await board.render() +``` + +`gridId`, `grid_id` +: Verbinden die Zeichenfläche mit dem Python-Gitter. Mehrere Ansichten derselben ID teilen die Anzeige; unabhängige Gitter benötigen unterschiedliche IDs. +`board[row][col]` +: Liefert eine leichte Zellansicht mit `color`, `text`, `text_color`, `text_scale`, `row`, `col` und `get(offset_x, offset_y)`. Die Nachbarsuche verbindet gegenüberliegende Ränder. +`board.set_cells(values, palette=None)` +: Übernimmt einen zusammenhängenden zweidimensionalen Integer-Buffer mit der Form `(rows, cols)`. Die Werte sind Indizes der bei `Grid(..., palette=colors)` oder bei `set_cells()` angegebenen Farbliste. +`await board.render()` +: Überträgt den vollständigen Zustand. Warte den Aufruf ab, bevor du erneut renderst. Farben und Text werden bis dahin nur im Worker geändert. + +Für grosse Gitter werden die Zellen kompakt gespeichert. NumPy erlaubt eine Berechnung ohne Python-Schleife über jede Zelle: + +```py +import numpy as np +from grid import Grid +from webio import gameloop, run + +colors = ['navy', 'royalblue', 'deepskyblue', 'lightcyan'] +board = Grid(1000, 1000, grid_id='board', palette=colors) +columns = np.arange(1000, dtype=np.uint16) +state = np.empty((1000, 1000), dtype=np.uint8) + +@gameloop(fps=30, sources=[board]) +async def update(ctx): + offset = int(ctx.time / 20) % 100 + state[:] = ((columns + offset) // 25) % len(colors) + board.set_cells(state) + await board.render() + +await run() +``` + +Die Python-Imports laden NumPy automatisch. Klickereignisse enthalten `row` und `col` sowie die Position `x` und `y` in Zellkoordinaten im Payload. Die zuletzt gezeichnete Ansicht bleibt nach Ende des Skripts erhalten. diff --git a/tdev-website/website/pyodide/grid/client.ts b/tdev-website/website/pyodide/grid/client.ts new file mode 100644 index 000000000..3a03b2553 --- /dev/null +++ b/tdev-website/website/pyodide/grid/client.ts @@ -0,0 +1,10 @@ +import { defineClientExtension } from '@tdev/pyodide-code/extensions'; +import type { GridFrame } from './protocol'; +import GridStore from './src/stores/GridStore'; + +export default defineClientExtension({ + createStore: (context) => new GridStore(context), + handleMessage: (store, { targetId, payload }) => { + store.useGrid(targetId).setFrame(payload); + } +}); diff --git a/tdev-website/website/pyodide/grid/docs/index.mdx b/tdev-website/website/pyodide/grid/docs/index.mdx new file mode 100644 index 000000000..15f620f91 --- /dev/null +++ b/tdev-website/website/pyodide/grid/docs/index.mdx @@ -0,0 +1,145 @@ +--- +page_id: 923acc02-a517-4492-9cd5-609e74aa69de +--- + +import CodeShowcase from '@tdev-components/CodeShowcase'; +import Steps from '@tdev-components/Steps'; +import Grid from '@tdev/pyodide/grid/src/components/Grid'; + +# Grid + +Die Erweiterung `grid` stellt farbige Zellen auf einer Zeichenfläche dar. Einzelne Zellen lassen sich wie im [Brython-Modul](/docs/packages/tdev/brython-code/libraries/grid) ansprechen. Änderungen werden mit `await board.render()` gemeinsam angezeigt. + + + + +```py live_pyo id=24e6722f-243c-4c8a-9d24-8f195d5b57f7 title=Zellen_und_Text +from grid import Grid + +board = Grid(4, 5, 'skyblue', grid_id='small-grid') +board[1][2] = 'navy' +board[1][2].text = 'A' +board[1][2].text_color = 'white' +board[2][4].get(1, 0).color = 'gold' +await board.render() +``` + + +## Grosse Gitter animieren + +Für viele Änderungen pro Bild werden Farbwerte als Zahlen in einem zusammenhängenden Array übergeben. NumPy berechnet sie mit Array-Operationen; eine Python-Schleife über jede Zelle würde die Berechnung wieder verlangsamen. `import numpy` lädt das Paket vor der Ausführung automatisch. + + + + +```py live_pyo id=cf6ba599-ad00-46e7-b442-4d914a109a13 title=Eine_Million_Zellen +import numpy as np +from os import system +from grid import Grid +from webio import gameloop, run + +colors = ['navy', 'royalblue', 'deepskyblue', 'lightcyan'] +board = Grid(1000, 1000, grid_id='large-grid', palette=colors) +columns = np.arange(1000, dtype=np.uint16) +state = np.empty((1000, 1000), dtype=np.uint8) + +@gameloop(fps=120, sources=[board]) +async def update(ctx): + offset = int(ctx.time / 20) % 100 + state[:] = ((columns + offset) // 25) % len(colors) + board.set_cells(state) + if ctx.frame % 10 == 0: + system('clear') + print('FPS', round(1/ctx.dt)) + await board.render() + +await run() +``` + + +Das Gitter speichert Farben als kompakte Palettenindizes. Ein Aufruf von `render()` überträgt einen vollständigen Zustand an die Zeichenfläche. Warte diesen Aufruf ab, bevor du den nächsten Zustand überträgst. Die tatsächlich erreichbare Bildrate hängt auch von der Berechnung und dem verwendeten Gerät ab; Details zur Spielschleife stehen in der [webio-Referenz](../../02-webio.mdx#spielschleife). + +## Gitter und Zellen + +`Grid(rows, cols, color='', *, grid_id='grid', palette=None, scale=None)` +: Erstellt ein Gitter mit positiven ganzzahligen Zeilen- und Spaltenzahlen. Ohne Farbe und Palette sind Zellen durchsichtig. Mit einer Palette und ohne Farbe beginnen alle Zellen bei Palettenindex `0`. Für Zellzugriffe ist NumPy nicht erforderlich. +`gridId`, `grid_id` +: Verbinden die React-Komponente mit dem Python-Gitter. Verwende für unabhängige Gitter unterschiedliche IDs. Standard in Python: `'grid'`. +`palette` +: Liste von CSS-Farben, beispielsweise `['black', 'white']`. Bei `set_cells()` entspricht `0` der ersten Farbe, `1` der zweiten Farbe usw. Einzelne Farbzuweisungen ergänzen bisher unbekannte Farben. Für lange Animationen mit wechselnden Farben ersetzt `set_cells(values, palette=colors)` die bisherige Palette, statt sie fortlaufend zu erweitern. +`scale` +: Positive ganzzahlige Zellgrösse in Zeichenflächenpixeln. Ohne Angabe wird das Gitter in eine Fläche von ungefähr 500 × 500 Pixeln eingepasst; jede Zelle bleibt mindestens einen Pixel gross. Die Darstellung verkleinert sich bei wenig Platz proportional. +`rows`, `cols`, `dim_y`, `dim_x` +: Unveränderliche Anzahl der Zeilen beziehungsweise Spalten. +`size` +: Tupel `(rows, cols)`. +`board[row][col]` +: Liefert eine leichte Ansicht einer Zelle. `board[row][col] = 'red'` ändert deren Farbe. Es werden nicht im Voraus Python-Objekte für alle Zellen angelegt. +`cell.row`, `cell.col` +: Zeile beziehungsweise Spalte der Zelle, beginnend bei `0`. +`cell.color` +: Veränderbare, vom Browser-Canvas unterstützte CSS-Farbe als Zeichenkette. Beispiele: `'red'`, `'#ff8800'`, `'rgb(20, 80, 160)'`. Mit `''` wird die Zelle durchsichtig. +`cell.get(offset_x, offset_y)` +: Liefert eine benachbarte Zelle. Die Ränder sind verbunden: Rechts neben der letzten Spalte liegt die erste Spalte. +`cell.text`, `cell.text_color`, `cell.text_scale` +: Veränderbarer Text und seine Darstellung. Standard: leerer Text, schwarze Schrift und Skalierung `0.8`. Text wird getrennt von den Zellfarben gespeichert; bei sehr kleinen Zellen ist er nicht lesbar. +`fill(color='')` +: Füllt alle Zellen mit einer Farbe. Text bleibt erhalten. +`await render()` +: Überträgt Farben und Text an alle Ansichten derselben `gridId`. Auch nach dem Füllen oder Ändern einzelner Zellen ist dieser Aufruf nötig. Ein weiterer gleichzeitiger Render-Aufruf wird abgelehnt. +`copy(*, grid_id=None)` +: Kopiert Zellfarben, Text und Skalierung. Ohne neue ID zeichnet die Kopie auf dieselbe Zeichenfläche. +`to_list()`, `color_grid`, `grid` +: Liefern die Farben als verschachtelte Python-Listen. Für grosse Gitter entstehen dabei viele Python-Werte; verwende diese Ansichten nicht für jeden Animationsschritt. + +## Ganze Zustände übernehmen + +`set_cells(values, palette=None)` +: Übernimmt Palettenindizes aus einem zusammenhängenden zweidimensionalen Integer-Buffer, beispielsweise einem NumPy-Array mit `dtype=np.uint8`. Die Form muss `(rows, cols)` entsprechen. Unterstützt werden vorzeichenlose und vorzeichenbehaftete Integer mit höchstens 32 Bit sowie Boolean-Werte (`False` als `0`, `True` als `1`). Alle Werte müssen nichtnegativ sein und auf eine vorhandene Palettenfarbe verweisen. +: Mit `palette` wird zugleich eine neue Farbliste gesetzt. Ohne Angabe gilt die bisherige Palette. Änderungen werden erst mit `await render()` sichtbar. + +Mit `np.ascontiguousarray(values)` kannst du eine nicht zusammenhängende NumPy-Ansicht vor der Übergabe umwandeln. Ein Gitter darf höchstens 16 777 216 Zellen enthalten. Die Zeichenfläche darf höchstens 8192 Pixel breit oder hoch sein und insgesamt höchstens 16 777 216 Pixel umfassen. Die Zellgrösse `scale` zählt dabei mit: Die Zeichenfläche ist `cols * scale` Pixel breit und `rows * scale` Pixel hoch. + +## Gitter aus Text + +```py +board = Grid.from_text(''' +..x.. +.xxx. +xxxxx +''', {'.': 'white', 'x': 'navy', 'bg': ''}, grid_id='small-grid') +await board.render() +``` + +`Grid.from_text(text, colors=None, *, grid_id='grid', scale=None)` +: Erstellt ein Gitter aus Textzeilen. Das Dictionary ordnet Zeichen Farben zu; `bg` bestimmt die Farbe unbekannter Zeichen und aufgefüllter Zellen. Ohne Dictionary werden `s`, `1` und `x` schwarz dargestellt, die übrigen Zellen bleiben durchsichtig. + +## Ereignisse + +`events` +: Ereignisquelle für `@gameloop`, `@on_click` und `@on_key` aus [webio](../../02-webio.mdx). +`event.payload['row']`, `event.payload['col']` +: Zeile und Spalte einer angeklickten Zelle, beginnend bei `0`. +`event.x`, `event.y` +: Klickposition in Zellkoordinaten; die Werte können Nachkommastellen enthalten. Dieselben Werte stehen im Payload. + +Tastaturereignisse gehen an die fokussierte Zeichenfläche und enthalten den Tastennamen in `event.payload['key']`. Die zuletzt gezeichnete Ansicht bleibt nach Ende oder Abbruch eines Skripts erhalten. Ein neues Python-Gitter beginnt mit seinen eigenen Startwerten und ersetzt die Anzeige beim ersten `render()`. + +## Auf der eigenen Website einrichten + +Voraussetzung ist das [installierte Pyodide-Package](../../README.mdx#installation). + + + +1. Kopiere den vollständigen Ordner [grid](https://github.com/GBSL-Informatik/teaching-dev/tree/main/tdev-website/website/pyodide/grid) nach __website/pyodide/grid__. +2. Ergänze den Ordner in der bestehenden `extensions`-Liste des [Pyodide-Plugins](../../README.mdx#erweiterungen-registrieren): + + ```ts title="siteConfig.ts" + extensions: [ + './website/pyodide/grid' + ] + ``` + +3. Starte den Entwicklungsserver neu. Danach kannst du die MDX-Beispiele oben verwenden. + + diff --git a/tdev-website/website/pyodide/grid/extension.json b/tdev-website/website/pyodide/grid/extension.json new file mode 100644 index 000000000..5ca69f3dc --- /dev/null +++ b/tdev-website/website/pyodide/grid/extension.json @@ -0,0 +1,6 @@ +{ + "name": "grid", + "worker": "./worker.ts", + "client": "./client.ts", + "showcase": "./docs" +} diff --git a/tdev-website/website/pyodide/grid/protocol.ts b/tdev-website/website/pyodide/grid/protocol.ts new file mode 100644 index 000000000..90892589e --- /dev/null +++ b/tdev-website/website/pyodide/grid/protocol.ts @@ -0,0 +1,21 @@ +export interface GridText { + row: number; + col: number; + text: string; + color: string; + scale: number; +} + +export interface GridFrame { + rows: number; + cols: number; + scale: number; + cells: GridCells; + palette: readonly string[]; + text: readonly GridText[]; +} + +export type GridCells = Uint8Array | Uint16Array | Uint32Array; + +export const MAX_GRID_CELLS = 16_777_216; +export const MAX_CANVAS_DIMENSION = 8192; diff --git a/tdev-website/website/pyodide/grid/src/components/Grid/index.tsx b/tdev-website/website/pyodide/grid/src/components/Grid/index.tsx new file mode 100644 index 000000000..3a568ef58 --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/components/Grid/index.tsx @@ -0,0 +1,63 @@ +import { useExtensionStore } from '@tdev/pyodide-code/hooks/useExtensionStore'; +import clsx from 'clsx'; +import { useEffect, useState, type KeyboardEvent, type MouseEvent } from 'react'; +import gridExtension from '../../../client'; +import GridRenderer from '../../rendering/GridRenderer'; +import styles from './styles.module.scss'; + +interface GridProps { + gridId: string; + className?: string; +} + +const SCROLL_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ']; + +const Grid = ({ gridId, className }: GridProps) => { + const store = useExtensionStore(gridExtension); + const grid = store.useGrid(gridId); + const [canvas, setCanvas] = useState(null); + + useEffect(() => { + if (!canvas) { + return; + } + return grid.attachRenderer(new GridRenderer(canvas)); + }, [canvas, grid]); + + const handleClick = (event: MouseEvent) => { + event.currentTarget.focus({ preventScroll: true }); + const bounds = event.currentTarget.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) { + return; + } + const x = ((event.clientX - bounds.left) / bounds.width) * grid.cols; + const y = ((event.clientY - bounds.top) / bounds.height) * grid.rows; + grid.handleClick(x, y); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!grid.isConnected) { + return; + } + if (SCROLL_KEYS.includes(event.key)) { + event.preventDefault(); + } + grid.handleKey(event.key); + }; + + return ( + + Gitter {gridId} + + ); +}; + +export default Grid; diff --git a/tdev-website/website/pyodide/grid/src/components/Grid/styles.module.scss b/tdev-website/website/pyodide/grid/src/components/Grid/styles.module.scss new file mode 100644 index 000000000..1b3592fb5 --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/components/Grid/styles.module.scss @@ -0,0 +1,12 @@ +.grid { + display: block; + max-width: 100%; + height: auto; + margin-block: 1rem; + image-rendering: pixelated; + + &:focus-visible { + outline: 3px solid var(--ifm-color-primary); + outline-offset: 4px; + } +} diff --git a/tdev-website/website/pyodide/grid/src/models/Grid.ts b/tdev-website/website/pyodide/grid/src/models/Grid.ts new file mode 100644 index 000000000..bfb59a388 --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/models/Grid.ts @@ -0,0 +1,94 @@ +import type { EventSource } from '@tdev/pyodide-code/events'; +import { computed } from 'mobx'; +import type { GridFrame } from '../../protocol'; +import type GridRenderer from '../rendering/GridRenderer'; +import type GridStore from '../stores/GridStore'; + +export default class Grid { + readonly id: string; + readonly store: GridStore; + readonly eventSource: EventSource; + private frame?: GridFrame; + private colorContext?: CanvasRenderingContext2D; + private readonly renderers = new Set(); + + constructor(id: string, store: GridStore) { + this.id = id; + this.store = store; + this.eventSource = { extension: store.context.name, targetId: id }; + } + + @computed + get isConnected(): boolean { + return this.store.context.events.isConnected(this.eventSource); + } + + get rows(): number { + return this.frame?.rows ?? 0; + } + + get cols(): number { + return this.frame?.cols ?? 0; + } + + setFrame(frame: GridFrame) { + this.validateColors(frame); + for (const renderer of this.renderers) { + renderer.draw(frame); + } + this.frame = frame; + } + + attachRenderer(renderer: GridRenderer): () => void { + if (this.frame) { + renderer.draw(this.frame); + } + this.renderers.add(renderer); + return () => { + this.renderers.delete(renderer); + }; + } + + handleClick(x: number, y: number) { + const row = Math.floor(y); + const col = Math.floor(x); + if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) { + return; + } + void this.store.context.events.emit(this.eventSource, { type: 'click', payload: { row, col, x, y } }); + } + + handleKey(key: string) { + void this.store.context.events.emit(this.eventSource, { type: 'key', payload: { key } }); + } + + private validateColors(frame: GridFrame) { + const context = this.getColorContext(); + for (const color of [...frame.palette, ...frame.text.map((cell) => cell.color)]) { + // Canvas ignores unsupported colors; two sentinels distinguish rejection from black or white. + context.fillStyle = '#000000'; + context.fillStyle = color; + const parsed = context.fillStyle; + context.fillStyle = '#ffffff'; + context.fillStyle = color; + if (context.fillStyle !== parsed) { + throw new Error(`Invalid grid color: ${color}`); + } + } + } + + private getColorContext(): CanvasRenderingContext2D { + if (this.colorContext) { + return this.colorContext; + } + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('The browser could not create a canvas context for grid colors.'); + } + this.colorContext = context; + return context; + } +} diff --git a/tdev-website/website/pyodide/grid/src/python/grid.py b/tdev-website/website/pyodide/grid/src/python/grid.py new file mode 100644 index 000000000..6959ad352 --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/python/grid.py @@ -0,0 +1,188 @@ +"""Packed grids for Teaching Dev's Pyodide renderer.""" + +from operator import index as _index +from pyodide.ffi import to_js as _to_js +from _grid_bridge import create_grid as _create_grid + + +def _position(value, length): + value = _index(value) + if value < 0: + value += length + if not 0 <= value < length: + raise IndexError('Grid index out of range.') + return value + + +class Cell: + __slots__ = ('_grid', 'row', 'col') + + def __init__(self, grid, row, col): + self._grid = grid + self.row = row + self.col = col + + @property + def color(self): + return self._grid._handle.get_color(self.row, self.col) + + @color.setter + def color(self, color): + self._grid._handle.set_color(self.row, self.col, color) + + @property + def text(self): + return self._grid._handle.get_text(self.row, self.col).text + + @text.setter + def text(self, text): + self._grid._handle.set_text(self.row, self.col, str(text)) + + @property + def text_color(self): + return self._grid._handle.get_text(self.row, self.col).color + + @text_color.setter + def text_color(self, color): + self._grid._handle.set_text_color(self.row, self.col, color) + + @property + def text_scale(self): + return self._grid._handle.get_text(self.row, self.col).scale + + @text_scale.setter + def text_scale(self, scale): + self._grid._handle.set_text_scale(self.row, self.col, scale) + + def get(self, offset_x, offset_y): + row = (self.row + _index(offset_y)) % self._grid.dim_y + col = (self.col + _index(offset_x)) % self._grid.dim_x + return self._grid[row][col] + + def __repr__(self): + return self.color + + +class Row: + __slots__ = ('_grid', '_row') + + def __init__(self, grid, row): + self._grid = grid + self._row = row + + def __getitem__(self, col): + return Cell(self._grid, self._row, _position(col, self._grid.dim_x)) + + def __setitem__(self, col, color): + self[col].color = color + + def __len__(self): + return self._grid.dim_x + + def __iter__(self): + for col in range(len(self)): + yield Cell(self._grid, self._row, col) + + +class Grid: + def __init__(self, rows, cols, color='', *, grid_id='grid', palette=None, scale=None): + colors = None if palette is None else _to_js(list(palette)) + self._handle = _create_grid(grid_id, rows, cols, color, colors, scale) + + @property + def grid_id(self): + return self._handle.grid_id + + @property + def rows(self): + return self._handle.rows + + @property + def cols(self): + return self._handle.cols + + @property + def dim_x(self): + return self._handle.cols + + @property + def dim_y(self): + return self._handle.rows + + @property + def size(self): + return (self.dim_y, self.dim_x) + + @property + def scale(self): + return self._handle.scale + + @property + def palette(self): + return tuple(self._handle.palette) + + @property + def events(self): + return self._handle.events + + @property + def color_grid(self): + return self.to_list() + + @property + def grid(self): + return self.to_list() + + def fill(self, color=''): + self._handle.fill(color) + + def set_cells(self, values, palette=None): + colors = None if palette is None else _to_js(list(palette)) + # A buffer view avoids converting a NumPy array into a million Python objects. + with memoryview(values) as view: + self._handle.set_cells(view, colors) + + async def render(self): + await self._handle.render() + + def copy(self, *, grid_id=None): + grid = Grid.__new__(Grid) + grid._handle = self._handle.copy(self.grid_id if grid_id is None else grid_id) + return grid + + def to_list(self): + return [[cell.color for cell in row] for row in self] + + @classmethod + def from_text(cls, text, colors=None, *, grid_id='grid', scale=None): + colors = {'s': 'black', '1': 'black', 'x': 'black'} if colors is None else dict(colors) + lines = text.lower().splitlines() + while lines and not lines[0]: + lines.pop(0) + cols = max(map(len, lines), default=0) + if cols == 0: + raise ValueError('Grid text must contain at least one non-empty line.') + background = colors.get('bg', '') + grid = cls(len(lines), cols, background, grid_id=grid_id, scale=scale) + for row, line in enumerate(lines): + for col, character in enumerate(line): + grid[row][col] = colors.get(character, background) + return grid + + def __getitem__(self, row): + return Row(self, _position(row, self.dim_y)) + + def __setitem__(self, row, colors): + values = list(colors) + if len(values) != self.dim_x: + raise ValueError('A row must contain exactly dim_x colors.') + target = self[row] + for col, color in enumerate(values): + target[col] = color + + def __len__(self): + return self.dim_y + + def __iter__(self): + for row in range(len(self)): + yield Row(self, row) diff --git a/tdev-website/website/pyodide/grid/src/rendering/GridRenderer.ts b/tdev-website/website/pyodide/grid/src/rendering/GridRenderer.ts new file mode 100644 index 000000000..eb0a99ad4 --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/rendering/GridRenderer.ts @@ -0,0 +1,94 @@ +import type { GridFrame } from '../../protocol'; + +export default class GridRenderer { + readonly canvas: HTMLCanvasElement; + private readonly context: CanvasRenderingContext2D; + private readonly pixelCanvas: HTMLCanvasElement; + private readonly pixelContext: CanvasRenderingContext2D; + private readonly colorContext: CanvasRenderingContext2D; + private colors = new Map(); + private image?: ImageData; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.context = this.getContext(canvas); + this.pixelCanvas = document.createElement('canvas'); + this.pixelContext = this.getContext(this.pixelCanvas); + const colorCanvas = document.createElement('canvas'); + colorCanvas.width = 1; + colorCanvas.height = 1; + this.colorContext = this.getContext(colorCanvas, true); + } + + draw(frame: GridFrame) { + const image = this.getImage(frame.rows, frame.cols); + const pixels = new Uint32Array(image.data.buffer, image.data.byteOffset, image.data.length / 4); + const palette = this.resolvePalette(frame.palette); + for (let index = 0; index < frame.cells.length; index++) { + pixels[index] = palette[frame.cells[index]]; + } + this.pixelContext.putImageData(image, 0, 0); + + const width = frame.cols * frame.scale; + const height = frame.rows * frame.scale; + if (this.canvas.width !== width || this.canvas.height !== height) { + this.canvas.width = width; + this.canvas.height = height; + } + this.context.imageSmoothingEnabled = false; + this.context.clearRect(0, 0, width, height); + this.context.drawImage(this.pixelCanvas, 0, 0, width, height); + this.drawText(frame); + } + + private getContext(canvas: HTMLCanvasElement, willReadFrequently = false): CanvasRenderingContext2D { + const context = canvas.getContext('2d', { willReadFrequently }); + if (!context) { + throw new Error('The browser could not create a canvas context for the grid.'); + } + return context; + } + + private getImage(rows: number, cols: number): ImageData { + if (!this.image || this.image.width !== cols || this.image.height !== rows) { + this.pixelCanvas.width = cols; + this.pixelCanvas.height = rows; + this.image = this.pixelContext.createImageData(cols, rows); + } + return this.image; + } + + private resolvePalette(colors: readonly string[]): number[] { + const currentColors = new Map(); + const palette = colors.map((color) => { + const value = currentColors.get(color) ?? this.colors.get(color) ?? this.parseColor(color); + currentColors.set(color, value); + return value; + }); + this.colors = currentColors; + return palette; + } + + private parseColor(color: string): number { + this.colorContext.clearRect(0, 0, 1, 1); + this.colorContext.fillStyle = color; + this.colorContext.fillRect(0, 0, 1, 1); + const pixel = this.colorContext.getImageData(0, 0, 1, 1).data; + return new Uint32Array(pixel.buffer, pixel.byteOffset, 1)[0]; + } + + private drawText(frame: GridFrame) { + this.context.textAlign = 'center'; + this.context.textBaseline = 'middle'; + for (const cell of frame.text) { + this.context.fillStyle = cell.color; + this.context.font = `${frame.scale * cell.scale}px Arial`; + this.context.fillText( + cell.text, + (cell.col + 0.5) * frame.scale, + (cell.row + 0.5) * frame.scale, + frame.scale * 0.9 + ); + } + } +} diff --git a/tdev-website/website/pyodide/grid/src/stores/GridStore.ts b/tdev-website/website/pyodide/grid/src/stores/GridStore.ts new file mode 100644 index 000000000..40aec50ac --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/stores/GridStore.ts @@ -0,0 +1,23 @@ +import type { ClientContext } from '@tdev/pyodide-code/extensions'; +import { action, observable } from 'mobx'; +import Grid from '../models/Grid'; + +export default class GridStore { + readonly context: ClientContext; + readonly grids = observable.map([], { deep: false }); + + constructor(context: ClientContext) { + this.context = context; + } + + @action + useGrid(id: string): Grid { + const existingGrid = this.grids.get(id); + if (existingGrid) { + return existingGrid; + } + const grid = new Grid(id, this); + this.grids.set(id, grid); + return grid; + } +} diff --git a/tdev-website/website/pyodide/grid/src/worker/WorkerGrid.ts b/tdev-website/website/pyodide/grid/src/worker/WorkerGrid.ts new file mode 100644 index 000000000..c3008bdd2 --- /dev/null +++ b/tdev-website/website/pyodide/grid/src/worker/WorkerGrid.ts @@ -0,0 +1,248 @@ +import type { ExecutionEventSource } from '@tdev/pyodide-code/events'; +import type { WorkerContext } from '@tdev/pyodide-code/extensions'; +import type { PyBuffer, PyBufferView } from 'pyodide/ffi'; +import { + MAX_CANVAS_DIMENSION, + MAX_GRID_CELLS, + type GridCells, + type GridFrame, + type GridText +} from '../../protocol'; + +const TRANSPARENT = 'rgba(0,0,0,0)'; +const DEFAULT_TEXT = { text: '', color: 'black', scale: 0.8 }; + +export default class WorkerGrid { + readonly events: ExecutionEventSource; + readonly rows: number; + readonly cols: number; + readonly scale: number; + readonly grid_id: string; + private readonly context: WorkerContext; + private cells: GridCells; + private colors: string[]; + private colorIndices: Map; + private readonly labels = new Map(); + private rendering = false; + + constructor( + context: WorkerContext, + id: string, + rows: number, + cols: number, + color = '', + palette?: string[], + scale = Math.max(1, Math.floor(500 / Math.max(rows, cols))) + ) { + this.validateDimensions(rows, cols, scale); + this.context = context; + this.grid_id = id; + this.events = context.events.source(id); + this.rows = rows; + this.cols = cols; + this.scale = scale; + this.colors = this.validatePalette(palette ?? [color]); + this.colorIndices = new Map(this.colors.map((value, index) => [value, index])); + this.cells = this.createCells(this.colors.length); + if (color !== '') { + this.fill(color); + } + } + + get palette(): readonly string[] { + return [...this.colors]; + } + + get_color(row: number, col: number): string { + return this.colors[this.cells[this.index(row, col)]]; + } + + set_color(row: number, col: number, color: string) { + const index = this.index(row, col); + const value = this.colorIndex(color); + this.cells[index] = value; + } + + fill(color = '') { + const index = this.colorIndex(color); + this.cells.fill(index); + } + + set_cells(values: PyBuffer, palette?: string[]) { + const colors = palette === undefined ? this.colors : this.validatePalette(palette); + const view = values.getBuffer(); + try { + this.cells = this.readCells(view, colors.length); + if (palette !== undefined) { + this.colors = colors; + this.colorIndices = new Map(colors.map((value, index) => [value, index])); + } + } finally { + view.release(); + } + } + + get_text(row: number, col: number) { + return this.labels.get(this.index(row, col)) ?? DEFAULT_TEXT; + } + + set_text(row: number, col: number, text: string) { + this.updateText(row, col, { text }); + } + + set_text_color(row: number, col: number, color: string) { + this.updateText(row, col, { color: this.validateColor(color) }); + } + + set_text_scale(row: number, col: number, scale: number) { + if (!Number.isFinite(scale) || scale <= 0) { + throw new Error('Cell text scale must be a positive finite number.'); + } + this.updateText(row, col, { scale }); + } + + copy(id = this.grid_id): WorkerGrid { + const grid = new WorkerGrid(this.context, id, this.rows, this.cols, '', this.colors, this.scale); + grid.cells.set(this.cells); + for (const [index, label] of this.labels) { + grid.labels.set(index, { ...label }); + } + return grid; + } + + async render() { + if (this.rendering) { + throw new Error('Await grid.render() before rendering another frame.'); + } + this.rendering = true; + try { + await this.context.sendAndWait(this.grid_id, { + rows: this.rows, + cols: this.cols, + scale: this.scale, + cells: this.cells.slice(), + palette: [...this.colors], + text: [...this.labels.values()] + .filter(({ text }) => text !== '') + .map((label) => ({ ...label })) + }); + } finally { + this.rendering = false; + } + } + + private validateDimensions(rows: number, cols: number, scale: number) { + if (!Number.isSafeInteger(rows) || rows < 1 || !Number.isSafeInteger(cols) || cols < 1) { + throw new Error('Grid rows and columns must be positive integers.'); + } + if (rows * cols > MAX_GRID_CELLS) { + throw new Error(`A grid may contain at most ${MAX_GRID_CELLS} cells.`); + } + if (!Number.isSafeInteger(scale) || scale < 1) { + throw new Error('Grid scale must be a positive integer.'); + } + if (rows * scale > MAX_CANVAS_DIMENSION || cols * scale > MAX_CANVAS_DIMENSION) { + throw new Error(`A grid canvas may be at most ${MAX_CANVAS_DIMENSION} pixels wide or high.`); + } + if (rows * cols * scale * scale > MAX_GRID_CELLS) { + throw new Error(`A grid canvas may contain at most ${MAX_GRID_CELLS} pixels.`); + } + } + + private readCells(view: PyBufferView, paletteSize: number): GridCells { + if (view.ndim !== 2 || view.shape[0] !== this.rows || view.shape[1] !== this.cols) { + throw new Error(`Cell data must have shape (${this.rows}, ${this.cols}).`); + } + if (!view.c_contiguous) { + throw new Error('Cell data must be contiguous. Use numpy.ascontiguousarray(data).'); + } + const data = view.data; + if (!( + data instanceof Uint8Array || + data instanceof Int8Array || + data instanceof Uint16Array || + data instanceof Int16Array || + data instanceof Uint32Array || + data instanceof Int32Array + )) { + throw new Error('Cell data must use 8-, 16-, or 32-bit integers.'); + } + const cells = this.createCells(paletteSize); + for (let index = 0; index < cells.length; index++) { + const value = data[view.offset + index]; + if (value < 0 || value >= paletteSize) { + throw new Error(`Cell palette index ${value} is out of range.`); + } + cells[index] = value; + } + return cells; + } + + private index(row: number, col: number): number { + if ( + !Number.isSafeInteger(row) || + row < 0 || + row >= this.rows || + !Number.isSafeInteger(col) || + col < 0 || + col >= this.cols + ) { + throw new Error('Grid cell coordinates are out of range.'); + } + return row * this.cols + col; + } + + private validateColor(color: string): string { + if (typeof color !== 'string') { + throw new Error('Cell colors must be CSS color strings.'); + } + return color === '' ? TRANSPARENT : color; + } + + private validatePalette(palette: string[]): string[] { + if (!Array.isArray(palette) || palette.length === 0) { + throw new Error('A grid palette must contain at least one CSS color.'); + } + return palette.map((color) => this.validateColor(color)); + } + + private colorIndex(color: string): number { + const value = this.validateColor(color); + const existing = this.colorIndices.get(value); + if (existing !== undefined) { + return existing; + } + const index = this.colors.length; + this.colors.push(value); + this.colorIndices.set(value, index); + if ( + (index === 256 && this.cells.BYTES_PER_ELEMENT === 1) || + (index === 65536 && this.cells.BYTES_PER_ELEMENT === 2) + ) { + const cells = this.createCells(this.colors.length); + cells.set(this.cells); + this.cells = cells; + } + return index; + } + + private createCells(paletteSize: number): GridCells { + const size = this.rows * this.cols; + if (paletteSize <= 256) { + return new Uint8Array(size); + } + if (paletteSize <= 65536) { + return new Uint16Array(size); + } + return new Uint32Array(size); + } + + private updateText( + row: number, + col: number, + values: Partial> + ) { + const index = this.index(row, col); + this.labels.set(index, { ...DEFAULT_TEXT, ...this.labels.get(index), row, col, ...values }); + } +} diff --git a/tdev-website/website/pyodide/grid/worker.ts b/tdev-website/website/pyodide/grid/worker.ts new file mode 100644 index 000000000..4ba2f898b --- /dev/null +++ b/tdev-website/website/pyodide/grid/worker.ts @@ -0,0 +1,20 @@ +import source from '!!raw-loader!./src/python/grid.py'; +import { defineWorkerExtension } from '@tdev/pyodide-code/extensions'; +import type { GridFrame } from './protocol'; +import WorkerGrid from './src/worker/WorkerGrid'; + +export default defineWorkerExtension( + (context) => ({ + _grid_bridge: { + create_grid: ( + id: string, + rows: number, + cols: number, + color: string, + palette?: string[], + scale?: number + ) => new WorkerGrid(context, id, rows, cols, color, palette, scale) + } + }), + { grid: source } +); diff --git a/tdev-website/website/pyodide/snowfall-game/README.md b/tdev-website/website/pyodide/snowfall-game/README.md new file mode 100644 index 000000000..41bda8e62 --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/README.md @@ -0,0 +1,56 @@ +# Schneefall-Spiel + +Voraussetzung ist eine Teaching-Dev-Website mit installiertem `pyodide-code`-Package und `webio.gameloop`. Kopiere diesen vollständigen Ordner nach `website/pyodide/snowfall-game` und ergänze im vorhandenen Pyodide-Plugin die `extensions`-Liste: + +```ts +extensions: [ + './website/pyodide/snowfall-game' +] +``` + +Starte den Entwicklungsserver neu. Der Browser-Store wird automatisch angelegt. + +## Im Unterrichtsmaterial verwenden + +```mdx +import SnowfallGame from '@tdev/pyodide/snowfall-game/src/components/SnowfallGame'; + + +``` + +```python +from snowfall_game import use_scene +from webio import gameloop, run + +scene = use_scene('snowfall') +scene.configure(width=400, height=800, background_color='black') +scene.add_snowflake(200, 0, 12, 80, color='lightblue') +scene.add_dot(200, 400, 6, color='#ff8800') + +@gameloop(fps=30, sources=[scene]) +def update(ctx): + for snowflake in scene.snowflakes: + snowflake.y += snowflake.speed * ctx.dt + if snowflake.y > scene.height: + scene.remove_snowflake(snowflake) + scene.render() + +await run() +``` + +`ctx.dt` ist die seit dem letzten Frame verstrichene Zeit in Sekunden. `render()` überträgt den aktuellen Zustand als eine Nachricht an die Anzeige. Änderungen an Schneeflocken werden bis dahin nur im Worker gespeichert. + +`scene.add_snowflake(x, y, size, speed, color='white')` +: Erzeugt ein `mdiSnowflake`-Symbol mit Mittelpunkt `(x, y)` und halber Symbolgrösse `size`. Gibt einen Handle mit veränderbaren Eigenschaften `x`, `y`, `size`, `speed` und `color` zurück. +`scene.add_dot(x, y, size, color='white')` +: Erzeugt einen ausgefüllten Kreis mit Mittelpunkt `(x, y)` und Radius `size`. Gibt einen Handle mit veränderbaren Eigenschaften `x`, `y`, `size` und `color` zurück. Punkte werden vor den Schneeflocken gezeichnet. +`scene.snowflakes`, `scene.dots` +: Momentaufnahmen der jeweiligen Handles. Während der Iteration können Elemente entfernt werden. +`scene.remove_snowflake(snowflake)`, `scene.remove_dot(dot)` +: Entfernen den angegebenen Handle aus der Szene. +`scene.reset()` +: Entfernt alle Schneeflocken und Punkte und behält die Szenenkonfiguration. +`scene.render()` +: Überträgt Positionen, Grössen und CSS-Farben gemeinsam an die Anzeige. + +Klickereignisse enthalten `x` und `y` im `payload`, bereits in die konfigurierten Szenenkoordinaten umgerechnet. Das SVG passt sich schmalen Fenstern an. Mehrere Ansichten derselben `sceneId` teilen ihren Zustand. diff --git a/tdev-website/website/pyodide/snowfall-game/client.ts b/tdev-website/website/pyodide/snowfall-game/client.ts new file mode 100644 index 000000000..5b3ceafcd --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/client.ts @@ -0,0 +1,10 @@ +import { defineClientExtension } from '@tdev/pyodide-code/extensions'; +import type { SceneSnapshot } from './protocol'; +import SceneStore from './src/stores/SceneStore'; + +export default defineClientExtension({ + createStore: (context) => new SceneStore(context), + handleMessage: (store, { targetId, payload }) => { + store.useScene(targetId).setSnapshot(payload); + } +}); diff --git a/tdev-website/website/pyodide/snowfall-game/docs/index.mdx b/tdev-website/website/pyodide/snowfall-game/docs/index.mdx new file mode 100644 index 000000000..fd15f4afb --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/docs/index.mdx @@ -0,0 +1,154 @@ +--- +page_id: 5aeee167-b38a-4c16-ad00-21aa4686a136 +--- + +import CodeShowcase from '@tdev-components/CodeShowcase'; +import Steps from '@tdev-components/Steps'; +import SnowfallGame from '@tdev/pyodide/snowfall-game/src/components/SnowfallGame'; + +# Schneefall-Spiel + +Mit `@gameloop` aktualisiert Python eine Szene regelmässig und verarbeitet dabei Browser-Ereignisse. Klicke auf eine Schneeflocke, um sie zu entfernen. + + + + +```py live_pyo id=8ee3ce4d-6f41-43e4-afd8-17da3141e3d6 title=Schneefall +from os import system +from random import randint +from webio import gameloop, run +from snowfall_game import use_scene + +scene = use_scene('snowfall') +scene.reset() +scene.configure(width=400, height=600, background_color='black') +scene.render() +last_spawn_time = -1000 +click = None + +def spawn_snowflake(): + x = randint(0, int(scene.width)) + size = randint(8, 14) + speed = randint(40, 100) + scene.add_snowflake(x, 0, size, speed, color='lightblue') + +def update_snowflakes(dt): + for snowflake in scene.snowflakes: + snowflake.y += snowflake.speed * dt + if snowflake.y > scene.height: + scene.remove_snowflake(snowflake) + +def remove_click(): + global click + if click is None: + return + scene.remove_dot(click) + click = None + +def handle_click(event): + global click + remove_click() + click = scene.add_dot(event.x, event.y, 10, color='red') + + for snowflake in scene.snowflakes: + distance_squared = (snowflake.x - event.x)**2 + (snowflake.y - event.y)**2 + if distance_squared < (snowflake.size + 10)**2: + scene.remove_snowflake(snowflake) + remove_click() + break + +@gameloop(fps=120, sources=[scene]) +def update(ctx): + global last_spawn_time + if ctx.time - last_spawn_time >= 1000: + spawn_snowflake() + last_spawn_time = ctx.time + + update_snowflakes(ctx.dt) + + for event in ctx.events: + if event.type == 'click': + handle_click(event) + + if ctx.frame % 10 == 0: + system('clear') + print(round(1/ctx.dt)) + + scene.render() + +await run() +``` + + +Die Geschwindigkeiten sind in Pixeln pro Sekunde angegeben. `ctx.dt` berücksichtigt die tatsächlich vergangene Zeit zwischen zwei Bildern. `scene.render()` überträgt alle Änderungen eines Bildes gemeinsam an den Browser. Details zum Ablauf stehen in der [webio-Referenz](../../02-webio.mdx#spielschleife). + +## Szene, Schneeflocken und Punkte + +`sceneId` +: Verbindet die React-Komponente mit `use_scene(id)`. Mehrere Ansichten derselben ID teilen ihre Anzeige. +`use_scene(id)` +: Liefert innerhalb derselben Ausführung denselben lokalen Handle zurück. +`configure(width=400, height=800, background_color='black')` +: Legt die logische Grösse und Hintergrundfarbe fest. Die Darstellung verkleinert sich bei wenig Platz proportional; Spielkoordinaten bleiben unverändert. +`width`, `height` +: Logische Breite und Höhe der Szene in Pixeln. +`reset()` +: Entfernt alle Schneeflocken und Punkte und behält die Konfiguration bei. +`add_snowflake(x, y, size, speed, color='white')` +: Erzeugt eine Schneeflocke mit dem SVG-Symbol `mdiSnowflake` und gibt ihren Handle zurück. `size` ist die halbe Symbolgrösse in Pixeln; `(x, y)` ist die Mitte. Die Bewegung mit `speed` legt der Python-Code fest. +`snowflakes` +: Momentaufnahme der aktuellen Schneeflocken. Während der Iteration können Schneeflocken entfernt werden. +`snowflake.x`, `snowflake.y`, `snowflake.size`, `snowflake.speed`, `snowflake.color` +: Veränderbare Eigenschaften einer Schneeflocke. Position, Grösse und Farbe werden beim nächsten `render()` sichtbar. +`remove_snowflake(snowflake)` +: Entfernt die angegebene Schneeflocke. +`add_dot(x, y, size, color='white')` +: Erzeugt einen ausgefüllten Kreis mit Mittelpunkt `(x, y)` und Radius `size` in Pixeln. Gibt seinen Handle zurück. Punkte werden vor den Schneeflocken gezeichnet. +`dots` +: Momentaufnahme der aktuellen Punkte. Während der Iteration können Punkte entfernt werden. +`dot.x`, `dot.y`, `dot.size`, `dot.color` +: Veränderbare Eigenschaften eines Punktes. Änderungen werden beim nächsten `render()` sichtbar. +`remove_dot(dot)` +: Entfernt den angegebenen Punkt. +`color` +: CSS-Farbe als Zeichenkette, beispielsweise `'white'`, `'lightblue'` oder `'#ff8800'`. Standard für Schneeflocken und Punkte: `'white'`. +`render()` +: Überträgt den aktuellen Zustand der ganzen Szene. Änderungen an Handles, `configure()` und `reset()` werden erst damit sichtbar. +`events` +: Ereignisquelle für `@gameloop`, `@on_click` und weitere Handler aus `webio`. + +Klick-Ereignisse enthalten `event.x` und `event.y` in logischen Szenenkoordinaten, auch bei verkleinerter Darstellung. Dieselben Werte stehen in `event.payload['x']` und `event.payload['y']`. Tastatureingaben gehen an die fokussierte Szene und enthalten den Tastennamen in `event.payload['key']`. + +Einzelne Punkte lassen sich unabhängig von den Schneeflocken platzieren und verändern: + +```py +dot = scene.add_dot(200, 400, 6, color='#ff8800') +dot.x += 10 +dot.color = 'yellow' +scene.render() +``` + +## Zustand und Lebensdauer + +Die Browser-Anzeige bleibt nach Ende oder Abbruch eines Skripts erhalten. Ein neuer Python-Handle beginnt mit den Standardwerten und ohne Schneeflocken oder Punkte. Sein erster `render()` ersetzt die bisherige Anzeige. Mit `reset()` und `configure()` am Skriptanfang lässt sich der gewünschte Startzustand ausdrücklich festlegen. + +## Auf der eigenen Website einrichten + +Voraussetzung ist das [installierte Pyodide-Package](../../README.mdx#installation). + + + +1. Kopiere den vollständigen Ordner [snowfall-game](https://github.com/GBSL-Informatik/teaching-dev/tree/main/tdev-website/website/pyodide/snowfall-game) nach __website/pyodide/snowfall-game__. +2. Ergänze den Ordner in der bestehenden `extensions`-Liste des [Pyodide-Plugins](../../README.mdx#erweiterungen-registrieren): + + ```ts title="siteConfig.ts" + extensions: [ + './website/pyodide/snowfall-game' + ] + ``` + +3. Starte den Entwicklungsserver neu. Danach kannst du das MDX-Beispiel oben verwenden. + + + +Die __extension.json__ im kopierten Ordner legt den Namen und die Einstiegspunkte fest. Der Browser-Store wird automatisch initialisiert; der `SiteStore` deiner Website benötigt keine Ergänzungen. diff --git a/tdev-website/website/pyodide/snowfall-game/extension.json b/tdev-website/website/pyodide/snowfall-game/extension.json new file mode 100644 index 000000000..0492be71b --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/extension.json @@ -0,0 +1,6 @@ +{ + "name": "snowfall_game", + "worker": "./worker.ts", + "client": "./client.ts", + "showcase": "./docs" +} diff --git a/tdev-website/website/pyodide/snowfall-game/protocol.ts b/tdev-website/website/pyodide/snowfall-game/protocol.ts new file mode 100644 index 000000000..7697634a4 --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/protocol.ts @@ -0,0 +1,28 @@ +export interface SceneConfiguration { + width: number; + height: number; + background_color: string; +} + +export interface DotSnapshot { + readonly id: number; + x: number; + y: number; + size: number; + color: string; +} + +export interface SnowflakeSnapshot extends DotSnapshot { + speed: number; +} + +export interface SceneSnapshot extends SceneConfiguration { + snowflakes: readonly SnowflakeSnapshot[]; + dots: readonly DotSnapshot[]; +} + +export const DEFAULT_SCENE_CONFIGURATION: Readonly = { + width: 400, + height: 200, + background_color: 'black' +}; diff --git a/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/index.tsx b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/index.tsx new file mode 100644 index 000000000..4312b410e --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/index.tsx @@ -0,0 +1,79 @@ +import { mdiSnowflake } from '@mdi/js'; +import { useExtensionStore } from '@tdev/pyodide-code/hooks/useExtensionStore'; +import clsx from 'clsx'; +import { observer } from 'mobx-react-lite'; +import type { KeyboardEvent, MouseEvent } from 'react'; +import snowfallExtension from '../../../client'; +import styles from './styles.module.scss'; + +interface SnowfallGameProps { + sceneId: string; + className?: string; +} + +const SCROLL_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ']; + +const SnowfallGame = observer(({ sceneId, className }: SnowfallGameProps) => { + const store = useExtensionStore(snowfallExtension); + const scene = store.useScene(sceneId); + const { width, height, background_color, snowflakes, dots } = scene.snapshot; + + const handleClick = (event: MouseEvent) => { + event.currentTarget.focus({ preventScroll: true }); + const bounds = event.currentTarget.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) { + return; + } + const x = ((event.clientX - bounds.left) / bounds.width) * width; + const y = ((event.clientY - bounds.top) / bounds.height) * height; + scene.handleClick(x, y); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!scene.isConnected) { + return; + } + if (SCROLL_KEYS.includes(event.key)) { + event.preventDefault(); + } + scene.handleKey(event.key); + }; + + return ( +
+ + + + {dots.map((dot) => ( + + ))} + + + {snowflakes.map((snowflake) => ( + + ))} + + + + {scene.isConnected ? 'Klicke auf eine Schneeflocke.' : 'Starte zuerst das Python-Programm.'} + +
+ ); +}); + +export default SnowfallGame; diff --git a/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/styles.module.scss b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/styles.module.scss new file mode 100644 index 000000000..55c845f1e --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/styles.module.scss @@ -0,0 +1,26 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + max-width: 100%; + margin-block: 1rem; +} + +.scene { + display: block; + max-width: 100%; + height: auto; + cursor: crosshair; + border-radius: var(--ifm-global-radius); + + &:focus-visible { + outline: 3px solid var(--ifm-color-primary); + outline-offset: 4px; + } +} + +.status { + color: var(--ifm-color-emphasis-700); + font-size: 0.875rem; +} diff --git a/tdev-website/website/pyodide/snowfall-game/src/models/Scene.ts b/tdev-website/website/pyodide/snowfall-game/src/models/Scene.ts new file mode 100644 index 000000000..758fc515f --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/src/models/Scene.ts @@ -0,0 +1,39 @@ +import type { EventSource } from '@tdev/pyodide-code/events'; +import { action, computed, observableRef } from 'mobx'; +import { DEFAULT_SCENE_CONFIGURATION, type SceneSnapshot } from '../../protocol'; +import type SceneStore from '../stores/SceneStore'; + +export default class Scene { + readonly id: string; + readonly store: SceneStore; + readonly eventSource: EventSource; + @observableRef accessor snapshot: SceneSnapshot = { + ...DEFAULT_SCENE_CONFIGURATION, + snowflakes: [], + dots: [] + }; + + constructor(id: string, store: SceneStore) { + this.id = id; + this.store = store; + this.eventSource = { extension: store.context.name, targetId: id }; + } + + @computed + get isConnected(): boolean { + return this.store.context.events.isConnected(this.eventSource); + } + + @action + setSnapshot(snapshot: SceneSnapshot) { + this.snapshot = snapshot; + } + + handleClick(x: number, y: number) { + void this.store.context.events.emit(this.eventSource, { type: 'click', payload: { x, y } }); + } + + handleKey(key: string) { + void this.store.context.events.emit(this.eventSource, { type: 'key', payload: { key } }); + } +} diff --git a/tdev-website/website/pyodide/snowfall-game/src/stores/SceneStore.ts b/tdev-website/website/pyodide/snowfall-game/src/stores/SceneStore.ts new file mode 100644 index 000000000..e1cb107e5 --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/src/stores/SceneStore.ts @@ -0,0 +1,23 @@ +import type { ClientContext } from '@tdev/pyodide-code/extensions'; +import { action, observable } from 'mobx'; +import Scene from '../models/Scene'; + +export default class SceneStore { + readonly context: ClientContext; + readonly scenes = observable.map([], { deep: false }); + + constructor(context: ClientContext) { + this.context = context; + } + + @action + useScene(id: string): Scene { + const existingScene = this.scenes.get(id); + if (existingScene) { + return existingScene; + } + const scene = new Scene(id, this); + this.scenes.set(id, scene); + return scene; + } +} diff --git a/tdev-website/website/pyodide/snowfall-game/src/worker/WorkerScene.ts b/tdev-website/website/pyodide/snowfall-game/src/worker/WorkerScene.ts new file mode 100644 index 000000000..0657e4057 --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/src/worker/WorkerScene.ts @@ -0,0 +1,158 @@ +import type { ExecutionEventSource } from '@tdev/pyodide-code/events'; +import type { WorkerContext } from '@tdev/pyodide-code/extensions'; +import { + DEFAULT_SCENE_CONFIGURATION, + type DotSnapshot, + type SceneConfiguration, + type SceneSnapshot, + type SnowflakeSnapshot +} from '../../protocol'; + +interface ColorOptions { + color?: string; +} + +const SCENE_OPTIONS = ['width', 'height', 'background_color']; + +export default class WorkerScene { + readonly events: ExecutionEventSource; + private readonly context: WorkerContext; + private readonly id: string; + private readonly flakes = new Set(); + private readonly sceneDots = new Set(); + private configuration: SceneConfiguration = { ...DEFAULT_SCENE_CONFIGURATION }; + private nextShapeId = 0; + + constructor(context: WorkerContext, id: string) { + this.context = context; + this.id = id; + this.events = context.events.source(id); + } + + get width(): number { + return this.configuration.width; + } + + get height(): number { + return this.configuration.height; + } + + get background_color(): string { + return this.configuration.background_color; + } + + get snowflakes(): SnowflakeSnapshot[] { + // A snapshot keeps removal during Python iteration from skipping the next flake. + return [...this.flakes]; + } + + get dots(): DotSnapshot[] { + return [...this.sceneDots]; + } + + configure(options: Partial = {}) { + if (!options || typeof options !== 'object') { + throw new Error('Scene configuration must contain width, height, or background_color options.'); + } + for (const option of Object.keys(options)) { + if (!SCENE_OPTIONS.includes(option)) { + throw new Error(`Unknown scene configuration option: ${option}`); + } + } + const configuration = { ...this.configuration, ...options }; + this.validateConfiguration(configuration); + this.configuration = configuration; + } + + reset() { + this.flakes.clear(); + this.sceneDots.clear(); + } + + add_snowflake( + x: number, + y: number, + size: number, + speed: number, + options: ColorOptions = {} + ): SnowflakeSnapshot { + const snowflake = Object.assign(this.createDot(x, y, size, options), { speed }); + this.validateSnowflake(snowflake); + this.flakes.add(snowflake); + return snowflake; + } + + add_dot(x: number, y: number, size: number, options: ColorOptions = {}): DotSnapshot { + const dot = this.createDot(x, y, size, options); + this.sceneDots.add(dot); + return dot; + } + + remove_dot(dot: DotSnapshot) { + this.sceneDots.delete(dot); + } + + remove_snowflake(snowflake: SnowflakeSnapshot) { + this.flakes.delete(snowflake); + } + + render() { + this.validateConfiguration(this.configuration); + const snowflakes = [...this.flakes].map((snowflake) => { + this.validateSnowflake(snowflake); + return { ...snowflake }; + }); + const dots = [...this.sceneDots].map((dot) => { + this.validateDot(dot); + return { ...dot }; + }); + this.context.send(this.id, { ...this.configuration, snowflakes, dots }); + } + + private validateConfiguration(configuration: SceneConfiguration) { + if (!Number.isFinite(configuration.width) || configuration.width <= 0) { + throw new Error('Scene width must be a positive finite number.'); + } + if (!Number.isFinite(configuration.height) || configuration.height <= 0) { + throw new Error('Scene height must be a positive finite number.'); + } + if (typeof configuration.background_color !== 'string' || !configuration.background_color.trim()) { + throw new Error('Scene background_color must be a non-empty CSS color string.'); + } + } + + private createDot(x: number, y: number, size: number, options: ColorOptions): DotSnapshot { + const id = this.nextShapeId; + const dot: DotSnapshot = { + get id() { + return id; + }, + x, + y, + size, + color: options.color ?? 'white' + }; + this.validateDot(dot); + this.nextShapeId += 1; + return dot; + } + + private validateDot(dot: DotSnapshot) { + if (!Number.isFinite(dot.x) || !Number.isFinite(dot.y)) { + throw new Error('Shape coordinates must be finite numbers.'); + } + if (!Number.isFinite(dot.size) || dot.size <= 0) { + throw new Error('Shape size must be a positive finite number.'); + } + if (typeof dot.color !== 'string' || !dot.color.trim()) { + throw new Error('Shape color must be a non-empty CSS color string.'); + } + } + + private validateSnowflake(snowflake: SnowflakeSnapshot) { + this.validateDot(snowflake); + if (!Number.isFinite(snowflake.speed)) { + throw new Error('Snowflake speed must be a finite number.'); + } + } +} diff --git a/tdev-website/website/pyodide/snowfall-game/worker.ts b/tdev-website/website/pyodide/snowfall-game/worker.ts new file mode 100644 index 000000000..f5d86b981 --- /dev/null +++ b/tdev-website/website/pyodide/snowfall-game/worker.ts @@ -0,0 +1,20 @@ +import { defineWorkerExtension } from '@tdev/pyodide-code/extensions'; +import type { SceneSnapshot } from './protocol'; +import WorkerScene from './src/worker/WorkerScene'; + +export default defineWorkerExtension((context) => { + const scenes = new Map(); + return { + snowfall_game: { + use_scene: (id: string) => { + const existingScene = scenes.get(id); + if (existingScene) { + return existingScene; + } + const scene = new WorkerScene(context, id); + scenes.set(id, scene); + return scene; + } + } + }; +}); diff --git a/tdev-website/website/stores/SiteStore.ts b/tdev-website/website/stores/SiteStore.ts index 09b6fdf30..7ee7803be 100644 --- a/tdev-website/website/stores/SiteStore.ts +++ b/tdev-website/website/stores/SiteStore.ts @@ -1,8 +1,5 @@ import { RootStore } from '@tdev-stores/rootStore'; -import { observableRef } from 'mobx'; -import ClockStore from './ClockStore'; export default class SiteStore { - @observableRef accessor clockStore = new ClockStore(); constructor(root: RootStore) {} } diff --git a/updateSync/packageDocsSync/actions.ts b/updateSync/packageDocsSync/actions.ts index 34764c077..d2c396c8c 100644 --- a/updateSync/packageDocsSync/actions.ts +++ b/updateSync/packageDocsSync/actions.ts @@ -219,63 +219,39 @@ export const getPackageDocsConfigs = async (packagesDir: string): Promise { +export const getDebouncedSyncer = async ( + packageDir: string, + destDir: string, + afterSync?: () => Promise +) => { const syncQueue = new Set(); - const PackageConfigCache: Map = new Map(); - getPackageDocsConfigs(packageDir).then((configs) => { - for (const cfg of configs) { - const pkgKey = `${cfg.docs.org}/${cfg.docs.package}`; - PackageConfigCache.set(pkgKey, cfg); - } - }); - - const setPackageConfig = async (pkgKey: string, newConfig?: TdevPackageConfig | null) => { - if (!newConfig) { - return false; - } - const prevConfig = PackageConfigCache.get(pkgKey); - if (prevConfig) { - const prevDocsPath = resolveDir(destDir, prevConfig.docs.org, prevConfig.docs.package); - const currentDocsPath = resolveDir(destDir, newConfig.docs.org, newConfig.docs.package); - if (prevDocsPath !== currentDocsPath) { - fs.rm(prevDocsPath, { recursive: true, force: true }).catch(() => {}); - } - } - PackageConfigCache.set(pkgKey, newConfig); - return true; - }; - + let pendingSync = Promise.resolve(); const syncDebounced = debounce(() => { - const syncTasks: Promise[] = []; + const packages = new Map(); for (const pkgInfo of syncQueue) { - if (!pkgInfo) { - continue; - } - const pkgKey = `${pkgInfo.org}/${pkgInfo.package}`; - if (pkgInfo.relativeSubPath === TDEV_PACKAGE_CONFIG_YML) { - const res = getPackageDocsConfig(packageDir, pkgInfo.org, pkgInfo.package).then( - (newConfig) => { - return setPackageConfig(pkgKey, newConfig).then((changed) => { - if (changed) { - return syncDocsFolder(newConfig!, destDir); - } - return Promise.resolve(`ℹ️ ${pkgKey} docs config unchanged.`); + packages.set(`${pkgInfo.org}/${pkgInfo.package}`, pkgInfo); + } + syncQueue.clear(); + // Package copies delete extra files, so extension docs must follow them in the same queue. + pendingSync = pendingSync + .then(async () => { + for (const pkgInfo of packages.values()) { + const config = await getPackageDocsConfig(packageDir, pkgInfo.org, pkgInfo.package); + if (config) { + console.log(await syncDocsFolder(config, destDir)); + } else { + await fs.rm(path.join(destDir, pkgInfo.org, pkgInfo.package), { + recursive: true, + force: true }); } - ); - syncTasks.push(res); - } else { - const pkgConfig = PackageConfigCache.get(pkgKey); - if (pkgConfig) { - syncTasks.push(syncDocsFolder(pkgConfig, destDir)); } - } - } - syncQueue.clear(); - return Promise.all(syncTasks).then((res) => { - console.log(res.join('\n')); - return res; - }); + await afterSync?.(); + }) + .catch((error) => { + console.error('Error synchronizing package documentation:', error); + }); + return pendingSync; }, 300); return { syncQueue, syncDebounced }; }; diff --git a/updateSync/packageDocsSync/configuration.ts b/updateSync/packageDocsSync/configuration.ts new file mode 100644 index 000000000..34f26da41 --- /dev/null +++ b/updateSync/packageDocsSync/configuration.ts @@ -0,0 +1,25 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { SiteConfig } from '../../src/siteConfig/siteConfig'; +import { withSiteConfig } from '../../src/siteConfig/withSiteConfig'; +import type { PackageDocsExtension } from './types'; + +export const loadPyodideDocsSync = async (packageDir: string): Promise => { + const packagePath = path.resolve(packageDir, 'tdev/pyodide-code'); + try { + await fs.access(packagePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + + // Keep optional package code out of both the runtime and TypeScript import graph. + const modulePath = pathToFileURL(path.join(packagePath, 'docsSync/index.ts')).href; + const adapter = (await import(modulePath)) as { + createDocsSync: (siteConfig: SiteConfig) => PackageDocsExtension; + }; + return adapter.createDocsSync(await withSiteConfig()); +}; diff --git a/updateSync/packageDocsSync/index.ts b/updateSync/packageDocsSync/index.ts index 5133bd01e..116bf9393 100644 --- a/updateSync/packageDocsSync/index.ts +++ b/updateSync/packageDocsSync/index.ts @@ -1,8 +1,9 @@ import fs from 'fs/promises'; import path from 'path'; import { getPackageDocsConfigs, syncCategoryFile, syncDocsFolder } from './actions'; +import type { PackageDocsExtension } from './types'; -const packageDocsSync = async (packageDir: string, destDir: string) => { +const packageDocsSync = async (packageDir: string, destDir: string, extension?: PackageDocsExtension) => { const srcPath = path.resolve(process.cwd(), packageDir); const data = await getPackageDocsConfigs(srcPath); // create the dest dir if it doesn't exist @@ -26,6 +27,7 @@ const packageDocsSync = async (packageDir: string, destDir: string) => { }), syncCategoryFile(srcPath, destPath) ]); + await extension?.sync(destPath); console.log(result.filter(Boolean).join('\n')); return result; }; diff --git a/updateSync/packageDocsSync/preBuild.ts b/updateSync/packageDocsSync/preBuild.ts index 61afad104..d349d8a87 100644 --- a/updateSync/packageDocsSync/preBuild.ts +++ b/updateSync/packageDocsSync/preBuild.ts @@ -1,6 +1,8 @@ +import 'dotenv/config'; import minimist from 'minimist'; import path from 'path'; import packageDocsSync from '.'; +import { loadPyodideDocsSync } from './configuration'; const argv = minimist(process.argv.slice(2), { string: ['src', 'dest'], alias: { src: 'packages', dest: 'out' }, @@ -15,7 +17,7 @@ const PACKAGES_DIR = path.resolve(CWD, src); const DEST_ROOT = path.resolve(CWD, dest); const main = async () => { - await packageDocsSync(PACKAGES_DIR, DEST_ROOT); + await packageDocsSync(PACKAGES_DIR, DEST_ROOT, await loadPyodideDocsSync(PACKAGES_DIR)); console.log('✅ Pre-build docs sync completed.'); }; diff --git a/updateSync/packageDocsSync/tests/configuration.test.ts b/updateSync/packageDocsSync/tests/configuration.test.ts new file mode 100644 index 000000000..585cedb95 --- /dev/null +++ b/updateSync/packageDocsSync/tests/configuration.test.ts @@ -0,0 +1,179 @@ +import { execFile, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import packageDocsSync from '..'; +import { withSiteConfig } from '../../../src/siteConfig/withSiteConfig'; +import { loadPyodideDocsSync } from '../configuration'; + +vi.mock('../../../src/siteConfig/withSiteConfig', () => ({ + withSiteConfig: vi.fn(async () => ({})) +})); + +const temporaryDirectories: string[] = []; +const execute = promisify(execFile); +const resolve = createRequire(import.meta.url).resolve; + +const createSite = async () => { + const siteDir = await fs.mkdtemp(path.join(os.tmpdir(), 'optional-pyodide-docs-')); + temporaryDirectories.push(siteDir); + const packageDir = path.join(siteDir, 'packages'); + const destination = path.join(siteDir, 'generated'); + const docs = path.join(packageDir, 'other/example/docs'); + await fs.mkdir(docs, { recursive: true }); + await fs.writeFile(path.join(docs, 'README.md'), '# Example\n'); + return { siteDir, packageDir, destination, docs }; +}; + +const writeAdapter = async (packageDir: string, source: string) => { + const directory = path.join(packageDir, 'tdev/pyodide-code/docsSync'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, 'index.ts'), source); +}; + +beforeEach(() => vi.clearAllMocks()); + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })) + ); +}); + +describe('Optional Pyodide documentation integration', () => { + it('syncs other packages without loading site configuration when Pyodide is absent', async () => { + const site = await createSite(); + const extension = await loadPyodideDocsSync(site.packageDir); + expect(extension).toBeUndefined(); + expect(withSiteConfig).not.toHaveBeenCalled(); + + await packageDocsSync(site.packageDir, site.destination, extension); + expect(await fs.readFile(path.join(site.destination, 'other/example/README.md'), 'utf8')).toBe( + '# Example\n' + ); + await expect(fs.access(path.join(site.destination, 'tdev/pyodide-code'))).rejects.toThrow(); + }); + + it('loads a present adapter and its watch paths', async () => { + const site = await createSite(); + await writeAdapter( + site.packageDir, + ` +export const createDocsSync = () => ({ watchPaths: ['example'], sync: async () => {} }); +` + ); + const extension = await loadPyodideDocsSync(site.packageDir); + expect(extension?.watchPaths).toEqual(['example']); + expect(withSiteConfig).toHaveBeenCalledOnce(); + await extension?.sync(site.destination); + }); + + it('reports a missing adapter in a present package', async () => { + const site = await createSite(); + await fs.mkdir(path.join(site.packageDir, 'tdev/pyodide-code'), { recursive: true }); + await expect(loadPyodideDocsSync(site.packageDir)).rejects.toThrow(); + }); + + it.each([ + "throw new Error('broken adapter');", + "export const createDocsSync = () => { throw new Error('broken adapter'); };" + ])('does not hide errors from an installed adapter: %s', async (source) => { + const site = await createSite(); + await writeAdapter(site.packageDir, source); + await expect(loadPyodideDocsSync(site.packageDir)).rejects.toThrow('broken adapter'); + }); + + it('runs prebuild, watch updates, and typecheck in a copied CLI without Pyodide', async () => { + const site = await createSite(); + await prepareCopiedCli(site.siteDir); + const cliDir = path.join(site.siteDir, 'updateSync/packageDocsSync'); + const args = ['--src', site.packageDir, '--dest', site.destination]; + const options = { cwd: site.siteDir, env: { ...process.env, SITE_CONFIG_PATH: './siteConfig.ts' } }; + await execute( + process.execPath, + ['--import', resolve('tsx'), path.join(cliDir, 'preBuild.ts'), ...args], + { ...options, timeout: 5000 } + ); + expect(await fs.readFile(path.join(site.destination, 'other/example/README.md'), 'utf8')).toBe( + '# Example\n' + ); + const compiler = path.join(path.dirname(resolve('typescript/package.json')), 'bin/tsc'); + await execute(process.execPath, [compiler, '--project', site.siteDir], { ...options, timeout: 5000 }); + + const logPath = path.join(site.siteDir, 'watch.log'); + const log = await fs.open(logPath, 'w'); + const watcher = spawn( + process.execPath, + ['--import', resolve('tsx'), path.join(cliDir, 'watch.ts'), ...args], + { ...options, stdio: ['ignore', log.fd, log.fd] } + ); + try { + await vi.waitFor( + async () => { + expect(await fs.readFile(logPath, 'utf8')).toContain('Watching for package'); + }, + { timeout: 3000 } + ); + await fs.writeFile(path.join(site.docs, 'README.md'), '# Updated example\n'); + await vi.waitFor( + async () => { + expect( + await fs.readFile(path.join(site.destination, 'other/example/README.md'), 'utf8') + ).toBe('# Updated example\n'); + }, + { timeout: 5000 } + ); + } finally { + if (watcher.exitCode === null && watcher.signalCode === null) { + const stopped = once(watcher, 'exit'); + watcher.kill(); + await stopped; + } + await log.close(); + } + }, 15000); +}); + +const prepareCopiedCli = async (siteDir: string) => { + await fs.cp(path.resolve(import.meta.dirname, '..'), path.join(siteDir, 'updateSync/packageDocsSync'), { + recursive: true, + filter: (source) => path.basename(source) !== 'tests' + }); + await fs.mkdir(path.join(siteDir, 'src/siteConfig'), { recursive: true }); + await fs.copyFile( + path.resolve(import.meta.dirname, '../../../src/siteConfig/withSiteConfig.ts'), + path.join(siteDir, 'src/siteConfig/withSiteConfig.ts') + ); + await fs.writeFile( + path.join(siteDir, 'src/siteConfig/siteConfig.ts'), + 'export interface SiteConfig {}\n' + ); + await fs.writeFile( + path.join(siteDir, 'siteConfig.ts'), + "throw new Error('Must not load optional site config');\n" + ); + await fs.writeFile(path.join(siteDir, 'package.json'), JSON.stringify({ private: true })); + await fs.writeFile( + path.join(siteDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + noEmit: true, + types: ['node'] + }, + include: ['updateSync/**/*.ts', 'src/**/*.ts'] + }) + ); + await fs.mkdir(path.join(siteDir, 'node_modules'), { recursive: true }); + for (const name of ['@types', 'chokidar', 'dotenv', 'es-toolkit', 'js-yaml', 'minimist', 'tsx']) { + await fs.symlink(path.resolve('node_modules', name), path.join(siteDir, 'node_modules', name), 'dir'); + } +}; diff --git a/updateSync/packageDocsSync/types.ts b/updateSync/packageDocsSync/types.ts new file mode 100644 index 000000000..af4b2a94c --- /dev/null +++ b/updateSync/packageDocsSync/types.ts @@ -0,0 +1,4 @@ +export interface PackageDocsExtension { + watchPaths: string[]; + sync: (destination: string) => Promise; +} diff --git a/updateSync/packageDocsSync/watch.ts b/updateSync/packageDocsSync/watch.ts index 9852b1bc6..43ce032f6 100644 --- a/updateSync/packageDocsSync/watch.ts +++ b/updateSync/packageDocsSync/watch.ts @@ -1,11 +1,14 @@ import chokidar from 'chokidar'; +import 'dotenv/config'; import minimist from 'minimist'; import path from 'path'; import packageDocsSync from '.'; import { categoryFileLocation, getDebouncedSyncer, packageInfo, syncCategoryFile } from './actions'; +import { loadPyodideDocsSync } from './configuration'; const argv = minimist(process.argv.slice(2), { string: ['src', 'dest'], + boolean: ['skip-initial'], alias: { src: 'packages', dest: 'out' }, default: { src: 'packages' @@ -15,16 +18,33 @@ const argv = minimist(process.argv.slice(2), { const PACKAGES_DIR = path.resolve(process.cwd(), argv.src); const DEST_ROOT = path.resolve(process.cwd(), argv.dest); -const watcher = chokidar.watch(PACKAGES_DIR, { ignoreInitial: true, persistent: true }); - const main = async () => { - const { syncQueue, syncDebounced } = await getDebouncedSyncer(PACKAGES_DIR, DEST_ROOT); - const NODE_MODULES_TEST = /node_modules/; - await packageDocsSync(PACKAGES_DIR, DEST_ROOT); + const pyodide = await loadPyodideDocsSync(PACKAGES_DIR); + const extensionPaths = pyodide?.watchPaths ?? []; + if (!argv['skip-initial']) { + await packageDocsSync(PACKAGES_DIR, DEST_ROOT, pyodide); + } + const { syncQueue, syncDebounced } = await getDebouncedSyncer( + PACKAGES_DIR, + DEST_ROOT, + pyodide ? () => pyodide.sync(DEST_ROOT) : undefined + ); + const watcher = chokidar.watch([PACKAGES_DIR, ...extensionPaths], { + ignoreInitial: true, + persistent: true, + ignored: /(^|[/\\])node_modules([/\\]|$)/ + }); watcher .on('all', async (_event, filePath) => { - if (NODE_MODULES_TEST.test(filePath)) { + const isExtension = extensionPaths.some((directory) => { + const relativePath = path.relative(directory, filePath); + return ( + relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); + }); + if (isExtension) { + syncDebounced(); return null; } const pkgInfo = packageInfo(filePath, PACKAGES_DIR); @@ -39,7 +59,7 @@ const main = async () => { } }) .on('ready', () => { - console.log('Watching for docs changes in packages...'); + console.log('Watching for package and Pyodide extension documentation changes...'); }); }; diff --git a/updateSync/tsconfig.json b/updateSync/tsconfig.json index 6491f4b50..a3e732778 100644 --- a/updateSync/tsconfig.json +++ b/updateSync/tsconfig.json @@ -1,10 +1,4 @@ { - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "outDir": "../../build/updateTdev", - "rootDir": "./", - "esModuleInterop": true, - "skipLibCheck": true - } + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] }