diff --git a/.changeset/seven-singers-mate.md b/.changeset/seven-singers-mate.md new file mode 100644 index 000000000000..1daa0ad3df02 --- /dev/null +++ b/.changeset/seven-singers-mate.md @@ -0,0 +1,20 @@ +--- +'docs': patch +'astro': patch +--- + +Adds support for Astro.resolve + +`Astro.resolve()` helps with creating URLs relative to the current Astro file, allowing you to reference files within your `src/` folder. + +Astro *does not* resolve relative links within HTML, such as images: + +```html + +``` + +The above will be sent to the browser as-is and the browser will resolve it relative to the current __page__. If you want it to be resolved relative to the .astro file you are working in, use `Astro.resolve`: + +```astro + +``` \ No newline at end of file diff --git a/docs/src/pages/reference/api-reference.md b/docs/src/pages/reference/api-reference.md index 1482416912a1..2f73cb30c9fd 100644 --- a/docs/src/pages/reference/api-reference.md +++ b/docs/src/pages/reference/api-reference.md @@ -64,6 +64,30 @@ const data = Astro.fetchContent('../pages/post/*.md'); // returns an array of po `Astro.site` returns a `URL` made from `buildOptions.site` in your Astro config. If undefined, this will return a URL generated from `localhost`. +```astro +--- +const path = Astro.site.pathname; +--- + +

Welcome to {path}

+``` + +### `Astro.resolve()` + +`Astro.resolve()` helps with creating URLs relative to the current Astro file, allowing you to reference files within your `src/` folder. + +Astro *does not* resolve relative links within HTML, such as images: + +```html + +``` + +The above will be sent to the browser as-is and the browser will resolve it relative to the current __page__. If you want it to be resolved relative to the .astro file you are working in, use `Astro.resolve`: + +```astro + +``` + ## `getStaticPaths()` If a page uses dynamic params in the filename, that component will need to export a `getStaticPaths()` function. diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts index 9c4f3bf50884..2557f8b54bec 100644 --- a/packages/astro/src/compiler/codegen/index.ts +++ b/packages/astro/src/compiler/codegen/index.ts @@ -2,7 +2,7 @@ import type { Ast, Script, Style, TemplateNode, Expression } from '@astrojs/pars import type { CompileOptions } from '../../@types/compiler'; import type { AstroConfig, AstroMarkdownOptions, TransformResult, ComponentInfo, Components } from '../../@types/astro'; import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, ImportDefaultSpecifier } from '@babel/types'; - +import type { Attribute } from './interfaces'; import eslexer from 'es-module-lexer'; import esbuild from 'esbuild'; import path from 'path'; @@ -15,6 +15,7 @@ import * as babelTraverse from '@babel/traverse'; import { error, warn, parseError } from '../../logger.js'; import { yellow } from 'kleur/colors'; import { isComponentTag, isCustomElementTag, positionAt } from '../utils.js'; +import { warnIfRelativeStringLiteral } from './utils.js'; import { renderMarkdown } from '@astrojs/markdown-support'; import { camelCase } from 'camel-case'; import { transform } from '../transform/index.js'; @@ -32,15 +33,6 @@ const { transformSync } = esbuild; const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible', 'client:media']); -interface Attribute { - start: number; - end: number; - type: 'Attribute' | 'Spread'; - name: string; - value: TemplateNode[] | boolean; - expression?: Expression; -} - interface CodeGenOptions { compileOptions: CompileOptions; filename: string; @@ -67,8 +59,10 @@ function findHydrationAttributes(attrs: Record): HydrationAttrib return { method, value }; } + + /** Retrieve attributes from TemplateNode */ -async function getAttributes(attrs: Attribute[], state: CodegenState, compileOptions: CompileOptions): Promise> { +async function getAttributes(nodeName: string, attrs: Attribute[], state: CodegenState, compileOptions: CompileOptions): Promise> { let result: Record = {}; for (const attr of attrs) { if (attr.type === 'Spread') { @@ -118,9 +112,13 @@ async function getAttributes(attrs: Attribute[], state: CodegenState, compileOpt } continue; } - case 'Text': - result[attr.name] = JSON.stringify(getTextFromAttribute(val)); + case 'Text': { + let text = getTextFromAttribute(val); + + warnIfRelativeStringLiteral(compileOptions.logging, nodeName, attr, text); + result[attr.name] = JSON.stringify(text); continue; + } case 'AttributeShorthand': result[attr.name] = '(' + attr.name + ')'; continue; @@ -641,7 +639,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile throw new Error('AHHHH'); } try { - const attributes = await getAttributes(node.attributes, state, compileOptions); + const attributes = await getAttributes(name, node.attributes, state, compileOptions); const hydrationAttributes = findHydrationAttributes(attributes); buffers.out += buffers.out === '' ? '' : ','; diff --git a/packages/astro/src/compiler/codegen/interfaces.ts b/packages/astro/src/compiler/codegen/interfaces.ts new file mode 100644 index 000000000000..a487b85e9de3 --- /dev/null +++ b/packages/astro/src/compiler/codegen/interfaces.ts @@ -0,0 +1,10 @@ +import type { Expression, TemplateNode } from '@astrojs/parser'; + +export interface Attribute { + start: number; + end: number; + type: 'Attribute' | 'Spread'; + name: string; + value: TemplateNode[] | boolean; + expression?: Expression; +} \ No newline at end of file diff --git a/packages/astro/src/compiler/codegen/utils.ts b/packages/astro/src/compiler/codegen/utils.ts index 8183f9142cbb..a4e66fbe8ab5 100644 --- a/packages/astro/src/compiler/codegen/utils.ts +++ b/packages/astro/src/compiler/codegen/utils.ts @@ -3,6 +3,9 @@ */ import type { VariableDeclarator, CallExpression } from '@babel/types'; +import type { Attribute } from './interfaces'; +import type { LogOptions } from '../../logger'; +import { warn } from '../../logger.js'; /** Is this an import.meta.* built-in? You can pass an optional 2nd param to see if the name matches as well. */ export function isImportMetaDeclaration(declaration: VariableDeclarator, metaName?: string): boolean { @@ -18,3 +21,21 @@ export function isImportMetaDeclaration(declaration: VariableDeclarator, metaNam if (metaName && (init.callee.property.type !== 'Identifier' || init.callee.property.name !== metaName)) return false; return true; } + +const warnableRelativeValues = new Set([ + 'img+src', + 'a+href', + 'script+src', + 'link+href', + 'source+srcset' +]); + +const matchesRelative = /^(?![A-Za-z][+-.0-9A-Za-z]*:|\/)/; + +export function warnIfRelativeStringLiteral(logging: LogOptions, nodeName: string, attr: Attribute, value: string) { + let key = nodeName + '+' + attr.name; + if(warnableRelativeValues.has(key) && matchesRelative.test(value)) { + let message = `This value will be resolved relative to the page: <${nodeName} ${attr.name}="${value}">`; + warn(logging, 'relative-link', message); + } +} \ No newline at end of file diff --git a/packages/astro/src/compiler/index.ts b/packages/astro/src/compiler/index.ts index b824e65a9b63..a84abf97fba2 100644 --- a/packages/astro/src/compiler/index.ts +++ b/packages/astro/src/compiler/index.ts @@ -107,8 +107,13 @@ interface CompileComponentOptions { /** Compiles an Astro component */ export async function compileComponent(source: string, { compileOptions, filename, projectRoot }: CompileComponentOptions): Promise { const result = await transformFromSource(source, { compileOptions, filename, projectRoot }); + const { mode } = compileOptions; const { hostname, port } = compileOptions.astroConfig.devOptions; - const site = compileOptions.astroConfig.buildOptions.site || `http://${hostname}:${port}`; + const devSite = `http://${hostname}:${port}`; + const site = compileOptions.astroConfig.buildOptions.site || devSite; + + const fileID = path.join('/_astro', path.relative(projectRoot, filename)); + const fileURL = new URL('.' + fileID, mode === 'production' ? site : devSite); // return template let moduleJavaScript = ` @@ -123,6 +128,12 @@ ${/* Global Astro Namespace (shadowed & extended by the scoped namespace inside const __TopLevelAstro = { site: new URL(${JSON.stringify(site)}), fetchContent: (globResult) => fetchContent(globResult, import.meta.url), + resolve(...segments) { + return segments.reduce( + (url, segment) => new URL(segment, url), + new URL(${JSON.stringify(fileURL)}) + ).pathname + }, }; const Astro = __TopLevelAstro; @@ -158,10 +169,14 @@ async function __render(props, ...children) { value: (props[__astroInternal] && props[__astroInternal].isPage) || false, enumerable: true }, + resolve: { + value: (props[__astroContext] && props[__astroContext].resolve) || {}, + enumerable: true + }, request: { value: (props[__astroContext] && props[__astroContext].request) || {}, enumerable: true - } + }, }); ${result.script} @@ -186,6 +201,7 @@ export async function __renderPage({request, children, props, css}) { value: { pageCSS: css, request, + resolve: __TopLevelAstro.resolve, createAstroRootUID(seed) { return seed + astroRootUIDCounter++; }, }, writable: false, diff --git a/packages/astro/test/astro-global-build.test.js b/packages/astro/test/astro-global-build.test.js new file mode 100644 index 000000000000..6acdfd4284f6 --- /dev/null +++ b/packages/astro/test/astro-global-build.test.js @@ -0,0 +1,23 @@ +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import { doc } from './test-utils.js'; +import { setup } from './helpers.js'; + +const GlobalBuild = suite('Astro.* built'); + +setup(GlobalBuild, './fixtures/astro-global', { + runtimeOptions: { + mode: 'production' + } +}); + +GlobalBuild('Astro.resolve in the build', async (context) => { + const result = await context.runtime.load('/resolve'); + assert.ok(!result.error, `build error: ${result.error}`); + + const html = result.contents; + const $ = doc(html); + assert.equal($('img').attr('src'), '/blog/_astro/src/images/penguin.png'); +}); + +GlobalBuild.run(); \ No newline at end of file diff --git a/packages/astro/test/astro-global.test.js b/packages/astro/test/astro-global.test.js index 556bf66ada65..9a80973aee73 100644 --- a/packages/astro/test/astro-global.test.js +++ b/packages/astro/test/astro-global.test.js @@ -41,4 +41,13 @@ Global('Astro.site', async (context) => { assert.equal($('#site').attr('href'), 'https://mysite.dev/blog/'); }); -Global.run(); +Global('Astro.resolve in development', async (context) => { + const result = await context.runtime.load('/resolve'); + assert.ok(!result.error, `build error: ${result.error}`); + + const html = result.contents; + const $ = doc(html); + assert.equal($('img').attr('src'), '/_astro/src/images/penguin.png'); +}); + +Global.run(); \ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-global/src/components/ChildResolve.astro b/packages/astro/test/fixtures/astro-global/src/components/ChildResolve.astro new file mode 100644 index 000000000000..185a1fb1c092 --- /dev/null +++ b/packages/astro/test/fixtures/astro-global/src/components/ChildResolve.astro @@ -0,0 +1,5 @@ +--- +const penguinUrl = Astro.resolve('../images/penguin.png'); +--- + + \ No newline at end of file diff --git a/packages/astro/test/fixtures/astro-global/src/pages/resolve.astro b/packages/astro/test/fixtures/astro-global/src/pages/resolve.astro new file mode 100644 index 000000000000..df2b5f5cb364 --- /dev/null +++ b/packages/astro/test/fixtures/astro-global/src/pages/resolve.astro @@ -0,0 +1,12 @@ +--- +import Child from '../components/ChildResolve.astro'; +--- + + + + Testing + + + + + \ No newline at end of file