Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The one automated check is `test:browser` (`scripts/check-ide-browser.mjs`): `pl
- `src/index.css` — the shell's styles (`ide-*` classes), from glpython-editor's `--glp-*` tokens. glpython-editor's `style.css` styles only the workspace (plus `icon-button`, `hidden-input` and `auth-notice`, which the shell uses too).
- `src/i18n.ts` — only the shell's own strings (`ShellStrings`, de/en). The workspace's strings come from glpython-editor's `coreStrings`, so don't copy them here. A new workspace string goes into glpython-editor's `src/i18n.ts`.
- `src/tabProjects.ts`: per-tab projects (P25). The tab's id is in sessionStorage and `history.state` (never in the address); projects are in localStorage, one entry per id. Nothing is deleted when a tab goes away: a page can't tell a close from iPad Safari unloading it (only `history.state` survives that). Open tabs answer over a BroadcastChannel, which finds duplicated tabs and the projects for «Zuletzt geschlossen».
- `src/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload: v1 every file's code, v2 also libraries by URL, fetched by `src/libraries.ts`; optional `openFile`, the tab to open with (a library by URL once fetched, unless the learner chose a tab: `tabChosenRef`), kept on the `TabEntry`, in «Teilen» links and in the ZIP; the tabs in the link's order (`fileOrder`), which «Teilen» keeps; a link that can't be read opens an empty project with a notice).
- `src/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (share links: written as `?p=`, raw deflate (fflate, synchronous so «Teilen» copies within the tap on an iPad) in base64url without padding; `?project=` (lz-string) read forever; versioned payload: v1 every file's code, v2 also libraries by URL, fetched by `src/libraries.ts`; optional `openFile`, the tab to open with (a library by URL once fetched, unless the learner chose a tab: `tabChosenRef`), kept on the `TabEntry`, in «Teilen» links and in the ZIP; the tabs in the link's order (`fileOrder`), which «Teilen» keeps; a link that can't be read opens an empty project with a notice).
- `src/markdown.ts`: the workspace's `renderMarkdown` (marked + DOMPurify, both pinned; HTML in the Markdown becomes text, links get `target="_blank"` and `rel="noopener noreferrer"`).
- `src/auth.ts` — MSAL. Only active with `VITE_ENTRA_CLIENT_ID` in `.env.local` (README «Microsoft sign-in»).
- `vite.config.ts` — `optimizeDeps.exclude: ['glpython-editor']` keeps the editor's worker next to its `dist/index.js`. Don't remove it.
Expand Down
43 changes: 38 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,21 +110,54 @@ The ⋮ menu and the bar support:
- **Projekt herunterladen** as a ZIP with all project files and `glpython.json`
metadata
- **Teilen**, which serializes the entire project structure (files, contents,
and metadata) into a compressed link (`?project=`, or `#project=` beyond
GitHub Pages' URL limit) so a project can be shared or reopened without a
server, similar to
and metadata) into a compressed link (`?p=`, or `#p=` beyond GitHub Pages'
URL limit; see «The link format» below) so a project can be shared or
reopened without a server, similar to
[webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/). A link longer
than 2,083 characters (OneNote cuts links there) is still copied, with a
note to download the project for a hand-in and a **Herunterladen** button.

