diff --git a/README.md b/README.md index 052a7ec..91a42b0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,27 @@ browser acts as the runtime host for editing, render, lint, and typecheck flows. - use AI chat with tab-aware proposals and apply/undo controls - switch theme and collapse the preview panel while preserving fast feedback loops +## CSS Query Imports In Editor Tabs + +`@knighted/develop` supports `@knighted/css` query syntax in workspace tabs, including: + +- `./styles/button.module.css?knighted-css` +- `./button.tsx?knighted-css&combined` + +For Lit + React-in-Shadow-DOM flows, a minimal pattern is: + +1. In `button.tsx`, export your React component and import CSS Modules normally. +2. In `lit-host.ts`, import from `./button.tsx?knighted-css&combined` and use `knightedCss`. +3. Apply `unsafeCSS(knightedCss)` in `static styles` so styles render inside the shadow root. + +Example imports: + +- `import { ReactButton, knightedCss } from './button.tsx?knighted-css&combined'` +- `import { LitElement, css, html, unsafeCSS } from 'lit'` + +`knightedCssModules` is optional for this flow and is not required when you only need +the compiled CSS text plus your component exports. + ## Why this shape The app started as a focused compile-and-preview loop and has grown into a diff --git a/playwright/rendering-modes/core.spec.ts b/playwright/rendering-modes/core.spec.ts index b4f3489..4a28a12 100644 --- a/playwright/rendering-modes/core.spec.ts +++ b/playwright/rendering-modes/core.spec.ts @@ -165,6 +165,69 @@ test('renders in react mode with css modules', async ({ page }) => { await expectPreviewHasRenderedContent(page) }) +test('reactJsx tag interpolation renders memo and forwardRef components', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import { memo, forwardRef } from 'react'", + "import { reactJsx } from '@knighted/jsx/react'", + '', + 'type ButtonProps = {', + ' label: string', + '}', + '', + 'const MemoButton = memo(({ label }: ButtonProps) => (', + ' ', + '))', + '', + 'const ForwardRefButton = forwardRef(', + ' ({ label }, ref) => (', + ' ', + ' ),', + ')', + '', + 'const App = () =>', + ' reactJsx`', + '
', + ' <${MemoButton} label="Memo OK" />', + ' <${ForwardRefButton} label="ForwardRef OK" />', + '
', + ' `', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByTestId('memo-button')).toHaveText('Memo OK') + await expect(getPreviewFrame(page).getByTestId('forward-ref-button')).toHaveText( + 'ForwardRef OK', + ) + + await expect + .poll(async () => { + return getPreviewFrame(page) + .locator('html') + .evaluate( + () => + Array.from(document.querySelectorAll('*')).filter(node => + /^__kx_expr__/i.test(node.localName), + ).length, + ) + }) + .toBe(0) +}) + test('react mode keeps App.ts entry but surfaces rename guidance until compatible', async ({ page, }) => { @@ -306,6 +369,128 @@ test('css module imports expose class map for module tabs', async ({ page }) => .not.toContain('.item:active') }) +test('workspace modules support ?knighted-css&combined imports', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await renameWorkspaceTab(page, { + from: 'app.css', + to: 'button.module.css', + }) + + await page.getByRole('button', { name: 'Open tab button.module.css' }).click() + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') + + await setWorkspaceTabSource(page, { + fileName: 'button.module.css', + kind: 'styles', + source: ['.btn {', ' color: rgb(7, 89, 160);', ' font-weight: 700;', '}'].join( + '\n', + ), + }) + + await addWorkspaceTab(page, { type: 'script' }) + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'button.tsx', + }) + + await setWorkspaceTabSource(page, { + fileName: 'button.tsx', + source: [ + "import styles from '../styles/button.module.css'", + '', + 'type ButtonProps = {', + ' label: string', + '}', + '', + 'export const ReactButton = ({ label }: ButtonProps) => (', + ' ', + ')', + ].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import { ReactButton, knightedCss } from './button.tsx?knighted-css&combined'", + '', + 'const App = () => (', + ' <>', + ' ', + ' ', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre.preview-runtime-error')).toHaveCount(0) + await expect( + getPreviewFrame(page).getByRole('button', { name: 'Combined query works' }), + ).toHaveCSS('color', 'rgb(7, 89, 160)') +}) + +test('workspace style tabs support ?knighted-css query exports', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await renameWorkspaceTab(page, { + from: 'app.css', + to: 'button.module.css', + }) + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') + + await setWorkspaceTabSource(page, { + fileName: 'button.module.css', + kind: 'styles', + source: [ + '.btn {', + ' color: rgb(14, 110, 173);', + ' border: 1px solid rgb(14, 110, 173);', + '}', + ].join('\n'), + }) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await setComponentEditorSource( + page, + [ + "import styles, { knightedCss } from '../styles/button.module.css?knighted-css'", + '', + 'const App = () => (', + ' <>', + ' ', + ' ', + ' Style query works', + ' ', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre.preview-runtime-error')).toHaveCount(0) + + const previewButton = getPreviewFrame(page).getByRole('button', { + name: 'Style query works', + }) + await expect(previewButton).toHaveCSS('color', 'rgb(14, 110, 173)') + await expect(previewButton).toHaveAttribute('data-css-length', /[1-9]\d*/) +}) + test('preview styles require explicit import from entry graph', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/modules/preview-runtime/virtual-workspace-modules.js b/src/modules/preview-runtime/virtual-workspace-modules.js index bc7dc8c..a22067e 100644 --- a/src/modules/preview-runtime/virtual-workspace-modules.js +++ b/src/modules/preview-runtime/virtual-workspace-modules.js @@ -54,6 +54,55 @@ const stripQueryAndHash = value => { return value.split(/[?#]/, 1)[0] } +const getOwnRecordValue = (record, key) => { + if (!record || typeof record !== 'object') { + return undefined + } + + if (!Object.prototype.hasOwnProperty.call(record, key)) { + return undefined + } + + return record[key] +} + +const parseSpecifierQueryFlags = specifier => { + if (typeof specifier !== 'string') { + return { + hasKnightedCss: false, + isCombined: false, + } + } + + const queryIndex = specifier.indexOf('?') + if (queryIndex < 0) { + return { + hasKnightedCss: false, + isCombined: false, + } + } + + const hashIndex = specifier.indexOf('#', queryIndex + 1) + const queryText = + hashIndex >= 0 + ? specifier.slice(queryIndex + 1, hashIndex) + : specifier.slice(queryIndex + 1) + + const flags = new Set( + queryText + .split('&') + .map(part => part.trim()) + .filter(Boolean) + .map(part => part.split('=')[0]?.trim().toLowerCase()) + .filter(Boolean), + ) + + return { + hasKnightedCss: flags.has('knighted-css'), + isCombined: flags.has('combined'), + } +} + const normalizePathSegments = value => { const normalized = toModuleSpecifierKey(value) const inputParts = normalized.split('/') @@ -421,6 +470,61 @@ const toStyleModuleDataUrl = ({ moduleKey, styleModuleExports }) => { ) } +const toKnightedCssQueryDataUrl = ({ + moduleKey, + targetModuleUrl, + targetStyleModuleUrl, + cssText, + isCombined, +}) => { + const safeCssText = typeof cssText === 'string' ? cssText : '' + const safeModuleKey = moduleKey || 'module' + + if (typeof targetStyleModuleUrl === 'string' && targetStyleModuleUrl.length > 0) { + const sourceUrl = `//# sourceURL=knighted-workspace/${safeModuleKey}.knighted-css.style.mjs` + return toModuleDataUrl( + [ + `import __knightedCssDefault from ${JSON.stringify(targetStyleModuleUrl)}`, + 'export default __knightedCssDefault', + `export const knightedCss = ${JSON.stringify(safeCssText)}`, + 'export const knightedCssModules = __knightedCssDefault', + 'export const stableSelectors = {}', + sourceUrl, + ].join('\n'), + ) + } + + if (typeof targetModuleUrl !== 'string' || targetModuleUrl.length === 0) { + return null + } + + if (isCombined) { + const sourceUrl = `//# sourceURL=knighted-workspace/${safeModuleKey}.knighted-css.combined.mjs` + return toModuleDataUrl( + [ + `import * as __knightedCombinedModule from ${JSON.stringify(targetModuleUrl)}`, + `export * from ${JSON.stringify(targetModuleUrl)}`, + 'const __knightedCombinedDefault = __knightedCombinedModule.default', + 'export default __knightedCombinedDefault', + `export const knightedCss = ${JSON.stringify(safeCssText)}`, + 'export const knightedCssModules = {}', + 'export const stableSelectors = {}', + sourceUrl, + ].join('\n'), + ) + } + + const sourceUrl = `//# sourceURL=knighted-workspace/${safeModuleKey}.knighted-css.mjs` + return toModuleDataUrl( + [ + `export const knightedCss = ${JSON.stringify(safeCssText)}`, + 'export const knightedCssModules = {}', + 'export const stableSelectors = {}', + sourceUrl, + ].join('\n'), + ) +} + const runtimeSpecifierRewrites = runtimeSpecifiers => ({ react: runtimeSpecifiers.react, 'react-dom': runtimeSpecifiers.reactDom, @@ -563,6 +667,7 @@ export const planWorkspaceVirtualModules = ({ mode, runtimeSpecifiers, styleModuleExportsByTabId = {}, + styleCssByTabId = {}, }) => { if (!entryTab || typeof entryTab.content !== 'string') { return null @@ -694,6 +799,72 @@ export const planWorkspaceVirtualModules = ({ const moduleDataByTabId = new Map() const styleModuleUrlByTabId = new Map() + const styleDependencyIdsByModuleTabId = new Map() + const styleDependencyResolutionInProgress = new Set() + + const collectStyleDependencyIdsForTabId = tabId => { + if (styleDependencyIdsByModuleTabId.has(tabId)) { + return styleDependencyIdsByModuleTabId.get(tabId) + } + + if (styleDependencyResolutionInProgress.has(tabId)) { + return [] + } + + const tab = byId.get(tabId) + if (!tab || isStyleTab(tab)) { + styleDependencyIdsByModuleTabId.set(tabId, []) + return [] + } + + styleDependencyResolutionInProgress.add(tabId) + + const importerModuleKey = toTabModuleKey(tab) + const imports = getParsedImportsForTab(tab) + const collectedStyleIds = [] + + for (const entry of imports) { + if (!isRelativeSpecifier(entry?.source) && !isStyleImportSpecifier(entry?.source)) { + continue + } + + const target = resolveWorkspaceImport({ + importerModuleKey, + source: entry.source, + byModuleKey, + }) + + if (!target || typeof target.id !== 'string') { + continue + } + + if (isStyleTab(target)) { + collectedStyleIds.push(target.id) + continue + } + + const nestedStyleIds = collectStyleDependencyIdsForTabId(target.id) + collectedStyleIds.push(...nestedStyleIds) + } + + styleDependencyResolutionInProgress.delete(tabId) + + const uniqueStyleIds = [...new Set(collectedStyleIds)] + styleDependencyIdsByModuleTabId.set(tabId, uniqueStyleIds) + return uniqueStyleIds + } + + const toCssTextForStyleIds = styleIds => { + if (!Array.isArray(styleIds) || styleIds.length === 0) { + return '' + } + + return styleIds + .map(styleId => getOwnRecordValue(styleCssByTabId, styleId) ?? '') + .filter(part => typeof part === 'string' && part.length > 0) + .join('\n\n') + } + for (const tabId of moduleDependencyOrder) { const tab = byId.get(tabId) if (!tab) { @@ -723,10 +894,7 @@ export const planWorkspaceVirtualModules = ({ } const moduleKey = toTabModuleKey(tab) || tab.id - const styleModuleExports = - styleModuleExportsByTabId && typeof styleModuleExportsByTabId === 'object' - ? styleModuleExportsByTabId[tabId] - : {} + const styleModuleExports = getOwnRecordValue(styleModuleExportsByTabId, tabId) ?? {} const moduleCacheKey = [ 'style-module', moduleKey, @@ -767,6 +935,70 @@ export const planWorkspaceVirtualModules = ({ source: moduleData.source, imports: importsForRewrite, resolveSpecifier: sourceSpecifier => { + const queryFlags = parseSpecifierQueryFlags(sourceSpecifier) + + if ( + queryFlags.hasKnightedCss && + (isRelativeSpecifier(sourceSpecifier) || + isStyleImportSpecifier(sourceSpecifier)) + ) { + const target = resolveWorkspaceImport({ + importerModuleKey: moduleData.moduleKey, + source: sourceSpecifier, + byModuleKey, + }) + + if (!target || typeof target.id !== 'string') { + return null + } + + const isStyleTarget = isStyleTab(target) + const targetStyleModuleUrl = isStyleTarget + ? (styleModuleUrlByTabId.get(target.id) ?? null) + : null + const targetModuleUrl = isStyleTarget + ? null + : (moduleUrlByTabId.get(target.id) ?? null) + + const queryCssText = isStyleTarget + ? toCssTextForStyleIds([target.id]) + : toCssTextForStyleIds(collectStyleDependencyIdsForTabId(target.id)) + + const queryModuleKey = `${moduleData.moduleKey || tab.id}->${toTabModuleKey(target) || target.id}` + const queryModuleCacheKey = [ + 'knighted-css-query', + queryModuleKey, + queryFlags.isCombined ? 'combined' : 'plain', + targetModuleUrl ?? '', + targetStyleModuleUrl ?? '', + queryCssText, + ].join('\u0000') + + const cachedQueryModuleUrl = getCachedValue( + moduleDataUrlCache, + queryModuleCacheKey, + ) + if (typeof cachedQueryModuleUrl === 'string') { + return cachedQueryModuleUrl + } + + const queryModuleUrl = toKnightedCssQueryDataUrl({ + moduleKey: queryModuleKey, + targetModuleUrl, + targetStyleModuleUrl, + cssText: queryCssText, + isCombined: queryFlags.isCombined, + }) + + if (typeof queryModuleUrl !== 'string' || queryModuleUrl.length === 0) { + return null + } + + moduleDataUrlCache.set(queryModuleCacheKey, queryModuleUrl) + trimCache(moduleDataUrlCache, maxModuleDataUrlCacheEntries) + return queryModuleUrl + } + if ( isRelativeSpecifier(sourceSpecifier) || isStyleImportSpecifier(sourceSpecifier) diff --git a/src/modules/preview/render-runtime.js b/src/modules/preview/render-runtime.js index da18f79..ea6f8e6 100644 --- a/src/modules/preview/render-runtime.js +++ b/src/modules/preview/render-runtime.js @@ -381,7 +381,12 @@ export const createRenderRuntimeController = ({ if (!entryTab) { clearStyleDiagnostics() - return { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} } + return { + css: '', + userStyleSheets: [], + styleModuleExportsByTabId: {}, + styleCssByTabId: {}, + } } const runtimeSpecifiers = getWorkspaceRuntimeSpecifiers() @@ -397,7 +402,12 @@ export const createRenderRuntimeController = ({ if (!virtualModulePlan) { clearStyleDiagnostics() - return { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} } + return { + css: '', + userStyleSheets: [], + styleModuleExportsByTabId: {}, + styleCssByTabId: {}, + } } const workspaceTabById = new Map( @@ -451,7 +461,12 @@ export const createRenderRuntimeController = ({ if (styleInputs.length === 0) { clearStyleDiagnostics() - const output = { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} } + const output = { + css: '', + userStyleSheets: [], + styleModuleExportsByTabId: {}, + styleCssByTabId: {}, + } compiledStylesCache = { key: cacheKey, value: output, @@ -525,7 +540,8 @@ export const createRenderRuntimeController = ({ }), ) - const styleModuleExportsByTabId = {} + const styleModuleExportsByTabId = Object.create(null) + const styleCssByTabId = Object.create(null) const compiledCssParts = [] const userStyleSheets = [] @@ -536,13 +552,14 @@ export const createRenderRuntimeController = ({ if (part && typeof part.css === 'string') { compiledCssParts.push(part.css) userStyleSheets.push(part.css) + styleCssByTabId[input.id] = part.css } if (input?.dialect !== 'module' || !part?.moduleExports) { continue } - const normalizedModuleExports = {} + const normalizedModuleExports = Object.create(null) for (const [localClassName, exportedValue] of Object.entries( part.moduleExports, )) { @@ -565,6 +582,7 @@ export const createRenderRuntimeController = ({ css: compiledCssParts.join('\n\n'), userStyleSheets, styleModuleExportsByTabId, + styleCssByTabId, } if (styleWarningLines.length > 0) { setStyleDiagnosticsDetails({ @@ -680,6 +698,7 @@ export const createRenderRuntimeController = ({ cssText, userStyleSheets = [], styleModuleExportsByTabId = {}, + styleCssByTabId = {}, }) => { const workspaceTabs = getWorkspaceTabsForPreview() const entryTab = resolveWorkspaceEntryTab(workspaceTabs) @@ -718,6 +737,7 @@ export const createRenderRuntimeController = ({ mode, runtimeSpecifiers, styleModuleExportsByTabId, + styleCssByTabId, }) if (!virtualModulePlan) { @@ -790,6 +810,7 @@ export const createRenderRuntimeController = ({ cssText: compiledStyles.css, userStyleSheets: compiledStyles.userStyleSheets, styleModuleExportsByTabId: compiledStyles.styleModuleExportsByTabId, + styleCssByTabId: compiledStyles.styleCssByTabId, }) } @@ -809,6 +830,7 @@ export const createRenderRuntimeController = ({ cssText: compiledStyles.css, userStyleSheets: compiledStyles.userStyleSheets, styleModuleExportsByTabId: compiledStyles.styleModuleExportsByTabId, + styleCssByTabId: compiledStyles.styleCssByTabId, }) }