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 `