**The link format.** A share link carries the project as JSON (the payload
below: `v`, `name`, `mainFile`, optional `openFile`, `files` in tab order),
in one of two encodings:
- **`?p=`** (written since P46): the JSON's UTF-8 bytes compressed with **raw
deflate** (RFC 1951: no zlib or gzip header or checksum; the IDE uses
[fflate](https://github.com/101arrowz/fflate) at level 9, Node's
`zlib.deflateRawSync(bytes, { level: 9 })` is equally right), written in
**base64url** (RFC 4648 §5, alphabet `A–Z a–z 0–9 - _`) **without `=`
padding**. Any raw deflate stream reads, whichever encoder wrote it. About
40 % shorter than `?project=` for a program: a hand-in of 1,980 characters
becomes 1,185.
- **`?project=`** (before P46): lz-string's `compressToEncodedURIComponent`.
The IDE reads it forever, so every link handed in keeps working.

A link longer than 8,192 characters in path and query puts the same value in
the fragment instead: `#p=…` (or `#project=…`). A test vector: the payload

```json
{"v":1,"name":"Test","mainFile":"main.py","files":{"main.py":{"path":"main.py","label":"main","kind":"python","code":"print(\"Grüezi\")\n"}}}
```

(exactly these bytes, as `JSON.stringify` writes them) gives, with
`zlib.deflateRawSync(Buffer.from(json, 'utf8'), { level: 9 }).toString('base64url')`,

```text
VcxBCoMwFATQu8yqQhDc_gO0F3CZTVq_-DF-gwbBhtysOy9WAiK4m3kMk7CBGgN1E4PQ8hphMDnRp_giJdZhh0EvnldQuogSgovDbeTdm_0pMBhFOxDCHoe59M_cldOwiMaHxWs5fvwVi8oqcs5_
```

so `https://glpython-edu.github.io/?p=VcxBCoMw…` opens it (`test:browser`
checks this). The IDE's own encoder may write other bytes for the same
payload; both open.

**Libraries by URL.** A share link (version 2) can name a library by its URL
instead of carrying its code, e.g. a course's `parser.py`:
`{"path": "parser.py", "url": "https://…/parser.py", "readOnly": true}`. The
IDE fetches it once when the link opens (https only, or http on localhost; at
most 1 MB; without cookies; the server must send CORS headers, as GitHub
Pages and raw.githubusercontent.com do) and keeps the text with the tab's
project, so a reload or working offline uses that copy. The file drawer lists
it under **Bibliotheken** with its host and a lock. If it can't be loaded
it with its host and a lock: a Python file under **Bibliotheken**, any
other file (e.g. a task's instructions) under **Zum Lesen**. If it can't be loaded
(e.g. HTTP 404, no network, no CORS), the drawer and a notice say so
(«Die Bibliothek parser.py konnte nicht geladen werden (HTTP 404).») with
**Erneut versuchen**; importing it then raises Python's own
Expand Down Expand Up @@ -179,7 +212,7 @@ a new file comes last. For example:
}
```

as `?project=` + `LZString.compressToEncodedURIComponent(JSON.stringify(…))`.
in either encoding of «The link format» above.
A Markdown file can also carry its code (`"kind": "text", "code": "…"`,
`"readOnly": true`); then it is open at once.

Expand Down
28 changes: 28 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,34 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

### fflate 0.8.3

License: MIT · https://github.com/101arrowz/fflate

```text
MIT License

Copyright (c) 2026 Arjun Barrett

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

### marked 18.1.1

License: MIT · https://github.com/markedjs/marked
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@azure/msal-browser": "^5.22.0",
"dompurify": "3.4.17",
"fflate": "0.8.3",
"glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.29",
"jszip": "^3.10.2",
"lucide-react": "^1.47.0",
Expand Down
70 changes: 67 additions & 3 deletions scripts/check-ide-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
// - a task's instructions as a tab (P45): a link's `openFile` and a
// read-only aufgabe.md shown as a page («Ansicht / Text»), ▶, «Tests»
// and «Visualisieren» on the file that runs, «Teilen» and a reload keep
// it; an old link as before; the page's HTML sanitized.
// it; an old link as before; the page's HTML sanitized;
// - compact links (P46): «Teilen» writes `?p=` (raw deflate, base64url),
// which opens the same project; zlib's test vector, old `?project=` and
// `#project=` links open; a cut `?p=` link shows the notice.
// The browser's «leave the page» question and the iPad cases stay manual:
// headless Chrome can't confirm them.
//
Expand All @@ -24,6 +27,7 @@ import { spawn } from 'node:child_process'
import { readFileSync } from 'node:fs'
import http from 'node:http'
import lz from 'lz-string'
import { deflateRawSync, inflateRawSync } from 'node:zlib'
import { chromium } from 'playwright-core'

const port = 4196
Expand Down Expand Up @@ -62,11 +66,21 @@ const link = (files, mainFile, version = 1, extra = {}) => {
url.searchParams.set('project', lz.compressToEncodedURIComponent(JSON.stringify({ v: version, name: 'Check', mainFile, ...extra, files: Object.fromEntries(files.map((entry) => [entry.path, entry])) })))
return url.toString()
}
// A shared link's payload, in either encoding; the compact one (`?p=`, raw
// deflate in base64url) read with Node's zlib, as a course's script would.
const payloadOf = (shareUrl) => {
const url = new URL(shareUrl)
const encoded = url.searchParams.get('project') ?? new URLSearchParams(url.hash.slice(1)).get('project')
return JSON.parse(lz.decompressFromEncodedURIComponent(encoded))
const fragment = new URLSearchParams(url.hash.slice(1))
const compact = url.searchParams.get('p') ?? fragment.get('p')
if (compact !== null) return JSON.parse(inflateRawSync(Buffer.from(compact, 'base64url')).toString('utf8'))
return JSON.parse(lz.decompressFromEncodedURIComponent(url.searchParams.get('project') ?? fragment.get('project')))
}
// A link in the compact form, written with Node's zlib (not the IDE's fflate).
const compactLink = (payload) => `${base}?p=${deflateRawSync(Buffer.from(JSON.stringify(payload), 'utf8'), { level: 9 }).toString('base64url')}`
// The fixed test vector in the README: this payload, deflated with zlib at
// level 9, in base64url without padding.
const vectorPayload = { v: 1, name: 'Test', mainFile: 'main.py', files: { 'main.py': { path: 'main.py', label: 'main', kind: 'python', code: 'print("Grüezi")\n' } } }
const vector = 'VcxBCoMwFATQu8yqQhDc_gO0F3CZTVq_-DF-gwbBhtysOy9WAiK4m3kMk7CBGgN1E4PQ8hphMDnRp_giJdZhh0EvnldQuogSgovDbeTdm_0pMBhFOxDCHoe59M_cldOwiMaHxWs5fvwVi8oqcs5_'
const helfer = 'def doppelt(x):\n return 2 * x\n'
const main = 'from helfer import doppelt\n\n\ndef viermal(x):\n """\n >>> viermal(2)\n 8\n """\n return doppelt(doppelt(x))\n\n\nprint(viermal(21))\n'
const twoFiles = link([file('helfer.py', helfer), file('main.py', main)], 'main.py')
Expand Down Expand Up @@ -380,6 +394,10 @@ try {
assert.equal(await page.locator('.editor-tab.active').innerText(), 'aufgabe.md')
assert.equal(await markdownSwitch(page), 'Ansicht')
assert.deepEqual(await tabs(page), ['aufgabe.md', 'bib.py', 'auswerter.py'])
// The drawer: Python files by URL as libraries, the instructions to read.
await page.getByRole('button', { name: 'Dateien' }).click()
assert.deepEqual(await page.locator('.ide-libraries').evaluateAll((groups) => groups.map((group) => [group.querySelector('.ide-project-label').textContent, [...group.querySelectorAll('.ide-library-name > span')].map((name) => name.textContent)])), [['Bibliotheken', ['bib.py']], ['Zum Lesen', ['aufgabe.md']]])
await page.keyboard.press('Escape')
await page.getByRole('button', { name: 'Teilen' }).click()
await page.waitForTimeout(500)
const shared = payloadOf(await page.evaluate(() => navigator.clipboard.readText()))
Expand Down Expand Up @@ -414,6 +432,52 @@ try {
await context.close()
})

await check('compact links: «Teilen» writes ?p=, which opens the same project, order and tab', async () => {
const context = await newContext()
const page = await open(context, taskByUrl(`${libraryBase}aufgabe.md`))
await page.locator('.markdown-page h1').waitFor({ timeout: 15000 })
await openTab(page, 'auswerter.py')
await typeAtStart(page, '# meine Lösung\n')
await openTab(page, 'aufgabe.md')
await page.getByRole('button', { name: 'Teilen' }).click()
await page.waitForTimeout(500)
const shareUrl = await page.evaluate(() => navigator.clipboard.readText())
assert.match(shareUrl, /^http:\/\/localhost:\d+\/\?p=[A-Za-z0-9_-]+$/, 'the compact form, no padding')
const before = await stored(page)
const again = await open(context, shareUrl)
await again.locator('.markdown-page h1').waitFor({ timeout: 15000 })
assert.equal(await again.locator('.editor-tab.active').innerText(), 'aufgabe.md')
assert.deepEqual(await tabs(again), ['aufgabe.md', 'bib.py', 'auswerter.py'])
const after = await stored(again)
for (const path of ['auswerter.py', 'aufgabe.md', 'bib.py']) assert.deepEqual([after.files[path].code, after.files[path].readOnly], [before.files[path].code, before.files[path].readOnly], path)
assert.match(after.files['auswerter.py'].code, /^# meine Lösung\n/)
assert.equal(after.mainFile, 'auswerter.py')
await context.close()
})

await check('compact links: zlib\'s test vector opens; old ?project= and #project= links too', async () => {
assert.equal(deflateRawSync(Buffer.from(JSON.stringify(vectorPayload), 'utf8'), { level: 9 }).toString('base64url'), vector, 'the README\'s vector')
const context = await newContext()
const page = await open(context, `${base}?p=${vector}`)
assert.equal(await editorText(page), 'print("Grüezi")\n')
const fragment = new URL(twoFiles)
const oldFragment = await open(context, `${base}#project=${fragment.searchParams.get('project')}`)
assert.deepEqual(await tabs(oldFragment), ['helfer.py', 'main.py'])
const oldQuery = await open(context, twoFiles)
assert.deepEqual(await tabs(oldQuery), ['helfer.py', 'main.py'])
await context.close()
})

await check('compact links: a cut ?p= link shows the notice', async () => {
const context = await newContext()
const full = compactLink({ v: 1, name: 'Check', mainFile: 'main.py', files: { 'main.py': file('main.py', noise) } })
const page = await open(context, full.slice(0, Math.floor(full.length * 0.6)))
assert.match((await notices(page)).join(' | '), /Dieser Link ist unvollständig oder beschädigt\./)
assert.deepEqual(await tabs(page), ['main.py'])
assert.equal(await editorText(page), '')
await context.close()
})

await check('an old link: opens on the file that runs, Markdown as text', async () => {
const context = await newContext()
const page = await open(context, link([file('main.py', 'print(1)\n'), { path: 'notizen.md', label: 'notizen', kind: 'text', code: '# Notizen\n' }], 'main.py', 1, { openFile: 'fehlt.md' }))
Expand Down
29 changes: 17 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,18 +454,23 @@ function App() {
</li> })}
</ul>
<button type="button" className="secondary-button ide-new-file" onClick={addFile}><Plus size={15} /> {t.addFile}</button>
{/* Libraries by URL (libraries.ts): read-only, with where they come from. */}
{Object.keys(libraries).length > 0 && <div className="ide-libraries">
<span className="ide-project-label">{t.libraries}</span>
<ul className="ide-file-list">
{Object.entries(libraries).map(([path, library]) => <li key={path} className={path === activeFile ? 'active' : undefined}>
<button type="button" className="ide-file-open" aria-current={path === activeFile ? 'true' : undefined} disabled={library.status !== 'loaded'} onClick={() => chooseTab(path)}>{/\.py$/i.test(path) ? <FileCode2 size={15} /> : <BookOpen size={15} />}<span className="ide-library-name"><span>{path}</span><small className={library.status === 'failed' ? 'ide-library-failed' : undefined}>{library.status === 'loaded' ? t.librarySource(libraryHost(library.url)) : library.status === 'loading' ? t.libraryLoading : t.libraryFailed(path, library.failure ?? '')}</small></span></button>
{library.status === 'failed'
? <button type="button" className="icon-button small" aria-label={t.retryLibrary(path)} title={t.retryLibrary(path)} onClick={() => retryLibrary(path)}><RotateCcw size={14} /></button>
: <span className="ide-file-read-only" role="img" aria-label={t.readOnlyFile} title={t.readOnlyFile}><Lock size={14} /></span>}
</li>)}
</ul>
</div>}
{/* Files by URL (libraries.ts): read-only, with where they come from -
Python files as libraries, other files (e.g. a task's instructions)
to read. */}
{[{ heading: t.libraries, python: true }, { heading: t.readingFiles, python: false }].map(({ heading, python }) => {
const group = Object.entries(libraries).filter(([path]) => /\.py$/i.test(path) === python)
return group.length > 0 && <div className="ide-libraries" key={heading}>
<span className="ide-project-label">{heading}</span>
<ul className="ide-file-list">
{group.map(([path, library]) => <li key={path} className={path === activeFile ? 'active' : undefined}>
<button type="button" className="ide-file-open" aria-current={path === activeFile ? 'true' : undefined} disabled={library.status !== 'loaded'} onClick={() => chooseTab(path)}>{/\.py$/i.test(path) ? <FileCode2 size={15} /> : <BookOpen size={15} />}<span className="ide-library-name"><span>{path}</span><small className={library.status === 'failed' ? 'ide-library-failed' : undefined}>{library.status === 'loaded' ? t.librarySource(libraryHost(library.url)) : library.status === 'loading' ? t.libraryLoading : t.libraryFailed(path, library.failure ?? '')}</small></span></button>
{library.status === 'failed'
? <button type="button" className="icon-button small" aria-label={t.retryLibrary(path)} title={t.retryLibrary(path)} onClick={() => retryLibrary(path)}><RotateCcw size={14} /></button>
: <span className="ide-file-read-only" role="img" aria-label={t.readOnlyFile} title={t.readOnlyFile}><Lock size={14} /></span>}
</li>)}
</ul>
</div>
})}
</aside>}
<main className="ide-main">
{authErrorMessage && <div className="auth-notice" role="status">{authErrorMessage}</div>}
Expand Down
Loading
Loading