diff --git a/package-lock.json b/package-lock.json index de97cb35f..bc40db90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0", "license": "MIT", "dependencies": { + "pako": "^2.1.0", "seedrandom": "^3.0.5" }, "devDependencies": { @@ -33,6 +34,7 @@ "@types/markdown-it-attrs": "^4.1.0", "@types/markdown-it-footnote": "^3.0.0", "@types/node": "^16", + "@types/pako": "^2.0.0", "@types/prismjs": "^1.26.0", "@types/seedrandom": "^3.0.5", "@types/semver": "^7.5.0", @@ -2837,6 +2839,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.0.tgz", + "integrity": "sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==", + "dev": true + }, "node_modules/@types/prismjs": { "version": "1.26.0", "dev": true, @@ -7694,6 +7702,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -11998,6 +12011,12 @@ "version": "2.4.1", "dev": true }, + "@types/pako": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.0.tgz", + "integrity": "sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==", + "dev": true + }, "@types/prismjs": { "version": "1.26.0", "dev": true @@ -15026,6 +15045,11 @@ "version": "2.2.0", "dev": true }, + "pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "parent-module": { "version": "1.0.1", "dev": true, diff --git a/package.json b/package.json index af1d85f94..8b79582d2 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@types/markdown-it-attrs": "^4.1.0", "@types/markdown-it-footnote": "^3.0.0", "@types/node": "^16", + "@types/pako": "^2.0.0", "@types/prismjs": "^1.26.0", "@types/seedrandom": "^3.0.5", "@types/semver": "^7.5.0", @@ -99,6 +100,7 @@ "typogr": "^0.6.8" }, "dependencies": { + "pako": "^2.1.0", "seedrandom": "^3.0.5" } } diff --git a/source/assets/js/playground.ts b/source/assets/js/playground.ts index 95f9139bc..b49cef07d 100644 --- a/source/assets/js/playground.ts +++ b/source/assets/js/playground.ts @@ -42,7 +42,7 @@ function setupPlayground() { debouncedUpdateCSS(); } if (['inputFormat', 'outputFormat', 'inputValue'].includes(prop)) { - updateURL(); + debounceUpdateURL(); } return set; }, @@ -234,6 +234,8 @@ function setupPlayground() { } } + const debounceUpdateURL = debounce(updateURL, 200); + function updateURL() { const hash = stateToBase64(playgroundState); history.replaceState('playground', '', `#${hash}`); diff --git a/source/assets/js/playground/utils.ts b/source/assets/js/playground/utils.ts index 42bb51aa5..b1b637429 100644 --- a/source/assets/js/playground/utils.ts +++ b/source/assets/js/playground/utils.ts @@ -1,6 +1,7 @@ /* eslint-disable node/no-extraneous-import */ import {Diagnostic} from '@codemirror/lint'; import {Exception, Importer, OutputStyle, Syntax} from 'sass'; +import {deflate, inflate} from 'pako'; import {ConsoleLog, ConsoleLogDebug, ConsoleLogWarning} from './console-utils'; @@ -23,16 +24,44 @@ export function stateToBase64(state: PlaygroundState): string { const inputFormatChar = state.inputFormat === 'scss' ? 1 : 0; const outputFormatChar = state.outputFormat === 'expanded' ? 1 : 0; const persistedState = `${inputFormatChar}${outputFormatChar}${state.inputValue}`; - return btoa(encodeURIComponent(persistedState)); + return deflateToBase64(persistedState); } -export function base64ToState(string: string): Partial { +/** Compresses `input` and returns a base64 string of the compressed bytes. */ +function deflateToBase64(input: string): string { + const deflated = deflate(input); + // btoa() input can't contain multi-byte characters, so it must be manually + // decoded into an ASCII string. TextDecoder can't take multi-byte characters, + // and encodeURIComponent doesn't escape all characters. + return btoa(String.fromCharCode(...deflated)); +} + +/** Decompresses a base64 `input` into the original string. */ +function inflateFromBase64(input: string): string { + const base64 = atob(input); + const deflated = new Uint8Array(base64.length); + // Manually encode the inflated string because it was manually decoded without + // TextDecode. TextEncoder would generate a different representation for the + // given input. + for (let i = 0; i < base64.length; i++) { + deflated[i] = base64.charCodeAt(i); + } + return inflate(deflated, {to: 'string'}); +} + +export function base64ToState(input: string): Partial { const state: Partial = {}; - let decoded; + let decoded: string; try { - decoded = decodeURIComponent(atob(string)); + decoded = inflateFromBase64(input); } catch (error) { - return {}; + // For backwards compatibility, decode the URL using the old decoding + // strategy if the URL could not be inflated. + try { + decoded = decodeURIComponent(atob(input)); + } catch (error) { + return {}; + } } if (!/\d\d.*/.test(decoded)) return {};