Add Astro.request.canonicalURL and Astro.site to global (#199)

This commit is contained in:
Drew Powers 2021-05-11 17:31:52 -06:00 committed by GitHub
parent d2eb413a6e
commit 7184149514
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 234 additions and 98 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Added canonical URL and site globals for .astro files

View file

@ -4,14 +4,6 @@
The `Astro` global is available in all contexts in `.astro` files. It has the following functions:
#### `config`
`Astro.config` returns an object with the following properties:
| Name | Type | Description |
| :----- | :------- | :--------------------------------------------------------------------------------------------------------- |
| `site` | `string` | Your websites public root domain. Set it with `site: "https://mysite.com"` in your [Astro config][config] |
#### `fetchContent()`
`Astro.fetchContent()` is a way to load local `*.md` files into your static site setup. You can either use this on its own, or within [Astro Collections][docs-collections].
@ -47,9 +39,16 @@ const data = Astro.fetchContent('../pages/post/*.md'); // returns an array of po
`Astro.request` returns an object with the following properties:
| Name | Type | Description |
| :---- | :---- | :------------------------------------- |
| `url` | `URL` | The URL of the request being rendered. |
| Name | Type | Description |
| :------------- | :---- | :---------------------------------------------- |
| `url` | `URL` | The URL of the request being rendered. |
| `canonicalURL` | `URL` | [Canonical URL][canonical] of the current page. |
⚠️ Temporary restriction: this is only accessible in top-level pages and not in sub-components.
#### `site`
`Astro.site` returns a `URL` made from `buildOptions.site` in your Astro config. If undefined, this will return a URL generated from `localhost`.
### `collection`
@ -147,6 +146,7 @@ Astro will generate an RSS 2.0 feed at `/feed/[collection].xml` (for example, `/
<link rel="alternate" type="application/rss+xml" title="My RSS Feed" href="/feed/podcast.xml" />
```
[canonical]: https://en.wikipedia.org/wiki/Canonical_link_element
[config]: ../README.md#%EF%B8%8F-configuration
[docs-collections]: ./collections.md
[rss]: #-rss-feed

View file

@ -4,6 +4,9 @@ export let title: string;
export let description: string;
export let image: string | undefined;
export let type: string | undefined;
export let next: string | undefined;
export let prev: string | undefined;
export let canonicalURL: string | undefined;
// internal data
const OG_TYPES = {
@ -17,6 +20,10 @@ const OG_TYPES = {
<title>{title}</title>
<meta name="description" content={description} />
<link rel="stylesheet" href="/global.css" />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="canonical" href={canonicalURL} />
{next && <link rel="next" href={next} />}
{prev && <link rel="prev" href={prev} />}
<!-- OpenGraph -->
<meta property="og:title" content={title} />

View file

@ -21,7 +21,11 @@ let firstThree = allPosts.slice(0, 3);
<html>
<head>
<title>{title}</title>
<MainHead title={title} description={description} />
<MainHead
title={title}
description={description}
canonicalURL={Astro.request.canonicalURL.href}
/>
</head>
<body>

View file

@ -17,7 +17,7 @@ import { buildCollectionPage, buildStaticPage, getPageType } from './build/page.
import { generateSitemap } from './build/sitemap.js';
import { logURLStats, collectBundleStats, mapBundleStatsToURLStats } from './build/stats.js';
import { getDistPath, sortSet, stopTimer } from './build/util.js';
import { debug, defaultLogDestination, error, info, trapWarn } from './logger.js';
import { debug, defaultLogDestination, error, info, warn, trapWarn } from './logger.js';
import { createRuntime } from './runtime.js';
const logging: LogOptions = {
@ -55,6 +55,9 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> {
dest: defaultLogDestination,
};
// warn users if missing config item in build that may result in broken SEO (cant disable, as they should provide this)
warn(logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`);
const mode: RuntimeMode = 'production';
const runtime = await createRuntime(astroConfig, { mode, logging: runtimeLogging });
const { runtimeConfig } = runtime;
@ -170,8 +173,6 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> {
await fs.promises.writeFile(sitemapPath, sitemap, 'utf8');
info(logging, 'build', green('✔'), 'sitemap built.');
debug(logging, 'build', `built sitemap [${stopTimer(timer.sitemap)}]`);
} else if (astroConfig.buildOptions.sitemap) {
info(logging, 'tip', `Set "buildOptions.site" in astro.config.mjs to generate a sitemap.xml, or set "buildOptions.sitemap: false" to disable this message.`);
}
// write to disk and free up memory

View file

@ -27,7 +27,7 @@ export function generateRSS<T>(input: { data: T[]; site: string } & CollectionRS
// title, description, customData
xml += `<title><![CDATA[${input.title}]]></title>`;
xml += `<description><![CDATA[${input.description}]]></description>`;
xml += `<link>${canonicalURL('/feed/' + filename + '.xml', input.site)}</link>`;
xml += `<link>${canonicalURL('/feed/' + filename + '.xml', input.site).href}</link>`;
if (typeof input.customData === 'string') xml += input.customData;
// items
@ -40,7 +40,7 @@ export function generateRSS<T>(input: { data: T[]; site: string } & CollectionRS
if (!result.title) throw new Error(`[${filename}] rss.item() returned object but required "title" is missing.`);
if (!result.link) throw new Error(`[${filename}] rss.item() returned object but required "link" is missing.`);
xml += `<title><![CDATA[${result.title}]]></title>`;
xml += `<link>${canonicalURL(result.link, input.site)}</link>`;
xml += `<link>${canonicalURL(result.link, input.site).href}</link>`;
if (result.description) xml += `<description><![CDATA[${result.description}]]></description>`;
if (result.pubDate) {
// note: this should be a Date, but if user provided a string or number, we can work with that, too.

View file

@ -4,18 +4,18 @@ import { canonicalURL } from './util';
/** Construct sitemap.xml given a set of URLs */
export function generateSitemap(buildState: BuildOutput, site: string): string {
const pages: string[] = [];
const uniqueURLs = new Set<string>();
// TODO: find way to respect <link rel="canonical"> URLs here
// TODO: find way to exclude pages from sitemap
// look through built pages, only add HTML
for (const id of Object.keys(buildState)) {
if (buildState[id].contentType !== 'text/html' || id.endsWith('/1/index.html')) continue; // note: exclude auto-generated "page 1" pages (duplicates of index)
let url = canonicalURL(id.replace(/index\.html$/, ''), site);
pages.push(url);
if (buildState[id].contentType !== 'text/html') continue;
uniqueURLs.add(canonicalURL(id, site).href);
}
const pages = [...uniqueURLs];
pages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time
let sitemap = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;

View file

@ -5,11 +5,11 @@ import path from 'path';
import { fileURLToPath, URL } from 'url';
/** Normalize URL to its canonical form */
export function canonicalURL(url: string, base?: string): string {
return new URL(
path.extname(url) ? url : url.replace(/(\/+)?$/, '/'), // add trailing slash if theres no extension
base
).href;
export function canonicalURL(url: string, base?: string): URL {
let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical
pathname = pathname.replace(/\/1\/?$/, ''); // neither is a trailing /1/ (impl. detail of collections)
if (!path.extname(pathname)) pathname = pathname.replace(/(\/+)?$/, '/'); // add trailing slash if theres no extension
return new URL(pathname, base);
}
/** Sort a Set */

View file

@ -65,7 +65,7 @@ function printHelp() {
${colors.bold('Flags:')}
--config <path> Specify the path to the Astro config file.
--project-root <path> Specify the path to the project root folder.
--no-sitemap Disable sitemap generation (build only).
--no-sitemap Disable sitemap generation (build only).
--version Show the version number and exit.
--help Show this help message.
`);

View file

@ -3,16 +3,18 @@ import type { AstroConfig, ValidExtensionPlugins } from '../../@types/astro';
import type { Ast, Script, Style, TemplateNode } from 'astro-parser';
import type { TransformResult } from '../../@types/astro';
import 'source-map-support/register.js';
import eslexer from 'es-module-lexer';
import esbuild from 'esbuild';
import path from 'path';
import { fileURLToPath } from 'url';
import { walk } from 'estree-walker';
import _babelGenerator from '@babel/generator';
import babelParser from '@babel/parser';
import { codeFrameColumns } from '@babel/code-frame';
import * as babelTraverse from '@babel/traverse';
import { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier } from '@babel/types';
import { warn } from '../../logger.js';
import { error, warn } from '../../logger.js';
import { fetchContent } from './content.js';
import { isFetchContent } from './utils.js';
import { yellow } from 'kleur/colors';
@ -77,7 +79,12 @@ function getAttributes(attrs: Attribute[]): Record<string, string> {
switch (val.type) {
case 'MustacheTag': {
// FIXME: this won't work when JSX element can appear in attributes (rare but possible).
result[attr.name] = '(' + val.expression.codeChunks[0] + ')';
const codeChunks = val.expression.codeChunks[0];
if (codeChunks) {
result[attr.name] = '(' + codeChunks + ')';
} else {
throw new Error(`Parse error: ${attr.name}={}`); // if bad codeChunk, throw error
}
continue;
}
case 'Text':
@ -388,7 +395,7 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
if (!id || !init || id.type !== 'Identifier') continue;
if (init.type === 'AwaitExpression') {
init = init.argument;
const shortname = path.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
warn(compileOptions.logging, shortname, yellow('awaiting Astro.fetchContent() not necessary'));
}
if (init.type !== 'CallExpression') continue;
@ -572,29 +579,36 @@ function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOption
if (!name) {
throw new Error('AHHHH');
}
const attributes = getAttributes(node.attributes);
try {
const attributes = getAttributes(node.attributes);
outSource += outSource === '' ? '' : ',';
if (node.type === 'Slot') {
outSource += `(children`;
return;
}
const COMPONENT_NAME_SCANNER = /^[A-Z]/;
if (!COMPONENT_NAME_SCANNER.test(name)) {
outSource += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`;
return;
}
const [componentName, componentKind] = name.split(':');
const componentImportData = components[componentName];
if (!componentImportData) {
throw new Error(`Unknown Component: ${componentName}`);
}
const { wrapper, wrapperImport } = getComponentWrapper(name, components[componentName], { astroConfig, dynamicImports, filename });
if (wrapperImport) {
importExportStatements.add(wrapperImport);
}
outSource += outSource === '' ? '' : ',';
if (node.type === 'Slot') {
outSource += `(children`;
return;
}
const COMPONENT_NAME_SCANNER = /^[A-Z]/;
if (!COMPONENT_NAME_SCANNER.test(name)) {
outSource += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`;
return;
}
const [componentName, componentKind] = name.split(':');
const componentImportData = components[componentName];
if (!componentImportData) {
throw new Error(`Unknown Component: ${componentName}`);
}
const { wrapper, wrapperImport } = getComponentWrapper(name, components[componentName], { astroConfig, dynamicImports, filename });
if (wrapperImport) {
importExportStatements.add(wrapperImport);
}
outSource += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`;
outSource += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`;
} catch (err) {
// handle errors in scope with filename
const rel = filename.replace(fileURLToPath(astroConfig.projectRoot), '');
// TODO: return actual codeframe here
error(compileOptions.logging, rel, err.toString());
}
return;
}
case 'Attribute': {

View file

@ -124,6 +124,7 @@ export async function compileComponent(
{ compileOptions, filename, projectRoot }: { compileOptions: CompileOptions; filename: string; projectRoot: string }
): Promise<CompileResult> {
const result = await transformFromSource(source, { compileOptions, filename, projectRoot });
const site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`;
// return template
let modJsx = `
@ -137,7 +138,8 @@ import { h, Fragment } from '${internalImport('h.js')}';
const __astroRequestSymbol = Symbol('astro.request');
async function __render(props, ...children) {
const Astro = {
request: props[__astroRequestSymbol]
request: props[__astroRequestSymbol] || {},
site: new URL('/', ${JSON.stringify(site)}),
};
${result.script}

View file

@ -1,5 +1,6 @@
import 'source-map-support/register.js';
import type { AstroConfig } from './@types/astro';
import 'source-map-support/register.js';
import { join as pathJoin, resolve as pathResolve } from 'path';
import { existsSync } from 'fs';
@ -9,25 +10,36 @@ const type = (thing: any): string => (Array.isArray(thing) ? 'Array' : typeof th
/** Throws error if a user provided an invalid config. Manually-implemented to avoid a heavy validation library. */
function validateConfig(config: any): void {
// basic
if (config === undefined || config === null) throw new Error(`[astro config] Config empty!`);
if (typeof config !== 'object') throw new Error(`[astro config] Expected object, received ${typeof config}`);
if (config === undefined || config === null) throw new Error(`[config] Config empty!`);
if (typeof config !== 'object') throw new Error(`[config] Expected object, received ${typeof config}`);
// strings
for (const key of ['projectRoot', 'astroRoot', 'dist', 'public', 'site']) {
for (const key of ['projectRoot', 'astroRoot', 'dist', 'public']) {
if (config[key] !== undefined && config[key] !== null && typeof config[key] !== 'string') {
throw new Error(`[astro config] ${key}: ${JSON.stringify(config[key])}\n Expected string, received ${type(config[key])}.`);
throw new Error(`[config] ${key}: ${JSON.stringify(config[key])}\n Expected string, received ${type(config[key])}.`);
}
}
// booleans
for (const key of ['sitemap']) {
if (config[key] !== undefined && config[key] !== null && typeof config[key] !== 'boolean') {
throw new Error(`[astro config] ${key}: ${JSON.stringify(config[key])}\n Expected boolean, received ${type(config[key])}.`);
throw new Error(`[config] ${key}: ${JSON.stringify(config[key])}\n Expected boolean, received ${type(config[key])}.`);
}
}
// buildOptions
if (config.buildOptions && config.buildOptions.site !== undefined) {
if (typeof config.buildOptions.site !== 'string') throw new Error(`[config] buildOptions.site is not a string`);
try {
new URL(config.buildOptions.site);
} catch (err) {
throw new Error('[config] buildOptions.site must be a valid URL');
}
}
// devOptions
if (typeof config.devOptions?.port !== 'number') {
throw new Error(`[astro config] devOptions.port: Expected number, received ${type(config.devOptions?.port)}`);
throw new Error(`[config] devOptions.port: Expected number, received ${type(config.devOptions?.port)}`);
}
}

View file

@ -3,9 +3,9 @@ import type { AstroConfig } from './@types/astro';
import type { LogOptions } from './logger.js';
import { logger as snowpackLogger } from 'snowpack';
import { bold, green } from 'kleur/colors';
import { green } from 'kleur/colors';
import http from 'http';
import { relative as pathRelative } from 'path';
import path from 'path';
import { performance } from 'perf_hooks';
import { defaultLogDestination, error, info, parseError } from './logger.js';
import { createRuntime } from './runtime.js';
@ -63,7 +63,7 @@ export default async function dev(astroConfig: AstroConfig) {
switch (result.type) {
case 'parse-error': {
const err = result.error;
err.filename = pathRelative(projectRoot.pathname, err.filename);
if (err.filename) err.filename = path.posix.relative(projectRoot.pathname, err.filename);
parseError(logging, err);
break;
}

View file

@ -114,6 +114,10 @@ export function table(opts: LogOptions, columns: number[]) {
/** Pretty format error for display */
export function parseError(opts: LogOptions, err: CompileError) {
if (!err.frame) {
return error(opts, 'parse-error', err.message || err);
}
let frame = err.frame
// Switch colons for pipes
.replace(/^([0-9]+)(:)/gm, `${bold('$1')}`)

View file

@ -1,14 +1,15 @@
import 'source-map-support/register.js';
import { fileURLToPath } from 'url';
import type { SnowpackDevServer, ServerRuntime as SnowpackServerRuntime, SnowpackConfig } from 'snowpack';
import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Params, RuntimeMode } from './@types/astro';
import type { LogOptions } from './logger';
import type { CompileError } from 'astro-parser';
import { debug, info } from './logger.js';
import { searchForPage } from './search.js';
import type { LogOptions } from './logger';
import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Params, RuntimeMode } from './@types/astro';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { loadConfiguration, logger as snowpackLogger, startServer as startSnowpackServer } from 'snowpack';
import { canonicalURL } from './build/util.js';
import { debug, info } from './logger.js';
import { searchForPage } from './search.js';
// We need to use require.resolve for snowpack plugins, so create a require function here.
import { createRequire } from 'module';
@ -49,9 +50,10 @@ snowpackLogger.level = 'silent';
/** Pass a URL to Astro to resolve and build */
async function load(config: RuntimeConfig, rawPathname: string | undefined): Promise<LoadResult> {
const { logging, backendSnowpackRuntime, frontendSnowpack } = config;
const { astroRoot } = config.astroConfig;
const { astroRoot, buildOptions, devOptions } = config.astroConfig;
const fullurl = new URL(rawPathname || '/', 'https://example.org/');
let origin = buildOptions.site ? new URL(buildOptions.site).origin : `http://localhost:${devOptions.port}`;
const fullurl = new URL(rawPathname || '/', origin);
const reqPath = decodeURI(fullurl.pathname);
info(logging, 'access', reqPath);
@ -208,6 +210,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
request: {
// params should go here when implemented
url: requestURL,
canonicalURL: canonicalURL(requestURL.pathname, requestURL.origin),
},
children: [],
props: { collection },

View file

@ -0,0 +1,45 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { doc } from './test-utils.js';
import { setup } from './helpers.js';
const Global = suite('Astro.*');
setup(Global, './fixtures/astro-global');
Global('Astro.request.url', async (context) => {
const result = await context.runtime.load('/');
assert.equal(result.statusCode, 200);
const $ = doc(result.contents);
assert.equal($('#pathname').text(), '/');
});
Global('Astro.request.canonicalURL', async (context) => {
// given a URL, expect the following canonical URL
const canonicalURLs = {
'/': 'https://mysite.dev/',
'/post/post': 'https://mysite.dev/post/post/',
'/posts': 'https://mysite.dev/posts/',
'/posts/1': 'https://mysite.dev/posts/', // should be the same as /posts
'/posts/2': 'https://mysite.dev/posts/2/',
};
for (const [url, canonicalURL] of Object.entries(canonicalURLs)) {
const result = await context.runtime.load(url);
const $ = doc(result.contents);
assert.equal($('link[rel="canonical"]').attr('href'), canonicalURL);
}
});
Global('Astro.site', async (context) => {
const result = await context.runtime.load('/');
assert.equal(result.statusCode, 200);
const $ = doc(result.contents);
assert.equal($('#site').attr('href'), 'https://mysite.dev');
});
Global.run();

View file

@ -1,19 +0,0 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { doc } from './test-utils.js';
import { setup } from './helpers.js';
const Request = suite('Astro.request');
setup(Request, './fixtures/astro-request');
Request('Astro.request available', async (context) => {
const result = await context.runtime.load('/');
assert.equal(result.statusCode, 200);
const $ = doc(result.contents);
assert.equal($('h1').text(), '/');
});
Request.run();

View file

@ -0,0 +1,6 @@
export default {
buildOptions: {
site: 'https://mysite.dev',
sitemap: false,
},
};

View file

@ -0,0 +1,12 @@
---
export let content;
---
<html>
<head>
<title>{content.title}</title>
<link rel="canonical" href={Astro.request.canonicalURL.href}>
</head>
<body>
<slot></slot>
</body>
</html>

View file

@ -0,0 +1,28 @@
---
export let collection;
export function createCollection() {
return {
async data() {
const data = Astro.fetchContent('./post/*.md');
return data;
},
pageSize: 1,
};
}
---
<html>
<head>
<title>All Posts</title>
<link rel="canonical" href={Astro.request.canonicalURL.href} />
</head>
<body>
{collection.data.map((data) => (
<div>
<h1>{data.title}</h1>
<a href={data.url}>Read</a>
</div>
))}
</body>
</html>

View file

@ -0,0 +1,10 @@
<html>
<head>
<title>Test</title>
<link rel="canonical" href={Astro.request.canonicalURL.href}>
</head>
<body>
<div id="pathname">{Astro.request.url.pathname}</div>
<a id="site" href={Astro.site.origin}>Home</a>
</body>
</html>

View file

@ -0,0 +1,6 @@
---
title: 'My Post 2'
layout: '../../layouts/post.astro'
---
# Post 2

View file

@ -0,0 +1,6 @@
---
title: 'My Post'
layout: '../../layouts/post.astro'
---
# My Post

View file

@ -1,10 +0,0 @@
---
let path = Astro.request.url.pathname;
---
<html>
<head><title>Test</title></head>
<body>
<h1>{path}</h1>
</body>
</html>