astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
306 lines (305 loc) • 12.9 kB
JavaScript
import colors from "piccolore";
import { partitionByKind } from "../csp/runtime.js";
import { getDocsForError, renderErrorMarkdown } from "../errors/dev/runtime.js";
import { AstroError, AstroUserError, CompilerError } from "../errors/index.js";
import { padMultilineString } from "../util-runtime.js";
const { bgGreen, bgYellow, bgCyan, bgWhite, black, blue, bold, cyan, dim, green, red, underline, yellow } = colors;
/**
* Prestyled messages for the CLI. Used by astro CLI commands.
*/
/** Display each request being served with the path and the status code. */
export function req({ url, method, statusCode, reqTime, isRewrite }) {
const color = statusCode >= 500 ? red : statusCode >= 300 ? yellow : blue;
return color(`[${statusCode}]`) + ` ` + `${isRewrite ? color("(rewrite) ") : ""}` + (method && method !== "GET" ? color(method) + " " : "") + url + ` ` + (reqTime ? dim(Math.round(reqTime) + "ms") : "");
}
/** Display server host and startup time */
export function serverStart({ startupTime, resolvedUrls, host, base, astroVersionProvider, textStyler }) {
const localPrefix = `${textStyler.dim("┃")} Local `;
const networkPrefix = `${textStyler.dim("┃")} Network `;
const emptyPrefix = " ".repeat(11);
const localUrlMessages = resolvedUrls.local.map((url, i) => {
return `${i === 0 ? localPrefix : emptyPrefix}${textStyler.cyan(new URL(url).origin + base)}`;
});
const networkUrlMessages = resolvedUrls.network.map((url, i) => {
return `${i === 0 ? networkPrefix : emptyPrefix}${textStyler.cyan(new URL(url).origin + base)}`;
});
if (networkUrlMessages.length === 0) {
const networkLogging = getNetworkLogging(host);
if (networkLogging === "host-to-expose") {
networkUrlMessages.push(`${networkPrefix}${textStyler.dim("use --host to expose")}`);
} else if (networkLogging === "visible") {
networkUrlMessages.push(`${networkPrefix}${textStyler.dim("unable to find network to expose")}`);
}
}
const messages = [
"",
`${textStyler.bgGreen(textStyler.bold(` astro `))} ${textStyler.green(`v${astroVersionProvider.version}`)} ${textStyler.dim(`ready in`)} ${Math.round(startupTime)} ${textStyler.dim("ms")}`,
"",
...localUrlMessages,
...networkUrlMessages,
""
];
return messages.filter(Boolean).join("\n");
}
/** Display custom dev server shortcuts */
export function serverShortcuts({ key, label }) {
return [
dim(" Press"),
key,
dim("to"),
label
].join(" ");
}
export function telemetryNotice() {
const headline = blue(`▶ Astro collects anonymous usage data.`);
const why = " This information helps us improve Astro.";
const disable = ` Run "astro telemetry disable" to opt-out.`;
const details = ` ${cyan(underline("https://astro.build/telemetry"))}`;
return [
headline,
why,
disable,
details
].join("\n");
}
export function telemetryEnabled() {
return [
green("▶ Anonymous telemetry ") + bgGreen(" enabled "),
` Thank you for helping us improve Astro!`,
``
].join("\n");
}
export function preferenceEnabled(name) {
return `${green("◉")} ${name} is now ${bgGreen(black(" enabled "))}\n`;
}
export function preferenceSet(name, value) {
return `${green("◉")} ${name} has been set to ${bgGreen(black(` ${JSON.stringify(value)} `))}\n`;
}
export function preferenceGet(name, value) {
return `${green("◉")} ${name} is set to ${bgGreen(black(` ${JSON.stringify(value)} `))}\n`;
}
export function preferenceDefaultIntro(name) {
return `${yellow("◯")} ${name} has not been set. It defaults to\n`;
}
export function preferenceDefault(name, value) {
return `${yellow("◯")} ${name} has not been set. It defaults to ${bgYellow(black(` ${JSON.stringify(value)} `))}\n`;
}
export function preferenceDisabled(name) {
return `${yellow("◯")} ${name} is now ${bgYellow(black(" disabled "))}\n`;
}
export function preferenceReset(name) {
return `${cyan("◆")} ${name} has been ${bgCyan(black(" reset "))}\n`;
}
export function telemetryDisabled() {
return [
green("▶ Anonymous telemetry ") + bgGreen(" disabled "),
` Astro is no longer collecting anonymous usage data.`,
``
].join("\n");
}
export function telemetryReset() {
return [green("▶ Anonymous telemetry preferences reset."), ``].join("\n");
}
export function fsStrictWarning() {
const title = yellow("▶ " + `${bold("vite.server.fs.strict")} has been disabled!`);
const subtitle = ` Files on your machine are likely accessible on your network.`;
return `${title}\n${subtitle}\n`;
}
export function prerelease({ currentVersion }) {
const tag = currentVersion.split("-").slice(1).join("-").replace(/\..*$/, "") || "unknown";
const badge = bgYellow(black(` ${tag} `));
const title = yellow("▶ " + `This is a ${badge} prerelease build!`);
const subtitle = ` Report issues here: ${cyan(underline("https://astro.build/issues"))}`;
return `${title}\n${subtitle}\n`;
}
export function success(message, tip) {
const badge = bgGreen(black(` success `));
const headline = green(message);
const footer = tip ? `\n ▶ ${tip}` : undefined;
return [
"",
`${badge} ${headline}`,
footer
].filter((v) => v !== undefined).map((msg) => ` ${msg}`).join("\n");
}
export function actionRequired(message) {
const badge = bgYellow(black(` action required `));
const headline = yellow(message);
return ["", `${badge} ${headline}`].filter((v) => v !== undefined).map((msg) => ` ${msg}`).join("\n");
}
export function cancelled(message, tip) {
const badge = bgYellow(black(` cancelled `));
const headline = yellow(message);
const footer = tip ? `\n ▶ ${tip}` : undefined;
return [
"",
`${badge} ${headline}`,
footer
].filter((v) => v !== undefined).map((msg) => ` ${msg}`).join("\n");
}
const LOCAL_IP_HOSTS = new Set(["localhost", "127.0.0.1"]);
function getNetworkLogging(host) {
if (host === false) {
return "host-to-expose";
} else if (typeof host === "string" && LOCAL_IP_HOSTS.has(host)) {
return "none";
} else {
return "visible";
}
}
const codeRegex = /`([^`]+)`/g;
export function formatConfigErrorMessage(err) {
const errorList = err.issues.map((issue) => `! ${renderErrorMarkdown(issue.message, "cli")}`.replaceAll(codeRegex, blue("$1")).split("\n").map((line, index) => index === 0 ? red(line) : " " + line).join("\n"));
return `${red("[config]")} Astro found issue(s) with your configuration:\n\n${errorList.join("\n\n")}`;
}
// a regex to match the first line of a stack trace
const STACK_LINE_REGEXP = /^\s+at /g;
const IRRELEVANT_STACK_REGEXP = /node_modules|astro[/\\]dist/g;
function formatErrorStackTrace(err, showFullStacktrace) {
const stackLines = (err.stack || "").split("\n").filter((line) => STACK_LINE_REGEXP.test(line));
// If full details are required, just return the entire stack trace.
if (showFullStacktrace) {
return stackLines.join("\n");
}
// Grab every string from the user's codebase, exit when you hit node_modules or astro/dist
const irrelevantStackIndex = stackLines.findIndex((line) => IRRELEVANT_STACK_REGEXP.test(line));
if (irrelevantStackIndex <= 0) {
const errorId = err.id;
const errorLoc = err.loc;
if (errorId || errorLoc?.file) {
const prettyLocation = ` at ${errorId ?? errorLoc?.file}${errorLoc?.line && errorLoc.column ? `:${errorLoc.line}:${errorLoc.column}` : ""}`;
return prettyLocation + "\n [...] See full stack trace in the browser, or rerun with --verbose.";
} else {
return stackLines.join("\n");
}
}
// If the error occurred inside of a dependency, grab the entire stack.
// Otherwise, only grab the part of the stack that is relevant to the user's codebase.
return stackLines.splice(0, irrelevantStackIndex).join("\n") + "\n [...] See full stack trace in the browser, or rerun with --verbose.";
}
export function formatErrorMessage(err, showFullStacktrace) {
const isOurError = AstroError.is(err) || CompilerError.is(err) || AstroUserError.is(err);
let message = "";
if (isOurError) {
message += red(`[${err.name}]`) + " " + renderErrorMarkdown(err.message, "cli");
} else {
message += err.message;
}
const output = [message];
if (err.hint) {
output.push(` ${bold("Hint:")}`);
output.push(yellow(padMultilineString(renderErrorMarkdown(err.hint, "cli"), 4)));
}
const docsLink = getDocsForError(err);
if (docsLink) {
output.push(` ${bold("Error reference:")}`);
output.push(` ${cyan(underline(docsLink))}`);
}
if (showFullStacktrace && err.loc) {
output.push(` ${bold("Location:")}`);
output.push(` ${underline(`${err.loc.file}:${err.loc.line ?? 0}:${err.loc.column ?? 0}`)}`);
}
if (err.stack) {
output.push(` ${bold("Stack trace:")}`);
output.push(dim(formatErrorStackTrace(err, showFullStacktrace)));
}
if (err.cause) {
output.push(` ${bold("Caused by:")}`);
let causeMessage = " ";
if (err.cause instanceof Error) {
causeMessage += err.cause.message + "\n" + formatErrorStackTrace(err.cause, showFullStacktrace);
} else {
causeMessage += JSON.stringify(err.cause);
}
output.push(dim(causeMessage));
}
return output.join("\n");
}
/** @deprecated Migrate to HelpDisplay */
export function printHelp({ commandName, headline, usage, tables, description }) {
const linebreak = () => "";
const title = (label) => ` ${bgWhite(black(` ${label} `))}`;
const table = (rows, { padding }) => {
const split = process.stdout.columns < 60;
let raw = "";
for (const row of rows) {
if (split) {
raw += ` ${row[0]}\n `;
} else {
raw += `${`${row[0]}`.padStart(padding)}`;
}
raw += " " + dim(row[1]) + "\n";
}
return raw.slice(0, -1);
};
let message = [];
if (headline) {
message.push(linebreak(), ` ${bgGreen(black(` ${commandName} `))} ${green(`v${"7.4.2" ?? ""}`)} ${headline}`);
}
if (usage) {
message.push(linebreak(), ` ${green(commandName)} ${bold(usage)}`);
}
if (tables) {
function calculateTablePadding(rows) {
return rows.reduce((val, [first]) => Math.max(val, first.length), 0) + 2;
}
const tableEntries = Object.entries(tables);
const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows)));
for (const [tableTitle, tableRows] of tableEntries) {
message.push(linebreak(), title(tableTitle), table(tableRows, { padding }));
}
}
if (description) {
message.push(linebreak(), `${description}`);
}
// biome-ignore lint/suspicious/noConsole: allowed
console.log(message.join("\n") + "\n");
}
export function warnIfCspWithShiki(config, logger) {
// Check if CSP is enabled
const cspEnabled = config.security.csp !== false;
if (!cspEnabled) return;
// Check if Shiki is being used (string or object form)
const syntaxHighlight = config.markdown.syntaxHighlight;
const isShiki = syntaxHighlight === "shiki" || typeof syntaxHighlight === "object" && syntaxHighlight?.type === "shiki";
if (isShiki) {
logger.warn("config", "Shiki syntax highlighting uses inline styles that are not compatible with Content Security Policy (CSP). " + "Consider using Prism syntax highlighting instead, or disable CSP if Shiki is required.");
}
}
/**
* Warns when a `scriptDirective`/`styleDirective` defines `default`-kind resources alongside
* `element`/`attribute`-kind entries. Because the more specific directive (`*-src-elem`/`*-src-attr`)
* overrides the generic one for its scope and browsers do not fall back, the generic resources will
* not apply there. Astro's generated hashes are folded automatically, so this only concerns
* user-provided resources.
*/
export function warnIfCspResourceFallbackShadowing(config, logger) {
const csp = config.security.csp;
// Only the object form has `scriptDirective`/`styleDirective` to inspect. (Reading the config
// directly here keeps this module free of the Node-only `csp/common.js`, which must not leak
// into the SSR runtime.)
if (typeof csp !== "object") return;
const families = [{
name: "script",
directive: csp.scriptDirective
}, {
name: "style",
directive: csp.styleDirective
}];
for (const { name, directive } of families) {
const sources = partitionByKind({
resources: directive?.resources ?? [],
hashes: directive?.hashes ?? []
});
if (sources.default.resources.length === 0) continue;
const shadowed = [];
if (sources.element.resources.length > 0 || sources.element.hashes.length > 0) {
shadowed.push(`\`${name}-src-elem\``);
}
if (sources.attribute.resources.length > 0 || sources.attribute.hashes.length > 0) {
shadowed.push(`\`${name}-src-attr\``);
}
if (shadowed.length === 0) continue;
logger.warn("csp", `\`security.csp.${name}Directive\` defines \`${name}-src\` resources (${sources.default.resources.join(" ")}) as well as ${shadowed.join(" and ")} resources or hashes. Because ${shadowed.join(" and ")} override \`${name}-src\` for their scope (browsers do not fall back), those \`${name}-src\` resources will not apply there. Add them to the corresponding \`kind\` if needed.`);
}
}