Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions electron/componentFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,60 @@ const toPosix = (p: string): string => p.split(path.sep).join('/');
// `<card />` renders a literal <card> 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;
Expand All @@ -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[];
Expand All @@ -47,6 +103,7 @@ function componentFile({
projectPath,
pagePath,
name,
folder = '',
nodes,
imports = [],
props = [],
Expand All @@ -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
Expand All @@ -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 };
});

Expand Down
9 changes: 9 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }) => {
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
110 changes: 110 additions & 0 deletions electron/variablesFile.ts
Original file line number Diff line number Diff line change
@@ -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 '<spec>';` 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 };
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions shared/ipc-payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }))),
Expand All @@ -114,6 +115,7 @@ export const IPC_PAYLOADS = {
before: cssTarget,
at: optional(count),
}),
'css:createVariablesFile': pathText,
'css:addVariables': object({
...project,
adds: optional(
Expand Down
5 changes: 5 additions & 0 deletions shared/ipc-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
};
readonly 'css:addVariables': {
readonly ok: boolean;
readonly error?: string;
Expand Down
1 change: 1 addition & 0 deletions shared/preload-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading