From 568c2bb53ecfe3a5325d9eebbbf4d91b44c58357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Maurugeon?= Date: Wed, 30 Sep 2026 13:05:35 +0200 Subject: [PATCH] =?UTF-8?q?Menu=20d'=C3=A9tats,=20h=C3=A9ritage,=20composa?= =?UTF-8?q?nts=20avec=20dossiers,=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/componentFile.ts | 68 ++++- electron/main.ts | 9 + electron/preload.ts | 1 + electron/variablesFile.ts | 110 ++++++++ package.json | 5 + shared/ipc-payloads.ts | 2 + shared/ipc-results.ts | 5 + shared/preload-api.ts | 1 + src/App.tsx | 77 +++++- src/appBridge.ts | 7 +- src/componentName.ts | 25 ++ src/extractAssets.ts | 358 +++++++++++++++++++++++++ src/panels/PaletteDialogs.tsx | 82 +++++- src/panels/PalettePanel.tsx | 13 +- src/panels/PreviewPane.tsx | 4 +- src/panels/PreviewToolbar.tsx | 30 ++- src/panels/StylePanel.tsx | 2 + src/panels/VariablesPanel.tsx | 38 ++- src/panels/variableEdits.ts | 4 + src/style-panel/EmbedEditor.tsx | 70 ++++- src/style-panel/ProvenanceList.tsx | 4 +- src/style-panel/StateSelect.tsx | 86 ++++++ src/style-panel/VariableConnect.tsx | 67 ++++- src/style-panel/embed-editor.css | 47 ++++ src/style-panel/lib/cascade.ts | 64 ++++- src/style-panel/lib/class-input.ts | 37 +++ src/style-panel/lib/host.ts | 5 + src/style-panel/lib/native-styles.ts | 11 +- src/style-panel/lib/resolved.ts | 88 +++++- src/style-panel/lib/selectors.ts | 4 +- src/style-panel/lib/states.ts | 120 +++++++++ src/style-panel/lib/webflow.ts | 5 +- src/styles.css | 11 + test/class-inheritance.js | 109 ++++++++ test/class-input.js | 43 +++ test/component-nested.js | 146 ++++++++++ test/contracts/ipc-payloads.test.ts | 1 + test/contracts/main-boundaries.test.ts | 2 +- test/extract-assets.js | 125 +++++++++ test/state-select.js | 121 +++++++++ 40 files changed, 1949 insertions(+), 58 deletions(-) create mode 100644 electron/variablesFile.ts create mode 100644 src/extractAssets.ts create mode 100644 src/style-panel/StateSelect.tsx create mode 100644 src/style-panel/lib/class-input.ts create mode 100644 src/style-panel/lib/states.ts create mode 100644 test/class-inheritance.js create mode 100644 test/class-input.js create mode 100644 test/component-nested.js create mode 100644 test/extract-assets.js create mode 100644 test/state-select.js diff --git a/electron/componentFile.ts b/electron/componentFile.ts index 21efde86..1d02a382 100644 --- a/electron/componentFile.ts +++ b/electron/componentFile.ts @@ -24,6 +24,60 @@ const toPosix = (p: string): string => p.split(path.sep).join('/'); // `` renders a literal and the component never appears. const VALID_NAME = /^[A-Z][A-Za-z0-9]*$/; +// A folder is a path made of plain words, so it can never point outside +// src/components ("..", absolute paths and drive letters have no place in it), +// and it is bounded: nobody's component tree is deeper than this. +const FOLDER_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; +const FOLDER_DEPTH_MAX = 5; + +function folderSegments(folder: string | undefined): readonly string[] { + const segments = String(folder || '') + .split(/[\\/]+/) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length > FOLDER_DEPTH_MAX) { + throw new Error(`Folders can be at most ${FOLDER_DEPTH_MAX} levels deep.`); + } + for (const segment of segments) { + if (!FOLDER_SEGMENT.test(segment)) { + throw new Error(`"${segment}" can't be a folder name — use letters, digits, - or _.`); + } + } + return segments; +} + +// The component tree is walked whole because the name is also the import and the +// tag: two `Eyebrow` files in different folders would be two things called the +// same. Bounded, so a symlink loop or a huge tree cannot run away. +const CLASH_SCAN_ENTRIES_MAX = 20000; + +function findNameClash(componentsDir: string, name: string): string | null { + const wanted = `${name.toLowerCase()}.astro`; + const pending = [componentsDir]; + let seen = 0; + while (pending.length > 0 && seen < CLASH_SCAN_ENTRIES_MAX) { + const dir = pending.pop(); + if (dir === undefined) { + break; + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + seen += 1; + if (entry.isDirectory()) { + pending.push(path.join(dir, entry.name)); + } else if (entry.name.toLowerCase() === wanted) { + return path.basename(entry.name, '.astro'); + } + } + } + return null; +} + interface ComponentImport { readonly name?: string; readonly path?: string; @@ -33,6 +87,8 @@ interface ComponentFileArgs { readonly projectPath: string; readonly pagePath: string; readonly name: string; + /** Sub-folders under src/components, e.g. `heading` or `sections/hero`. */ + readonly folder?: string; readonly nodes: readonly unknown[]; readonly imports?: readonly ComponentImport[]; readonly props?: readonly unknown[]; @@ -47,6 +103,7 @@ function componentFile({ projectPath, pagePath, name, + folder = '', nodes, imports = [], props = [], @@ -59,14 +116,13 @@ function componentFile({ } const componentsDir = path.join(projectPath, 'src', 'components'); - const target = path.join(componentsDir, `${name}.astro`); + const targetDir = path.join(componentsDir, ...folderSegments(folder)); + const target = path.join(targetDir, `${name}.astro`); // Case-insensitively: on a Mac, Card.astro and card.astro are the same file, // and writing the second silently replaces the first. - const clash = fs.existsSync(componentsDir) - ? fs.readdirSync(componentsDir).find((f) => f.toLowerCase() === `${name.toLowerCase()}.astro`) - : null; + const clash = fs.existsSync(componentsDir) ? findNameClash(componentsDir, name) : null; if (clash) { - throw new Error(`There's already a component called ${path.basename(clash, '.astro')}.`); + throw new Error(`There's already a component called ${clash}.`); } // Which of the page's imports this piece actually uses. Matched against the @@ -87,7 +143,7 @@ function componentFile({ if (!spec.startsWith('.')) { return { ...imp }; } - const rel = toPosix(path.relative(componentsDir, path.resolve(pageDir, spec))); + const rel = toPosix(path.relative(targetDir, path.resolve(pageDir, spec))); return { ...imp, path: rel.startsWith('.') ? rel : './' + rel }; }); diff --git a/electron/main.ts b/electron/main.ts index 69bb019e..70eaade2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -109,6 +109,7 @@ import * as starterModule from './starter'; const { createStarter } = starterModule; import * as windowBoundsModule from './windowBounds'; const { openingBounds } = windowBoundsModule; +import { createVariablesFile } from './variablesFile'; import * as componentFileModule from './componentFile'; const { componentFile } = componentFileModule; import * as componentUsageModule from './componentUsage'; @@ -2736,6 +2737,14 @@ ipcMain.handle('css:addVariables', async (_e, { projectPath, adds }) => { return last; }); +// The first stylesheet of variables, for a project that has none — written and +// imported, so what the panel then edits is what the page ships. +ipcMain.handle('css:createVariablesFile', async (_e, projectPath) => { + const created = createVariablesFile(String(projectPath), (file) => markSelfWrite(file)); + send('css:changed', {}); + return { ok: true, ...created }; +}); + // A row dragged to a new place: the declaration moves inside its rule, which is // where the order actually lives. ipcMain.handle('css:moveVariables', async (_e, { projectPath, moves }) => { diff --git a/electron/preload.ts b/electron/preload.ts index 53815424..37773b88 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1960,6 +1960,7 @@ contextBridge.exposeInMainWorld('avb', { setCssVariable: invoke('css:setVariable'), moveCssVariables: invoke('css:moveVariables'), addCssVariables: invoke('css:addVariables'), + createVariablesFile: invoke('css:createVariablesFile'), renameCssVariables: invoke('css:renameVariables'), setCssSectionTitle: invoke('css:setSectionTitle'), removeCssSection: invoke('css:removeSection'), diff --git a/electron/variablesFile.ts b/electron/variablesFile.ts new file mode 100644 index 00000000..5af89277 --- /dev/null +++ b/electron/variablesFile.ts @@ -0,0 +1,110 @@ +// A stylesheet for a project's variables, when it has none. +// +// The Variables panel reads custom properties out of the project's CSS, so a +// project with no stylesheet had nothing to show and nowhere to add to — the only +// way in was to make a base.css by hand. This makes the first one: a file with a +// `:root` block to start from, and the import that makes Astro actually ship it. +// A stylesheet nobody imports is a file, not a style: the variables would be +// editable in the panel and absent from the page. + +import fs from 'fs'; +import path from 'path'; + +export const VARIABLES_REL = 'src/styles/variables.css'; + +// Three variables in three families, so the panel has groups to show straight +// away and the naming pattern it groups by (`--color-…`, `--space-…`) is on +// display for whoever adds the fourth. +export const VARIABLES_STARTER = `:root { + --color-primary: #2563eb; + --color-text: #111111; + --space-m: 1rem; +} +`; + +const FENCE = /^---[ \t]*$/m; + +/** + * `source` with `import '';` added to its frontmatter — after the imports + * already there, or in a frontmatter made for the purpose. Unchanged when the + * file already mentions the stylesheet. + */ +export function withStylesheetImport(source: string, spec: string): string { + if (source.includes(spec)) { + return source; + } + const eol = source.includes('\r\n') ? '\r\n' : '\n'; + const text = source.replace(/\r\n/g, '\n'); + const statement = `import '${spec}';`; + if (!/^---[ \t]*\n/.test(text)) { + return `---\n${statement}\n---\n${text}`.replace(/\n/g, eol); + } + const afterOpen = text.indexOf('\n') + 1; + const close = text.slice(afterOpen).search(FENCE); + if (close === -1) { + return source; // an unterminated frontmatter is not ours to guess at + } + const head = text.slice(afterOpen, afterOpen + close); + const lines = head.split('\n'); + let last = -1; + lines.forEach((line, index) => { + if (/^\s*import\b[^]*?['"];?\s*$/.test(line)) { + last = index; + } + }); + lines.splice(last + 1, 0, statement); + const next = text.slice(0, afterOpen) + lines.join('\n') + text.slice(afterOpen + close); + return next.replace(/\n/g, eol); +} + +/** Layouts at the top of src/layouts, or the index page when there are none. */ +function importHosts(projectPath: string): string[] { + const layouts = path.join(projectPath, 'src', 'layouts'); + const found = fs.existsSync(layouts) + ? fs + .readdirSync(layouts, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.astro')) + .map((entry) => path.join(layouts, entry.name)) + .sort() + : []; + if (found.length > 0) { + return found; + } + const index = path.join(projectPath, 'src', 'pages', 'index.astro'); + return fs.existsSync(index) ? [index] : []; +} + +export interface VariablesFileResult { + readonly rel: string; + readonly imported: readonly string[]; +} + +/** + * Writes the starter stylesheet (appending a `:root` block if the file is already + * there without one) and imports it. `onWrite` is told about every file touched, so + * the caller can mark its own writes and not mistake them for outside edits. + */ +export function createVariablesFile( + projectPath: string, + onWrite: (file: string) => void, +): VariablesFileResult { + const file = path.join(projectPath, ...VARIABLES_REL.split('/')); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + if (!/:root\b/.test(existing)) { + onWrite(file); + fs.writeFileSync(file, existing ? `${existing.trimEnd()}\n\n${VARIABLES_STARTER}` : VARIABLES_STARTER, 'utf8'); + } + const imported: string[] = []; + for (const host of importHosts(projectPath)) { + const spec = path.relative(path.dirname(host), file).split(path.sep).join('/'); + const before = fs.readFileSync(host, 'utf8'); + const after = withStylesheetImport(before, spec.startsWith('.') ? spec : `./${spec}`); + if (after !== before) { + onWrite(host); + fs.writeFileSync(host, after, 'utf8'); + imported.push(path.relative(projectPath, host).split(path.sep).join('/')); + } + } + return { rel: VARIABLES_REL, imported }; +} diff --git a/package.json b/package.json index f8d2b526..32e7374b 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,11 @@ "test:commentmarkers": "node test/comment-markers.js", "test:selectorslive": "node test/selectors-live.js", "test:selectorwell": "node test/selector-well.js", + "test:stateselect": "node test/state-select.js", + "test:componentnested": "node test/component-nested.js", + "test:extractassets": "node test/extract-assets.js", + "test:classinput": "node test/class-input.js", + "test:classinheritance": "node test/class-inheritance.js", "test:popuplayers": "node test/popup-layers.js", "test:scopecompletions": "node test/scope-completions.js", "test:querylist": "node test/query-list.js", diff --git a/shared/ipc-payloads.ts b/shared/ipc-payloads.ts index 2d4baa7e..031fe8f1 100644 --- a/shared/ipc-payloads.ts +++ b/shared/ipc-payloads.ts @@ -89,6 +89,7 @@ export const IPC_PAYLOADS = { 'cms:write': object({ ...relative, data }), 'component:create': object({ ...named, + folder: optional(text), pagePath: pathText, nodes: list(data), imports: optional(list(object({ name: optional(text), path: optional(text) }))), @@ -114,6 +115,7 @@ export const IPC_PAYLOADS = { before: cssTarget, at: optional(count), }), + 'css:createVariablesFile': pathText, 'css:addVariables': object({ ...project, adds: optional( diff --git a/shared/ipc-results.ts b/shared/ipc-results.ts index ef8aed2d..e096d9d6 100644 --- a/shared/ipc-results.ts +++ b/shared/ipc-results.ts @@ -180,6 +180,11 @@ export interface IpcResults { readonly stale?: boolean; readonly title?: string; }; + readonly 'css:createVariablesFile': { + readonly ok: true; + readonly rel: string; + readonly imported: ReadonlyArray; + }; readonly 'css:addVariables': { readonly ok: boolean; readonly error?: string; diff --git a/shared/preload-api.ts b/shared/preload-api.ts index 8b0dfd6b..996d236e 100644 --- a/shared/preload-api.ts +++ b/shared/preload-api.ts @@ -43,6 +43,7 @@ interface InvokeChannels { readonly setCssVariable: 'css:setVariable'; readonly moveCssVariables: 'css:moveVariables'; readonly addCssVariables: 'css:addVariables'; + readonly createVariablesFile: 'css:createVariablesFile'; readonly renameCssVariables: 'css:renameVariables'; readonly setCssSectionTitle: 'css:setSectionTitle'; readonly removeCssSection: 'css:removeSection'; diff --git a/src/App.tsx b/src/App.tsx index b1816b07..fe74f789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ import { hasClass, namesIn, withClass } from './classAttr.js'; import { toComponentName } from './componentName.js'; import { resolveInstanceProps } from './instanceProps.js'; import { propsForExtraction } from './extractProps.js'; +import { planAssets } from './extractAssets'; import TerminalDock from './panels/TerminalDock'; import { cleanError, stripAnsi } from './cleanError.js'; import { elementLabel } from './classNames.js'; @@ -1780,8 +1781,8 @@ export default function App() { // edit to two files, and the component file is written first: a page that // imports a file that isn't there yet is a broken page, however briefly. const createComponentFromSelection = useCallback( - async (name: string, options: { readonly withProps?: boolean } = {}) => { - const { withProps = true } = options; + async (name: string, options: { readonly withProps?: boolean; readonly folder?: string } = {}) => { + const { withProps = true, folder = '' } = options; const page = pageStateRef.current.currentPage; const state = pageStateRef.current.pageState; const model = state?.editable ? state.model : null; @@ -1789,13 +1790,18 @@ export default function App() { const projectPath = projectRef.current?.path; if (!page?.path || !model || !node || !projectPath) {return;} const props = withProps ? propsNeededFor(model, node) : []; + // The page's own styles and scripts about this piece go with it, so the page + // is left holding the chain of components and nothing else. + const assets = planAssets(model.nodes, node.id, newId); let created; try { created = await createProjectComponent({ projectPath, pagePath: page.path, name, + folder, nodes: node, + assets: assets.moved, imports: model.imports || [], props, }); @@ -1821,6 +1827,13 @@ export default function App() { props: Object.fromEntries(props.map((p) => [p, { type: 'expr', value: p }])), children: null, }; + for (const edit of assets.edits) { + const holder = findParentList(m, edit.id); + const block = holder ? holder.list[holder.index] : null; + if (!holder || !block) {continue;} + if (edit.inner === null) {holder.list.splice(holder.index, 1);} + else {block.inner = edit.inner;} + } return m; }, true); setSelectedId(id); @@ -1829,12 +1842,18 @@ export default function App() { // — an expression naming something that isn't a value the page holds, or // props turned off. The person who just moved it knows what it needs. const stranded = usesPageScope(node) && !props.length; + const moved = assets.moved.length + ? ` Moved ${assets.moved.length} style/script block${assets.moved.length === 1 ? '' : 's'} with it.` + : ''; + const held = assets.held + ? ` ${assets.held} selector${assets.held === 1 ? ' was' : 's were'} kept on the page because their class is used elsewhere too.` + : ''; showToast( stranded - ? `Created ${created.rel} — it reads page data, so it will need props.` + ? `Created ${created.rel} — it reads page data, so it will need props.${moved}${held}` : props.length - ? `Created ${created.rel} with ${props.length} prop${props.length === 1 ? '' : 's'}.` - : `Created ${created.rel}` + ? `Created ${created.rel} with ${props.length} prop${props.length === 1 ? '' : 's'}.${moved}${held}` + : `Created ${created.rel}.${moved}${held}` ); }, [mutateModel, propsNeededFor, rescan, showToast] @@ -2896,6 +2915,31 @@ export default function App() { // plain `class`, a `class:list`, a template literal (see classAttr.js). An // element whose class is some other expression is code we would have to // understand to extend, so that one is said out loud rather than dropped. + // Rules for a class need a stylesheet to live in. A page with no `