Skip to content

Commit

Permalink
Add new preferences module (#9115)
Browse files Browse the repository at this point in the history
  • Loading branch information
natemoo-re authored Nov 29, 2023
1 parent 34e96b1 commit 3b77889
Show file tree
Hide file tree
Showing 15 changed files with 507 additions and 4 deletions.
17 changes: 17 additions & 0 deletions .changeset/gentle-cobras-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'astro': minor
---

Adds the `astro preferences` command to manage user preferences. User preferences are specific to individual Astro users, unlike the `astro.config.mjs` file which changes behavior for everyone working on a project.

User preferences are scoped to the current project by default, stored in a local `.astro/settings.json` file. Using the `--global` flag, user preferences can also be applied to every Astro project on the current machine. Global user preferences are stored in an operating system-specific location.

```sh
# Disable the dev overlay for the current user in the current project
npm run astro preferences disable devOverlay
# Disable the dev overlay for the current user in all Astro projects on this machine
npm run astro preferences --global disable devOverlay

# Check if the dev overlay is enabled for the current user
npm run astro preferences list devOverlay
```
4 changes: 4 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,14 @@
"deterministic-object-hash": "^2.0.1",
"devalue": "^4.3.2",
"diff": "^5.1.0",
"dlv": "^1.1.3",
"dset": "^3.1.3",
"es-module-lexer": "^1.4.1",
"esbuild": "^0.19.6",
"estree-walker": "^3.0.3",
"execa": "^8.0.1",
"fast-glob": "^3.3.2",
"flattie": "^1.1.0",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"html-escaper": "^3.0.3",
Expand Down Expand Up @@ -185,6 +188,7 @@
"@types/cookie": "^0.5.4",
"@types/debug": "^4.1.12",
"@types/diff": "^5.0.8",
"@types/dlv": "^1.1.4",
"@types/dom-view-transitions": "^1.0.4",
"@types/estree": "^1.0.5",
"@types/hast": "^3.0.3",
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server/index.js';
import type { OmitIndexSignature, Simplify } from '../type-utils.js';
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
import type { AstroPreferences } from '../preferences/index.js';

export { type AstroIntegrationLogger };

Expand Down Expand Up @@ -1678,6 +1679,7 @@ export interface AstroAdapterFeatures {
export interface AstroSettings {
config: AstroConfig;
adapter: AstroAdapter | undefined;
preferences: AstroPreferences;
injectedRoutes: InjectedRoute[];
resolvedInjectedRoutes: ResolvedInjectedRoute[];
pageExtensions: string[];
Expand Down
11 changes: 10 additions & 1 deletion packages/astro/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type CLICommand =
| 'sync'
| 'check'
| 'info'
| 'preferences'
| 'telemetry';

/** Display --help flag */
Expand All @@ -33,6 +34,7 @@ async function printAstroHelp() {
['info', 'List info about your current Astro setup.'],
['preview', 'Preview your build locally.'],
['sync', 'Generate content collection types.'],
['preferences', 'Configure user preferences.'],
['telemetry', 'Configure telemetry settings.'],
],
'Global Flags': [
Expand Down Expand Up @@ -64,6 +66,7 @@ function resolveCommand(flags: yargs.Arguments): CLICommand {
'add',
'sync',
'telemetry',
'preferences',
'dev',
'build',
'preview',
Expand Down Expand Up @@ -114,6 +117,12 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
const exitCode = await sync({ flags });
return process.exit(exitCode);
}
case 'preferences': {
const { preferences } = await import('./preferences/index.js');
const [subcommand, key, value] = flags._.slice(3).map(v => v.toString());
const exitCode = await preferences(subcommand, key, value, { flags });
return process.exit(exitCode);
}
}

// In verbose/debug mode, we log the debug logs asap before any potential errors could appear
Expand Down Expand Up @@ -177,7 +186,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {

/** The primary CLI action */
export async function cli(args: string[]) {
const flags = yargs(args);
const flags = yargs(args, { boolean: ['global'], alias: { g: 'global' } });
const cmd = resolveCommand(flags);
try {
await runCommand(cmd, flags);
Expand Down
227 changes: 227 additions & 0 deletions packages/astro/src/cli/preferences/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/* eslint-disable no-console */
import type yargs from 'yargs-parser';
import type { AstroSettings } from '../../@types/astro.js';

import { bold } from 'kleur/colors';
import { fileURLToPath } from 'node:url';

import * as msg from '../../core/messages.js';
import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
import { resolveConfig } from '../../core/config/config.js';
import { createSettings } from '../../core/config/settings.js';
import { coerce, isValidKey, type PreferenceKey } from '../../preferences/index.js';
import { DEFAULT_PREFERENCES } from '../../preferences/defaults.js';
import dlv from 'dlv';
// @ts-expect-error flattie types are mispackaged
import { flattie } from 'flattie';
import { formatWithOptions } from 'node:util';
import { collectErrorMetadata } from '../../core/errors/dev/utils.js';

interface PreferencesOptions {
flags: yargs.Arguments;
}

const PREFERENCES_SUBCOMMANDS = ['get', 'set', 'enable', 'disable', 'delete', 'reset', 'list'] as const;
export type Subcommand = typeof PREFERENCES_SUBCOMMANDS[number];

function isValidSubcommand(subcommand: string): subcommand is Subcommand {
return PREFERENCES_SUBCOMMANDS.includes(subcommand as Subcommand);
}

export async function preferences(subcommand: string, key: string, value: string | undefined, { flags }: PreferencesOptions): Promise<number> {
if (!isValidSubcommand(subcommand) || flags?.help || flags?.h) {
msg.printHelp({
commandName: 'astro preferences',
usage: '[command]',
tables: {
Commands: [
['list', 'Pretty print all current preferences'],
['list --json', 'Log all current preferences as a JSON object'],
['get [key]', 'Log current preference value'],
['set [key] [value]', 'Update preference value'],
['reset [key]', 'Reset preference value to default'],
['enable [key]', 'Set a boolean preference to true'],
['disable [key]', 'Set a boolean preference to false'],
],
Flags: [
['--global', 'Scope command to global preferences (all Astro projects) rather than the current project'],
],
},
});
return 0;
}

const inlineConfig = flagsToAstroInlineConfig(flags);
const logger = createLoggerFromFlags(flags);
const { astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev');
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
const opts: SubcommandOptions = {
location: flags.global ? 'global' : undefined,
json: flags.json
}

if (subcommand === 'list') {
return listPreferences(settings, opts);
}

if (subcommand === 'enable' || subcommand === 'disable') {
key = `${key}.enabled` as PreferenceKey;
}

if (!isValidKey(key)) {
logger.error('preferences', `Unknown preference "${key}"\n`);
return 1;
}

if (subcommand === 'set' && value === undefined) {
const type = typeof dlv(DEFAULT_PREFERENCES, key);
console.error(msg.formatErrorMessage(collectErrorMetadata(new Error(`Please provide a ${type} value for "${key}"`)), true));
return 1;
}

switch (subcommand) {
case 'get': return getPreference(settings, key, opts);
case 'set': return setPreference(settings, key, value, opts);
case 'reset':
case 'delete': return resetPreference(settings, key, opts);
case 'enable': return enablePreference(settings, key, opts);
case 'disable': return disablePreference(settings, key, opts);
}
}

interface SubcommandOptions {
location?: 'global' | 'project';
json?: boolean;
}

// Default `location` to "project" to avoid reading default preferencesa
async function getPreference(settings: AstroSettings, key: PreferenceKey, { location = 'project' }: SubcommandOptions) {
try {
let value = await settings.preferences.get(key, { location });
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (Object.keys(value).length === 0) {
value = dlv(DEFAULT_PREFERENCES, key);
console.log(msg.preferenceDefaultIntro(key));
}
prettyPrint({ [key]: value });
return 0;
}
if (value === undefined) {
const defaultValue = await settings.preferences.get(key);
console.log(msg.preferenceDefault(key, defaultValue));
return 0;
}
console.log(msg.preferenceGet(key, value));
return 0;
} catch {}
return 1;
}

async function setPreference(settings: AstroSettings, key: PreferenceKey, value: unknown, { location }: SubcommandOptions) {
try {
const defaultType = typeof dlv(DEFAULT_PREFERENCES, key);
if (typeof coerce(key, value) !== defaultType) {
throw new Error(`${key} expects a "${defaultType}" value!`)
}

await settings.preferences.set(key, coerce(key, value), { location });
console.log(msg.preferenceSet(key, value))
return 0;
} catch (e) {
if (e instanceof Error) {
console.error(msg.formatErrorMessage(collectErrorMetadata(e), true));
return 1;
}
throw e;
}
}

async function enablePreference(settings: AstroSettings, key: PreferenceKey, { location }: SubcommandOptions) {
try {
await settings.preferences.set(key, true, { location });
console.log(msg.preferenceEnabled(key.replace('.enabled', '')))
return 0;
} catch {}
return 1;
}

async function disablePreference(settings: AstroSettings, key: PreferenceKey, { location }: SubcommandOptions) {
try {
await settings.preferences.set(key, false, { location });
console.log(msg.preferenceDisabled(key.replace('.enabled', '')))
return 0;
} catch {}
return 1;
}

async function resetPreference(settings: AstroSettings, key: PreferenceKey, { location }: SubcommandOptions) {
try {
await settings.preferences.set(key, undefined as any, { location });
console.log(msg.preferenceReset(key))
return 0;
} catch {}
return 1;
}


async function listPreferences(settings: AstroSettings, { location, json }: SubcommandOptions) {
const store = await settings.preferences.getAll({ location });
if (json) {
console.log(JSON.stringify(store, null, 2));
return 0;
}
prettyPrint(store);
return 0;
}

function prettyPrint(value: Record<string, string | number | boolean>) {
const flattened = flattie(value);
const table = formatTable(flattened, ['Preference', 'Value']);
console.log(table);
}

const chars = {
h: '─',
hThick: '━',
hThickCross: '┿',
v: '│',
vRight: '├',
vRightThick: '┝',
vLeft: '┤',
vLeftThick: '┥',
hTop: '┴',
hBottom: '┬',
topLeft: '╭',
topRight: '╮',
bottomLeft: '╰',
bottomRight: '╯',
}

function formatTable(object: Record<string, string | number | boolean>, columnLabels: [string, string]) {
const [colA, colB] = columnLabels;
const colALength = [colA, ...Object.keys(object)].reduce(longest, 0) + 3;
const colBLength = [colB, ...Object.values(object)].reduce(longest, 0) + 3;
function formatRow(i: number, a: string, b: string | number | boolean, style: (value: string | number | boolean) => string = (v) => v.toString()): string {
return `${chars.v} ${style(a)} ${space(colALength - a.length - 2)} ${chars.v} ${style(b)} ${space(colBLength - b.toString().length - 3)} ${chars.v}`
}
const top = `${chars.topLeft}${chars.h.repeat(colALength + 1)}${chars.hBottom}${chars.h.repeat(colBLength)}${chars.topRight}`
const bottom = `${chars.bottomLeft}${chars.h.repeat(colALength + 1)}${chars.hTop}${chars.h.repeat(colBLength)}${chars.bottomRight}`
const divider = `${chars.vRightThick}${chars.hThick.repeat(colALength + 1)}${chars.hThickCross}${chars.hThick.repeat(colBLength)}${chars.vLeftThick}`
const rows: string[] = [top, formatRow(-1, colA, colB, bold), divider];
let i = 0;
for (const [key, value] of Object.entries(object)) {
rows.push(formatRow(i, key, value, (v) => formatWithOptions({ colors: true }, v)));
i++;
}
rows.push(bottom);
return rows.join('\n');
}

function space(len: number) {
return ' '.repeat(len);
}

const longest = (a: number, b: string | number | boolean) => {
const { length: len } = b.toString();
return a > len ? a : len;
};
3 changes: 3 additions & 0 deletions packages/astro/src/core/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import { formatYAMLException, isYAMLException } from '../errors/utils.js';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../constants.js';
import { AstroTimer } from './timer.js';
import { loadTSConfig } from './tsconfig.js';
import createPreferences from '../../preferences/index.js';

export function createBaseSettings(config: AstroConfig): AstroSettings {
const { contentDir } = getContentPaths(config);
const preferences = createPreferences(config);
return {
config,
preferences,
tsConfig: undefined,
tsConfigPath: undefined,
adapter: undefined,
Expand Down
7 changes: 5 additions & 2 deletions packages/astro/src/core/dev/restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ async function createRestartedContainer(
return newContainer;
}

const configRE = new RegExp(`.*astro\.config\.((mjs)|(cjs)|(js)|(ts))$`);
const preferencesRE = new RegExp(`.*\.astro\/settings\.json$`);

export function shouldRestartContainer(
{ settings, inlineConfig, restartInFlight }: Container,
changedFile: string
Expand All @@ -43,9 +46,9 @@ export function shouldRestartContainer(
}
// Otherwise, watch for any astro.config.* file changes in project root
else {
const exp = new RegExp(`.*astro\.config\.((mjs)|(cjs)|(js)|(ts))$`);
const normalizedChangedFile = vite.normalizePath(changedFile);
shouldRestart = exp.test(normalizedChangedFile);
shouldRestart = configRE.test(normalizedChangedFile) || preferencesRE.test(normalizedChangedFile);

}

if (!shouldRestart && settings.watchFiles.length > 0) {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/logger/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type LoggerLabel =
| 'vite'
| 'watch'
| 'middleware'
| 'preferences'
// SKIP_FORMAT: A special label that tells the logger not to apply any formatting.
// Useful for messages that are already formatted, like the server start message.
| 'SKIP_FORMAT';
Expand Down
Loading

0 comments on commit 3b77889

Please sign in to comment.