diff --git a/e2e/specs/sdk-options.spec.ts b/e2e/specs/sdk-options.spec.ts new file mode 100644 index 0000000000..913fbcbcdd --- /dev/null +++ b/e2e/specs/sdk-options.spec.ts @@ -0,0 +1,120 @@ +import { expect } from '@playwright/test'; +import { compressToEncodedURIComponent } from 'lz-string'; +import { getPlaygroundUrl, type Config, type EmbedOptions } from '../../src/sdk/index'; +import { getLoadedApp, waitForEditorFocus } from '../helpers'; +import { test } from '../test-fixtures'; + +test.describe('SDK options', () => { + test('params', async ({ page, getTestUrl }) => { + const params: EmbedOptions['params'] = { + md: `# Hello, World!`, + css: `h1 { color: red; }`, + }; + + const url = getPlaygroundUrl({ appUrl: getTestUrl(), params }); + await page.goto(url); + + const { app, getResult, waitForResultUpdate } = await getLoadedApp(page); + + await waitForEditorFocus(app); + await waitForResultUpdate(); + + const titleText = await getResult().innerText('h1'); + expect(titleText).toBe('Hello, World!'); + expect(await getResult().$eval('h1', (e) => getComputedStyle(e).color)).toBe('rgb(255, 0, 0)'); + }); + + test('config', async ({ page, getTestUrl }) => { + const config: Partial = { + markup: { + language: 'markdown', + content: `# Hello, World!`, + }, + style: { + language: 'css', + content: `h1 { color: red; }`, + }, + }; + + const url = getPlaygroundUrl({ appUrl: getTestUrl(), config }); + await page.goto(url); + + const { app, getResult, waitForResultUpdate } = await getLoadedApp(page); + + await waitForEditorFocus(app); + await waitForResultUpdate(); + + const titleText = await getResult().innerText('h1'); + expect(titleText).toBe('Hello, World!'); + expect(await getResult().$eval('h1', (e) => getComputedStyle(e).color)).toBe('rgb(255, 0, 0)'); + }); + + test('template', async ({ page, getTestUrl }) => { + const url = getPlaygroundUrl({ appUrl: getTestUrl(), template: 'typescript' }); + await page.goto(url); + + const { app, getResult, waitForResultUpdate } = await getLoadedApp(page); + + await waitForEditorFocus(app); + await waitForResultUpdate(); + + const titleText = await getResult().innerText('h1'); + expect(titleText).toBe('Hello, TypeScript!'); + }); + + test('import', async ({ page, getTestUrl }) => { + const url = getPlaygroundUrl({ + appUrl: getTestUrl(), + import: 'https://hatemhosny.github.io/typescript-demo-for-testing-import-/', + }); + await page.goto(url); + + const { app, getResult, waitForResultUpdate } = await getLoadedApp(page); + + await waitForEditorFocus(app); + await waitForResultUpdate(); + + const titleText = await getResult().innerText('h1'); + expect(titleText).toBe('Hello, World!'); + }); + + test('options override: template -> import -> config -> params', async ({ page, getTestUrl }) => { + const url = getPlaygroundUrl({ + appUrl: getTestUrl(), + template: 'react', + import: + 'code/' + + compressToEncodedURIComponent( + JSON.stringify({ + style: { + language: 'css', + content: `h1 { color: green; }`, + }, + stylesheets: ['data:text/css,h2 { color: blue; }'], + }), + ), + config: { + markup: { + language: 'markdown', + content: `## Hello, from config!`, + }, + }, + params: { + css: `h1 { color: red; }`, + }, + }); + await page.goto(url); + + const { app, getResult, waitForResultUpdate } = await getLoadedApp(page); + + await waitForEditorFocus(app); + await waitForResultUpdate(); + + const h1 = await getResult().innerText('h1'); + expect(h1).toBe('Hello, React!'); + const h2 = await getResult().innerText('h2'); + expect(h2).toBe('Hello, from config!'); + expect(await getResult().$eval('h1', (e) => getComputedStyle(e).color)).toBe('rgb(255, 0, 0)'); + expect(await getResult().$eval('h2', (e) => getComputedStyle(e).color)).toBe('rgb(0, 0, 255)'); + }); +}); diff --git a/src/livecodes/UI/create-language-menus.ts b/src/livecodes/UI/create-language-menus.ts index 6f50d10934..eea8ed6fa1 100644 --- a/src/livecodes/UI/create-language-menus.ts +++ b/src/livecodes/UI/create-language-menus.ts @@ -17,7 +17,7 @@ export const createLanguageMenus = ( eventsManager: EventsManager, showLanguageInfo: (languageInfo: HTMLElement) => void, loadStarterTemplate: (templateName: Template['name']) => void, - importCode: (options: { url: string }) => Promise, + importCode: (options: { importUrl: string }) => Promise, registerMenuButton: (menu: HTMLElement, button: HTMLElement) => void, ) => { const editorIds: EditorId[] = ['markup', 'style', 'script']; @@ -143,7 +143,7 @@ export const createLanguageMenus = ( 'click', async (event) => { event.preventDefault(); - importCode({ url: codeUrl }); + importCode({ importUrl: codeUrl }); }, false, ); diff --git a/src/livecodes/config/build-config.ts b/src/livecodes/config/build-config.ts index 2bf2a81729..6fabcfe775 100644 --- a/src/livecodes/config/build-config.ts +++ b/src/livecodes/config/build-config.ts @@ -140,6 +140,7 @@ export const getParams = ( if (params[key] === 'true') params[key] = true; if (params[key] === 'false') params[key] = false; }); + params.x ??= params.import; return params; }; diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index c585a87bfb..c52cd311f8 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -1,28 +1,31 @@ import { getPlaygroundUrl } from '../sdk'; -import { createCustomEditors, createEditor, getFontFamily } from './editor'; import { - getLanguageByAlias, - getLanguageCompiler, - getLanguageEditorId, - getLanguageExtension, - getLanguageSpecs, - getLanguageTitle, - languageIsEnabled, - languages, - mapLanguage, - processorIsEnabled, - processors, -} from './languages'; -import { - createStores, - fakeStorage, - initializeStores, - type StorageItem, - type Stores, -} from './storage'; - + createLoginContainer, + createOpenItem, + createProjectInfoUI, + createSplitPanes, + createStarterTemplateLink, + createTemplatesContainer, + displayLoggedIn, + displayLoggedOut, + getFullscreenButton, + getResultElement, + loadingMessage, + noUserTemplates, +} from './UI'; +import type { + BroadcastData, + BroadcastInfo, + BroadcastResponseData, + BroadcastResponseError, +} from './UI/broadcast'; +import { getCommandMenuActions } from './UI/command-menu-actions'; +import { createLanguageMenus, createProcessorItem } from './UI/create-language-menus'; +import { createModal } from './UI/modal'; +import * as UI from './UI/selectors'; +import { themeColors } from './UI/theme-colors'; import { cacheIsValid, getCache, getCachedCode, setCache, updateCache } from './cache'; -import { cjs2esm, getAllCompilers, getCompiler, getCompileResult } from './compiler'; +import { cjs2esm, getAllCompilers, getCompileResult, getCompiler } from './compiler'; import { buildConfig, defaultConfig, @@ -35,6 +38,7 @@ import { setConfig, upgradeAndValidate, } from './config'; +import { createCustomEditors, createEditor, getFontFamily } from './editor'; import { hasJsx } from './editor/ts-compiler-options'; import { createEventsManager, createPub } from './events'; import { customEvents } from './events/custom-events'; @@ -62,9 +66,23 @@ import type { I18nValueType, } from './i18n'; import { appLanguages } from './i18n/app-languages'; -import { isCompressedCode, isGithub } from './import/check-src'; +import { isGithub } from './import/check-src'; +import { importCompressedCode } from './import/code'; import { importFromFiles } from './import/files'; import { populateConfig } from './import/utils'; +import { + getLanguageByAlias, + getLanguageCompiler, + getLanguageEditorId, + getLanguageExtension, + getLanguageSpecs, + getLanguageTitle, + languageIsEnabled, + languages, + mapLanguage, + processorIsEnabled, + processors, +} from './languages'; import type { API, APICommands, @@ -91,8 +109,8 @@ import type { Modal, Notifications, Processor, - Screen, SDKEvent, + Screen, ShareData, Template, TestResult, @@ -108,34 +126,16 @@ import { cleanResultFromDev, createResultPage } from './result'; import { createAuthService, getAppCDN, sandboxService, shareService } from './services'; import type { GitHubFile } from './services/github'; import { permanentUrlService } from './services/permanent-url'; +import { + createStores, + fakeStorage, + initializeStores, + type StorageItem, + type Stores, +} from './storage'; import { getStarterTemplates, getTemplate } from './templates'; import { createToolsPane } from './toolspane'; import { createTypeLoader, getDefaultTypes } from './types'; -import { - createLoginContainer, - createOpenItem, - createProjectInfoUI, - createSplitPanes, - createStarterTemplateLink, - createTemplatesContainer, - displayLoggedIn, - displayLoggedOut, - getFullscreenButton, - getResultElement, - loadingMessage, - noUserTemplates, -} from './UI'; -import type { - BroadcastData, - BroadcastInfo, - BroadcastResponseData, - BroadcastResponseError, -} from './UI/broadcast'; -import { getCommandMenuActions } from './UI/command-menu-actions'; -import { createLanguageMenus, createProcessorItem } from './UI/create-language-menus'; -import { createModal } from './UI/modal'; -import * as UI from './UI/selectors'; -import { themeColors } from './UI/theme-colors'; import { capitalize, colorToHex, @@ -149,8 +149,8 @@ import { loadStylesheet, predefinedValues, safeName, - stringify, stringToValidJson, + stringify, toDataUrl, } from './utils'; import { @@ -166,8 +166,6 @@ import { snackbarUrl, } from './vendors'; -import { importCompressedCode } from './import/code'; - // declare global dependencies declare global { interface Window { @@ -5240,25 +5238,31 @@ const configureModes = ({ const importExternalContent = async (options: { config?: Config; + sdkConfig?: Partial; configUrl?: string; template?: string; - url?: string; + importUrl?: string; }): Promise => { - const { config = defaultConfig, configUrl, template, url } = options; + const { config = defaultConfig, sdkConfig, configUrl, template } = options; + let importUrl = options.importUrl; const hasContentUrls = (conf: Partial) => editorIds.filter( (editorId) => (conf[editorId]?.contentUrl && !conf[editorId]?.content) || (conf[editorId]?.hiddenContentUrl && !conf[editorId]?.hiddenContent), ).length > 0; + const validConfigUrl = getValidUrl(configUrl); + if (importUrl?.startsWith('config') || importUrl?.startsWith('params')) { + importUrl = ''; // ignore hash params + } - if (!configUrl && !template && !url && !hasContentUrls(config)) return false; + if (!validConfigUrl && !template && !importUrl && !hasContentUrls(config)) return false; const loadingMessage = window.deps.translateString('core.import.loading', 'Loading Project...'); notifications.info(loadingMessage); let templateConfig: Partial = {}; - let urlConfig: Partial = {}; + let importUrlConfig: Partial = {}; let contentUrlConfig: Partial = {}; let configUrlConfig: Partial = {}; @@ -5278,24 +5282,30 @@ const importExternalContent = async (options: { ); } } - if (url) { - let validUrl = url; - if (url.startsWith('http') || url.startsWith('data')) { + if (importUrl) { + let validImportUrl = importUrl; + if (importUrl.startsWith('http') || importUrl.startsWith('data')) { try { - validUrl = new URL(url).href; + validImportUrl = new URL(importUrl).href; } catch { - validUrl = decodeURIComponent(url); + validImportUrl = decodeURIComponent(importUrl); } } - // import code from hash: code / github / github gist / url html / ...etc + // import code from hash: github / github gist / url html / ...etc let user; - if (isGithub(validUrl) && !isEmbed) { + if (isGithub(validImportUrl) && !isEmbed) { await initializeAuth(); user = await authService?.getUser(); } const importModule: typeof import('./UI/import') = await import(baseUrl + '{{hash:import.js}}'); - urlConfig = await importModule.importCode(validUrl, params, getConfig(), user, baseUrl); + importUrlConfig = await importModule.importCode(validImportUrl, params, config, user, baseUrl); + + if (Object.keys(importUrlConfig).length === 0) { + notifications.error( + window.deps.translateString('core.error.invalidImport', 'Invalid import URL'), + ); + } } if (hasContentUrls(config)) { @@ -5326,37 +5336,25 @@ const importExternalContent = async (options: { }; } - const validConfigUrl = getValidUrl(configUrl); if (validConfigUrl) { configUrlConfig = upgradeAndValidate( await fetch(validConfigUrl) .then((res) => res.json()) .catch(() => ({})), ); - } else { - // the url is config=code/... - const searchParams = new URLSearchParams(url); - if (searchParams.get('config') && isCompressedCode(searchParams.get('config') ?? '')) { - configUrlConfig = importCompressedCode(searchParams.get('config')!); + if (hasContentUrls(configUrlConfig)) { + return importExternalContent({ ...options, config: { ...config, ...configUrlConfig } }); } } - if (configUrlConfig && hasContentUrls(configUrlConfig)) { - return importExternalContent({ config: { ...config, ...configUrlConfig } }); - } - - if (Object.keys(urlConfig).length === 0 && !configUrlConfig) { - notifications.error( - window.deps.translateString('core.error.invalidImport', 'Invalid import URL'), - ); - } await loadConfig( buildConfig({ ...config, ...templateConfig, - ...urlConfig, - ...contentUrlConfig, + ...importUrlConfig, ...configUrlConfig, + ...sdkConfig, + ...contentUrlConfig, }), parent.location.href, false, @@ -5421,25 +5419,29 @@ const initializePlayground = async ( }, initializeFn?: () => void | Promise, ) => { + const importUrl = params.x || parent.location.hash.substring(1); // for backward compatibility const appConfig = options?.config ?? {}; + const codeImportConfig = importCompressedCode(importUrl); + const sdkConfig = importCompressedCode(params.config ?? ''); + const initialConfig = { ...codeImportConfig, ...appConfig, ...sdkConfig }; baseUrl = options?.baseUrl ?? '/livecodes/'; isHeadless = options?.isHeadless ?? false; isLite = params.mode === 'lite' || (params.lite != null && params.lite !== false) || // for backward compatibility - appConfig.mode === 'lite' || + initialConfig.mode === 'lite' || false; isEmbed = isHeadless || isLite || (options?.isEmbed ?? false) || - appConfig.mode === 'simple' || + initialConfig.mode === 'simple' || params.mode === 'simple'; window.history.replaceState(null, '', './'); // fix URL from "/app" to "/" await initializeStores(stores, isEmbed); - loadUserConfig(/* updateUI = */ false); - setConfig(buildConfig({ ...getConfig(), ...appConfig })); + const userConfig = stores.userConfig?.getValue() ?? {}; + setConfig(buildConfig({ ...getConfig(), ...userConfig, ...initialConfig })); configureModes({ config: getConfig(), isEmbed, isLite }); compiler = (window as any).compiler = await getCompiler({ config: getConfig(), @@ -5470,9 +5472,10 @@ const initializePlayground = async ( } importExternalContent({ config: getConfig(), + sdkConfig, configUrl: params.config, template: params.template, - url: params.x || parent.location.hash.substring(1), + importUrl: Object.keys(codeImportConfig).length > 0 ? '' : importUrl, // do not re-import compressed code }).then(async (contentImported) => { if (!contentImported) { loadSelectedScreen(); diff --git a/src/livecodes/import/code.ts b/src/livecodes/import/code.ts index 5ce4583649..1490e9311e 100644 --- a/src/livecodes/import/code.ts +++ b/src/livecodes/import/code.ts @@ -1,8 +1,10 @@ import type { Config } from '../models'; import { decompress } from '../utils/compression'; +import { isCompressedCode } from './check-src'; export const importCompressedCode = (url: string) => { - const code = url.slice(5); + if (!isCompressedCode(url)) return {}; + const code = url.slice('code/'.length); let config: Partial; try { config = JSON.parse(decompress(code) || '{}');