From 41bb732ce0f29c7dd97cd82b18d3c821113be8da Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sat, 8 Nov 2025 08:25:19 +0200 Subject: [PATCH 001/143] add multi file config --- src/livecodes/config/config.ts | 22 ++++++++++ src/livecodes/config/default-config.ts | 1 + src/livecodes/config/validate-config.ts | 47 +++++++++++++++++---- src/livecodes/core.ts | 28 +++++++++---- src/livecodes/utils/utils.ts | 2 +- src/sdk/index.ts | 2 +- src/sdk/models.ts | 55 +++++++++++++++++++++++-- 7 files changed, 136 insertions(+), 21 deletions(-) diff --git a/src/livecodes/config/config.ts b/src/livecodes/config/config.ts index 5f4b765533..8aa0ee2057 100644 --- a/src/livecodes/config/config.ts +++ b/src/livecodes/config/config.ts @@ -4,6 +4,8 @@ import type { ContentConfig, EditorConfig, FormatterConfig, + MultiFileConfig, + SingleFileConfig, UserConfig, } from '../models'; import { cloneObject } from '../utils'; @@ -95,5 +97,25 @@ export const getFormatterConfig = (config: Config | UserConfig): FormatterConfig trailingComma: config.trailingComma, }); +export const getSingleFileConfig = (config: Config): SingleFileConfig => { + const { files, mainFile, ...SingleFileConfig } = config; + return cloneObject(SingleFileConfig); +}; + +export const getMultiFileConfig = (config: Config): MultiFileConfig => { + const { + markup, + style, + script, + stylesheets, + scripts, + cssPreset, + htmlAttrs, + head, + ...multiFileConfig + } = config; + return cloneObject(multiFileConfig); +}; + export const upgradeAndValidate = (config: Partial) => validateConfig(upgradeConfig(config as any)); diff --git a/src/livecodes/config/default-config.ts b/src/livecodes/config/default-config.ts index b14a80539b..fc53c9ff7a 100644 --- a/src/livecodes/config/default-config.ts +++ b/src/livecodes/config/default-config.ts @@ -37,6 +37,7 @@ export const defaultConfig: Config = { language: 'javascript', content: '', }, + files: [], stylesheets: [], scripts: [], cssPreset: '', diff --git a/src/livecodes/config/validate-config.ts b/src/livecodes/config/validate-config.ts index 33bf2d9b3c..5d876b6d45 100644 --- a/src/livecodes/config/validate-config.ts +++ b/src/livecodes/config/validate-config.ts @@ -1,6 +1,15 @@ import { getLanguageByAlias, getLanguageEditorId } from '../languages'; -import type { Config, Editor, EditorId, Language, Tool, ToolsPaneStatus } from '../models'; -import { removeDuplicates } from '../utils'; +import type { + Config, + Editor, + EditorId, + Language, + MultiFileConfig, + SourceFile, + Tool, + ToolsPaneStatus, +} from '../models'; +import { getFileExtension, removeDuplicates } from '../utils'; import { defaultConfig } from './default-config'; export const validateConfig = (config: Partial): Partial => { @@ -45,7 +54,7 @@ export const validateConfig = (config: Partial): Partial => { const isFoldedLines = (x: any) => is(x, 'object') && (is(x.from, 'number') || is(x.to, 'number')); - const fixSfcLanguage = (lang: Language, editorId: EditorId) => + const fixSfcLanguage = (lang: Language | undefined, editorId: EditorId) => editorId !== 'markup' ? lang : lang === 'svelte' @@ -54,13 +63,16 @@ export const validateConfig = (config: Partial): Partial => { ? 'vue-app' : lang; + const getEditorDefaultLanguage = (editorId: EditorId) => defaultConfig[editorId].language; + const validateEditorProps = (x: Editor, editorId: EditorId): Editor => ({ - language: fixSfcLanguage( - getLanguageEditorId(fixSfcLanguage(x.language, editorId)) === editorId - ? getLanguageByAlias(x.language) || defaultConfig[editorId].language - : defaultConfig[editorId].language, - editorId, - ), + language: + fixSfcLanguage( + getLanguageEditorId(fixSfcLanguage(x.language, editorId)) === editorId + ? getLanguageByAlias(x.language) || getEditorDefaultLanguage(editorId) + : getEditorDefaultLanguage(editorId), + editorId, + ) || getEditorDefaultLanguage(editorId), ...(is(x.title, 'string') ? { title: x.title } : {}), ...(is(x.content, 'string') ? { content: x.content } : {}), ...(is(x.contentUrl, 'string') ? { contentUrl: x.contentUrl } : {}), @@ -75,6 +87,16 @@ export const validateConfig = (config: Partial): Partial => { ...(is(x.position, 'object') ? { position: x.position } : {}), }); + const validateFileProps = (x: SourceFile): Required | null => + is(x.filename, 'string') && x.filename.trim() !== '' + ? { + filename: x.filename, + content: is(x.content, 'string') ? x.content ?? '' : '', + language: getLanguageByAlias(x.language || getFileExtension(x.filename)) || 'html', + hidden: is(x.hidden, 'boolean') ? x.hidden ?? false : false, + } + : null; + const validateTestsProps = (x: Partial): Partial => ({ ...(x && is(x.language, 'string') ? { language: x.language } : {}), ...(x && is(x.content, 'string') ? { content: x.content } : {}), @@ -147,6 +169,13 @@ export const validateConfig = (config: Partial): Partial => { ...(is(config.script, 'object') ? { script: validateEditorProps(config.script as Editor, 'script') } : {}), + ...(is((config as MultiFileConfig).files, 'array', 'object') + ? { + files: (config as MultiFileConfig).files + .map((f) => validateFileProps(f)) + .filter((f) => f != null) as SourceFile[], + } + : {}), ...(is(config.tools, 'object') ? { tools: validateToolsProps(config.tools as Config['tools']) } : {}), diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index f09d75af7d..cc2963a573 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -576,15 +576,23 @@ const reloadEditors = async (config: Config) => { const updateEditors = async (editors: Editors, config: Config) => { const editorIds = Object.keys(editors) as Array; for (const editorId of editorIds) { - const language = getLanguageByAlias(config[editorId].language); + const language = getLanguageByAlias( + config[editorId as EditorId]?.language || + config.files.find((f) => f.filename === editorId)?.language, + ); if (language) { - await changeLanguage(language, config[editorId].content, true); + await changeLanguage( + language, + config[editorId as EditorId]?.content || + config.files.find((f) => f.filename === editorId)?.content, + true, + ); } const editor = editors[editorId]; if (config.foldRegions) { await editor.foldRegions?.(); } - const foldedLines = config[editorId].foldedLines; + const foldedLines = config[editorId as EditorId]?.foldedLines; if (foldedLines?.length) { await editor.foldLines?.(foldedLines); } @@ -693,10 +701,16 @@ const showMode = (mode?: Config['mode'], view?: Config['view']) => { window.dispatchEvent(new Event(customEvents.resizeEditor)); }; -const showEditor = (editorId: EditorId = 'markup', isUpdate = false) => { +const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = false) => { const config = getConfig(); const allHidden = editorIds.every((editor) => config[editor].hideTitle); - if (config[editorId].hideTitle && !allHidden) return; + if ( + (config[editorId as EditorId]?.hideTitle || + config.files.find((f) => f.filename === editorId)?.hidden) && + !allHidden + ) { + return; + } const titles = UI.getEditorTitles(); const editorIsVisible = () => Array.from(titles) @@ -731,7 +745,7 @@ const showEditor = (editorId: EditorId = 'markup', isUpdate = false) => { showEditorModeStatus(editorId); }; -const showEditorModeStatus = (editorId: EditorId) => { +const showEditorModeStatus = (editorId: EditorId | (string & {})) => { const editorStatusNodes = document.querySelectorAll( '#editor-status > span[data-status]', ); @@ -1976,7 +1990,7 @@ const getAllEditors = (): CodeEditor[] => ...Object.values(editors), toolsPane?.console?.getEditor?.(), toolsPane?.compiled?.getEditor?.(), - ].filter((x) => x != null); + ].filter((x) => x != null) as CodeEditor[]; const setTheme = (theme: Theme, editorTheme: Config['editorTheme']) => { const themes = ['light', 'dark']; diff --git a/src/livecodes/utils/utils.ts b/src/livecodes/utils/utils.ts index 5ae0cc8929..bc789ff484 100644 --- a/src/livecodes/utils/utils.ts +++ b/src/livecodes/utils/utils.ts @@ -230,7 +230,7 @@ export const loadStylesheet = (url: string, id?: string, insertBefore?: string) }; export const typedArrayToBuffer = /* @__PURE__ */ (array: Uint8Array): ArrayBuffer => - array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset); + array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset) as ArrayBuffer; export const getDate = /* @__PURE__ */ () => { let date = new Date(); diff --git a/src/sdk/index.ts b/src/sdk/index.ts index e8a5c4b2ac..360b19350b 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -3,7 +3,7 @@ import { compressToEncodedURIComponent } from 'lz-string'; import type { API, Code, - Config, + SDKConfig as Config, CustomEvents, EmbedOptions, Language, diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 8464e7cd2a..1bffcbf106 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -343,7 +343,7 @@ export interface EmbedOptions { * If supplied and is not an object or a valid URL, an error is thrown. * @default {} */ - config?: Partial | string; + config?: Partial | string; /** * If `true`, the playground is loaded in [headless mode](https://livecodes.io/docs/sdk/headless). @@ -405,6 +405,20 @@ export interface EmbedOptions { */ export interface Config extends ContentConfig, AppConfig, UserConfig {} +export interface SingleFileConfig + extends Omit, + AppConfig, + UserConfig {} + +export interface MultiFileConfig + extends Omit, + AppConfig, + UserConfig { + files: Array<{ filename: string } & Partial>; +} + +export type SDKConfig = SingleFileConfig | MultiFileConfig; + /** * The properties that define the content of the current [project](https://livecodes.io/docs/features/projects). */ @@ -450,9 +464,9 @@ export interface ContentConfig { * Selects the active editor to show. * * Defaults to the last used editor for user, otherwise `"markup"` - * @type {`"markup"` | `"style"` | `"script"` | `undefined`} + * @type {`"markup"` | `"style"` | `"script"` | `string` | `undefined`} */ - activeEditor: EditorId | undefined; + activeEditor: EditorId | (string & {}) | undefined; /** * List of enabled languages. @@ -485,6 +499,16 @@ export interface ContentConfig { */ script: Prettify; + /** + * List of source files. + */ + files: SourceFile[]; + /** + * The name of the main markup file. + * @default "index.html" + */ + mainFile?: string; + /** * List of URLs for [external stylesheets](https://livecodes.io/docs/features/external-resources) to add to the [result page](https://livecodes.io/docs/features/result). */ @@ -583,6 +607,30 @@ export interface ContentConfig { readonly version: string; } +export type MultiFileContentConfig = Pick< + ContentConfig, + | 'title' + | 'description' + | 'tags' + | 'activeEditor' + | 'files' + | 'mainFile' + | 'languages' + | 'processors' + | 'customSettings' + | 'imports' + | 'types' + | 'tests' + | 'version' +>; + +export interface SourceFile { + filename: string; + content: string; + language: Language; + hidden: boolean; +} + /** * These are properties that define how the app behaves. */ @@ -1173,6 +1221,7 @@ export interface EditorPosition { export type EditorId = 'markup' | 'style' | 'script'; export interface Editors { + [key: string]: CodeEditor; markup: CodeEditor; style: CodeEditor; script: CodeEditor; From 86dbd99a1800de86129e81820b934f45f1e46b80 Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sat, 8 Nov 2025 08:25:19 +0200 Subject: [PATCH 002/143] add multi-file UI --- src/livecodes/UI/create-language-menus.ts | 84 ++++++++- src/livecodes/config/default-config.ts | 21 ++- src/livecodes/config/validate-config.ts | 4 +- src/livecodes/core.ts | 161 ++++++++++++++---- .../i18n/locales/en/translation.lokalise.json | 4 + src/livecodes/i18n/locales/en/translation.ts | 3 + src/livecodes/styles/app.scss | 25 ++- src/sdk/models.ts | 1 + 8 files changed, 263 insertions(+), 40 deletions(-) diff --git a/src/livecodes/UI/create-language-menus.ts b/src/livecodes/UI/create-language-menus.ts index 0b64986cfd..697a6cd9a0 100644 --- a/src/livecodes/UI/create-language-menus.ts +++ b/src/livecodes/UI/create-language-menus.ts @@ -19,7 +19,7 @@ export const createLanguageMenus = ( registerMenuButton: (menu: HTMLElement, button: HTMLElement) => void, ) => { const editorIds: EditorId[] = ['markup', 'style', 'script']; - const rootList = document.createElement('ul'); + const rootList = document.createElement('div'); document.querySelector('#select-editor')?.appendChild(rootList); let editorsNumber = editorIds.length; @@ -30,6 +30,7 @@ export const createLanguageMenus = ( editorSelector.id = editorId + '-selector'; editorSelector.classList.add('editor-title', 'noselect'); editorSelector.dataset.editor = editorId; + editorSelector.dataset.singleFile = 'true'; editorSelector.innerHTML = ` void; + deleteFile: (title: string) => void; + isMainFile: boolean; +}) => { + const selector = document.querySelector(`.editor-title[data-editor="${title}"]`); + if (selector) return; + const editorSelector = document.createElement('a'); + editorSelector.href = '#'; + editorSelector.classList.add('editor-title', 'noselect'); + editorSelector.dataset.editor = title; + editorSelector.dataset.multiFile = 'true'; + + const label = document.createElement('span'); + label.innerHTML = title; + editorSelector.appendChild(label); + + if (!isMainFile) { + const deleteButton = document.createElement('button'); + deleteButton.classList.add('delete-file-button'); + deleteButton.innerHTML = '×'; + deleteButton.addEventListener('click', () => { + if ( + confirm( + window.deps.translateString('core.confirm.deleteFile', 'Delete file: {{title}}?', { + title, + }), + ) + ) { + deleteFile(label.innerText); + editorSelector.remove(); + } + }); + editorSelector.appendChild(deleteButton); + } + + const selectAll = (element: HTMLElement) => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }; + + const accept = () => { + editFileName(label.innerText); + label.contentEditable = 'false'; + window.removeEventListener('click', onClick); + window.removeEventListener('keydown', onEnter); + isEditing = false; + }; + const onClick = (event: MouseEvent) => { + if (event.target !== editorSelector.querySelector('span')) { + accept(); + } + }; + const onEnter = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + accept(); + } + }; + + let isEditing = false; + editorSelector.ondblclick = () => { + isEditing = true; + label.contentEditable = 'true'; + requestAnimationFrame(() => label.focus()); + selectAll(label); + window.addEventListener('click', onClick, { capture: true }); + window.addEventListener('keydown', onEnter); + }; + + document.querySelector('#select-editor > div')?.appendChild(editorSelector); +}; + export const createProcessorItem = (processor: { name: string; title: string }) => { const processorItem = document.createElement('li'); processorItem.classList.add('language-item', 'processor-item'); diff --git a/src/livecodes/config/default-config.ts b/src/livecodes/config/default-config.ts index fc53c9ff7a..8550af9528 100644 --- a/src/livecodes/config/default-config.ts +++ b/src/livecodes/config/default-config.ts @@ -37,7 +37,26 @@ export const defaultConfig: Config = { language: 'javascript', content: '', }, - files: [], + files: [ + { + filename: 'index.html', + content: '

hello world

', + language: 'html', + hidden: false, + }, + { + filename: 'style.css', + content: 'body{ color: blue; }', + language: 'css', + hidden: false, + }, + { + filename: 'script.js', + content: 'console.log("hi");', + language: 'javascript', + hidden: false, + }, + ], stylesheets: [], scripts: [], cssPreset: '', diff --git a/src/livecodes/config/validate-config.ts b/src/livecodes/config/validate-config.ts index 5d876b6d45..425756716c 100644 --- a/src/livecodes/config/validate-config.ts +++ b/src/livecodes/config/validate-config.ts @@ -87,8 +87,8 @@ export const validateConfig = (config: Partial): Partial => { ...(is(x.position, 'object') ? { position: x.position } : {}), }); - const validateFileProps = (x: SourceFile): Required | null => - is(x.filename, 'string') && x.filename.trim() !== '' + const validateFileProps = (x: Partial): Required | null => + x.filename && is(x.filename, 'string') && x.filename.includes('.') ? { filename: x.filename, content: is(x.content, 'string') ? x.content ?? '' : '', diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index cc2963a573..7d7cea24ae 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -20,7 +20,11 @@ import type { BroadcastResponseError, } from './UI/broadcast'; import { getCommandMenuActions } from './UI/command-menu-actions'; -import { createLanguageMenus, createProcessorItem } from './UI/create-language-menus'; +import { + createLanguageMenus, + createMultiFileEditorTab, + createProcessorItem, +} from './UI/create-language-menus'; import { createModal } from './UI/modal'; import * as UI from './UI/selectors'; import { themeColors } from './UI/theme-colors'; @@ -39,6 +43,7 @@ import { upgradeAndValidate, } from './config'; import { createCustomEditors, createEditor, getFontFamily } from './editor'; +import { createFakeEditor } from './editor/fake-editor'; import { hasJsx } from './editor/ts-compiler-options'; import { createEventsManager, createPub } from './events'; import { customEvents } from './events/custom-events'; @@ -462,6 +467,50 @@ const createCopyButtons = () => { }); }; +const createEditorUI = (title: string) => { + const editorsElement = UI.getEditorsElement(); + let container = editorsElement.querySelector(`[data-editor-id="${title}"]`); + if (!container) { + container = document.createElement('div'); + container.setAttribute('data-editor-id', title); + container.classList.add('editor'); + editorsElement.insertBefore(container, UI.getEditorToolbar()); + } + const editFileName = (filename: string) => { + const config = getConfig(); + setConfig({ + ...config, + files: config.files.map((f) => ({ + ...f, + filename: f.filename === title ? filename : f.filename, + })), + }); + if (config.autoupdate) { + run(); + } + }; + const deleteFile = (filename: string) => { + const config = getConfig(); + setConfig({ + ...config, + files: config.files.filter((f) => f.filename !== filename), + }); + if (config.autoupdate) { + run(); + } + }; + + const config = getConfig(); + const isMainFile = config.mainFile + ? config.mainFile === title + : title === 'index.html' || + config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename === title; + + createMultiFileEditorTab({ title, editFileName, deleteFile, isMainFile }); + + return container; +}; + const createEditors = async (config: Config) => { let isReload = false; if (editors) { @@ -479,7 +528,9 @@ const createEditors = async (config: Config) => { ? 'style' : config.script.content ? 'script' - : 'markup'); + : config.files?.length + ? config.files[0].filename + : 'markup'); const baseOptions = { baseUrl, @@ -527,27 +578,51 @@ const createEditors = async (config: Config) => { value: languageIsEnabled(config.script.language, config) ? config.script.content || '' : '', }; - const markupEditor = await createEditor(markupOptions); - const styleEditor = await createEditor(styleOptions); - const scriptEditor = await createEditor(scriptOptions); - - currentEditorConfig = { ...getEditorConfig(config), ...getFormatterConfig(config) }; + if (config.files?.length) { + editorLanguages = { markup: 'html', style: 'css', script: 'javascript' }; + editors = { + markup: createFakeEditor(markupOptions), + style: createFakeEditor(styleOptions), + script: createFakeEditor(scriptOptions), + }; - setEditorTitle('markup', markupOptions.language); - setEditorTitle('style', styleOptions.language); - setEditorTitle('script', scriptOptions.language); + for (const file of config.files) { + const editorId = file.filename; + const container = createEditorUI(file.filename); + const editorOptions = { + ...baseOptions, + container, + editorId, + language: file.language, + value: file.content, + }; + const editor = await createEditor(editorOptions); + editorLanguages[editorId] = file.language; + editors[editorId] = editor; + } + } else { + const markupEditor = await createEditor(markupOptions); + const styleEditor = await createEditor(styleOptions); + const scriptEditor = await createEditor(scriptOptions); + + setEditorTitle('markup', markupOptions.language); + setEditorTitle('style', styleOptions.language); + setEditorTitle('script', scriptOptions.language); + + editorLanguages = { + markup: markupOptions.language, + style: styleOptions.language, + script: scriptOptions.language, + }; - editorLanguages = { - markup: markupOptions.language, - style: styleOptions.language, - script: scriptOptions.language, - }; + editors = { + markup: markupEditor, + style: styleEditor, + script: scriptEditor, + }; + } - editors = { - markup: markupEditor, - style: styleEditor, - script: scriptEditor, - }; + currentEditorConfig = { ...getEditorConfig(config), ...getFormatterConfig(config) }; (Object.keys(editors) as EditorId[]).forEach((editorId) => { const language = editorLanguages?.[editorId] || 'html'; @@ -703,31 +778,30 @@ const showMode = (mode?: Config['mode'], view?: Config['view']) => { const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = false) => { const config = getConfig(); - const allHidden = editorIds.every((editor) => config[editor].hideTitle); if ( - (config[editorId as EditorId]?.hideTitle || - config.files.find((f) => f.filename === editorId)?.hidden) && - !allHidden + config[editorId as EditorId]?.hideTitle || + config.files.find((f) => f.filename === editorId)?.hidden ) { return; } - const titles = UI.getEditorTitles(); - const editorIsVisible = () => - Array.from(titles) - .map((title) => title.dataset.editor) - .includes(editorId); + const titles = [...UI.getEditorTitles()]; + const editorIsVisible = () => titles.map((title) => title.dataset.editor).includes(editorId); if (!editorIsVisible()) { // select first visible editor instead - editorId = (titles[0].dataset.editor as EditorId) || 'markup'; + editorId = (titles[0]?.dataset.editor as EditorId) || 'markup'; } titles.forEach((selector) => selector.classList.remove('active')); - const activeTitle = document.getElementById(editorId + '-selector'); + const activeTitle = titles.find((title) => title.dataset.editor === editorId); activeTitle?.classList.add('active'); - const editorDivs = UI.getEditorDivs(); + const editorDivs = [...UI.getEditorDivs()]; editorDivs.forEach((editor) => (editor.style.display = 'none')); - const activeEditor = document.getElementById(editorId) as HTMLElement; - activeEditor.style.display = 'block'; - activeEditor.style.visibility = 'visible'; + const activeEditor = editorDivs.find( + (editor) => editor.id === editorId || editor.dataset.editorId === editorId, + ) as HTMLElement; + if (activeEditor) { + activeEditor.style.display = 'block'; + activeEditor.style.visibility = 'visible'; + } if (!isEmbed && !isUpdate) { editors[editorId]?.focus(); } @@ -810,6 +884,22 @@ const configureEditorTools = (language: Language) => { return true; }; +const configureMultiFile = (config: Config) => { + const editorTabsContainer = document.querySelector('#select-editor > div')!; + const singleFileTabs = [ + ...editorTabsContainer.querySelectorAll('[data-single-file]'), + ]; + const multiFileTabs = [...editorTabsContainer.querySelectorAll('[data-multi-file]')]; + const isMultiFile = config.files.length > 0; + singleFileTabs.forEach((tab) => { + tab.classList.toggle('hidden', isMultiFile); + }); + if (!isMultiFile) { + multiFileTabs.forEach((tab) => tab.remove()); + UI.getEditorDivs().forEach((editor) => editor.remove()); + } +}; + const addPhpToken = (code: string) => code.includes(', reload = false, oldConfig } phpHelper({ editor: editors.script }); setLoading(true); + configureMultiFile(combinedConfig); await setActiveEditor(combinedConfig); if (!isEmbed) { diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index 83f8a79203..ed5511e826 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -940,6 +940,10 @@ "notes": "", "translation": "Loading {{lang}}. This may take a while!" }, + "core.confirm.deleteFile": { + "notes": "", + "translation": "Delete file: {{title}}?" + }, "core.copy.copied": { "notes": "", "translation": "Code copied to clipboard" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index 9e7810b549..9c6e0aed6f 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -379,6 +379,9 @@ const translation = { hint: 'Change Language', message: 'Loading {{lang}}. This may take a while!', }, + confirm: { + deleteFile: 'Delete file: {{title}}?', + }, copy: { copied: 'Code copied to clipboard', copiedAsDataURL: 'Code copied as data URL', diff --git a/src/livecodes/styles/app.scss b/src/livecodes/styles/app.scss index 5ef4a2e49a..ed54fac6ad 100644 --- a/src/livecodes/styles/app.scss +++ b/src/livecodes/styles/app.scss @@ -1311,7 +1311,7 @@ a.tools-pane-title { margin: 0 0 calc(-1 * var(--s2)) var(--s8); position: relative; - > ul { + > div { display: flex; height: 100%; } @@ -1398,6 +1398,29 @@ a.editor-title { display: block; } } + + &[data-multi-file='true'] { + width: fit-content; + + > span { + padding: 0 4px; + } + } + + .delete-file-button { + background: transparent; + border: 0; + border-radius: 50%; + color: var(--editor-title-color); + cursor: pointer; + height: 12px; + margin-inline-end: -3px; + margin-inline-start: 3px; + + &:hover { + color: var(--editor-title-active); + } + } } @import 'inc-menu'; diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 1bffcbf106..c4c122f2f7 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -1227,6 +1227,7 @@ export interface Editors { script: CodeEditor; } export interface EditorLanguages { + [key: string]: Language; markup: Language; style: Language; script: Language; From d48fea1c5a2c3a1bfd4bad817a381f59640f65e9 Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sat, 8 Nov 2025 08:25:19 +0200 Subject: [PATCH 003/143] multi-file result page --- src/livecodes/cache/cache.ts | 2 + .../compiler/__tests__/import-map.spec.ts | 135 ++++++ src/livecodes/compiler/import-map.ts | 84 +++- src/livecodes/config/config.ts | 2 + src/livecodes/config/default-config.ts | 50 +- src/livecodes/core.ts | 197 ++++++-- .../processor-tailwindcss-compiler.ts | 2 +- src/livecodes/models.ts | 6 + .../result/multi-file-result-page.ts | 442 ++++++++++++++++++ src/livecodes/result/result-page.ts | 8 +- src/sdk/models.ts | 2 + 11 files changed, 869 insertions(+), 61 deletions(-) create mode 100644 src/livecodes/result/multi-file-result-page.ts diff --git a/src/livecodes/cache/cache.ts b/src/livecodes/cache/cache.ts index e138d74dad..7c5ebd5004 100644 --- a/src/livecodes/cache/cache.ts +++ b/src/livecodes/cache/cache.ts @@ -8,6 +8,8 @@ const initialCache: Cache = { style: { ...defaultContentConfig.style, compiled: '', modified: '' }, script: { ...defaultContentConfig.script, compiled: '', modified: '' }, tests: { language: 'javascript', ...defaultContentConfig.tests, compiled: '' }, + files: [], + mainFile: undefined, result: '', styleOnlyUpdate: false, }; diff --git a/src/livecodes/compiler/__tests__/import-map.spec.ts b/src/livecodes/compiler/__tests__/import-map.spec.ts index 5b1209ada8..c5634dc795 100644 --- a/src/livecodes/compiler/__tests__/import-map.spec.ts +++ b/src/livecodes/compiler/__tests__/import-map.spec.ts @@ -1,9 +1,11 @@ import type { Config } from '../../models'; import { createImportMap, + getStyleImports, hasStyleImports, replaceImports, replaceStyleImports, + resolvePath, } from '../import-map'; describe('Import map', () => { @@ -180,4 +182,137 @@ body { const processedCode = replaceStyleImports(code); expect(processedCode).toEqual(expectedCode); }); + + test('get style imports', () => { + const code = ` +@import "github-markdown-css-1"; +@import "jsdelivr:github-markdown-css-2"; +@import "https://cdn.jsdelivr.net/npm/github-markdown-css-3"; +@import 'https://cdn.jsdelivr.net/npm/github-markdown-css-4'; +@import url("https://cdn.jsdelivr.net/npm/github-markdown-css-5"); +@import url('https://cdn.jsdelivr.net/npm/github-markdown-css-6'); +@import url(https://cdn.jsdelivr.net/npm/github-markdown-css-7); +@import url(https://cdn.jsdelivr.net/npm/github-markdown-css-8) print; +@import "github-markdown-css-9" print; +@import "github-markdown-css-10" screen and (orientation:landscape); +@import "github-markdown-css-11" layer(layer-name); +@import "github-markdown-css-12" layer(layer-name) supports(supports-condition); +@import "github-markdown-css-13" layer(layer-name) supports(supports-condition) list-of-media-queries; +@import "github-markdown-css-14" layer(layer-name) list-of-media-queries; +@import "github-markdown-css-15" supports(supports-condition); +@import "github-markdown-css-16" supports(supports-condition) list-of-media-queries; +@import "github-markdown-css-17" list-of-media-queries; +@import "./styles.css"; + +body { + color: blue; +} `; + + const expectedImports = [ + 'github-markdown-css-1', + 'jsdelivr:github-markdown-css-2', + 'https://cdn.jsdelivr.net/npm/github-markdown-css-3', + 'https://cdn.jsdelivr.net/npm/github-markdown-css-4', + 'https://cdn.jsdelivr.net/npm/github-markdown-css-5', + 'https://cdn.jsdelivr.net/npm/github-markdown-css-6', + 'https://cdn.jsdelivr.net/npm/github-markdown-css-7', + 'https://cdn.jsdelivr.net/npm/github-markdown-css-8', + 'github-markdown-css-9', + 'github-markdown-css-10', + 'github-markdown-css-11', + 'github-markdown-css-12', + 'github-markdown-css-13', + 'github-markdown-css-14', + 'github-markdown-css-15', + 'github-markdown-css-16', + 'github-markdown-css-17', + './styles.css', + ]; + + const imports = getStyleImports(code); + expect(imports).toEqual(expectedImports); + }); + + test('get style imports (deduped)', () => { + const code = ` +@import "github-markdown-css"; +@import "jsdelivr:github-markdown-css"; +@import "https://cdn.jsdelivr.net/npm/github-markdown-css"; +@import 'https://cdn.jsdelivr.net/npm/github-markdown-css'; +@import url("https://cdn.jsdelivr.net/npm/github-markdown-css"); +@import url('https://cdn.jsdelivr.net/npm/github-markdown-css'); +@import url(https://cdn.jsdelivr.net/npm/github-markdown-css); +@import url(https://cdn.jsdelivr.net/npm/github-markdown-css) print; +@import "github-markdown-css" print; +@import "github-markdown-css" screen and (orientation:landscape); +@import "github-markdown-css" layer(layer-name); +@import "github-markdown-css" layer(layer-name) supports(supports-condition); +@import "github-markdown-css" layer(layer-name) supports(supports-condition) list-of-media-queries; +@import "github-markdown-css" layer(layer-name) list-of-media-queries; +@import "github-markdown-css" supports(supports-condition); +@import "github-markdown-css" supports(supports-condition) list-of-media-queries; +@import "github-markdown-css" list-of-media-queries; +@import "./styles.css"; + +body { + color: blue; +} `; + + const expectedImports = [ + 'github-markdown-css', + 'jsdelivr:github-markdown-css', + 'https://cdn.jsdelivr.net/npm/github-markdown-css', + './styles.css', + ]; + + const imports = getStyleImports(code); + expect(imports).toEqual(expectedImports); + }); + + test('resolve path', () => { + expect(resolvePath('https://someurl/path', './script.js')).toEqual('https://someurl/path'); + expect(resolvePath('https://someurl/path', 'script.js')).toEqual('https://someurl/path'); + expect(resolvePath('https://someurl/path', './deep/script.js')).toEqual('https://someurl/path'); + expect(resolvePath('https://someurl/path', 'deep/script.js')).toEqual('https://someurl/path'); + expect(resolvePath('https://someurl/path')).toEqual('https://someurl/path'); + + const dataUrl = + 'data:text/javascript;charset=UTF-8;base64,Y29uc29sZS5sb2coIkhlbGxvLCBXb3JsZCEiKTs='; + expect(resolvePath(dataUrl, './script.js')).toEqual(dataUrl); + expect(resolvePath(dataUrl, 'script.js')).toEqual(dataUrl); + expect(resolvePath(dataUrl, './deep/script.js')).toEqual(dataUrl); + expect(resolvePath(dataUrl, 'deep/script.js')).toEqual(dataUrl); + expect(resolvePath(dataUrl)).toEqual(dataUrl); + + expect(resolvePath('my-lib', './script.js')).toEqual('my-lib'); + expect(resolvePath('my-lib', 'script.js')).toEqual('my-lib'); + expect(resolvePath('my-lib', './deep/script.js')).toEqual('my-lib'); + expect(resolvePath('my-lib', 'deep/script.js')).toEqual('my-lib'); + expect(resolvePath('my-lib')).toEqual('my-lib'); + + expect(resolvePath('/utils.js', './script.js')).toEqual('./utils.js'); + expect(resolvePath('/utils.js', 'script.js')).toEqual('./utils.js'); + expect(resolvePath('/utils.js', './deep/script.js')).toEqual('./utils.js'); + expect(resolvePath('/utils.js', 'deep/script.js')).toEqual('./utils.js'); + expect(resolvePath('/utils.js')).toEqual('./utils.js'); + + expect(resolvePath('./utils.js', './script.js')).toEqual('./utils.js'); + expect(resolvePath('./utils.js', 'script.js')).toEqual('./utils.js'); + expect(resolvePath('./utils.js', './deep/script.js')).toEqual('./deep/utils.js'); + expect(resolvePath('./utils.js', 'deep/script.js')).toEqual('./deep/utils.js'); + expect(resolvePath('./utils.js')).toEqual('./utils.js'); + + expect(resolvePath('../utils.js', './script.js')).toEqual('./utils.js'); + expect(resolvePath('../utils.js', 'script.js')).toEqual('./utils.js'); + expect(resolvePath('../utils.js', './deep/script.js')).toEqual('./utils.js'); + expect(resolvePath('../utils.js', 'deep/script.js')).toEqual('./utils.js'); + expect(resolvePath('../utils.js')).toEqual('./utils.js'); + + expect(resolvePath('../path/utils.js', './script.js')).toEqual('./path/utils.js'); + expect(resolvePath('../path/utils.js', 'script.js')).toEqual('./path/utils.js'); + expect(resolvePath('../path/utils.js', './deep/script.js')).toEqual('./path/utils.js'); + expect(resolvePath('../path/utils.js', 'deep/script.js')).toEqual('./path/utils.js'); + expect(resolvePath('../path/utils.js', 'very/deep/script.js')).toEqual('./very/path/utils.js'); + expect(resolvePath('../path/utils.js')).toEqual('./path/utils.js'); + }); }); diff --git a/src/livecodes/compiler/import-map.ts b/src/livecodes/compiler/import-map.ts index a915cd0040..27c1ec9cf8 100644 --- a/src/livecodes/compiler/import-map.ts +++ b/src/livecodes/compiler/import-map.ts @@ -11,25 +11,29 @@ import { } from '../utils/utils'; import { compileInCompiler } from './compile-in-compiler'; -// https://regexr.com/8a2j7 +// https://regexr.com/8hs1i export const importsPattern = - /(import\s+?(?:(?:(?:[\w*\s{},\$]*)\s+from\s+?)|))((?:".*?")|(?:'.*?'))([\s]*?(?:;|$|))/g; + /((?:im|ex)port\s+?(?:(?:(?:[\w*\s{},\$]*)\s+from\s+?)|))((?:".*?")|(?:'.*?'))([\s]*?(?:;|$|))/g; // https://regexr.com/8a2ja export const dynamicImportsPattern = /(import\s*?\(\s*?((?:".*?")|(?:'.*?'))\s*?\))/g; export const getImports = (code: string, removeSpecifier = false) => - [ - ...[...removeComments(code).matchAll(new RegExp(importsPattern))], - ...[...removeComments(code).matchAll(new RegExp(dynamicImportsPattern))], - ] - .map((arr) => arr[2].replace(/"/g, '').replace(/'/g, '')) - .map((mod) => { - if (!removeSpecifier || !isBare(mod) || !mod.includes(':')) { - return mod; - } - return mod.split(':')[1]; - }); + Array.from( + new Set( + [ + ...[...removeComments(code).matchAll(new RegExp(importsPattern))], + ...[...removeComments(code).matchAll(new RegExp(dynamicImportsPattern))], + ] + .map((arr) => arr[2].replace(/"/g, '').replace(/'/g, '')) + .map((mod) => { + if (!removeSpecifier || !isBare(mod) || !mod.includes(':')) { + return mod; + } + return mod.split(':')[1]; + }), + ), + ); const needsBundler = /* @__PURE__ */ (mod: string) => !mod.startsWith('https://deno.bundlejs.com/') && @@ -61,6 +65,9 @@ const isStylesheet = /* @__PURE__ */ (mod: string) => mod.endsWith('.styl')) && !mod.startsWith('./style'); +const isRelative = /* @__PURE__ */ (mod: string) => + mod.startsWith('./') || mod.startsWith('../') || mod.startsWith('/'); + export const findImportMapKey = /* @__PURE__ */ (mod: string, importmap: Record) => Object.keys(importmap).find((key) => key === mod || mod.startsWith(key + '/')); @@ -71,7 +78,11 @@ export const createImportMap = ( ) => getImports(code) .map((libName) => { - if ((!needsBundler(libName) && !isBare(libName)) || isStylesheet(libName)) { + if ( + isRelative(libName) || + (!needsBundler(libName) && !isBare(libName)) || + isStylesheet(libName) + ) { return {}; } else { const imports = { ...config.imports, ...config.customSettings?.imports }; @@ -116,12 +127,13 @@ export const replaceImports = ( { importMap, external }: { importMap?: Record; external?: string } = {}, ) => { importMap = importMap || createImportMap(code, config, { external }); - return code.replace(new RegExp(importsPattern), (statement) => { + + const replaceFn = (pattern: RegExp) => (statement: string) => { if (!importMap) { return statement; } const libName = statement - .replace(new RegExp(importsPattern), '$2') + .replace(new RegExp(pattern), '$2') .replace(/"/g, '') .replace(/'/g, ''); @@ -130,7 +142,11 @@ export const replaceImports = ( return statement; } return statement.replace(key, importMap[key]); - }); + }; + + return code + .replace(new RegExp(importsPattern), replaceFn(importsPattern)) + .replace(new RegExp(dynamicImportsPattern), replaceFn(dynamicImportsPattern)); }; export const isScriptImport = (mod: string) => @@ -236,12 +252,40 @@ export const removeImports = (code: string, mods: string[]) => return mods.includes(libName) ? '' : statement; }); +export const resolvePath = (path: string, currentPath = './index.html') => { + if (!path.startsWith('.') && !path.startsWith('/')) return path; + const baseUrl = 'https://localhost'; + const basePath = new URL(currentPath, baseUrl).href; + try { + const url = new URL(path, basePath).href; + return url.replace(baseUrl, '.'); + } catch { + return null; + } +}; + +// https://regexr.com/8hrlf export const styleimportsPattern = - /(?:@import\s+?)((?:".*?")|(?:'.*?')|(?:url\('.*?'\))|(?:url\(".*?"\)))(.*)?;/g; + /(?:@import\s+?)((?:".*?")|(?:'.*?')|(?:url\('?.*?'?\))|(?:url\(".*?"\)))(.*)?;/g; export const hasStyleImports = (code: string) => new RegExp(styleimportsPattern).test(code); -export const replaceStyleImports = (code: string, exceptions?: string[] | RegExp[]) => +export const getStyleImports = (code: string) => + Array.from( + new Set( + [...removeComments(code).matchAll(new RegExp(styleimportsPattern))].map((arr) => + arr[1].replace(/url\(/g, '').replace(/\)/g, '').replace(/"/g, '').replace(/'/g, ''), + ), + ), + ); + +export const replaceStyleImports = ( + code: string, + { + exceptions, + stylesImportMap, + }: { exceptions?: string[] | RegExp[]; stylesImportMap?: Record } = {}, +) => code.replace(new RegExp(styleimportsPattern), (statement, match, media) => { if ( exceptions?.some( @@ -257,7 +301,7 @@ export const replaceStyleImports = (code: string, exceptions?: string[] | RegExp .replace(/'/g, '') .replace(/url\(/g, '') .replace(/\)/g, ''); - const modified = '@import "' + modulesService.getUrl(url) + '";'; + const modified = '@import "' + stylesImportMap?.[url] || modulesService.getUrl(url) + '";'; const mediaQuery = media?.trim(); return !isBare(url) ? statement diff --git a/src/livecodes/config/config.ts b/src/livecodes/config/config.ts index 8aa0ee2057..764807224e 100644 --- a/src/livecodes/config/config.ts +++ b/src/livecodes/config/config.ts @@ -32,6 +32,8 @@ export const getContentConfig = (config: Config | ContentConfig): ContentConfig markup: config.markup, style: config.style, script: config.script, + files: config.files, + mainFile: config.mainFile, stylesheets: config.stylesheets, scripts: config.scripts, cssPreset: config.cssPreset, diff --git a/src/livecodes/config/default-config.ts b/src/livecodes/config/default-config.ts index 8550af9528..803f6685d8 100644 --- a/src/livecodes/config/default-config.ts +++ b/src/livecodes/config/default-config.ts @@ -40,20 +40,58 @@ export const defaultConfig: Config = { files: [ { filename: 'index.html', - content: '

hello world

', + content: `

hello world

+ + + +`, language: 'html', hidden: false, }, { - filename: 'style.css', - content: 'body{ color: blue; }', + filename: 'styles.css', + content: `@import "./middle.css"; +`, + language: 'css', + hidden: false, + }, + { + filename: 'middle.css', + content: `@import "./colors.css"; +`, + language: 'css', + hidden: false, + }, + { + filename: 'colors.css', + content: `h1 { + font-family: Arial, Helvetica, sans-serif; + color: red; +} +`, language: 'css', hidden: false, }, { - filename: 'script.js', - content: 'console.log("hi");', - language: 'javascript', + filename: 'script.ts', + content: `import { v4 } from 'uuid'; + import { msg } from './middle.ts'; + + console.log(v4()); + console.log(msg);`, + language: 'typescript', + hidden: false, + }, + { + filename: 'middle.ts', + content: `export { msg } from './message.ts';`, + language: 'typescript', + hidden: false, + }, + { + filename: 'message.ts', + content: `export const msg: string = 'Hello!';`, + language: 'typescript', hidden: false, }, ], diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index 7d7cea24ae..0ef70a4e9a 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -118,6 +118,7 @@ import type { SDKEvent, Screen, ShareData, + SourceFile, Template, TestResult, Theme, @@ -129,6 +130,7 @@ import type { } from './models'; import { createNotifications } from './notifications'; import { cleanResultFromDev, createResultPage } from './result'; +import { createMultiFileResultPage } from './result/multi-file-result-page'; import { createAuthService, getAppCDN, sandboxService, shareService } from './services'; import type { GitHubFile } from './services/github'; import { permanentUrlService } from './services/permanent-url'; @@ -244,7 +246,7 @@ const sdkWatchers = { destroy: createPub(), } as const satisfies Record>>; -const getEditorLanguage = (editorId: EditorId = 'markup') => editorLanguages?.[editorId]; +const getEditorLanguage = (editorId = 'markup') => editorLanguages?.[editorId]; const getEditorLanguages = () => Object.values(editorLanguages || {}); const getActiveEditor = () => editors[getConfig().activeEditor || 'markup']; const setActiveEditor = async (config: Config) => showEditor(config.activeEditor); @@ -1021,13 +1023,23 @@ const getResultPage = async ({ sourceEditor = undefined as EditorId | undefined, forExport = false, template = resultTemplate, - singleFile = true, + singleFileResult = true, runTests = false, }) => { updateConfig(); const config = getConfig(); const contentConfig = getContentConfig(config); + if (config.files.length > 0) { + return getMultiFileResultPage({ + sourceEditor, + forExport, + template, + singleFileResult, + runTests, + }); + } + const getContent = (editor: Partial | undefined) => { const editorContent = editor?.content ?? ''; const hiddenContent = editor?.hiddenContent ?? ''; @@ -1094,17 +1106,10 @@ const getResultPage = async ({ }); const compiledScript = scriptCompileResult.code; - let compileInfo: CompileInfo = { - ...markupCompileResult.info, - ...scriptCompileResult.info, - importedContent: - (markupCompileResult.info.importedContent || '') + - (scriptCompileResult.info.importedContent || ''), - imports: { - ...scriptCompileResult.info.imports, - ...markupCompileResult.info.imports, - }, - }; + let compileInfo: CompileInfo = mergeCompileInfo( + markupCompileResult.info, + scriptCompileResult.info, + ); const [styleCompileResult, testsCompileResult] = await Promise.all([ compiler.compile(styleContent, styleLanguage, config, { @@ -1120,10 +1125,7 @@ const getResultPage = async ({ ]); const [compiledStyle, compiledTests] = [styleCompileResult, testsCompileResult].map((result) => { const { code, info } = getCompileResult(result); - compileInfo = { - ...compileInfo, - ...info, - }; + compileInfo = mergeCompileInfo(compileInfo, info); return code; }); @@ -1155,11 +1157,13 @@ const getResultPage = async ({ ...contentConfig.tests, compiled: compiledTests, }, + files: [], + mainFile: undefined, }; compiledCode.script.modified = compiledCode.script.compiled; if (scriptType != null && scriptType !== 'module') { - singleFile = true; + singleFileResult = true; } const result = await createResultPage({ @@ -1168,7 +1172,7 @@ const getResultPage = async ({ forExport, template, baseUrl, - singleFile, + singleFileResult, runTests, compileInfo, }); @@ -1183,7 +1187,7 @@ const getResultPage = async ({ logError(scriptLanguage, scriptCompileResult.info?.errors); logError(testsLanguage, getCompileResult(testsCompileResult).info?.errors); - if (singleFile) { + if (singleFileResult) { setCache({ ...getCache(), ...compiledCode, @@ -1202,6 +1206,135 @@ const getResultPage = async ({ return result; }; +const getMultiFileResultPage = async ({ + sourceEditor = undefined as EditorId | undefined, + forExport = false, + template = resultTemplate, + singleFileResult = true, + runTests = false, +}) => { + const config = getConfig(); + const cache = getCache(); + + const forceCompileStyles = [...config.processors, ...cache.processors].some((name) => + processors.find((p) => name === p.name && p.needsHTML), + ); + + const testsNotChanged = + (!config.tests?.content && !cache.tests?.content) || + (config.tests?.language === cache.tests?.language && + config.tests?.content === cache.tests?.content && + cache.tests?.compiled); + + if (testsNotChanged && !config.tests?.content) { + toolsPane?.tests?.showResults({ results: [] }); + } + + const compiledFiles: Array = []; + let compileInfo: CompileInfo = {}; + const errors: Array<{ language: Language; filename: string; errors: string[] }> = []; + + for (const file of config.files) { + const { filename, language, content } = file; + if (getLanguageEditorId(language) === 'style') continue; + const compileResult = await compiler.compile(content, language, config, { compileInfo }); + compiledFiles.push({ ...file, compiled: compileResult.code }); + compileInfo = mergeCompileInfo(compileInfo, compileResult.info); + if (compileInfo.errors?.length) { + errors.push({ language, filename, errors: compileInfo.errors || [] }); + } + } + + const compiledContent = compiledFiles.map((file) => file.compiled).join('\n'); + for (const file of config.files) { + const { filename, language, content } = file; + if (getLanguageEditorId(language) !== 'style') continue; + const compileResult = await compiler.compile(content, language, config, { + compileInfo, + forceCompile: forceCompileStyles, + html: `${compiledContent}`, + }); + compiledFiles.push({ ...file, compiled: compileResult.code }); + compileInfo = mergeCompileInfo(compileInfo, compileResult.info); + if (compileInfo.errors?.length) { + errors.push({ language, filename, errors: compileInfo.errors || [] }); + } + } + + const testsCompileResult = await (runTests + ? testsNotChanged + ? Promise.resolve(getCache().tests?.compiled || '') + : compiler.compile( + config.tests?.content || '', + config.tests?.language || 'javascript', + config, + {}, + ) + : Promise.resolve(getCompileResult(getCache().tests?.compiled || ''))); + const { code: compiledTests, info: testsCompileInfo } = getCompileResult(testsCompileResult); + if (testsCompileInfo?.errors?.length) { + errors.push({ + language: config.tests?.language || 'javascript', + filename: 'tests', + errors: testsCompileInfo.errors || [], + }); + } + + // TODO: handle modified HTML + + const result = await createMultiFileResultPage({ + compiledFiles, + compiledTests, + config, + forExport, + template, + baseUrl, + singleFileResult, + runTests, + compileInfo, + }); + + const styleOnlyUpdate = sourceEditor === 'style' && !compileInfo.cssModules; + + const logError = (language: Language, errors: string[] = []) => { + errors.forEach((err) => toolsPane?.console?.error(`[${getLanguageTitle(language)}] ${err}`)); + }; + errors.forEach(({ language, errors }) => logError(language, errors)); + + if (singleFileResult) { + setCache({ + ...getCache(), + files: compiledFiles, + mainFile: config.mainFile, + result: cleanResultFromDev(result), + styleOnlyUpdate, + }); + + if (broadcastInfo.isBroadcasting) { + broadcast(); + } + if (resultPopup && !resultPopup.closed) { + resultPopup?.postMessage({ result }, location.origin); + } + } + + return result; +}; + +const mergeCompileInfo = (compileInfo: CompileInfo, newCompileInfo: CompileInfo) => ({ + ...compileInfo, + ...newCompileInfo, + cssModules: { + ...compileInfo.cssModules, + ...newCompileInfo.cssModules, + }, + importedContent: (compileInfo.importedContent || '') + (newCompileInfo.importedContent || ''), + imports: { + ...compileInfo.imports, + ...newCompileInfo.imports, + }, +}); + const reloadCompiler = async (config: Config, force = false) => { if (!compiler.isFake && !force) return; compiler = (window as any).compiler = await getCompiler({ @@ -1482,16 +1615,20 @@ const share = async ( }; const updateConfig = () => { + const newConfig = getConfig(); editorIds.forEach((editorId) => { - setConfig({ - ...getConfig(), - [editorId]: { - ...getConfig()[editorId], - language: getEditorLanguage(editorId), - content: editors[editorId].getValue(), - }, - }); + newConfig[editorId] = { + ...newConfig[editorId], + language: getEditorLanguage(editorId) as Language, + content: editors[editorId].getValue(), + }; }); + newConfig.files = newConfig.files.map((file) => ({ + ...file, + language: getEditorLanguage(file.filename) as Language, + content: editors[file.filename].getValue(), + })); + setConfig(newConfig); }; const loadConfig = async ( @@ -2638,7 +2775,7 @@ const handleChangeContent = () => { } for (const key of Object.keys(customEditors)) { - if (config[editorId].language === key) { + if (config[editorId]?.language === key) { await customEditors[key]?.show(true, { baseUrl, editors, @@ -4939,7 +5076,7 @@ const handleResultPopup = () => { } if (ev.data.type === 'ready') { resultPopup?.postMessage( - { result: await getResultPage({ singleFile: true }) }, + { result: await getResultPage({ singleFileResult: true }) }, location.origin, ); } diff --git a/src/livecodes/languages/tailwindcss/processor-tailwindcss-compiler.ts b/src/livecodes/languages/tailwindcss/processor-tailwindcss-compiler.ts index d9bf67bf17..63fa6bfbfd 100644 --- a/src/livecodes/languages/tailwindcss/processor-tailwindcss-compiler.ts +++ b/src/livecodes/languages/tailwindcss/processor-tailwindcss-compiler.ts @@ -193,7 +193,7 @@ self.createTailwindcssCompiler = (): CompilerFunction => { const tailwind4: CompilerFunction = async (code, { config, options }) => { const prepareCode = (css: string, html: string) => { - let result = replaceStyleImports(css, [/tailwindcss/g]); + let result = replaceStyleImports(css, { exceptions: [/tailwindcss/g] }); if (!result.includes('@import')) { result = `@import "tailwindcss";${result}`; } diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index 353519be3a..9fbb7d2e8d 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -1,3 +1,5 @@ +import type { Config, SourceFile } from '../sdk/models'; + export type * from '../sdk/models'; export interface ModalOptions { @@ -46,3 +48,7 @@ export interface INinjaAction { ) => boolean; keepOpen?: boolean; } + +export type ConfigWithCompiled = Omit & { + files: Array; +}; diff --git a/src/livecodes/result/multi-file-result-page.ts b/src/livecodes/result/multi-file-result-page.ts new file mode 100644 index 0000000000..39a82d3a15 --- /dev/null +++ b/src/livecodes/result/multi-file-result-page.ts @@ -0,0 +1,442 @@ +import { + createImportMap, + getImports, + getStyleImports, + hasImports, + replaceImports, + resolvePath, +} from '../compiler/import-map'; +import { getLanguageByAlias, getLanguageCompiler, getLanguageEditorId } from '../languages/utils'; +import type { CompileInfo, Config, SourceFile } from '../models'; +import { getAppCDN, modulesService } from '../services/modules'; +import { testImports } from '../toolspane/test-imports'; +import { escapeScript, getAbsoluteUrl, isRelativeUrl, objectMap, toDataUrl } from '../utils/utils'; +import { browserJestUrl, esModuleShimsPath, spacingJsUrl } from '../vendors'; + +export const createMultiFileResultPage = async ({ + compiledFiles, + compiledTests, + config, + forExport, + template, + baseUrl, + // singleFileResult, + runTests, + compileInfo, +}: { + compiledFiles: Array; + compiledTests: string; + config: Config; + forExport: boolean; + template: string; + baseUrl: string; + singleFileResult: boolean; + runTests: boolean; + compileInfo: CompileInfo; +}): Promise => { + const absoluteBaseUrl = getAbsoluteUrl(baseUrl); + + const mainFile = getMainFile(config); + const mainFileHTML = compiledFiles.find((f) => f.filename === mainFile)?.compiled || ''; + + const domParser = new DOMParser(); + const dom = domParser.parseFromString(mainFileHTML, 'text/html'); + + // if export => clean, else => add utils + if (forExport) { + const utilsScript = dom.createElement('script'); + utilsScript.innerHTML = 'window.livecodes = window.livecodes || {};'; + dom.head.appendChild(utilsScript); + } else { + const templateDomParser = new DOMParser(); + const templateDom = templateDomParser.parseFromString(template, 'text/html'); + const script = templateDom.querySelector('script')!; + dom.head.appendChild(script.cloneNode(true)); + + const utilsScript = dom.createElement('script'); + utilsScript.src = absoluteBaseUrl + '{{hash:result-utils.js}}'; + utilsScript.dataset.env = 'development'; + dom.head.appendChild(utilsScript); + } + + // user-defined import map in -`, + + + `, language: 'html', hidden: false, }, { filename: 'styles.css', content: `@import "./middle.css"; -`, + `, language: 'css', hidden: false, }, { filename: 'middle.css', content: `@import "./colors.css"; -`, + `, language: 'css', hidden: false, }, { filename: 'colors.css', content: `h1 { - font-family: Arial, Helvetica, sans-serif; - color: red; -} -`, + font-family: Arial, Helvetica, sans-serif; + color: red; + } + `, language: 'css', hidden: false, }, { filename: 'script.ts', content: `import { v4 } from 'uuid'; - import { msg } from './middle.ts'; - - console.log(v4()); - console.log(msg);`, + import { msg } from './middle.ts'; + console.log(v4()); + console.log(msg);`, language: 'typescript', hidden: false, }, diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index b0c90ff425..372351469c 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -21,6 +21,7 @@ import type { } from './UI/broadcast'; import { getCommandMenuActions } from './UI/command-menu-actions'; import { + createAddFileButton, createLanguageMenus, createMultiFileEditorTab, createProcessorItem, @@ -76,6 +77,7 @@ import { importCompressedCode } from './import/code'; import { importFromFiles } from './import/files'; import { populateConfig } from './import/utils'; import { + getFileLanguage, getLanguageByAlias, getLanguageCompiler, getLanguageEditorId, @@ -207,7 +209,7 @@ let typeLoader: ReturnType; const screens: Screen[] = []; const params = getParams(); // query string params const iframeScrollPosition = { x: 0, y: 0 }; -const editorIds: EditorId[] = ['markup', 'style', 'script']; +const editorIds = ['markup', 'style', 'script']; let baseUrl: string; let isEmbed: boolean; @@ -417,7 +419,7 @@ const highlightSelectedLanguage = (editorId: EditorId, language: Language) => { }); }; -const setEditorTitle = (editorId: EditorId, title: string) => { +export const setEditorTitle = (editorId: EditorId, title: string) => { const editorTitle = document.querySelector(`#${editorId}-selector span`) as HTMLElement; const editorTitleContainer = document.querySelector(`#${editorId}-selector`) as HTMLElement; const language = getLanguageByAlias(title); @@ -453,7 +455,10 @@ const createCopyButtons = () => { copyButton.innerHTML = copyImgHtml; copyButton.classList.add('copy-button', 'tool-buttons'); copyButton.title = window.deps.translateString('core.copy.title', 'Copy'); - document.getElementById(editorId)?.appendChild(copyButton); + ( + document.getElementById(editorId) || + document.querySelector(`div[data-editor-id="${editorId}"]`) + )?.appendChild(copyButton); eventsManager.addEventListener(copyButton, 'click', () => { if (copyToClipboard(editors?.[editorId]?.getValue())) { copyButton.innerHTML = `copied`; @@ -469,47 +474,75 @@ const createCopyButtons = () => { }); }; -const createEditorUI = (title: string) => { - const editorsElement = UI.getEditorsElement(); - let container = editorsElement.querySelector(`[data-editor-id="${title}"]`); - if (!container) { - container = document.createElement('div'); - container.setAttribute('data-editor-id', title); - container.classList.add('editor'); - editorsElement.insertBefore(container, UI.getEditorToolbar()); - } - const editFileName = (filename: string) => { - const config = getConfig(); - setConfig({ - ...config, - files: config.files.map((f) => ({ - ...f, - filename: f.filename === title ? filename : f.filename, - })), - }); - if (config.autoupdate) { - run(); +const renameFile = (filename: string, newName: string) => { + // TODO: validate newName (existing file, extension, valid name) + const language = getFileLanguage(newName)!; + const config = getConfig(); + setConfig({ + ...config, + activeEditor: newName, + files: config.files.map((f) => ({ + ...f, + language: f.filename === filename ? language : f.language, + filename: f.filename === filename ? newName : f.filename, + })), + }); + UI.getEditorDivs().forEach((editorDiv) => { + if (editorDiv.dataset.editorId === filename) { + editorDiv.dataset.editorId = newName; } - }; - const deleteFile = (filename: string) => { - const config = getConfig(); - setConfig({ - ...config, - files: config.files.filter((f) => f.filename !== filename), - }); - if (config.autoupdate) { - run(); + }); + UI.getEditorTitles().forEach((editorTitle) => { + if (editorTitle.dataset.editor === filename) { + editorTitle.dataset.editor = newName; } - }; + }); + if (editorLanguages && editorLanguages[filename]) { + editorLanguages[newName] = editorLanguages[filename]; + delete editorLanguages[filename]; + } + if (editors[filename]) { + editors[newName] = editors[filename]; + delete editors[filename]; + } + changeLanguage(language, undefined, false, newName); +}; +const deleteFile = (filename: string) => { const config = getConfig(); - const isMainFile = config.mainFile - ? config.mainFile === title - : title === 'index.html' || - config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename === title; + setConfig({ + ...config, + files: config.files.filter((f) => f.filename !== filename), + }); + if (config.autoupdate) { + run(); + } +}; - createMultiFileEditorTab({ title, editFileName, deleteFile, isMainFile }); +const createEditorUI = (title: string, addTab = false) => { + const editorsElement = UI.getEditorsElement(); + editorsElement.querySelector(`.editor[data-editor-id="${title}"]`)?.remove(); + const container = document.createElement('div'); + container.dataset.editorId = title; + container.dataset.multiFile = 'true'; + container.classList.add('editor'); + editorsElement.insertBefore(container, UI.getEditorToolbar()); + if (addTab) { + const config = getConfig(); + const isMainFile = config.mainFile + ? config.mainFile === title + : title === 'index.html' || + config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename === title; + createMultiFileEditorTab({ + title, + showEditor, + renameFile, + deleteFile, + isMainFile, + isNewFile: false, + }); + } return container; }; @@ -581,6 +614,29 @@ const createEditors = async (config: Config) => { }; if (config.files?.length) { + createAddFileButton(() => { + createMultiFileEditorTab({ + title: 'script.js', + showEditor, + renameFile: async (filename: string, newName: string) => { + renameFile(filename, newName); + const fileLanguage = getFileLanguage(filename) || 'javascript'; + const editor = await createEditor({ + ...baseOptions, + container: createEditorUI(newName, true), + editorId: newName as EditorId, + language: fileLanguage, + value: '', + }); + editorLanguages![newName] = fileLanguage; + editors[newName] = editor; + }, + deleteFile, + isMainFile: false, + isNewFile: true, + }); + }); + editorLanguages = { markup: 'html', style: 'css', script: 'javascript' }; editors = { markup: createFakeEditor(markupOptions), @@ -588,9 +644,10 @@ const createEditors = async (config: Config) => { script: createFakeEditor(scriptOptions), }; + editorIds.length = 0; for (const file of config.files) { - const editorId = file.filename; - const container = createEditorUI(file.filename); + const editorId = file.filename as EditorId; + const container = createEditorUI(file.filename, /* addTab */ true); const editorOptions = { ...baseOptions, container, @@ -601,6 +658,7 @@ const createEditors = async (config: Config) => { const editor = await createEditor(editorOptions); editorLanguages[editorId] = file.language; editors[editorId] = editor; + editorIds.push(editorId); } } else { const markupEditor = await createEditor(markupOptions); @@ -653,23 +711,17 @@ const reloadEditors = async (config: Config) => { const updateEditors = async (editors: Editors, config: Config) => { const editorIds = Object.keys(editors) as Array; for (const editorId of editorIds) { - const language = getLanguageByAlias( - config[editorId as EditorId]?.language || - config.files.find((f) => f.filename === editorId)?.language, - ); + const source = + config[editorId as EditorId] || config.files.find((f) => f.filename === editorId); + const language = getLanguageByAlias(source.language); if (language) { - await changeLanguage( - language, - config[editorId as EditorId]?.content || - config.files.find((f) => f.filename === editorId)?.content, - true, - ); + await changeLanguage(language, source.content || '', true); } const editor = editors[editorId]; if (config.foldRegions) { await editor.foldRegions?.(); } - const foldedLines = config[editorId as EditorId]?.foldedLines; + const foldedLines = source.foldedLines; if (foldedLines?.length) { await editor.foldLines?.(foldedLines); } @@ -795,6 +847,7 @@ const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = fa titles.forEach((selector) => selector.classList.remove('active')); const activeTitle = titles.find((title) => title.dataset.editor === editorId); activeTitle?.classList.add('active'); + activeTitle?.scrollIntoView({ behavior: 'smooth' }); const editorDivs = [...UI.getEditorDivs()]; editorDivs.forEach((editor) => (editor.style.display = 'none')); const activeEditor = editorDivs.find( @@ -817,7 +870,7 @@ const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = fa if (initialized || config.view !== 'result') { split?.show('code'); } - configureEditorTools(getActiveEditor().getLanguage()); + configureEditorTools(getActiveEditor()?.getLanguage()); showEditorModeStatus(editorId); }; @@ -893,16 +946,19 @@ const configureMultiFile = (config: Config) => { ]; const multiFileTabs = [...editorTabsContainer.querySelectorAll('[data-multi-file]')]; const isMultiFile = config.files.length > 0; + singleFileTabs.forEach((tab) => { tab.classList.toggle('hidden', isMultiFile); }); + multiFileTabs.forEach((tab) => { + tab.classList.toggle('hidden', !isMultiFile); + }); + document.documentElement.classList.toggle('multi-file', isMultiFile); + + // clean up if (!isMultiFile) { - document.documentElement.classList.remove('multi-file'); multiFileTabs.forEach((tab) => tab.remove()); - UI.getEditorDivs().forEach((editor) => editor.remove()); - } - if (isMultiFile) { - document.documentElement.classList.add('multi-file'); + UI.getMultiFileEditorDivs().forEach((editor) => editor.remove()); } }; @@ -937,8 +993,13 @@ const applyLanguageConfigs = async (language: Language) => { }); }; -const changeLanguage = async (language: Language, value?: string, isUpdate = false) => { - const editorId = getLanguageEditorId(language); +const changeLanguage = async ( + language: Language, + value?: string, + isUpdate = false, + filename?: string, +) => { + const editorId = filename || getLanguageEditorId(language); if (!editorId || !language || !languageIsEnabled(language, getConfig())) return; if (getLanguageSpecs(language)?.largeDownload) { notifications.info( @@ -952,11 +1013,14 @@ const changeLanguage = async (language: Language, value?: string, isUpdate = fal ); } const editor = editors[editorId]; - editor.setLanguage(language, value ?? (getConfig()[editorId].content || '')); + editor.setLanguage( + language, + filename ? undefined : value ?? (getConfig()[editorId as EditorId]?.content || ''), + ); if (editorLanguages) { editorLanguages[editorId] = language; } - setEditorTitle(editorId, language); + setEditorTitle(editorId as EditorId, language); showEditor(editorId, isUpdate); phpHelper({ editor: editors.script }); if (!isEmbed && !isUpdate) { @@ -1621,16 +1685,18 @@ const share = async ( const updateConfig = () => { const newConfig = getConfig(); editorIds.forEach((editorId) => { - newConfig[editorId] = { - ...newConfig[editorId], - language: getEditorLanguage(editorId) as Language, - content: editors[editorId].getValue(), - }; + if (editorId === 'markup' || editorId === 'style' || editorId === 'script') { + newConfig[editorId] = { + ...newConfig[editorId], + language: getEditorLanguage(editorId) as Language, + content: editors[editorId]?.getValue(), + }; + } }); newConfig.files = newConfig.files.map((file) => ({ ...file, language: getEditorLanguage(file.filename) as Language, - content: editors[file.filename].getValue(), + content: editors[file.filename]?.getValue(), })); setConfig(newConfig); }; @@ -1682,12 +1748,12 @@ const loadConfig = async ( const applyConfig = async (newConfig: Partial, reload = false, oldConfig?: Config) => { const currentConfig = oldConfig || getConfig(); const combinedConfig: Config = { ...currentConfig, ...newConfig }; + configureMultiFile(combinedConfig); if (reload) { await updateEditors(editors, getConfig()); } phpHelper({ editor: editors.script }); setLoading(true); - configureMultiFile(combinedConfig); await setActiveEditor(combinedConfig); if (!isEmbed) { @@ -5533,8 +5599,9 @@ const importExternalContent = async (options: { const hasContentUrls = (conf: Partial) => editorIds.filter( (editorId) => - (conf[editorId]?.contentUrl && !conf[editorId]?.content) || - (conf[editorId]?.hiddenContentUrl && !conf[editorId]?.hiddenContent), + (editorId === 'markup' || editorId === 'style' || editorId === 'script') && + ((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')) { @@ -5597,27 +5664,30 @@ const importExternalContent = async (options: { // load content from config contentUrl const editorsContent = await Promise.all( editorIds.map(async (editorId) => { - const contentUrl = config[editorId].contentUrl; - const hiddenContentUrl = config[editorId].hiddenContentUrl; + if (!['markup', 'style', 'script'].includes(editorId)) return; + const src = config[editorId as EditorId]; + const contentUrl = src.contentUrl; + const hiddenContentUrl = src.hiddenContentUrl; const [content, hiddenContent] = await Promise.all([ - contentUrl && getValidUrl(contentUrl) && !config[editorId].content + contentUrl && getValidUrl(contentUrl) && !src.content ? fetch(contentUrl).then((res) => res.text()) : Promise.resolve(''), - hiddenContentUrl && getValidUrl(hiddenContentUrl) && !config[editorId].hiddenContent + hiddenContentUrl && getValidUrl(hiddenContentUrl) && !src.hiddenContent ? fetch(hiddenContentUrl).then((res) => res.text()) : Promise.resolve(''), ]); return { - ...config[editorId], + ...src, ...(content ? { content } : {}), ...(hiddenContent ? { hiddenContent } : {}), }; }), ); + // TODO: handle files contentUrlConfig = { - markup: editorsContent[0], - style: editorsContent[1], - script: editorsContent[2], + markup: editorsContent[0] || config.markup, + style: editorsContent[1] || config.style, + script: editorsContent[2] || config.script, }; } diff --git a/src/livecodes/editor/monaco/monaco.ts b/src/livecodes/editor/monaco/monaco.ts index 338c985815..3c5afb42d8 100644 --- a/src/livecodes/editor/monaco/monaco.ts +++ b/src/livecodes/editor/monaco/monaco.ts @@ -300,7 +300,7 @@ export const createEditor = async (options: EditorOptions): Promise language: Language, ) => { const random = getRandomString(); - const ext = getLanguageExtension(language); + const ext = editorId.split('.').pop() || getLanguageExtension(language); const extension = monacoMapLanguage(language) === 'typescript' && !ext?.endsWith('ts') && !ext?.endsWith('tsx') ? ext + '.tsx' diff --git a/src/livecodes/languages/utils.ts b/src/livecodes/languages/utils.ts index 903ca3aa17..27bd6fafe5 100644 --- a/src/livecodes/languages/utils.ts +++ b/src/livecodes/languages/utils.ts @@ -13,6 +13,8 @@ export const getLanguageByAlias = (alias: string = ''): Language | undefined => )?.name; }; +export const getFileLanguage = (filename: string) => getLanguageByAlias(filename.split('.').pop()); + export const getLanguageTitle = (language: Language) => { const languageSpecs = window.deps.languages.find((lang) => lang.name === language); return languageSpecs?.longTitle || languageSpecs?.title || language.toUpperCase(); diff --git a/src/livecodes/styles/app.scss b/src/livecodes/styles/app.scss index adcaddecb9..fd4ef222be 100644 --- a/src/livecodes/styles/app.scss +++ b/src/livecodes/styles/app.scss @@ -642,10 +642,23 @@ a#result-button { } #select-editor { - width: calc(50vw - 200px); - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: none; + display: flex; + + & > div { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + width: calc(50vw - 262px); + } + } + + #add-file-button { + align-items: center; + border: 0; + cursor: pointer; + display: flex; + margin-inline-end: 0; + margin-top: -1px; } } } From 9ca7c30aa961359cc25754feafc0dfc1ed6b6acb Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sat, 8 Nov 2025 08:25:20 +0200 Subject: [PATCH 006/143] allow `editorId` to have filename --- src/livecodes/config/build-config.ts | 11 ++- src/livecodes/config/utils.ts | 18 ++++ src/livecodes/config/validate-config.ts | 11 ++- src/livecodes/core.ts | 99 ++++++++++++++----- src/livecodes/editor/codejar/codejar.ts | 11 ++- src/livecodes/editor/codemirror/codemirror.ts | 18 ++-- src/livecodes/editor/fake-editor.ts | 11 ++- src/livecodes/editor/monaco/monaco.ts | 17 +++- .../result/multi-file-result-page.ts | 9 +- src/sdk/models.ts | 3 +- 10 files changed, 149 insertions(+), 59 deletions(-) create mode 100644 src/livecodes/config/utils.ts diff --git a/src/livecodes/config/build-config.ts b/src/livecodes/config/build-config.ts index 0d3eae9776..5f644a8399 100644 --- a/src/livecodes/config/build-config.ts +++ b/src/livecodes/config/build-config.ts @@ -12,6 +12,8 @@ import { addProp, cloneObject, decodeHTML, removeDuplicates, stringToValidJson } import { decompress } from '../utils/compression'; import { defaultConfig } from './default-config'; import { upgradeAndValidate } from './index'; +// eslint-disable-next-line import/order +import { getMainFile, isEditorId } from './utils'; /** * Builds and validates a configuration object by merging default config with user config and URL params @@ -47,11 +49,14 @@ export const buildConfig = (appConfig: Partial): Config => { ...config, ...paramsConfig, }; - const activeEditor = config.activeEditor || 'markup'; + + const activeEditor = config.activeEditor || config.files[0]?.filename || 'markup'; + const mainFile = getMainFile(config); config = fixLanguageNames({ ...config, activeEditor, + mainFile, }); return config; }; @@ -173,7 +178,7 @@ export const loadParamConfig = (config: Config, params: UrlQueryParams): Partial const language = getLanguageByAlias(key); if (!language) return; const editorId = getLanguageEditorId(language); - if (editorId && !paramsConfig[editorId]) { + if (editorId && isEditorId(editorId) && !paramsConfig[editorId]) { const value = params[key]; const content = typeof value === 'string' ? decodeHTML(value) : ''; paramsConfig[editorId] = { language, content }; @@ -186,7 +191,7 @@ export const loadParamConfig = (config: Config, params: UrlQueryParams): Partial // ?lang=js const lang: any = getLanguageByAlias(params.language || params.lang); const editorId = getLanguageEditorId(lang); - if (editorId) { + if (editorId && isEditorId(editorId)) { if (paramsConfig[editorId]?.language === lang) { paramsConfig.activeEditor = editorId; } else if (!paramsConfig[editorId]?.content && config[editorId]?.language === lang) { diff --git a/src/livecodes/config/utils.ts b/src/livecodes/config/utils.ts new file mode 100644 index 0000000000..a7b21ea496 --- /dev/null +++ b/src/livecodes/config/utils.ts @@ -0,0 +1,18 @@ +import { getLanguageEditorId } from '../languages/utils'; +import type { Config, EditorId } from '../models'; + +export const isEditorId = (editorId: string): editorId is 'markup' | 'style' | 'script' => + editorId === 'markup' || editorId === 'style' || editorId === 'script'; + +export const getSource = (editorId: EditorId, config: Config) => + isEditorId(editorId) ? config[editorId] : config.files.find((f) => f.filename === editorId); + +export const getMainFile = (config: Config) => + !config.files?.length + ? undefined + : config.mainFile && config.files.find((f) => f.filename === config.mainFile) + ? config.mainFile + : config.files.find((f) => f.filename === 'index.html') + ? 'index.html' + : config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename || + 'index.html'; diff --git a/src/livecodes/config/validate-config.ts b/src/livecodes/config/validate-config.ts index 425756716c..894d8526f4 100644 --- a/src/livecodes/config/validate-config.ts +++ b/src/livecodes/config/validate-config.ts @@ -1,4 +1,4 @@ -import { getLanguageByAlias, getLanguageEditorId } from '../languages'; +import { getFileLanguage, getLanguageByAlias, getLanguageEditorId } from '../languages'; import type { Config, Editor, @@ -11,6 +11,7 @@ import type { } from '../models'; import { getFileExtension, removeDuplicates } from '../utils'; import { defaultConfig } from './default-config'; +import { isEditorId } from './utils'; export const validateConfig = (config: Partial): Partial => { type types = 'array' | 'boolean' | 'object' | 'number' | 'string' | 'undefined'; @@ -63,7 +64,8 @@ export const validateConfig = (config: Partial): Partial => { ? 'vue-app' : lang; - const getEditorDefaultLanguage = (editorId: EditorId) => defaultConfig[editorId].language; + const getEditorDefaultLanguage = (editorId: EditorId) => + (isEditorId(editorId) ? defaultConfig[editorId].language : getFileLanguage(editorId)) || 'html'; const validateEditorProps = (x: Editor, editorId: EditorId): Editor => ({ language: @@ -97,6 +99,9 @@ export const validateConfig = (config: Partial): Partial => { } : null; + const validateActiveEditor = (config: Partial) => + includes([...editorIds, ...(config.files || []).map((f) => f.filename)], config.activeEditor); + const validateTestsProps = (x: Partial): Partial => ({ ...(x && is(x.language, 'string') ? { language: x.language } : {}), ...(x && is(x.content, 'string') ? { content: x.content } : {}), @@ -156,7 +161,7 @@ export const validateConfig = (config: Partial): Partial => { ...(is(config.showSpacing, 'boolean') ? { showSpacing: config.showSpacing } : {}), ...(is(config.readonly, 'boolean') ? { readonly: config.readonly } : {}), ...(is(config.allowLangChange, 'boolean') ? { allowLangChange: config.allowLangChange } : {}), - ...(includes(editorIds, config.activeEditor) ? { activeEditor: config.activeEditor } : {}), + ...(validateActiveEditor(config) ? { activeEditor: config.activeEditor } : {}), ...(is(config.languages, 'array', 'string') ? { languages: removeDuplicates(config.languages) } : {}), diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index 372351469c..8bf9c6fc95 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -43,6 +43,7 @@ import { setConfig, upgradeAndValidate, } from './config'; +import { getSource, isEditorId } from './config/utils'; import { createCustomEditors, createEditor, getFontFamily } from './editor'; import { createFakeEditor } from './editor/fake-editor'; import { hasJsx } from './editor/ts-compiler-options'; @@ -251,7 +252,6 @@ const sdkWatchers = { const getEditorLanguage = (editorId = 'markup') => editorLanguages?.[editorId]; const getEditorLanguages = () => Object.values(editorLanguages || {}); const getActiveEditor = () => editors[getConfig().activeEditor || 'markup']; -const setActiveEditor = async (config: Config) => showEditor(config.activeEditor); const loadStyles = () => isHeadless @@ -420,6 +420,7 @@ const highlightSelectedLanguage = (editorId: EditorId, language: Language) => { }; export const setEditorTitle = (editorId: EditorId, title: string) => { + if (!isEditorId(editorId)) return; const editorTitle = document.querySelector(`#${editorId}-selector span`) as HTMLElement; const editorTitleContainer = document.querySelector(`#${editorId}-selector`) as HTMLElement; const language = getLanguageByAlias(title); @@ -505,6 +506,10 @@ const renameFile = (filename: string, newName: string) => { editors[newName] = editors[filename]; delete editors[filename]; } + const id = editorIds.findIndex((editorId) => editorId === filename); + if (id > -1) { + editorIds[id] = newName; + } changeLanguage(language, undefined, false, newName); }; @@ -514,6 +519,22 @@ const deleteFile = (filename: string) => { ...config, files: config.files.filter((f) => f.filename !== filename), }); + if (editorLanguages && editorLanguages[filename]) { + delete editorLanguages[filename]; + } + if (editors[filename]) { + editors[filename].destroy(); + delete editors[filename]; + } + const id = editorIds.findIndex((editorId) => editorId === filename); + if (id > -1) { + editorIds.splice(id, 1); + } + UI.getEditorDivs().forEach((editorDiv) => { + if (editorDiv.dataset.editorId === filename) { + editorDiv.remove(); + } + }); if (config.autoupdate) { run(); } @@ -624,12 +645,13 @@ const createEditors = async (config: Config) => { const editor = await createEditor({ ...baseOptions, container: createEditorUI(newName, true), - editorId: newName as EditorId, + editorId: newName, language: fileLanguage, value: '', }); editorLanguages![newName] = fileLanguage; editors[newName] = editor; + editorIds.push(newName); }, deleteFile, isMainFile: false, @@ -711,17 +733,23 @@ const reloadEditors = async (config: Config) => { const updateEditors = async (editors: Editors, config: Config) => { const editorIds = Object.keys(editors) as Array; for (const editorId of editorIds) { - const source = - config[editorId as EditorId] || config.files.find((f) => f.filename === editorId); + if (typeof editorId !== 'string') { + continue; + } + const source = getSource(editorId, config); + if (!source) { + continue; + } const language = getLanguageByAlias(source.language); if (language) { - await changeLanguage(language, source.content || '', true); + const filename = 'filename' in source ? source.filename : undefined; + await changeLanguage(language, source.content || '', true, filename); } const editor = editors[editorId]; if (config.foldRegions) { await editor.foldRegions?.(); } - const foldedLines = source.foldedLines; + const foldedLines = 'foldedLines' in source ? source.foldedLines : undefined; if (foldedLines?.length) { await editor.foldLines?.(foldedLines); } @@ -833,7 +861,7 @@ const showMode = (mode?: Config['mode'], view?: Config['view']) => { const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = false) => { const config = getConfig(); if ( - config[editorId as EditorId]?.hideTitle || + (isEditorId(editorId) && config[editorId].hideTitle) || config.files.find((f) => f.filename === editorId)?.hidden ) { return; @@ -1013,9 +1041,12 @@ const changeLanguage = async ( ); } const editor = editors[editorId]; + if (filename) { + editor.setEditorId(filename); + } editor.setLanguage( language, - filename ? undefined : value ?? (getConfig()[editorId as EditorId]?.content || ''), + value ?? (filename ? undefined : getSource(editorId, getConfig())?.content || ''), ); if (editorLanguages) { editorLanguages[editorId] = language; @@ -1059,10 +1090,18 @@ const updateCompiledCode = () => { style: 'css', script: 'javascript', }; - const lang = getLanguageCompiler(getConfig()[editorId].language)?.compiledCodeLanguage; + const srcLang = getSource(editorId, getConfig())?.language; + const lang = getLanguageCompiler(srcLang)?.compiledCodeLanguage; return { - language: lang || defaultLang[editorId], - label: lang === 'json' ? 'JSON' : getLanguageByAlias(lang) || lang || defaultLang[editorId], + language: lang || defaultLang[editorId] || getFileLanguage(editorId) || 'html', + label: + lang === 'json' + ? 'JSON' + : getLanguageByAlias(lang) || + lang || + defaultLang[editorId] || + getFileLanguage(editorId) || + 'html', }; }; const compiledLanguages: { [key in EditorId]: { language: Language; label: string } } = { @@ -1754,7 +1793,7 @@ const applyConfig = async (newConfig: Partial, reload = false, oldConfig } phpHelper({ editor: editors.script }); setLoading(true); - await setActiveEditor(combinedConfig); + showEditor(combinedConfig.activeEditor); if (!isEmbed) { loadSettings(combinedConfig); @@ -2844,8 +2883,10 @@ const handleChangeContent = () => { await getResultPage({ sourceEditor: editorId }); } + const lang = getSource(editorId, config)?.language; + for (const key of Object.keys(customEditors)) { - if (config[editorId]?.language === key) { + if (lang === key) { await customEditors[key]?.show(true, { baseUrl, editors, @@ -2984,22 +3025,32 @@ const handleKeyboardShortcuts = () => { // Ctrl + Alt + (1-3) activates editor 1-3 // Ctrl + Alt + (ArrowLeft/ArrowRight) activates previous/next editor - const editorIds = (['markup', 'style', 'script'] as EditorId[]).filter( - (id) => getConfig()[id].hideTitle !== true, - ); - if (ctrl(e) && e.altKey && ['1', '2', '3', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + const config = getConfig(); + const editorIds = ( + config.files.length + ? config.files.map((f) => f.filename) + : (['markup', 'style', 'script'] as EditorId[]) + ).filter((id) => { + const src = getSource(id, config); + if (!src) return false; + if ('hideTitle' in src) return src.hideTitle !== true; + if ('hidden' in src) return src.hidden !== true; + return true; + }); + const editorNumbers = editorIds.map((_, id) => String(id + 1)); + if (ctrl(e) && e.altKey && [...editorNumbers, 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); split?.show('code'); - const index = ['1', '2', '3'].includes(e.key) + const index = editorNumbers.includes(e.key) ? Number(e.key) - 1 : e.key === 'ArrowLeft' - ? editorIds.findIndex((id) => id === getConfig().activeEditor) - 1 || 0 + ? editorIds.findIndex((id) => id === config.activeEditor) - 1 || 0 : e.key === 'ArrowRight' - ? editorIds.findIndex((id) => id === getConfig().activeEditor) + 1 || 0 + ? editorIds.findIndex((id) => id === config.activeEditor) + 1 || 0 : 0; const editorIndex = index === editorIds.length ? 0 : index === -1 ? editorIds.length - 1 : index; - showEditor(editorIds[editorIndex] as EditorId); + showEditor(editorIds[editorIndex]); lastkeys = 'Ctrl + Alt + ' + e.key; return; } @@ -5664,8 +5715,8 @@ const importExternalContent = async (options: { // load content from config contentUrl const editorsContent = await Promise.all( editorIds.map(async (editorId) => { - if (!['markup', 'style', 'script'].includes(editorId)) return; - const src = config[editorId as EditorId]; + if (!isEditorId(editorId)) return; + const src = config[editorId]; const contentUrl = src.contentUrl; const hiddenContentUrl = src.hiddenContentUrl; const [content, hiddenContent] = await Promise.all([ @@ -5916,7 +5967,7 @@ const createApi = (): API => { split?.show('code', full); } else if (panel === 'console' || panel === 'compiled' || panel === 'tests') { split?.show('output'); - toolsPane?.setActiveTool(panel); + toolsPane?.setActiveTool(panel as 'console' | 'compiled' | 'tests'); if (full) { toolsPane?.maximize(); } else { diff --git a/src/livecodes/editor/codejar/codejar.ts b/src/livecodes/editor/codejar/codejar.ts index 400684159a..764a0adabc 100644 --- a/src/livecodes/editor/codejar/codejar.ts +++ b/src/livecodes/editor/codejar/codejar.ts @@ -10,6 +10,7 @@ import 'prismjs/components/prism-typescript'; import 'prismjs/plugins/autoloader/prism-autoloader'; import 'prismjs/plugins/line-numbers/prism-line-numbers'; +import { getFileLanguage } from '../../languages'; import type { CodeEditor, CodejarTheme, @@ -32,11 +33,10 @@ Prism.manual = true; Prism.plugins.autoloader.languages_path = prismBaseUrl; export const createEditor = async (options: EditorOptions): Promise => { - const { container, mode, editorId, readonly, isEmbed, getFormatterConfig, getFontFamily } = - options; + const { container, mode, readonly, isEmbed, getFormatterConfig, getFontFamily } = options; if (!container) throw new Error('editor container not found'); - let { value, language } = options; + let { value, language, editorId } = options; let currentPosition: EditorPosition = { lineNumber: 1 }; const mapLanguage = options.mapLanguage || ((lang: Language) => lang); let mappedLanguage = language === 'wat' ? 'wasm' : mapLanguage(language); @@ -135,6 +135,10 @@ export const createEditor = async (options: EditorOptions): Promise // codejar?.onPaste(handleUpdate); const getEditorId = () => editorId; + const setEditorId = (filename: string) => { + editorId = filename; + language = getFileLanguage(filename) || language; + }; const getValue = () => (codejar ? codejar.toString() : value); const setValue = (newValue = '\n') => { value = newValue; @@ -427,6 +431,7 @@ export const createEditor = async (options: EditorOptions): Promise getLanguage, setLanguage, getEditorId, + setEditorId, focus, getPosition, setPosition: (position) => setPosition(position), diff --git a/src/livecodes/editor/codemirror/codemirror.ts b/src/livecodes/editor/codemirror/codemirror.ts index 199efa8e0b..d8229d760c 100644 --- a/src/livecodes/editor/codemirror/codemirror.ts +++ b/src/livecodes/editor/codemirror/codemirror.ts @@ -29,6 +29,7 @@ import { colorPicker } from '@replit/codemirror-css-color-picker'; // these are imported normally import { getEditorModeNode } from '../../UI/selectors'; +import { getFileLanguage } from '../../languages'; import type { CodeEditor, CodemirrorTheme, @@ -56,15 +57,9 @@ let tabFocusMode = false; const changeTabFocusMode = debounce(() => (tabFocusMode = !tabFocusMode), 50); export const createEditor = async (options: EditorOptions): Promise => { - const { - container, - readonly, - isEmbed, - editorId, - getFormatterConfig, - getFontFamily, - getLanguageExtension, - } = options; + const { container, readonly, isEmbed, getFormatterConfig, getFontFamily, getLanguageExtension } = + options; + let editorId = options.editorId; let editorSettings: EditorConfig = { ...options }; if (!container) throw new Error('editor container not found'); @@ -308,6 +303,10 @@ export const createEditor = async (options: EditorOptions): Promise showEditorMode(options.editorMode); const getEditorId = () => editorId; + const setEditorId = (filename: string) => { + editorId = filename; + language = getFileLanguage(filename) || language; + }; const getValue = () => view.state.doc.toString(); const setValue = (value = '', newState = true) => { if (newState) { @@ -527,6 +526,7 @@ export const createEditor = async (options: EditorOptions): Promise getLanguage, setLanguage, getEditorId, + setEditorId, focus, getPosition, setPosition, diff --git a/src/livecodes/editor/fake-editor.ts b/src/livecodes/editor/fake-editor.ts index ef354afcf3..bcc47e9712 100644 --- a/src/livecodes/editor/fake-editor.ts +++ b/src/livecodes/editor/fake-editor.ts @@ -1,9 +1,8 @@ -import { getLanguageEditorId } from '../languages'; +import { getFileLanguage } from '../languages'; import type { CodeEditor, EditorOptions } from '../models'; export const createFakeEditor = (options: EditorOptions): CodeEditor => { - let value = options.value; - let language = options.language; + let { value, language, editorId } = options; return { getValue: () => value, setValue: (v = '') => { @@ -16,7 +15,11 @@ export const createFakeEditor = (options: EditorOptions): CodeEditor => { value = v; } }, - getEditorId: () => getLanguageEditorId(language) || 'markup', + getEditorId: () => editorId, + setEditorId: (fileName) => { + editorId = fileName; + language = getFileLanguage(fileName) || language; + }, focus: () => undefined, getPosition: () => ({ lineNumber: 1, column: 1 }), setPosition: () => undefined, diff --git a/src/livecodes/editor/monaco/monaco.ts b/src/livecodes/editor/monaco/monaco.ts index 3c5afb42d8..fb8e898563 100644 --- a/src/livecodes/editor/monaco/monaco.ts +++ b/src/livecodes/editor/monaco/monaco.ts @@ -2,6 +2,7 @@ import type * as Monaco from 'monaco-editor'; import { getEditorModeNode } from '../../UI/selectors'; import { getImports } from '../../compiler/import-map'; +import { getFileLanguage } from '../../languages/utils'; import type { APIError, CodeEditor, @@ -202,7 +203,7 @@ export const createEditor = async (options: EditorOptions): Promise ...consoleOptions, }; - const editorId = options.editorId; + let editorId = options.editorId; const initOptions = editorId === 'console' ? consoleOptions @@ -219,7 +220,7 @@ export const createEditor = async (options: EditorOptions): Promise const JSLangs = ['javascript', 'jsx', 'react', 'flow', 'solid', 'react-native']; const isJSLang = JSLangs.includes(language); if ( - !['script', 'tests', 'editorSettings'].includes(editorId) || + // !['script', 'tests', 'editorSettings'].includes(editorId) || !['javascript', 'typescript'].includes(monacoMapLanguage(language)) ) { return; @@ -300,12 +301,14 @@ export const createEditor = async (options: EditorOptions): Promise language: Language, ) => { const random = getRandomString(); - const ext = editorId.split('.').pop() || getLanguageExtension(language); + const ext = getLanguageExtension(language); const extension = monacoMapLanguage(language) === 'typescript' && !ext?.endsWith('ts') && !ext?.endsWith('tsx') ? ext + '.tsx' : ext; - modelUri = `file:///${editorId}.${random}.${extension}`; + modelUri = editorId.includes('.') + ? `file:///${editorId}` + : `file:///${editorId}.${random}.${extension}`; const oldModel = editor.getModel(); const model = monaco.editor.createModel( value || '', @@ -358,6 +361,11 @@ export const createEditor = async (options: EditorOptions): Promise } const getEditorId = () => editorId; + const setEditorId = (filename: string) => { + editorId = filename; + language = getFileLanguage(filename) || language; + setModel(editor, editor.getValue(), language); + }; const getValue = () => editor.getValue(); const setValue = (value = '') => { editor.getModel()?.setValue(value); @@ -905,6 +913,7 @@ export const createEditor = async (options: EditorOptions): Promise getLanguage, setLanguage, getEditorId, + setEditorId, focus, getPosition, setPosition, diff --git a/src/livecodes/result/multi-file-result-page.ts b/src/livecodes/result/multi-file-result-page.ts index 39a82d3a15..3065d6c3e8 100644 --- a/src/livecodes/result/multi-file-result-page.ts +++ b/src/livecodes/result/multi-file-result-page.ts @@ -6,6 +6,7 @@ import { replaceImports, resolvePath, } from '../compiler/import-map'; +import { getMainFile } from '../config/utils'; import { getLanguageByAlias, getLanguageCompiler, getLanguageEditorId } from '../languages/utils'; import type { CompileInfo, Config, SourceFile } from '../models'; import { getAppCDN, modulesService } from '../services/modules'; @@ -432,11 +433,3 @@ window.browserJest.run().then(results => { return '\n' + dom.documentElement.outerHTML; }; - -export const getMainFile = (config: Config) => - config.mainFile && config.files.find((f) => f.filename === config.mainFile) - ? config.mainFile - : config.files.find((f) => f.filename === 'index.html') - ? 'index.html' - : config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename || - 'index.html'; diff --git a/src/sdk/models.ts b/src/sdk/models.ts index f7e42171ca..9b6fc8dd22 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -1218,7 +1218,7 @@ export interface EditorPosition { column?: number; } -export type EditorId = 'markup' | 'style' | 'script'; +export type EditorId = 'markup' | 'style' | 'script' | (string & {}); export interface Editors { [key: string]: CodeEditor; @@ -1574,6 +1574,7 @@ export interface CodeEditor { getLanguage: () => Language; setLanguage: (language: Language, value?: string) => void; getEditorId: () => string; + setEditorId: (filename: string) => void; focus: () => void; getPosition: () => EditorPosition; setPosition: (position: EditorPosition) => void; From 07fee729b2c92245a98e53fd4cd2e89c00e91a32 Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sat, 8 Nov 2025 08:25:20 +0200 Subject: [PATCH 007/143] feat(Config): rename `Editor.hideTitle` to `Editor.hidden` --- .../configuration/configuration-object.mdx | 20 +++++---- docs/docs/languages/svelte.mdx | 2 +- docs/docs/languages/vue.mdx | 2 +- .../config/__tests__/build-config.spec.ts | 6 +-- src/livecodes/config/build-config.ts | 5 +-- src/livecodes/config/upgrade-config.ts | 15 +++++++ src/livecodes/config/validate-config.ts | 35 ++++++++++++---- src/livecodes/core.ts | 35 ++++++---------- src/sdk/models.ts | 41 ++++++++++++------- 9 files changed, 100 insertions(+), 61 deletions(-) diff --git a/docs/docs/configuration/configuration-object.mdx b/docs/docs/configuration/configuration-object.mdx index fbd5982f6b..b27487477e 100644 --- a/docs/docs/configuration/configuration-object.mdx +++ b/docs/docs/configuration/configuration-object.mdx @@ -141,6 +141,12 @@ An object that configures the language and content of the markup editor. This ca A URL to load `content` from. It has to be a valid URL that is CORS-enabled. The URL is only fetched if `content` property had no value. +- `hidden`: + Type: [`boolean | undefined`](../api/interfaces/Config.md#hidden) + Default: `""` + If `true`, the title of the code editor is hidden, however its code is still evaluated. + This can be useful in embedded playgrounds (e.g. for hiding unnecessary code). + - `hiddenContent`: Type: [`string | undefined`](../api/interfaces/Config.md#hiddencontent) Default: `undefined` @@ -153,18 +159,12 @@ An object that configures the language and content of the markup editor. This ca A URL to load `hiddenContent` from. It has to be a valid URL that is CORS-enabled. The URL is only fetched if `hiddenContent` property had no value. -- `foldedLines`: - Type: [`Array<{ from: number; to: number }> | undefined`](../api/interfaces/Config.md#foldedlines) - Default: `undefined` - Lines that get folded when the editor loads. The code can be unfolded by clicking on arrow beside the line. - This can be useful for less relevant code in embedded playgrounds. - - `title`: Type: [`string | undefined`](../api/interfaces/Config.md#title) Default: `""` If set, this is used as the title of the editor in the UI, overriding the default title set to the language name (e.g. "Python" can be used instead of "Py (Wasm)"). -- `hideTitle`: +- `hideTitle` (**deprecated**, use `hidden` instead): Type: [`boolean | undefined`](../api/interfaces/Config.md#hidetitle) Default: `""` If `true`, the title of the code editor is hidden, however its code is still evaluated. @@ -186,6 +186,12 @@ An object that configures the language and content of the markup editor. This ca The initial position of the cursor in the code editor. Example: `{lineNumber: 5, column: 10}` +- `foldedLines`: + Type: [`Array<{ from: number; to: number }> | undefined`](../api/interfaces/Config.md#foldedlines) + Default: `undefined` + Lines that get folded when the editor loads. The code can be unfolded by clicking on arrow beside the line. + This can be useful for less relevant code in embedded playgrounds. + ### `style` Type: [`Editor`](../api/interfaces/Config.md#style) diff --git a/docs/docs/languages/svelte.mdx b/docs/docs/languages/svelte.mdx index ce4c89aef0..a78f31998b 100644 --- a/docs/docs/languages/svelte.mdx +++ b/docs/docs/languages/svelte.mdx @@ -116,7 +116,7 @@ import Counter from './Counter.svelte'; -Please note that LiveCodes [does not have the concept of a file system](../features/projects.mdx). However, you can configure [editor options](../configuration/configuration-object.mdx#markup) like `title`, `order` and `hideTitle` to simulate multiple files, change editor order or even hide editors. +Please note that LiveCodes [does not have the concept of a file system](../features/projects.mdx). However, you can configure [editor options](../configuration/configuration-object.mdx#markup) like `title`, `order` and `hidden` to simulate multiple files, change editor order or even hide editors. Example: diff --git a/docs/docs/languages/vue.mdx b/docs/docs/languages/vue.mdx index ff0928adad..01abb418d0 100644 --- a/docs/docs/languages/vue.mdx +++ b/docs/docs/languages/vue.mdx @@ -188,7 +188,7 @@ import Counter from './Counter.vue'; -Please note that LiveCodes [does not have the concept of a file system](../features/projects.mdx). However, you can configure [editor options](../configuration/configuration-object.mdx#markup) like `title`, `order` and `hideTitle` to simulate multiple files, change editor order or even hide editors. +Please note that LiveCodes [does not have the concept of a file system](../features/projects.mdx). However, you can configure [editor options](../configuration/configuration-object.mdx#markup) like `title`, `order` and `hidden` to simulate multiple files, change editor order or even hide editors. Example: diff --git a/src/livecodes/config/__tests__/build-config.spec.ts b/src/livecodes/config/__tests__/build-config.spec.ts index db6f2ea8c9..fbe4957839 100644 --- a/src/livecodes/config/__tests__/build-config.spec.ts +++ b/src/livecodes/config/__tests__/build-config.spec.ts @@ -505,12 +505,12 @@ describe('loadParamConfig', () => { expect(output.customSettings).toEqual({ template: { prerender: false } }); }); - test('?markup.hideTitle=true&script.title=App.jsx', () => { + test('?markup.hidden=true&script.title=App.jsx', () => { const output: Partial = loadParamConfig(defaultConfig, { - 'markup.hideTitle': true, + 'markup.hidden': true, 'script.title': 'App.jsx', }); - expect(output.markup?.hideTitle).toEqual(true); + expect(output.markup?.hidden).toEqual(true); expect(output.script?.title).toEqual('App.jsx'); }); }); diff --git a/src/livecodes/config/build-config.ts b/src/livecodes/config/build-config.ts index 5f644a8399..94e72dc7db 100644 --- a/src/livecodes/config/build-config.ts +++ b/src/livecodes/config/build-config.ts @@ -10,9 +10,8 @@ import type { } from '../models'; import { addProp, cloneObject, decodeHTML, removeDuplicates, stringToValidJson } from '../utils'; import { decompress } from '../utils/compression'; +import { upgradeAndValidate } from './config'; import { defaultConfig } from './default-config'; -import { upgradeAndValidate } from './index'; -// eslint-disable-next-line import/order import { getMainFile, isEditorId } from './utils'; /** @@ -370,7 +369,7 @@ export const loadParamConfig = (config: Config, params: UrlQueryParams): Partial } } - // ?markup.hideTitle=true&script.title=App.jsx + // ?markup.hidden=true&script.title=App.jsx // ?customSettings.template.prerender=false Object.keys(params).forEach((k) => { if ( diff --git a/src/livecodes/config/upgrade-config.ts b/src/livecodes/config/upgrade-config.ts index 822196d883..8b42d12a58 100644 --- a/src/livecodes/config/upgrade-config.ts +++ b/src/livecodes/config/upgrade-config.ts @@ -7,6 +7,21 @@ interface genericConfig extends Config { } const upgradeSteps = [ + { + to: '48', + upgrade: (oldConfig: genericConfig, version: string): genericConfig => { + const config: genericConfig = clone(oldConfig); + ['markup', 'style', 'script'].forEach((prop) => { + if ((config[prop] as any)?.hideTitle) { + config[prop] = renameProperty(config[prop], 'hideTitle', 'hidden'); + } + }); + return { + ...config, + version, + }; + }, + }, { to: '18', upgrade: (oldConfig: genericConfig, version: string): genericConfig => { diff --git a/src/livecodes/config/validate-config.ts b/src/livecodes/config/validate-config.ts index 894d8526f4..5dc47eea81 100644 --- a/src/livecodes/config/validate-config.ts +++ b/src/livecodes/config/validate-config.ts @@ -78,7 +78,7 @@ export const validateConfig = (config: Partial): Partial => { ...(is(x.title, 'string') ? { title: x.title } : {}), ...(is(x.content, 'string') ? { content: x.content } : {}), ...(is(x.contentUrl, 'string') ? { contentUrl: x.contentUrl } : {}), - ...(is(x.hideTitle, 'boolean') ? { hideTitle: x.hideTitle } : {}), + ...(is(x.hidden, 'boolean') ? { hidden: x.hidden } : {}), ...(is(x.hiddenContent, 'string') ? { hiddenContent: x.hiddenContent } : {}), ...(is(x.hiddenContentUrl, 'string') ? { hiddenContentUrl: x.hiddenContentUrl } : {}), ...(is(x.foldedLines, 'array', 'object') && x.foldedLines?.every(isFoldedLines) @@ -89,15 +89,30 @@ export const validateConfig = (config: Partial): Partial => { ...(is(x.position, 'object') ? { position: x.position } : {}), }); - const validateFileProps = (x: Partial): Required | null => - x.filename && is(x.filename, 'string') && x.filename.includes('.') + const removeLeadingSlash = (x: string) => + x.startsWith('/') + ? x.slice(1) + : x.startsWith('./') + ? x.slice(2) + : x.startsWith('../') + ? x.slice(3) + : x.startsWith('~/') + ? x.slice(2) + : x; + + const validateFileProps = (x: Partial): SourceFile | undefined => + x.filename && is(x.filename, 'string') && removeLeadingSlash(x.filename).includes('.') ? { - filename: x.filename, + filename: removeLeadingSlash(x.filename), content: is(x.content, 'string') ? x.content ?? '' : '', language: getLanguageByAlias(x.language || getFileExtension(x.filename)) || 'html', - hidden: is(x.hidden, 'boolean') ? x.hidden ?? false : false, + ...(is(x.hidden, 'boolean') ? { hidden: x.hidden } : {}), + ...(is(x.position, 'object') ? { position: x.position } : {}), + ...(is(x.foldedLines, 'array', 'object') && x.foldedLines?.every(isFoldedLines) + ? { foldedLines: x.foldedLines } + : {}), } - : null; + : undefined; const validateActiveEditor = (config: Partial) => includes([...editorIds, ...(config.files || []).map((f) => f.filename)], config.activeEditor); @@ -176,11 +191,13 @@ export const validateConfig = (config: Partial): Partial => { : {}), ...(is((config as MultiFileConfig).files, 'array', 'object') ? { - files: (config as MultiFileConfig).files - .map((f) => validateFileProps(f)) - .filter((f) => f != null) as SourceFile[], + files: + (config.files + ?.map((f) => validateFileProps(f)) + .filter((f) => f != null) as Config['files']) || [], } : {}), + ...(is(config.mainFile, 'string') ? { mainFile: config.mainFile } : {}), ...(is(config.tools, 'object') ? { tools: validateToolsProps(config.tools as Config['tools']) } : {}), diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index 8bf9c6fc95..ea2854b202 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -426,7 +426,7 @@ export const setEditorTitle = (editorId: EditorId, title: string) => { const language = getLanguageByAlias(title); if (!editorTitle || !language) return; const config = getConfig(); - if (config[editorId].hideTitle) { + if (config[editorId].hidden) { editorTitleContainer.style.display = 'none'; return; } @@ -674,11 +674,11 @@ const createEditors = async (config: Config) => { ...baseOptions, container, editorId, - language: file.language, - value: file.content, + language: file.language!, + value: file.content || '', }; const editor = await createEditor(editorOptions); - editorLanguages[editorId] = file.language; + editorLanguages[editorId] = file.language!; editors[editorId] = editor; editorIds.push(editorId); } @@ -749,9 +749,8 @@ const updateEditors = async (editors: Editors, config: Config) => { if (config.foldRegions) { await editor.foldRegions?.(); } - const foldedLines = 'foldedLines' in source ? source.foldedLines : undefined; - if (foldedLines?.length) { - await editor.foldLines?.(foldedLines); + if (source.foldedLines?.length) { + await editor.foldLines?.(source.foldedLines); } } }; @@ -860,12 +859,7 @@ const showMode = (mode?: Config['mode'], view?: Config['view']) => { const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = false) => { const config = getConfig(); - if ( - (isEditorId(editorId) && config[editorId].hideTitle) || - config.files.find((f) => f.filename === editorId)?.hidden - ) { - return; - } + if (getSource(editorId, config)?.hidden) return; const titles = [...UI.getEditorTitles()]; const editorIsVisible = () => titles.map((title) => title.dataset.editor).includes(editorId); if (!editorIsVisible()) { @@ -1674,17 +1668,17 @@ const share = async ( markup: { ...config.markup, title: undefined, - hideTitle: undefined, + hidden: undefined, }, style: { ...config.style, title: undefined, - hideTitle: undefined, + hidden: undefined, }, script: { ...config.script, title: undefined, - hideTitle: undefined, + hidden: undefined, }, tools: { ...config.tools, @@ -2517,6 +2511,7 @@ const loadStarterTemplate = async (templateName: Template['name'], checkSaved = }; const getPlaygroundState = (): Config & Code => { + // TODO: handle files const config = getConfig(); const cachedCode = getCachedCode(); return { @@ -3030,13 +3025,7 @@ const handleKeyboardShortcuts = () => { config.files.length ? config.files.map((f) => f.filename) : (['markup', 'style', 'script'] as EditorId[]) - ).filter((id) => { - const src = getSource(id, config); - if (!src) return false; - if ('hideTitle' in src) return src.hideTitle !== true; - if ('hidden' in src) return src.hidden !== true; - return true; - }); + ).filter((id) => getSource(id, config)?.hidden !== true); const editorNumbers = editorIds.map((_, id) => String(id + 1)); if (ctrl(e) && e.altKey && [...editorNumbers, 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 9b6fc8dd22..bbfc5baf6e 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -624,12 +624,15 @@ export type MultiFileContentConfig = Pick< | 'version' >; -export interface SourceFile { - filename: string; - content: string; - language: Language; - hidden: boolean; -} +export type SourceFile = Prettify< + { + /** + * Name of the file with extension, including path (e.g. `index.html` or `components/Counter.jsx`). + */ + filename: string; + } & Required> & + Partial> +>; /** * These are properties that define how the app behaves. @@ -1159,6 +1162,13 @@ export interface Editor { */ contentUrl?: string; + /** + * If `true`, the code editor is hidden, however its code is still evaluated. + * + * This can be useful in embedded playgrounds (e.g. for hiding irrelevant code). + */ + hidden?: boolean; + /** * Hidden content that gets evaluated without being visible in the code editor. * @@ -1173,14 +1183,6 @@ export interface Editor { */ hiddenContentUrl?: string; - /** - * Lines that get folded when the editor loads. - * - * This can be used for less relevant content. - * @example [{ from: 5, to: 8 }, { from: 15, to: 20 }] - */ - foldedLines?: Array<{ from: number; to: number }>; - /** * If set, this is used as the title of the editor in the UI, * overriding the default title set to the language name @@ -1189,6 +1191,9 @@ export interface Editor { title?: string; /** + * @deprecated + * Use `hidden` instead. + * * If `true`, the title of the code editor is hidden, however its code is still evaluated. * * This can be useful in embedded playgrounds (e.g. for hiding unnecessary code). @@ -1206,6 +1211,14 @@ export interface Editor { */ selector?: string; + /** + * Lines that get folded when the editor loads. + * + * This can be used for less relevant content. + * @example [{ from: 5, to: 8 }, { from: 15, to: 20 }] + */ + foldedLines?: Array<{ from: number; to: number }>; + /** * The initial position of the cursor in the code editor. * @example {lineNumber: 5, column: 10} From bc35c7bf365fb26d63dcb16c8c468feace57b5d9 Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sun, 9 Nov 2025 00:43:27 +0200 Subject: [PATCH 008/143] add multi-file support in language specs --- src/livecodes/config/utils.ts | 19 ++++++++++++++++++- src/livecodes/config/validate-config.ts | 19 +++++-------------- .../art-template/lang-art-template.ts | 1 + .../languages/asciidoc/lang-asciidoc.ts | 1 + src/livecodes/languages/babel/lang-babel.ts | 1 + src/livecodes/languages/bbcode/lang-bbcode.ts | 1 + src/livecodes/languages/civet/lang-civet.ts | 1 + .../clojurescript/lang-clojurescript.ts | 1 + .../coffeescript/lang-coffeescript.ts | 1 + src/livecodes/languages/css/lang-css.ts | 1 + .../languages/diagrams/lang-diagrams.ts | 1 + src/livecodes/languages/dot/lang-dot.ts | 1 + src/livecodes/languages/ejs/lang-ejs.ts | 1 + src/livecodes/languages/eta/lang-eta.ts | 1 + src/livecodes/languages/flow/lang-flow.ts | 1 + src/livecodes/languages/haml/lang-haml.ts | 1 + .../languages/handlebars/lang-handlebars.ts | 1 + src/livecodes/languages/html/lang-html.ts | 1 + src/livecodes/languages/imba/lang-imba.ts | 1 + .../languages/javascript/lang-javascript.ts | 1 + src/livecodes/languages/jinja/lang-jinja.ts | 1 + src/livecodes/languages/jsx/lang-jsx.ts | 1 + src/livecodes/languages/jsx/lang-tsx.ts | 1 + src/livecodes/languages/less/lang-less.ts | 1 + src/livecodes/languages/liquid/lang-liquid.ts | 1 + .../languages/livescript/lang-livescript.ts | 1 + .../languages/markdown/lang-markdown.ts | 1 + src/livecodes/languages/mdx/lang-mdx.ts | 1 + src/livecodes/languages/mjml/lang-mjml.ts | 1 + .../languages/mustache/lang-mustache.ts | 1 + .../languages/nunjucks/lang-nunjucks.ts | 1 + src/livecodes/languages/pug/lang-pug.ts | 1 + .../react-native/lang-react-native-tsx.ts | 1 + .../react-native/lang-react-native.ts | 1 + .../languages/react/lang-react-tsx.ts | 1 + src/livecodes/languages/react/lang-react.ts | 1 + src/livecodes/languages/reason/lang-reason.ts | 1 + .../languages/rescript/lang-rescript.ts | 1 + .../languages/richtext/lang-richtext.ts | 1 + src/livecodes/languages/riot/lang-riot.ts | 1 + src/livecodes/languages/scss/lang-sass.ts | 1 + src/livecodes/languages/scss/lang-scss.ts | 1 + .../languages/solid/lang-solid-tsx.ts | 3 ++- src/livecodes/languages/solid/lang-solid.ts | 1 + .../languages/stencil/lang-stencil.ts | 1 + src/livecodes/languages/stylis/lang-stylis.ts | 1 + src/livecodes/languages/stylus/lang-stylus.ts | 1 + .../languages/sucrase/lang-sucrase.ts | 1 + src/livecodes/languages/svelte/lang-svelte.ts | 1 + src/livecodes/languages/twig/lang-twig.ts | 1 + .../languages/typescript/lang-typescript.ts | 1 + src/livecodes/languages/utils.ts | 3 +++ src/livecodes/languages/vento/lang-vento.ts | 1 + src/livecodes/languages/vue/lang-vue.ts | 1 + src/livecodes/languages/vue2/lang-vue2.ts | 1 + src/sdk/models.ts | 6 ++++-- 56 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/livecodes/config/utils.ts b/src/livecodes/config/utils.ts index a7b21ea496..deeb74ff8b 100644 --- a/src/livecodes/config/utils.ts +++ b/src/livecodes/config/utils.ts @@ -1,5 +1,11 @@ -import { getLanguageEditorId } from '../languages/utils'; +import { + getFileLanguage, + getLanguageEditorId, + languageIsEnabled, + supportsMultiFile, +} from '../languages/utils'; import type { Config, EditorId } from '../models'; +import { removeLeadingSlash } from '../utils/utils'; export const isEditorId = (editorId: string): editorId is 'markup' | 'style' | 'script' => editorId === 'markup' || editorId === 'style' || editorId === 'script'; @@ -16,3 +22,14 @@ export const getMainFile = (config: Config) => ? 'index.html' : config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename || 'index.html'; + +export const validateFileName = (filename: string, config: Partial) => { + filename = removeLeadingSlash(filename); + if (!filename) return false; + if (filename[0] >= '0' && filename[0] <= '9') return false; + const language = getFileLanguage(filename); + if (!language || !supportsMultiFile(language) || !languageIsEnabled(language, config as Config)) { + return false; + } + return true; +}; diff --git a/src/livecodes/config/validate-config.ts b/src/livecodes/config/validate-config.ts index 5dc47eea81..447ddb1e50 100644 --- a/src/livecodes/config/validate-config.ts +++ b/src/livecodes/config/validate-config.ts @@ -9,9 +9,9 @@ import type { Tool, ToolsPaneStatus, } from '../models'; -import { getFileExtension, removeDuplicates } from '../utils'; +import { getFileExtension, removeDuplicates, removeLeadingSlash } from '../utils'; import { defaultConfig } from './default-config'; -import { isEditorId } from './utils'; +import { isEditorId, validateFileName } from './utils'; export const validateConfig = (config: Partial): Partial => { type types = 'array' | 'boolean' | 'object' | 'number' | 'string' | 'undefined'; @@ -89,17 +89,6 @@ export const validateConfig = (config: Partial): Partial => { ...(is(x.position, 'object') ? { position: x.position } : {}), }); - const removeLeadingSlash = (x: string) => - x.startsWith('/') - ? x.slice(1) - : x.startsWith('./') - ? x.slice(2) - : x.startsWith('../') - ? x.slice(3) - : x.startsWith('~/') - ? x.slice(2) - : x; - const validateFileProps = (x: Partial): SourceFile | undefined => x.filename && is(x.filename, 'string') && removeLeadingSlash(x.filename).includes('.') ? { @@ -194,7 +183,9 @@ export const validateConfig = (config: Partial): Partial => { files: (config.files ?.map((f) => validateFileProps(f)) - .filter((f) => f != null) as Config['files']) || [], + .filter( + (f) => f != null && validateFileName(f.filename, config), + ) as Config['files']) || [], } : {}), ...(is(config.mainFile, 'string') ? { mainFile: config.mainFile } : {}), diff --git a/src/livecodes/languages/art-template/lang-art-template.ts b/src/livecodes/languages/art-template/lang-art-template.ts index 264c70a9a8..dead6e3fef 100644 --- a/src/livecodes/languages/art-template/lang-art-template.ts +++ b/src/livecodes/languages/art-template/lang-art-template.ts @@ -20,4 +20,5 @@ export const artTemplate: LanguageSpecs = { extensions: ['art', 'art-template'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/asciidoc/lang-asciidoc.ts b/src/livecodes/languages/asciidoc/lang-asciidoc.ts index 85de07c8bd..09ecde27da 100644 --- a/src/livecodes/languages/asciidoc/lang-asciidoc.ts +++ b/src/livecodes/languages/asciidoc/lang-asciidoc.ts @@ -19,4 +19,5 @@ export const asciidoc: LanguageSpecs = { }, extensions: ['adoc', 'asciidoc', 'asc'], editor: 'markup', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/babel/lang-babel.ts b/src/livecodes/languages/babel/lang-babel.ts index 61a3d17370..b8ec3ed449 100644 --- a/src/livecodes/languages/babel/lang-babel.ts +++ b/src/livecodes/languages/babel/lang-babel.ts @@ -33,4 +33,5 @@ export const babel: LanguageSpecs = { extensions: ['es', 'babel'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/bbcode/lang-bbcode.ts b/src/livecodes/languages/bbcode/lang-bbcode.ts index b7873e60cc..16918b533f 100644 --- a/src/livecodes/languages/bbcode/lang-bbcode.ts +++ b/src/livecodes/languages/bbcode/lang-bbcode.ts @@ -14,4 +14,5 @@ export const bbcode: LanguageSpecs = { }, extensions: ['bbcode', 'bb'], editor: 'markup', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/civet/lang-civet.ts b/src/livecodes/languages/civet/lang-civet.ts index 87402756f8..6e11477209 100644 --- a/src/livecodes/languages/civet/lang-civet.ts +++ b/src/livecodes/languages/civet/lang-civet.ts @@ -13,4 +13,5 @@ export const civet: LanguageSpecs = { extensions: ['civet'], editor: 'script', editorLanguage: 'coffeescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/clojurescript/lang-clojurescript.ts b/src/livecodes/languages/clojurescript/lang-clojurescript.ts index 60199edeee..69aacbf785 100644 --- a/src/livecodes/languages/clojurescript/lang-clojurescript.ts +++ b/src/livecodes/languages/clojurescript/lang-clojurescript.ts @@ -36,4 +36,5 @@ export const clojurescript: LanguageSpecs = { extensions: ['cljs', 'clj', 'cljc', 'edn', 'clojure'], editor: 'script', editorLanguage: 'clojure', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/coffeescript/lang-coffeescript.ts b/src/livecodes/languages/coffeescript/lang-coffeescript.ts index 10e8d21e14..3fd64f6910 100644 --- a/src/livecodes/languages/coffeescript/lang-coffeescript.ts +++ b/src/livecodes/languages/coffeescript/lang-coffeescript.ts @@ -18,4 +18,5 @@ export const coffeescript: LanguageSpecs = { }, extensions: ['coffee'], editor: 'script', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/css/lang-css.ts b/src/livecodes/languages/css/lang-css.ts index 29d5ac73fc..bfb215b5cd 100644 --- a/src/livecodes/languages/css/lang-css.ts +++ b/src/livecodes/languages/css/lang-css.ts @@ -14,4 +14,5 @@ export const css: LanguageSpecs = { }, extensions: ['css'], editor: 'style', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/diagrams/lang-diagrams.ts b/src/livecodes/languages/diagrams/lang-diagrams.ts index 4d067d4a57..7a597d54a5 100644 --- a/src/livecodes/languages/diagrams/lang-diagrams.ts +++ b/src/livecodes/languages/diagrams/lang-diagrams.ts @@ -20,4 +20,5 @@ export const diagrams: LanguageSpecs = { extensions: ['diagrams', 'diagram', 'graph', 'plt'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/dot/lang-dot.ts b/src/livecodes/languages/dot/lang-dot.ts index 8e5a4b5946..6ba907844b 100644 --- a/src/livecodes/languages/dot/lang-dot.ts +++ b/src/livecodes/languages/dot/lang-dot.ts @@ -19,4 +19,5 @@ export const dot: LanguageSpecs = { extensions: ['dot'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/ejs/lang-ejs.ts b/src/livecodes/languages/ejs/lang-ejs.ts index a18c844071..8cd6e02261 100644 --- a/src/livecodes/languages/ejs/lang-ejs.ts +++ b/src/livecodes/languages/ejs/lang-ejs.ts @@ -19,4 +19,5 @@ export const ejs: LanguageSpecs = { extensions: ['ejs'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/eta/lang-eta.ts b/src/livecodes/languages/eta/lang-eta.ts index 3b3cdfa8b7..4222132478 100644 --- a/src/livecodes/languages/eta/lang-eta.ts +++ b/src/livecodes/languages/eta/lang-eta.ts @@ -19,4 +19,5 @@ export const eta: LanguageSpecs = { extensions: ['eta'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/flow/lang-flow.ts b/src/livecodes/languages/flow/lang-flow.ts index 198df902b8..ff54aea0fe 100644 --- a/src/livecodes/languages/flow/lang-flow.ts +++ b/src/livecodes/languages/flow/lang-flow.ts @@ -25,4 +25,5 @@ export const flow: LanguageSpecs = { extensions: ['flow'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/haml/lang-haml.ts b/src/livecodes/languages/haml/lang-haml.ts index d3c8384055..df498ae888 100644 --- a/src/livecodes/languages/haml/lang-haml.ts +++ b/src/livecodes/languages/haml/lang-haml.ts @@ -13,4 +13,5 @@ export const haml: LanguageSpecs = { }, extensions: ['haml'], editor: 'markup', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/handlebars/lang-handlebars.ts b/src/livecodes/languages/handlebars/lang-handlebars.ts index 54a0311508..30fa5023df 100644 --- a/src/livecodes/languages/handlebars/lang-handlebars.ts +++ b/src/livecodes/languages/handlebars/lang-handlebars.ts @@ -22,4 +22,5 @@ export const handlebars: LanguageSpecs = { extensions: ['hbs', 'handlebars'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/html/lang-html.ts b/src/livecodes/languages/html/lang-html.ts index b323d67252..b089dbcd55 100644 --- a/src/livecodes/languages/html/lang-html.ts +++ b/src/livecodes/languages/html/lang-html.ts @@ -14,4 +14,5 @@ export const html: LanguageSpecs = { }, extensions: ['html', 'htm'], editor: 'markup', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/imba/lang-imba.ts b/src/livecodes/languages/imba/lang-imba.ts index df40e18c54..adfb7b2721 100644 --- a/src/livecodes/languages/imba/lang-imba.ts +++ b/src/livecodes/languages/imba/lang-imba.ts @@ -16,4 +16,5 @@ export const imba: LanguageSpecs = { }, extensions: ['imba'], editor: 'script', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/javascript/lang-javascript.ts b/src/livecodes/languages/javascript/lang-javascript.ts index eab21356ce..4ad3166e35 100644 --- a/src/livecodes/languages/javascript/lang-javascript.ts +++ b/src/livecodes/languages/javascript/lang-javascript.ts @@ -15,4 +15,5 @@ export const javascript: LanguageSpecs = { }, extensions: ['js'], editor: 'script', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/jinja/lang-jinja.ts b/src/livecodes/languages/jinja/lang-jinja.ts index 124c5db363..57b82fc218 100644 --- a/src/livecodes/languages/jinja/lang-jinja.ts +++ b/src/livecodes/languages/jinja/lang-jinja.ts @@ -27,4 +27,5 @@ export const jinja: LanguageSpecs = { extensions: ['jinja'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/jsx/lang-jsx.ts b/src/livecodes/languages/jsx/lang-jsx.ts index 75043344df..f5ff6837b9 100644 --- a/src/livecodes/languages/jsx/lang-jsx.ts +++ b/src/livecodes/languages/jsx/lang-jsx.ts @@ -12,4 +12,5 @@ export const jsx: LanguageSpecs = { extensions: ['jsx'], editor: 'script', editorLanguage: 'javascript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/jsx/lang-tsx.ts b/src/livecodes/languages/jsx/lang-tsx.ts index 969228af34..776d4bcb5b 100644 --- a/src/livecodes/languages/jsx/lang-tsx.ts +++ b/src/livecodes/languages/jsx/lang-tsx.ts @@ -12,4 +12,5 @@ export const tsx: LanguageSpecs = { extensions: ['tsx'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/less/lang-less.ts b/src/livecodes/languages/less/lang-less.ts index 5c9e2b2a26..0083c71abb 100644 --- a/src/livecodes/languages/less/lang-less.ts +++ b/src/livecodes/languages/less/lang-less.ts @@ -23,4 +23,5 @@ export const less: LanguageSpecs = { }, extensions: ['less'], editor: 'style', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/liquid/lang-liquid.ts b/src/livecodes/languages/liquid/lang-liquid.ts index f2707129d5..d4d837ac83 100644 --- a/src/livecodes/languages/liquid/lang-liquid.ts +++ b/src/livecodes/languages/liquid/lang-liquid.ts @@ -19,4 +19,5 @@ export const liquid: LanguageSpecs = { extensions: ['liquid', 'liquidjs'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/livescript/lang-livescript.ts b/src/livecodes/languages/livescript/lang-livescript.ts index a8db1be7fd..0fc601d528 100644 --- a/src/livecodes/languages/livescript/lang-livescript.ts +++ b/src/livecodes/languages/livescript/lang-livescript.ts @@ -18,4 +18,5 @@ export const livescript: LanguageSpecs = { }, extensions: ['ls'], editor: 'script', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/markdown/lang-markdown.ts b/src/livecodes/languages/markdown/lang-markdown.ts index 228630bf59..13ec3f4170 100644 --- a/src/livecodes/languages/markdown/lang-markdown.ts +++ b/src/livecodes/languages/markdown/lang-markdown.ts @@ -19,4 +19,5 @@ export const markdown: LanguageSpecs = { }, extensions: ['md', 'markdown', 'mdown', 'mkdn'], editor: 'markup', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/mdx/lang-mdx.ts b/src/livecodes/languages/mdx/lang-mdx.ts index 34335ed11b..4a9a370372 100644 --- a/src/livecodes/languages/mdx/lang-mdx.ts +++ b/src/livecodes/languages/mdx/lang-mdx.ts @@ -50,4 +50,5 @@ export const mdx: LanguageSpecs = { extensions: ['mdx'], editor: 'markup', editorLanguage: 'markdown', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/mjml/lang-mjml.ts b/src/livecodes/languages/mjml/lang-mjml.ts index e2b4130b7c..a165494a6b 100644 --- a/src/livecodes/languages/mjml/lang-mjml.ts +++ b/src/livecodes/languages/mjml/lang-mjml.ts @@ -32,4 +32,5 @@ export const mjml: LanguageSpecs = { extensions: ['mjml'], editor: 'markup', editorLanguage: 'xml', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/mustache/lang-mustache.ts b/src/livecodes/languages/mustache/lang-mustache.ts index 1b7f283e8f..dd03c7ab6f 100644 --- a/src/livecodes/languages/mustache/lang-mustache.ts +++ b/src/livecodes/languages/mustache/lang-mustache.ts @@ -19,4 +19,5 @@ export const mustache: LanguageSpecs = { extensions: ['mustache'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/nunjucks/lang-nunjucks.ts b/src/livecodes/languages/nunjucks/lang-nunjucks.ts index 2aa99f9fae..57260d40f9 100644 --- a/src/livecodes/languages/nunjucks/lang-nunjucks.ts +++ b/src/livecodes/languages/nunjucks/lang-nunjucks.ts @@ -22,4 +22,5 @@ export const nunjucks: LanguageSpecs = { extensions: ['njk', 'nunjucks'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/pug/lang-pug.ts b/src/livecodes/languages/pug/lang-pug.ts index d96e8db06a..698e9975fc 100644 --- a/src/livecodes/languages/pug/lang-pug.ts +++ b/src/livecodes/languages/pug/lang-pug.ts @@ -22,4 +22,5 @@ export const pug: LanguageSpecs = { }, extensions: ['pug', 'jade'], editor: 'markup', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/react-native/lang-react-native-tsx.ts b/src/livecodes/languages/react-native/lang-react-native-tsx.ts index 8e55b94dff..f9e90391bc 100644 --- a/src/livecodes/languages/react-native/lang-react-native-tsx.ts +++ b/src/livecodes/languages/react-native/lang-react-native-tsx.ts @@ -13,4 +13,5 @@ export const reactNativeTsx: LanguageSpecs = { extensions: ['react-native.tsx'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/react-native/lang-react-native.ts b/src/livecodes/languages/react-native/lang-react-native.ts index 7d42309368..fa448635c3 100644 --- a/src/livecodes/languages/react-native/lang-react-native.ts +++ b/src/livecodes/languages/react-native/lang-react-native.ts @@ -33,4 +33,5 @@ export const reactNative: LanguageSpecs = { extensions: ['react-native.jsx'], editor: 'script', editorLanguage: 'javascript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/react/lang-react-tsx.ts b/src/livecodes/languages/react/lang-react-tsx.ts index ac3bdd9de8..d744e3f947 100644 --- a/src/livecodes/languages/react/lang-react-tsx.ts +++ b/src/livecodes/languages/react/lang-react-tsx.ts @@ -12,4 +12,5 @@ export const reactTsx: LanguageSpecs = { extensions: ['react.tsx'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/react/lang-react.ts b/src/livecodes/languages/react/lang-react.ts index 9ed448adf1..71eea740cf 100644 --- a/src/livecodes/languages/react/lang-react.ts +++ b/src/livecodes/languages/react/lang-react.ts @@ -39,4 +39,5 @@ export const react: LanguageSpecs = { extensions: ['react.jsx', 'react-jsx'], editor: 'script', editorLanguage: 'javascript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/reason/lang-reason.ts b/src/livecodes/languages/reason/lang-reason.ts index 5d87d8d246..abcecfc457 100644 --- a/src/livecodes/languages/reason/lang-reason.ts +++ b/src/livecodes/languages/reason/lang-reason.ts @@ -11,4 +11,5 @@ export const reason: LanguageSpecs = { extensions: ['re', 'rei'], editor: 'script', editorLanguage: 'javascript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/rescript/lang-rescript.ts b/src/livecodes/languages/rescript/lang-rescript.ts index 96ea1041fb..43fd92bdae 100644 --- a/src/livecodes/languages/rescript/lang-rescript.ts +++ b/src/livecodes/languages/rescript/lang-rescript.ts @@ -26,4 +26,5 @@ export const rescript: LanguageSpecs = { extensions: ['res', 'resi'], editor: 'script', editorLanguage: 'javascript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/richtext/lang-richtext.ts b/src/livecodes/languages/richtext/lang-richtext.ts index 4f282cf1ea..8d62e4fea6 100644 --- a/src/livecodes/languages/richtext/lang-richtext.ts +++ b/src/livecodes/languages/richtext/lang-richtext.ts @@ -14,4 +14,5 @@ export const richtext: LanguageSpecs = { extensions: ['rte', 'rte.html', 'rich'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/riot/lang-riot.ts b/src/livecodes/languages/riot/lang-riot.ts index eb0e5bf11b..5a156b86df 100644 --- a/src/livecodes/languages/riot/lang-riot.ts +++ b/src/livecodes/languages/riot/lang-riot.ts @@ -23,4 +23,5 @@ export const riot: LanguageSpecs = { }, extensions: ['riot', 'riotjs'], editor: 'script', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/scss/lang-sass.ts b/src/livecodes/languages/scss/lang-sass.ts index 1559a13880..663b36bc8e 100644 --- a/src/livecodes/languages/scss/lang-sass.ts +++ b/src/livecodes/languages/scss/lang-sass.ts @@ -6,4 +6,5 @@ export const sass: LanguageSpecs = { compiler: 'scss', extensions: ['sass'], editor: 'style', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/scss/lang-scss.ts b/src/livecodes/languages/scss/lang-scss.ts index 4affdd6687..7418199434 100644 --- a/src/livecodes/languages/scss/lang-scss.ts +++ b/src/livecodes/languages/scss/lang-scss.ts @@ -18,4 +18,5 @@ export const scss: LanguageSpecs = { }, extensions: ['scss'], editor: 'style', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/solid/lang-solid-tsx.ts b/src/livecodes/languages/solid/lang-solid-tsx.ts index bde8639ca5..a8920710fb 100644 --- a/src/livecodes/languages/solid/lang-solid-tsx.ts +++ b/src/livecodes/languages/solid/lang-solid-tsx.ts @@ -9,7 +9,8 @@ export const solidTsx: LanguageSpecs = { pluginUrls: [parserPlugins.babel, parserPlugins.html], }, compiler: 'solid', - extensions: ['solid.tsx'], + extensions: ['solid.tsx', 'solid-tsx'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/solid/lang-solid.ts b/src/livecodes/languages/solid/lang-solid.ts index b0c2210cac..e2527ffe90 100644 --- a/src/livecodes/languages/solid/lang-solid.ts +++ b/src/livecodes/languages/solid/lang-solid.ts @@ -20,4 +20,5 @@ export const solid: LanguageSpecs = { extensions: ['solid.jsx'], editor: 'script', editorLanguage: 'javascript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/stencil/lang-stencil.ts b/src/livecodes/languages/stencil/lang-stencil.ts index 906ee582ac..106727cc64 100644 --- a/src/livecodes/languages/stencil/lang-stencil.ts +++ b/src/livecodes/languages/stencil/lang-stencil.ts @@ -34,4 +34,5 @@ export const stencil: LanguageSpecs = { extensions: ['stencil.tsx'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/stylis/lang-stylis.ts b/src/livecodes/languages/stylis/lang-stylis.ts index 1e80f3da8b..cb34ab4a80 100644 --- a/src/livecodes/languages/stylis/lang-stylis.ts +++ b/src/livecodes/languages/stylis/lang-stylis.ts @@ -14,4 +14,5 @@ export const stylis: LanguageSpecs = { extensions: ['stylis'], editor: 'style', editorLanguage: 'scss', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/stylus/lang-stylus.ts b/src/livecodes/languages/stylus/lang-stylus.ts index 8db5baa648..9dab34e087 100644 --- a/src/livecodes/languages/stylus/lang-stylus.ts +++ b/src/livecodes/languages/stylus/lang-stylus.ts @@ -10,4 +10,5 @@ export const stylus: LanguageSpecs = { }, extensions: ['styl'], editor: 'style', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/sucrase/lang-sucrase.ts b/src/livecodes/languages/sucrase/lang-sucrase.ts index 9459ac9fde..6019b08857 100644 --- a/src/livecodes/languages/sucrase/lang-sucrase.ts +++ b/src/livecodes/languages/sucrase/lang-sucrase.ts @@ -23,4 +23,5 @@ export const sucrase: LanguageSpecs = { extensions: ['sucrase'], editor: 'script', editorLanguage: 'typescript', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/svelte/lang-svelte.ts b/src/livecodes/languages/svelte/lang-svelte.ts index 9af5eb0a2e..a20e16be8d 100644 --- a/src/livecodes/languages/svelte/lang-svelte.ts +++ b/src/livecodes/languages/svelte/lang-svelte.ts @@ -45,6 +45,7 @@ export const svelte: LanguageSpecs = { }, extensions: ['svelte'], editor: 'script', + multiFileSupport: true, }; export const svelteApp: LanguageSpecs = { diff --git a/src/livecodes/languages/twig/lang-twig.ts b/src/livecodes/languages/twig/lang-twig.ts index e262f951f9..9f3e35e1f4 100644 --- a/src/livecodes/languages/twig/lang-twig.ts +++ b/src/livecodes/languages/twig/lang-twig.ts @@ -19,4 +19,5 @@ export const twig: LanguageSpecs = { extensions: ['twig'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/typescript/lang-typescript.ts b/src/livecodes/languages/typescript/lang-typescript.ts index 5aad236381..dab7665e80 100644 --- a/src/livecodes/languages/typescript/lang-typescript.ts +++ b/src/livecodes/languages/typescript/lang-typescript.ts @@ -46,4 +46,5 @@ export const typescript: LanguageSpecs = { }, extensions: ['ts', 'typescript'], editor: 'script', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/utils.ts b/src/livecodes/languages/utils.ts index 27bd6fafe5..6c467cc913 100644 --- a/src/livecodes/languages/utils.ts +++ b/src/livecodes/languages/utils.ts @@ -15,6 +15,9 @@ export const getLanguageByAlias = (alias: string = ''): Language | undefined => export const getFileLanguage = (filename: string) => getLanguageByAlias(filename.split('.').pop()); +export const supportsMultiFile = (language: Language) => + window.deps.languages.find((l) => l.name === language)?.multiFileSupport === true; + export const getLanguageTitle = (language: Language) => { const languageSpecs = window.deps.languages.find((lang) => lang.name === language); return languageSpecs?.longTitle || languageSpecs?.title || language.toUpperCase(); diff --git a/src/livecodes/languages/vento/lang-vento.ts b/src/livecodes/languages/vento/lang-vento.ts index a917ef9835..06d5cd2cf9 100644 --- a/src/livecodes/languages/vento/lang-vento.ts +++ b/src/livecodes/languages/vento/lang-vento.ts @@ -19,4 +19,5 @@ export const vento: LanguageSpecs = { extensions: ['vto', 'vento'], editor: 'markup', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/livecodes/languages/vue/lang-vue.ts b/src/livecodes/languages/vue/lang-vue.ts index aca5efd655..dc2147cc78 100644 --- a/src/livecodes/languages/vue/lang-vue.ts +++ b/src/livecodes/languages/vue/lang-vue.ts @@ -26,6 +26,7 @@ export const vue: LanguageSpecs = { extensions: ['vue', 'vue3'], editor: 'script', editorLanguage: 'html', + multiFileSupport: true, }; export const vueApp: LanguageSpecs = { diff --git a/src/livecodes/languages/vue2/lang-vue2.ts b/src/livecodes/languages/vue2/lang-vue2.ts index d150a15fb3..058cdfba09 100644 --- a/src/livecodes/languages/vue2/lang-vue2.ts +++ b/src/livecodes/languages/vue2/lang-vue2.ts @@ -25,4 +25,5 @@ export const vue2: LanguageSpecs = { extensions: ['vue2'], editor: 'script', editorLanguage: 'html', + multiFileSupport: true, }; diff --git a/src/sdk/models.ts b/src/sdk/models.ts index bbfc5baf6e..5da584e483 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -417,7 +417,7 @@ export interface MultiFileConfig files: Array<{ filename: string } & Partial>; } -export type SDKConfig = SingleFileConfig | MultiFileConfig; +export type SDKConfig = Prettify | Prettify; /** * The properties that define the content of the current [project](https://livecodes.io/docs/features/projects). @@ -1015,6 +1015,7 @@ export type Language = | 'solid' | 'solid.jsx' | 'solid.tsx' + | 'solid-tsx' | 'riot' | 'riotjs' | 'malina' @@ -1270,6 +1271,7 @@ export interface LanguageSpecs { editorLanguage?: Language; preset?: CssPresetId; largeDownload?: boolean; + multiFileSupport?: boolean; } export interface ProcessorSpecs { @@ -1587,7 +1589,7 @@ export interface CodeEditor { getLanguage: () => Language; setLanguage: (language: Language, value?: string) => void; getEditorId: () => string; - setEditorId: (filename: string) => void; + setEditorId: (filename: string, language?: Language) => void; focus: () => void; getPosition: () => EditorPosition; setPosition: (position: EditorPosition) => void; From f7274d90239b8b8b4fc9a970bcefa8ad2cfad38a Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sun, 9 Nov 2025 00:43:46 +0200 Subject: [PATCH 009/143] add file --- src/livecodes/UI/create-language-menus.ts | 43 +++--- src/livecodes/config/upgrade-config.ts | 2 +- src/livecodes/core.ts | 133 ++++++++++-------- src/livecodes/editor/codejar/codejar.ts | 7 +- src/livecodes/editor/codemirror/codemirror.ts | 7 +- src/livecodes/editor/monaco/monaco.ts | 27 ++-- .../i18n/locales/en/translation.lokalise.json | 10 +- src/livecodes/i18n/locales/en/translation.ts | 6 +- src/livecodes/styles/app.scss | 1 + src/livecodes/utils/utils.ts | 11 ++ 10 files changed, 151 insertions(+), 96 deletions(-) diff --git a/src/livecodes/UI/create-language-menus.ts b/src/livecodes/UI/create-language-menus.ts index 84dcdf003b..e794af7373 100644 --- a/src/livecodes/UI/create-language-menus.ts +++ b/src/livecodes/UI/create-language-menus.ts @@ -165,6 +165,7 @@ export const createLanguageMenus = ( export const createMultiFileEditorTab = ({ title, showEditor, + addFile, renameFile, deleteFile, isMainFile, @@ -172,23 +173,24 @@ export const createMultiFileEditorTab = ({ }: { title: string; showEditor: (filename: string) => void; - renameFile: (filename: string, newName: string) => void; + addFile?: (filename: string) => Promise; + renameFile: (filename: string, newName: string) => boolean; deleteFile: (filename: string) => void; isMainFile: boolean; isNewFile: boolean; }) => { let currentFileName = title; - const selector = document.querySelector(`.editor-title[data-editor="${title}"]`); + const selector = document.querySelector(`.editor-title[data-editor="${currentFileName}"]`); if (selector) return; const editorSelector = document.createElement('a'); editorSelector.href = '#'; editorSelector.classList.add('editor-title', 'noselect'); - editorSelector.dataset.editor = title; + editorSelector.dataset.editor = currentFileName; editorSelector.dataset.multiFile = 'true'; editorSelector.addEventListener('click', () => showEditor(currentFileName)); const label = document.createElement('span'); - label.innerHTML = title; + label.innerHTML = currentFileName; editorSelector.appendChild(label); if (!isMainFile) { @@ -198,12 +200,12 @@ export const createMultiFileEditorTab = ({ deleteButton.addEventListener('click', () => { if ( confirm( - window.deps.translateString('core.confirm.deleteFile', 'Delete file: {{title}}?', { - title, + window.deps.translateString('core.confirm.deleteFile', 'Delete file: {{filename}}?', { + filename: currentFileName, }), ) ) { - deleteFile(label.innerText); + deleteFile(currentFileName); editorSelector.remove(); } }); @@ -218,21 +220,22 @@ export const createMultiFileEditorTab = ({ selection?.addRange(range); }; - const accept = () => { - renameFile(currentFileName, label.innerText); + const accept = async () => { + const success = + isNewFile && typeof addFile === 'function' + ? await addFile(label.innerText) + : renameFile(currentFileName, label.innerText); + if (!success) { + onDblClick(); + return; + } label.contentEditable = 'false'; currentFileName = label.innerText; + isNewFile = false; showEditor(currentFileName); - window.removeEventListener('click', onClick); window.removeEventListener('keydown', onEnter); }; - const onClick = (event: MouseEvent) => { - if (event.target !== editorSelector.querySelector('span')) { - accept(); - } - }; - const onEnter = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); @@ -245,7 +248,6 @@ export const createMultiFileEditorTab = ({ label.contentEditable = 'true'; requestAnimationFrame(() => label.focus()); selectAll(label); - window.addEventListener('click', onClick, { capture: true }); window.addEventListener('keydown', onEnter); }; @@ -260,13 +262,14 @@ export const createMultiFileEditorTab = ({ } }; -export const createAddFileButton = (addFile: () => void) => { +export const createAddFileButton = ({ onclick: handleAddFileClick }: { onclick: () => void }) => { if (!document.querySelector('#add-file-button')) { const addFileButton = document.createElement('button'); addFileButton.id = 'add-file-button'; addFileButton.classList.add('app-menu-button', 'menu'); - addFileButton.innerHTML = ''; - addFileButton.addEventListener('click', addFile); + addFileButton.innerHTML = + ''; + addFileButton.addEventListener('click', handleAddFileClick); document.querySelector('#select-editor')?.appendChild(addFileButton); const scrollTo = document.createElement('div'); diff --git a/src/livecodes/config/upgrade-config.ts b/src/livecodes/config/upgrade-config.ts index 8b42d12a58..2664a4fd11 100644 --- a/src/livecodes/config/upgrade-config.ts +++ b/src/livecodes/config/upgrade-config.ts @@ -12,7 +12,7 @@ const upgradeSteps = [ upgrade: (oldConfig: genericConfig, version: string): genericConfig => { const config: genericConfig = clone(oldConfig); ['markup', 'style', 'script'].forEach((prop) => { - if ((config[prop] as any)?.hideTitle) { + if (config[prop] && 'hideTitle' in config[prop] && !('hidden' in config[prop])) { config[prop] = renameProperty(config[prop], 'hideTitle', 'hidden'); } }); diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index ea2854b202..c975133feb 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -43,7 +43,7 @@ import { setConfig, upgradeAndValidate, } from './config'; -import { getSource, isEditorId } from './config/utils'; +import { getMainFile, getSource, isEditorId, validateFileName } from './config/utils'; import { createCustomEditors, createEditor, getFontFamily } from './editor'; import { createFakeEditor } from './editor/fake-editor'; import { hasJsx } from './editor/ts-compiler-options'; @@ -475,8 +475,40 @@ const createCopyButtons = () => { }); }; +const checkFileName = (filename: string, config: Config) => { + if (!validateFileName(filename, config)) { + alert(window.deps.translateString('core.file.invalidName', 'Invalid file type!')); + return false; + } + if (config.files?.some((f) => f.filename === filename)) { + alert(window.deps.translateString('core.file.exists', 'File already exists!')); + return false; + } + return true; +}; + +const addFile = async ( + filename: string, + editorOptions: Omit, +) => { + if (!checkFileName(filename, getConfig())) return false; + const fileLanguage = getFileLanguage(filename) || 'javascript'; + const editor = await createEditor({ + ...editorOptions, + container: createEditorUI(filename, true), + editorId: filename, + language: fileLanguage, + value: '', + }); + editorLanguages![filename] = fileLanguage; + editors[filename] = editor; + editorIds.push(filename); + return true; +}; + const renameFile = (filename: string, newName: string) => { - // TODO: validate newName (existing file, extension, valid name) + if (filename === newName) return true; + if (!checkFileName(newName, getConfig())) return false; const language = getFileLanguage(newName)!; const config = getConfig(); setConfig({ @@ -511,6 +543,7 @@ const renameFile = (filename: string, newName: string) => { editorIds[id] = newName; } changeLanguage(language, undefined, false, newName); + return true; }; const deleteFile = (filename: string) => { @@ -549,18 +582,12 @@ const createEditorUI = (title: string, addTab = false) => { container.classList.add('editor'); editorsElement.insertBefore(container, UI.getEditorToolbar()); if (addTab) { - const config = getConfig(); - const isMainFile = config.mainFile - ? config.mainFile === title - : title === 'index.html' || - config.files.find((f) => getLanguageEditorId(f.language) === 'markup')?.filename === title; - createMultiFileEditorTab({ title, showEditor, renameFile, deleteFile, - isMainFile, + isMainFile: title === getMainFile(getConfig()), isNewFile: false, }); } @@ -635,28 +662,17 @@ const createEditors = async (config: Config) => { }; if (config.files?.length) { - createAddFileButton(() => { - createMultiFileEditorTab({ - title: 'script.js', - showEditor, - renameFile: async (filename: string, newName: string) => { - renameFile(filename, newName); - const fileLanguage = getFileLanguage(filename) || 'javascript'; - const editor = await createEditor({ - ...baseOptions, - container: createEditorUI(newName, true), - editorId: newName, - language: fileLanguage, - value: '', - }); - editorLanguages![newName] = fileLanguage; - editors[newName] = editor; - editorIds.push(newName); - }, - deleteFile, - isMainFile: false, - isNewFile: true, - }); + createAddFileButton({ + onclick: () => + createMultiFileEditorTab({ + title: ' ', + showEditor, + addFile: async (filename: string) => addFile(filename, baseOptions), + renameFile, + deleteFile, + isMainFile: false, + isNewFile: true, + }), }); editorLanguages = { markup: 'html', style: 'css', script: 'javascript' }; @@ -866,19 +882,23 @@ const showEditor = (editorId: EditorId | (string & {}) = 'markup', isUpdate = fa // select first visible editor instead editorId = (titles[0]?.dataset.editor as EditorId) || 'markup'; } - titles.forEach((selector) => selector.classList.remove('active')); - const activeTitle = titles.find((title) => title.dataset.editor === editorId); - activeTitle?.classList.add('active'); - activeTitle?.scrollIntoView({ behavior: 'smooth' }); - const editorDivs = [...UI.getEditorDivs()]; - editorDivs.forEach((editor) => (editor.style.display = 'none')); - const activeEditor = editorDivs.find( - (editor) => editor.id === editorId || editor.dataset.editorId === editorId, - ) as HTMLElement; - if (activeEditor) { - activeEditor.style.display = 'block'; - activeEditor.style.visibility = 'visible'; - } + titles.forEach((title) => { + if (title.dataset.editor === editorId) { + title.classList.add('active'); + title.scrollIntoView({ behavior: 'smooth' }); + } else { + title.classList.remove('active'); + } + }); + UI.getEditorDivs().forEach((editorDiv) => { + if (editorDiv.dataset.editorId === editorId || editorDiv.id === editorId) { + editorDiv.style.display = 'block'; + editorDiv.style.visibility = 'visible'; + } else { + editorDiv.style.display = 'none'; + editorDiv.style.visibility = 'hidden'; + } + }); if (!isEmbed && !isUpdate) { editors[editorId]?.focus(); } @@ -1034,19 +1054,17 @@ const changeLanguage = async ( ), ); } - const editor = editors[editorId]; - if (filename) { - editor.setEditorId(filename); - } - editor.setLanguage( - language, - value ?? (filename ? undefined : getSource(editorId, getConfig())?.content || ''), - ); if (editorLanguages) { editorLanguages[editorId] = language; } - setEditorTitle(editorId as EditorId, language); - showEditor(editorId, isUpdate); + const editor = editors[editorId]; + if (filename) { + editor.setEditorId(filename, language); + } else { + editor.setLanguage(language, value ?? (getSource(editorId, getConfig())?.content || '')); + setEditorTitle(editorId as EditorId, language); + showEditor(editorId, isUpdate); + } phpHelper({ editor: editors.script }); if (!isEmbed && !isUpdate) { setTimeout(() => editor.focus()); @@ -1872,10 +1890,11 @@ const applyConfig = async (newConfig: Partial, reload = false, oldConfig const hasEditorConfig = Object.keys(editorConfig).some((k) => k in newConfig); let shouldReloadEditors = (() => { - if (newConfig.editor != null && !(newConfig.editor in editors.markup)) return true; + const activeEditor = getActiveEditor(); + if (newConfig.editor != null && newConfig.editor in activeEditor) return true; if (newConfig.mode != null) { - if (newConfig.mode !== 'result' && editors.markup.isFake) return true; - if (newConfig.mode !== 'codeblock' && editors.markup.codejar) return true; + if (newConfig.mode !== 'result' && activeEditor.isFake) return true; + if (newConfig.mode !== 'codeblock' && activeEditor.codejar) return true; } return false; })(); diff --git a/src/livecodes/editor/codejar/codejar.ts b/src/livecodes/editor/codejar/codejar.ts index 764a0adabc..cccb24a235 100644 --- a/src/livecodes/editor/codejar/codejar.ts +++ b/src/livecodes/editor/codejar/codejar.ts @@ -135,9 +135,12 @@ export const createEditor = async (options: EditorOptions): Promise // codejar?.onPaste(handleUpdate); const getEditorId = () => editorId; - const setEditorId = (filename: string) => { + const setEditorId = (filename: string, lang?: Language) => { editorId = filename; - language = getFileLanguage(filename) || language; + const newLang = lang || getFileLanguage(filename); + if (newLang && newLang !== language) { + setLanguage(newLang); + } }; const getValue = () => (codejar ? codejar.toString() : value); const setValue = (newValue = '\n') => { diff --git a/src/livecodes/editor/codemirror/codemirror.ts b/src/livecodes/editor/codemirror/codemirror.ts index d8229d760c..bfbd84efac 100644 --- a/src/livecodes/editor/codemirror/codemirror.ts +++ b/src/livecodes/editor/codemirror/codemirror.ts @@ -303,9 +303,12 @@ export const createEditor = async (options: EditorOptions): Promise showEditorMode(options.editorMode); const getEditorId = () => editorId; - const setEditorId = (filename: string) => { + const setEditorId = (filename: string, lang?: Language) => { editorId = filename; - language = getFileLanguage(filename) || language; + const newLang = lang || getFileLanguage(filename); + if (newLang && newLang !== language) { + setLanguage(newLang); + } }; const getValue = () => view.state.doc.toString(); const setValue = (value = '', newState = true) => { diff --git a/src/livecodes/editor/monaco/monaco.ts b/src/livecodes/editor/monaco/monaco.ts index fb8e898563..697c828b22 100644 --- a/src/livecodes/editor/monaco/monaco.ts +++ b/src/livecodes/editor/monaco/monaco.ts @@ -294,6 +294,18 @@ export const createEditor = async (options: EditorOptions): Promise } }; + const getOrCreateModel = (value: string, lang: string | undefined, uri: Monaco.Uri) => { + const model = monaco.editor.getModel(uri); + if (model) { + if (model.getLanguageId() === monacoMapLanguage(lang as Language)) { + model.setValue(value); + return model; + } + model.dispose(); + } + return monaco.editor.createModel(value, lang, uri); + }; + let modelUri = ''; const setModel = ( editor: Monaco.editor.IStandaloneCodeEditor, @@ -310,7 +322,7 @@ export const createEditor = async (options: EditorOptions): Promise ? `file:///${editorId}` : `file:///${editorId}.${random}.${extension}`; const oldModel = editor.getModel(); - const model = monaco.editor.createModel( + const model = getOrCreateModel( value || '', monacoMapLanguage(language), monaco.Uri.parse(modelUri), @@ -335,15 +347,6 @@ export const createEditor = async (options: EditorOptions): Promise }, 50); } - const getOrCreateModel = (value: string, lang: string | undefined, uri: Monaco.Uri) => { - const model = monaco.editor.getModel(uri); - if (model) { - model.setValue(value); - return model; - } - return monaco.editor.createModel(value, lang, uri); - }; - const contentEditors: Array = ['markup', 'style', 'script', 'tests']; if (contentEditors.includes(editorId)) { editors.push(editor); @@ -361,9 +364,9 @@ export const createEditor = async (options: EditorOptions): Promise } const getEditorId = () => editorId; - const setEditorId = (filename: string) => { + const setEditorId = (filename: string, lang?: Language) => { editorId = filename; - language = getFileLanguage(filename) || language; + language = lang || getFileLanguage(filename) || language; setModel(editor, editor.getValue(), language); }; const getValue = () => editor.getValue(); diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index ed5511e826..cf21195970 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -942,7 +942,7 @@ }, "core.confirm.deleteFile": { "notes": "", - "translation": "Delete file: {{title}}?" + "translation": "Delete file: {{filename}}?" }, "core.copy.copied": { "notes": "", @@ -1036,6 +1036,14 @@ "notes": "", "translation": "Creating a public GitHub gist..." }, + "core.file.exists": { + "notes": "", + "translation": "File already exists!" + }, + "core.file.invalidName": { + "notes": "", + "translation": "Invalid file type!" + }, "core.fork.success": { "notes": "", "translation": "Forked as a new project" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index 9c6e0aed6f..260024f886 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -380,7 +380,7 @@ const translation = { message: 'Loading {{lang}}. This may take a while!', }, confirm: { - deleteFile: 'Delete file: {{title}}?', + deleteFile: 'Delete file: {{filename}}?', }, copy: { copied: 'Code copied to clipboard', @@ -411,6 +411,10 @@ const translation = { export: { gist: 'Creating a public GitHub gist...', }, + file: { + exists: 'File already exists!', + invalidName: 'Invalid file type!', + }, fork: { success: 'Forked as a new project', }, diff --git a/src/livecodes/styles/app.scss b/src/livecodes/styles/app.scss index fd4ef222be..7e97cdac9d 100644 --- a/src/livecodes/styles/app.scss +++ b/src/livecodes/styles/app.scss @@ -1378,6 +1378,7 @@ a.editor-title { > span { display: inline-block; flex-grow: 1; + min-width: 3em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/livecodes/utils/utils.ts b/src/livecodes/utils/utils.ts index bc789ff484..909eeb1dd9 100644 --- a/src/livecodes/utils/utils.ts +++ b/src/livecodes/utils/utils.ts @@ -685,6 +685,17 @@ export const addProp = /* @__PURE__ */ ( addProp(obj[first] as Record, rest.join('.'), value); }; +export const removeLeadingSlash = /* @__PURE__ */ (x: string) => + x.startsWith('/') + ? x.slice(1) + : x.startsWith('./') + ? x.slice(2) + : x.startsWith('../') + ? x.slice(3) + : x.startsWith('~/') + ? x.slice(2) + : x; + export const predefinedValues = { APP_VERSION: process.env.VERSION || '', SDK_VERSION: process.env.SDK_VERSION || '', From c4b3983e5521f8bc54c6ab1b0dd206f4159aa1e5 Mon Sep 17 00:00:00 2001 From: Hatem Hosny Date: Sun, 9 Nov 2025 02:46:46 +0200 Subject: [PATCH 010/143] fix types --- functions/vendors/templates.js | 2034 ++--------------- src/livecodes/cache/cache.ts | 6 +- src/livecodes/export/utils.ts | 41 +- src/livecodes/import/dom.ts | 9 +- src/livecodes/import/utils.ts | 5 +- .../result/multi-file-result-page.ts | 2 +- src/livecodes/result/result-page.ts | 12 +- 7 files changed, 225 insertions(+), 1884 deletions(-) diff --git a/functions/vendors/templates.js b/functions/vendors/templates.js index 85dbce5f28..ca34eaa3ae 100644 --- a/functions/vendors/templates.js +++ b/functions/vendors/templates.js @@ -1,18 +1,6 @@ var getTemplateName = (_, templateName) => templateName; -var d = { - name: 'angular', - title: getTemplateName('templates.starter.angular', 'Angular Starter'), - thumbnail: 'assets/templates/angular.svg', - activeEditor: 'script', - markup: { - language: 'html', - content: `Loading... -`, - }, - style: { language: 'css', content: '' }, - script: { - language: 'typescript', - content: ` +var d={name:"angular",title:getTemplateName("templates.starter.angular","Angular Starter"),thumbnail:"assets/templates/angular.svg",activeEditor:"script",markup:{language:"html",content:`Loading... +`},style:{language:"css",content:""},script:{language:"typescript",content:` import { Component, Input, NgModule, enableProdMode } from '@angular/core@12.2.13'; import { CommonModule } from '@angular/common@12.2.13'; import { BrowserModule } from '@angular/platform-browser@12.2.13'; @@ -82,18 +70,7 @@ class AppModule {} platformBrowserDynamic() .bootstrapModule(AppModule) .catch((err: Error) => console.error(err)); -`.trimStart(), - }, - customSettings: { typescript: { experimentalDecorators: !0 } }, -}; -var u = { - name: 'assemblyscript', - title: getTemplateName('templates.starter.assemblyscript', 'AssemblyScript Starter'), - thumbnail: 'assets/templates/assemblyscript.svg', - activeEditor: 'script', - markup: { - language: 'html', - content: ` +`.trimStart()},customSettings:{typescript:{experimentalDecorators:!0}}};var u={name:"assemblyscript",title:getTemplateName("templates.starter.assemblyscript","AssemblyScript Starter"),thumbnail:"assets/templates/assemblyscript.svg",activeEditor:"script",markup:{language:"html",content:`

Hello, World!

@@ -126,11 +103,7 @@ var u = { })(); <\/script> -`.trimStart(), - }, - style: { - language: 'css', - content: ` +`.trimStart()},style:{language:"css",content:` .container, .container button { text-align: center; @@ -139,33 +112,14 @@ var u = { .logo { width: 150px; } -`.trimStart(), - }, - script: { - language: 'assemblyscript', - content: ` +`.trimStart()},script:{language:"assemblyscript",content:` export function getTitle(): string { return "AssemblyScript"; } export function increment(num: i32): i32 { return num + 1; } -`.trimStart(), - }, - stylesheets: [], - scripts: [], - cssPreset: '', - imports: {}, - types: {}, -}; -var g = { - name: 'astro', - title: getTemplateName('templates.starter.astro', 'Astro Starter'), - thumbnail: 'assets/templates/astro.svg', - activeEditor: 'markup', - markup: { - language: 'astro', - content: ` +`.trimStart()},stylesheets:[],scripts:[],cssPreset:"",imports:{},types:{}};var g={name:"astro",title:getTemplateName("templates.starter.astro","Astro Starter"),thumbnail:"assets/templates/astro.svg",activeEditor:"markup",markup:{language:"astro",content:` --- import {format} from 'date-fns'; @@ -222,35 +176,14 @@ const builtAtFormatted = format(builtAt, 'MMMM dd, yyyy -- H:mm:ss.SSS'); -`.trimStart(), - }, - style: { language: 'css', content: '' }, - script: { language: 'javascript', content: '' }, - stylesheets: [], - scripts: [], - cssPreset: '', - imports: {}, - types: {}, -}; -var h = { - name: 'backbone', - title: getTemplateName('templates.starter.backbone', 'Backbone Starter'), - thumbnail: 'assets/templates/backbone.svg', - activeEditor: 'script', - markup: { - language: 'html', - content: ` +`.trimStart()},style:{language:"css",content:""},script:{language:"javascript",content:""},stylesheets:[],scripts:[],cssPreset:"",imports:{},types:{}};var h={name:"backbone",title:getTemplateName("templates.starter.backbone","Backbone Starter"),thumbnail:"assets/templates/backbone.svg",activeEditor:"script",markup:{language:"html",content:`

Hello, World!

You clicked 0 times.

-`.trimStart(), - }, - style: { - language: 'css', - content: ` +`.trimStart()},style:{language:"css",content:` .container, .container button { text-align: center; @@ -259,11 +192,7 @@ var h = { .logo { width: 150px; } -`.trimStart(), - }, - script: { - language: 'javascript', - content: ` +`.trimStart()},script:{language:"javascript",content:` var Counter = Backbone.Model.extend({ defaults: { value: 0, @@ -294,40 +223,7 @@ var AppView = Backbone.View.extend({ } }); var view = new AppView({ model: counter }); -`.trimStart(), - }, - stylesheets: [], - scripts: [ - 'https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.0/jquery.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js', - ], - cssPreset: '', - imports: {}, - types: {}, -}; -var b = { - name: 'blank', - title: getTemplateName('templates.starter.blank', 'Blank Project'), - thumbnail: 'assets/templates/blank.svg', - activeEditor: 'markup', - markup: { language: 'html', content: '' }, - style: { language: 'css', content: '' }, - script: { language: 'javascript', content: '' }, - stylesheets: [], - scripts: [], - cssPreset: '', - imports: {}, - types: {}, -}; -var f = { - name: 'blockly', - title: getTemplateName('templates.starter.blockly', 'Blockly Starter'), - thumbnail: 'assets/templates/blockly.svg', - activeEditor: 'script', - markup: { - language: 'html', - content: ` +`.trimStart()},stylesheets:[],scripts:["https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js","https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.0/jquery.min.js","https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js"],cssPreset:"",imports:{},types:{}};var b={name:"blank",title:getTemplateName("templates.starter.blank","Blank Project"),thumbnail:"assets/templates/blank.svg",activeEditor:"markup",markup:{language:"html",content:""},style:{language:"css",content:""},script:{language:"javascript",content:""},stylesheets:[],scripts:[],cssPreset:"",imports:{},types:{}};var f={name:"blockly",title:getTemplateName("templates.starter.blockly","Blockly Starter"),thumbnail:"assets/templates/blockly.svg",activeEditor:"script",markup:{language:"html",content:` You clicked 0 times.

-`.trimStart(), - }, - style: { - language: 'css', - content: ` +`.trimStart()},style:{language:"css",content:` .container, .container button { text-align: center; @@ -357,11 +249,7 @@ var f = { .logo { width: 150px; } -`.trimStart(), - }, - script: { - language: 'blockly', - content: ` +`.trimStart()},script:{language:"blockly",content:` count @@ -470,22 +358,7 @@ var f = { -`.trimStart(), - }, - stylesheets: [], - scripts: [], - cssPreset: '', - imports: {}, - types: {}, -}; -var v = { - name: 'bootstrap', - title: getTemplateName('templates.starter.bootstrap', 'Bootstrap Starter'), - thumbnail: 'assets/templates/bootstrap.svg', - activeEditor: 'markup', - markup: { - language: 'html', - content: ` +`.trimStart()},stylesheets:[],scripts:[],cssPreset:"",imports:{},types:{}};var v={name:"bootstrap",title:getTemplateName("templates.starter.bootstrap","Bootstrap Starter"),thumbnail:"assets/templates/bootstrap.svg",activeEditor:"markup",markup:{language:"html",content:`