Skip to content

Commit

Permalink
Implements build.format: 'preserve' (#9764)
Browse files Browse the repository at this point in the history
* Implements build.format: 'preserve'

* Restructure test

* Add a test for base

* Update .changeset/tame-flies-confess.md

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>

* Add trailing slash + i18n testing

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/tame-flies-confess.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* tiny punctuation/conjunction nit fixes

---------

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
4 people authored Jan 31, 2024
1 parent 84c100d commit fad4f64
Show file tree
Hide file tree
Showing 18 changed files with 176 additions and 37 deletions.
18 changes: 18 additions & 0 deletions .changeset/tame-flies-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'astro': minor
---

Adds a new `build.format` configuration option: 'preserve'. This option will preserve your source structure in the final build.

The existing configuration options, `file` and `directory`, either build all of your HTML pages as files matching the route name (e.g. `/about.html`) or build all your files as `index.html` within a nested directory structure (e.g. `/about/index.html`), respectively. It was not previously possible to control the HTML file built on a per-file basis.

One limitation of `build.format: 'file'` is that it cannot create `index.html` files for any individual routes (other than the base path of `/`) while otherwise building named files. Creating explicit index pages within your file structure still generates a file named for the page route (e.g. `src/pages/about/index.astro` builds `/about.html`) when using the `file` configuration option.

Rather than make a breaking change to allow `build.format: 'file'` to be more flexible, we decided to create a new `build.format: 'preserve'`.

The new format will preserve how the filesystem is structured and make sure that is mirrored over to production. Using this option:

- `about.astro` becomes `about.html`
- `about/index.astro` becomes `about/index.html`

See the [`build.format` configuration options reference] for more details.
10 changes: 6 additions & 4 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,14 +788,15 @@ export interface AstroUserConfig {
* @default `'directory'`
* @description
* Control the output file format of each page. This value may be set by an adapter for you.
* - If `'file'`, Astro will generate an HTML file (ex: "/foo.html") for each page.
* - If `'directory'`, Astro will generate a directory with a nested `index.html` file (ex: "/foo/index.html") for each page.
* - `'file'`: Astro will generate an HTML file named for each page route. (e.g. `src/pages/about.astro` and `src/pages/about/index.astro` both build the file `/about.html`)
* - `'directory'`: Astro will generate a directory with a nested `index.html` file for each page. (e.g. `src/pages/about.astro` and `src/pages/about/index.astro` both build the file `/about/index.html`)
* - `'preserve'`: Astro will generate HTML files exactly as they appear in your source folder. (e.g. `src/pages/about.astro` builds `/about.html` and `src/pages/about/index.astro` builds the file `/about/index.html`)
*
* ```js
* {
* build: {
* // Example: Generate `page.html` instead of `page/index.html` during build.
* format: 'file'
* format: 'preserve'
* }
* }
* ```
Expand All @@ -813,7 +814,7 @@ export interface AstroUserConfig {
* - `directory` - Set `trailingSlash: 'always'`
* - `file` - Set `trailingSlash: 'never'`
*/
format?: 'file' | 'directory';
format?: 'file' | 'directory' | 'preserve';
/**
* @docs
* @name build.client
Expand Down Expand Up @@ -2637,6 +2638,7 @@ export interface RouteData {
redirect?: RedirectConfig;
redirectRoute?: RouteData;
fallbackRoutes: RouteData[];
isIndex: boolean;
}

export type RedirectRouteData = RouteData & {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type SSRManifest = {
site?: string;
base: string;
trailingSlash: 'always' | 'never' | 'ignore';
buildFormat: 'file' | 'directory';
buildFormat: 'file' | 'directory' | 'preserve';
compressHTML: boolean;
assetsPrefix?: string;
renderers: SSRLoadedRenderer[];
Expand Down
28 changes: 25 additions & 3 deletions packages/astro/src/core/build/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import npath from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AstroConfig, RouteType } from '../../@types/astro.js';
import type { AstroConfig, RouteData } from '../../@types/astro.js';
import { appendForwardSlash } from '../../core/path.js';

const STATUS_CODE_PAGES = new Set(['/404', '/500']);
Expand All @@ -17,9 +17,10 @@ function getOutRoot(astroConfig: AstroConfig): URL {
export function getOutFolder(
astroConfig: AstroConfig,
pathname: string,
routeType: RouteType
routeData: RouteData
): URL {
const outRoot = getOutRoot(astroConfig);
const routeType = routeData.type;

// This is the root folder to write to.
switch (routeType) {
Expand All @@ -39,6 +40,17 @@ export function getOutFolder(
const d = pathname === '' ? pathname : npath.dirname(pathname);
return new URL('.' + appendForwardSlash(d), outRoot);
}
case 'preserve': {
let dir;
// If the pathname is '' then this is the root index.html
// If this is an index route, the folder should be the pathname, not the parent
if(pathname === '' || routeData.isIndex) {
dir = pathname;
} else {
dir = npath.dirname(pathname);
}
return new URL('.' + appendForwardSlash(dir), outRoot);
}
}
}
}
Expand All @@ -47,8 +59,9 @@ export function getOutFile(
astroConfig: AstroConfig,
outFolder: URL,
pathname: string,
routeType: RouteType
routeData: RouteData
): URL {
const routeType = routeData.type;
switch (routeType) {
case 'endpoint':
return new URL(npath.basename(pathname), outFolder);
Expand All @@ -67,6 +80,15 @@ export function getOutFile(
const baseName = npath.basename(pathname);
return new URL('./' + (baseName || 'index') + '.html', outFolder);
}
case 'preserve': {
let baseName = npath.basename(pathname);
// If there is no base name this is the root route.
// If this is an index route, the name should be `index.html`.
if(!baseName || routeData.isIndex) {
baseName = 'index';
}
return new URL(`./${baseName}.html`, outFolder);
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,8 +622,8 @@ async function generatePath(
body = Buffer.from(await response.arrayBuffer());
}

const outFolder = getOutFolder(pipeline.getConfig(), pathname, route.type);
const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route.type);
const outFolder = getOutFolder(pipeline.getConfig(), pathname, route);
const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route);
route.distURL = outFile;

await fs.promises.mkdir(outFolder, { recursive: true });
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ function buildManifest(
if (!route.prerender) continue;
if (!route.pathname) continue;

const outFolder = getOutFolder(opts.settings.config, route.pathname, route.type);
const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route.type);
const outFolder = getOutFolder(opts.settings.config, route.pathname, route);
const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route);
const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
routes.push({
file,
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/build/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function shouldAppendForwardSlash(
switch (buildFormat) {
case 'directory':
return true;
case 'preserve':
case 'file':
return false;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const AstroConfigSchema = z.object({
build: z
.object({
format: z
.union([z.literal('file'), z.literal('directory')])
.union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
client: z
Expand Down Expand Up @@ -539,7 +539,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
build: z
.object({
format: z
.union([z.literal('file'), z.literal('directory')])
.union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
client: z
Expand Down
19 changes: 8 additions & 11 deletions packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ interface Item {
routeSuffix: string;
}

interface ManifestRouteData extends RouteData {
isIndex: boolean;
}

function countOccurrences(needle: string, haystack: string) {
let count = 0;
for (const hay of haystack) {
Expand Down Expand Up @@ -193,7 +189,8 @@ function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[]
* For example, `/bar` is sorted before `/foo`.
* The definition of "alphabetically" is dependent on the default locale of the running system.
*/
function routeComparator(a: ManifestRouteData, b: ManifestRouteData) {

function routeComparator(a: RouteData, b: RouteData) {
const commonLength = Math.min(a.segments.length, b.segments.length);

for (let index = 0; index < commonLength; index++) {
Expand Down Expand Up @@ -301,9 +298,9 @@ export interface CreateRouteManifestParams {
function createFileBasedRoutes(
{ settings, cwd, fsMod }: CreateRouteManifestParams,
logger: Logger
): ManifestRouteData[] {
): RouteData[] {
const components: string[] = [];
const routes: ManifestRouteData[] = [];
const routes: RouteData[] = [];
const validPageExtensions = new Set<string>([
'.astro',
...SUPPORTED_MARKDOWN_FILE_EXTENSIONS,
Expand Down Expand Up @@ -444,7 +441,7 @@ function createFileBasedRoutes(
return routes;
}

type PrioritizedRoutesData = Record<RoutePriorityOverride, ManifestRouteData[]>;
type PrioritizedRoutesData = Record<RoutePriorityOverride, RouteData[]>;

function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): PrioritizedRoutesData {
const { config } = settings;
Expand Down Expand Up @@ -690,7 +687,7 @@ export function createRouteManifest(

const redirectRoutes = createRedirectRoutes(params, routeMap, logger);

const routes: ManifestRouteData[] = [
const routes: RouteData[] = [
...injectedRoutes['legacy'].sort(routeComparator),
...[...fileBasedRoutes, ...injectedRoutes['normal'], ...redirectRoutes['normal']].sort(
routeComparator
Expand Down Expand Up @@ -727,8 +724,8 @@ export function createRouteManifest(

// In this block of code we group routes based on their locale

// A map like: locale => ManifestRouteData[]
const routesByLocale = new Map<string, ManifestRouteData[]>();
// A map like: locale => RouteData[]
const routesByLocale = new Map<string, RouteData[]>();
// This type is here only as a helper. We copy the routes and make them unique, so we don't "process" the same route twice.
// The assumption is that a route in the file system belongs to only one locale.
const setRoutes = new Set(routes.filter((route) => route.type === 'page'));
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/routing/manifest/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
fallbackRoutes: rawRouteData.fallbackRoutes.map((fallback) => {
return deserializeRouteData(fallback);
}),
isIndex: rawRouteData.isIndex,
};
}
1 change: 1 addition & 0 deletions packages/astro/src/vite-plugin-astro-server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export async function handleRoute({
type: 'fallback',
route: '',
fallbackRoutes: [],
isIndex: false,
};
renderContext = await createRenderContext({
request,
Expand Down
48 changes: 36 additions & 12 deletions packages/astro/test/astro-pageDirectoryUrl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,45 @@ import { expect } from 'chai';
import { loadFixture } from './test-utils.js';

describe('build format', () => {
let fixture;
describe('build.format: file', () => {
/** @type {import('./test-utils.js').Fixture} */
let fixture;

before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-page-directory-url',
build: {
format: 'file',
},
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-page-directory-url',
build: {
format: 'file',
},
});
await fixture.build();
});

it('outputs', async () => {
expect(await fixture.readFile('/client.html')).to.be.ok;
expect(await fixture.readFile('/nested-md.html')).to.be.ok;
expect(await fixture.readFile('/nested-astro.html')).to.be.ok;
});
await fixture.build();
});

it('outputs', async () => {
expect(await fixture.readFile('/client.html')).to.be.ok;
expect(await fixture.readFile('/nested-md.html')).to.be.ok;
expect(await fixture.readFile('/nested-astro.html')).to.be.ok;
describe('build.format: preserve', () => {
/** @type {import('./test-utils.js').Fixture} */
let fixture;

before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-page-directory-url',
build: {
format: 'preserve',
},
});
await fixture.build();
});

it('outputs', async () => {
expect(await fixture.readFile('/client.html')).to.be.ok;
expect(await fixture.readFile('/nested-md/index.html')).to.be.ok;
expect(await fixture.readFile('/nested-astro/index.html')).to.be.ok;
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---
<html>
<head><title>testing</title></head>
<body><h1>testing</h1></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
const another = new URL('./another/', Astro.url);
---
<a id="another" href={another.pathname}></a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---
<html>
<head><title>testing</title></head>
<body><h1>testing</h1></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>
41 changes: 41 additions & 0 deletions packages/astro/test/page-format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,45 @@ describe('build.format', () => {
});
});
});

describe('preserve', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
base: '/test',
root: './fixtures/page-format/',
trailingSlash: 'always',
build: {
format: 'preserve',
},
i18n: {
locales: ['en'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: true,
}
}
});
});

describe('Build', () => {
before(async () => {
await fixture.build();
});

it('relative urls created point to sibling folders', async () => {
let html = await fixture.readFile('/en/nested/page.html');
let $ = cheerio.load(html);
expect($('#another').attr('href')).to.equal('/test/en/nested/another/');
});

it('index files are written as index.html', async () => {
let html = await fixture.readFile('/en/nested/index.html');
let $ = cheerio.load(html);
expect($('h1').text()).to.equal('Testing');
});
});
});
});

0 comments on commit fad4f64

Please sign in to comment.