diff --git a/CLAUDE.md b/CLAUDE.md index 1f0ed0f..a976882 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 2caa42f..0b64cbf 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,45 @@ 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 @@ -124,7 +156,8 @@ 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 @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1321e06..f8e4c35 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 2937eb4..2bb5005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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", @@ -790,6 +791,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/package.json b/package.json index 96bfeb8..d4aa49d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-ide-browser.mjs b/scripts/check-ide-browser.mjs index 9de131f..dba541f 100644 --- a/scripts/check-ide-browser.mjs +++ b/scripts/check-ide-browser.mjs @@ -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. // @@ -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 @@ -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') @@ -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())) @@ -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' })) diff --git a/src/App.tsx b/src/App.tsx index 1b30873..5c66a0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -454,18 +454,23 @@ function App() { })} - {/* Libraries by URL (libraries.ts): read-only, with where they come from. */} - {Object.keys(libraries).length > 0 &&
- {t.libraries} - -
} + {/* 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 &&
+ {heading} + +
+ })} }
{authErrorMessage &&
{authErrorMessage}
} diff --git a/src/i18n.ts b/src/i18n.ts index 61243fb..0999a6b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -50,6 +50,8 @@ type ShellStrings = { longLink: (length: number) => string downloadForHandIn: string libraries: string + // Files by URL that aren't Python (e.g. a task's instructions), in the drawer. + readingFiles: string librarySource: (host: string) => string libraryLoading: string libraryFailed: (name: string, reason: string) => string @@ -117,6 +119,7 @@ export const translations: Record = { longLink: (length) => `This link is very long (${length} characters). Some programs, e.g. OneNote, shorten such links. For a hand-in, download the project instead.`, downloadForHandIn: 'Download', libraries: 'Libraries', + readingFiles: 'To read', librarySource: (host) => `from ${host}`, libraryLoading: 'loading …', libraryFailed: (name, reason) => `Could not load library ${name} (${reason}).`, @@ -180,6 +183,7 @@ export const translations: Record = { longLink: (length) => `Dieser Link ist sehr lang (${length} Zeichen). Manche Programme, z. B. OneNote, kürzen solche Links. Für eine Abgabe das Projekt besser herunterladen.`, downloadForHandIn: 'Herunterladen', libraries: 'Bibliotheken', + readingFiles: 'Zum Lesen', librarySource: (host) => `von ${host}`, libraryLoading: 'wird geladen …', libraryFailed: (name, reason) => `Die Bibliothek ${name} konnte nicht geladen werden (${reason}).`, diff --git a/src/projectShare.ts b/src/projectShare.ts index 483a0b2..4ea0a1a 100644 --- a/src/projectShare.ts +++ b/src/projectShare.ts @@ -1,3 +1,4 @@ +import { deflateSync, inflateSync, strFromU8, strToU8 } from 'fflate' import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string' import type { Project, ProjectFile } from 'glpython-editor' @@ -17,7 +18,22 @@ import type { Project, ProjectFile } from 'glpython-editor' // page); ▶ still runs `mainFile`. One that isn't in the project is ignored; // an IDE from before reads the link as it did. A file with its code can be // `readOnly: true` too. +// +// Two encodings of the same JSON payload (P46): +// - `?p=` (written since P46): the payload's UTF-8 bytes compressed with raw +// deflate (RFC 1951, no zlib or gzip header; fflate's deflateSync, level 9), +// in base64url (RFC 4648 §5: A-Z a-z 0-9 - _) without "=" padding. About a +// third shorter than lz-string for a program. Any raw deflate stream reads, +// e.g. Node's zlib.deflateRawSync. +// - `?project=` (before P46): lz-string's compressToEncodedURIComponent. +// Read forever: every link handed in must keep working. +// fflate works synchronously, so «Teilen» builds the link and copies it in +// the tap's own handler, as iPad Safari requires for the clipboard. +const COMPACT_PARAM = 'p' const SHARE_PARAM = 'project' +// A link's payload inflates to at most this many bytes (a short link can't +// expand into a page-freezing amount). +const MAX_PAYLOAD_BYTES = 8_000_000 // GitHub Pages answers "414 URI Too Long" when a request's path and query // exceed 8192 characters - a program of roughly 5,000 to 30,000 characters, // depending on how well it compresses. A longer link carries the project in @@ -68,20 +84,49 @@ function isSharedProjectPayload(value: unknown): value is SharedProjectPayload { // link as URLs, not as their code. // `openFile`: the tab to open with (only if it is one of the project's); // `order`: the order of the tabs (files not in it follow). -export function encodeProjectForUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { +function payloadOf(project: Project, libraries: Record, openFile: string | undefined, order: string[]): SharedProjectPayload { const files: Record = {} const paths = [...new Set([...order, ...Object.keys(project.files), ...Object.keys(libraries)])] for (const path of paths) { if (libraries[path]) files[path] = { path, url: libraries[path], readOnly: true } else if (project.files[path]) files[path] = project.files[path] } - const payload: SharedProjectPayload = { v: Object.keys(libraries).length > 0 ? 2 : 1, name: project.name, mainFile: project.mainFile, ...(openFile && openFile !== project.mainFile && files[openFile] ? { openFile } : {}), files } - return compressToEncodedURIComponent(JSON.stringify(payload)) + return { v: Object.keys(libraries).length > 0 ? 2 : 1, name: project.name, mainFile: project.mainFile, ...(openFile && openFile !== project.mainFile && files[openFile] ? { openFile } : {}), files } +} + +// The old encoding (`?project=`), for the checks and as a reference. +export function encodeProjectForUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { + return compressToEncodedURIComponent(JSON.stringify(payloadOf(project, libraries, openFile, order))) +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = '' + for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fromBase64Url(text: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) return null + const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=')) + return Uint8Array.from(binary, (char) => char.charCodeAt(0)) +} + +// The compact encoding (`?p=`), see above. +export function encodeProjectCompact(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { + return toBase64Url(deflateSync(strToU8(JSON.stringify(payloadOf(project, libraries, openFile, order))), { level: 9 })) +} + +function inflatePayload(encoded: string): string | null { + const bytes = fromBase64Url(encoded) + if (!bytes || bytes.length === 0) return null + const inflated = inflateSync(bytes) + if (inflated.length > MAX_PAYLOAD_BYTES) return null + return strFromU8(inflated) } -export function decodeProjectFromUrl(encoded: string): SharedLink { +export function decodeProjectFromUrl(encoded: string, compact = false): SharedLink { try { - const json = decompressFromEncodedURIComponent(encoded) + const json = compact ? inflatePayload(encoded) : decompressFromEncodedURIComponent(encoded) if (!json) return { status: 'broken' } const payload = JSON.parse(json) as unknown if (!isSharedProjectPayload(payload) || !isProjectFile(payload.files[payload.mainFile])) return { status: 'broken' } @@ -107,11 +152,11 @@ export function decodeProjectFromUrl(encoded: string): SharedLink { export function buildShareUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { const url = new URL(import.meta.env.BASE_URL, window.location.origin) - const encoded = encodeProjectForUrl(project, libraries, openFile, order) - url.searchParams.set(SHARE_PARAM, encoded) + const encoded = encodeProjectCompact(project, libraries, openFile, order) + url.searchParams.set(COMPACT_PARAM, encoded) if (url.pathname.length + url.search.length <= MAX_PATH_AND_QUERY) return url.toString() url.search = '' - url.hash = new URLSearchParams({ [SHARE_PARAM]: encoded }).toString() + url.hash = new URLSearchParams({ [COMPACT_PARAM]: encoded }).toString() return url.toString() } @@ -119,8 +164,12 @@ export function buildShareUrl(project: Project, libraries: Record