diff --git a/.changeset/flat-stingrays-talk.md b/.changeset/flat-stingrays-talk.md new file mode 100644 index 000000000000..47f45a8afd01 --- /dev/null +++ b/.changeset/flat-stingrays-talk.md @@ -0,0 +1,6 @@ +--- +'@sveltejs/adapter-static': patch +'@sveltejs/kit': patch +--- + +[breaking] remove `createIndexFiles` option, derive from `trailingSlash` instead diff --git a/documentation/docs/14-configuration.md b/documentation/docs/14-configuration.md index a29dac8c9a63..5988c7d5ef7d 100644 --- a/documentation/docs/14-configuration.md +++ b/documentation/docs/14-configuration.md @@ -56,7 +56,6 @@ const config = { prerender: { concurrency: 1, crawl: true, - createIndexFiles: true, enabled: true, entries: ['*'], onError: 'fail' @@ -203,7 +202,6 @@ See [Prerendering](/docs/page-options#prerender). An object containing zero or m - `concurrency` — how many pages can be prerendered simultaneously. JS is single-threaded, but in cases where prerendering performance is network-bound (for example loading content from a remote CMS) this can speed things up by processing other tasks while waiting on the network response - `crawl` — determines whether SvelteKit should find pages to prerender by following links from the seed page(s) -- `createIndexFiles` - if set to `false`, will render `about.html` instead of `about/index.html` - `enabled` — set to `false` to disable prerendering altogether - `entries` — an array of pages to prerender, or start crawling from (if `crawl: true`). The `*` string includes all non-dynamic routes (i.e. pages with no `[parameters]` ) - `onError` @@ -249,6 +247,8 @@ Whether to remove, append, or ignore trailing slashes when resolving URLs to rou - `"always"` — redirect `/x` to `/x/` - `"ignore"` — don't automatically add or remove trailing slashes. `/x` and `/x/` will be treated equivalently +This option also affects [prerendering](/docs/page-options#prerender). If `trailingSlash` is `always`, a route like `/about` will result in an `about/index.html` file, otherwise it will create `about.html`, mirroring static webserver conventions. + > Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](/docs/hooks#handle) function. ### version diff --git a/packages/adapter-static/test/test.js b/packages/adapter-static/test/test.js index 16629cfa9469..ffc1f4072e7e 100644 --- a/packages/adapter-static/test/test.js +++ b/packages/adapter-static/test/test.js @@ -23,7 +23,7 @@ run('spa', (test) => { }); test('prerenders page with prerender=true', ({ cwd }) => { - assert.ok(fs.existsSync(`${cwd}/build/about/index.html`)); + assert.ok(fs.existsSync(`${cwd}/build/about.html`)); }); test('renders content in fallback page when JS runs', async ({ base, page }) => { diff --git a/packages/kit/package.json b/packages/kit/package.json index e59d91073b5b..d2a2a7db8745 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -66,8 +66,9 @@ "prepublishOnly": "npm run build", "test": "npm run test:unit && npm run test:packaging && npm run test:prerendering && npm run test:integration", "test:unit": "uvu src \"(spec\\.js|test[\\\\/]index\\.js)\" -i packaging", - "test:prerendering": "pnpm test:prerendering:basics", + "test:prerendering": "pnpm test:prerendering:basics && pnpm test:prerendering:options", "test:prerendering:basics": "cd test/prerendering/basics && pnpm test", + "test:prerendering:options": "cd test/prerendering/options && pnpm test", "test:packaging": "uvu src/packaging \"(spec\\.js|test[\\\\/]index\\.js)\"", "test:integration": "pnpm test:integration:amp && pnpm test:integration:basics && pnpm test:integration:options && pnpm test:integration:options-2", "test:integration:amp": "cd test/apps/amp && pnpm test", diff --git a/packages/kit/src/core/adapt/prerender/prerender.js b/packages/kit/src/core/adapt/prerender/prerender.js index df725a59daee..d1c245cfc220 100644 --- a/packages/kit/src/core/adapt/prerender/prerender.js +++ b/packages/kit/src/core/adapt/prerender/prerender.js @@ -15,21 +15,21 @@ import { escape_html_attr } from '../../../utils/escape.js'; * @typedef {import('types/internal').Logger} Logger */ -/** @type {(errorDetails: Parameters[0] ) => string} */ -function errorDetailsToString({ status, path, referrer, referenceType }) { +/** @type {(details: Parameters[0] ) => string} */ +function format_error({ status, path, referrer, referenceType }) { return `${status} ${path}${referrer ? ` (${referenceType} from ${referrer})` : ''}`; } /** @type {(log: Logger, onError: OnError) => PrerenderErrorHandler} */ -function chooseErrorHandler(log, onError) { +function normalise_error_handler(log, onError) { switch (onError) { case 'continue': - return (errorDetails) => { - log.error(errorDetailsToString(errorDetails)); + return (details) => { + log.error(format_error(details)); }; case 'fail': - return (errorDetails) => { - throw new Error(errorDetailsToString(errorDetails)); + return (details) => { + throw new Error(format_error(details)); }; default: return onError; @@ -79,7 +79,7 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a const app = new App(manifest); - const error = chooseErrorHandler(log, config.kit.prerender.onError); + const error = normalise_error_handler(log, config.kit.prerender.onError); const files = new Set([ ...build_data.static, @@ -116,12 +116,15 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a * @param {boolean} is_html */ function output_filename(path, is_html) { + path = path.slice(config.kit.paths.base.length) || '/'; + if (path === '/') { return '/index.html'; } + const parts = path.split('/'); if (is_html && parts[parts.length - 1] !== 'index.html') { - if (config.kit.prerender.createIndexFiles) { + if (config.kit.trailingSlash === 'always') { parts.push('index.html'); } else { parts[parts.length - 1] += '.html'; @@ -149,116 +152,115 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a * @param {string?} referrer */ async function visit(path, decoded_path, referrer) { + if (!path.startsWith(config.kit.paths.base)) { + error({ status: 404, path, referrer, referenceType: 'linked' }); + return; + } + /** @type {Map} */ const dependencies = new Map(); - const render_path = config.kit.paths?.base - ? `http://sveltekit-prerender${config.kit.paths.base}${path === '/' ? '' : path}` - : `http://sveltekit-prerender${path}`; - - const rendered = await app.render(new Request(render_path), { + const rendered = await app.render(new Request(`http://sveltekit-prerender${path}`), { prerender: { all, dependencies } }); - if (rendered) { - const response_type = Math.floor(rendered.status / 100); - const type = rendered.headers.get('content-type'); - const is_html = response_type === REDIRECT || type === 'text/html'; + const response_type = Math.floor(rendered.status / 100); + const type = rendered.headers.get('content-type'); + const is_html = response_type === REDIRECT || type === 'text/html'; - const file = `${out}${output_filename(decoded_path, is_html)}`; + const file = `${out}${output_filename(decoded_path, is_html)}`; - if (response_type === REDIRECT) { - const location = rendered.headers.get('location'); + if (response_type === REDIRECT) { + const location = rendered.headers.get('location'); - if (location) { - mkdirp(dirname(file)); + if (location) { + mkdirp(dirname(file)); - log.warn(`${rendered.status} ${decoded_path} -> ${location}`); + log.warn(`${rendered.status} ${decoded_path} -> ${location}`); - writeFileSync( - file, - `` - ); + writeFileSync( + file, + `` + ); - const resolved = resolve(path, location); - if (is_root_relative(resolved)) { - enqueue(resolved, path); - } - } else { - log.warn(`location header missing on redirect received from ${decoded_path}`); + const resolved = resolve(path, location); + if (is_root_relative(resolved)) { + enqueue(resolved, path); } - - return; + } else { + log.warn(`location header missing on redirect received from ${decoded_path}`); } - const text = await rendered.text(); + return; + } - if (rendered.status === 200) { - mkdirp(dirname(file)); + const text = await rendered.text(); - log.info(`${rendered.status} ${decoded_path}`); - writeFileSync(file, text); - paths.push(normalize(decoded_path)); - } else if (response_type !== OK) { - error({ status: rendered.status, path, referrer, referenceType: 'linked' }); - } + if (rendered.status === 200) { + mkdirp(dirname(file)); - for (const [dependency_path, result] of dependencies) { - const { status, headers } = result.response; + log.info(`${rendered.status} ${decoded_path}`); + writeFileSync(file, text); + paths.push(normalize(decoded_path)); + } else if (response_type !== OK) { + error({ status: rendered.status, path, referrer, referenceType: 'linked' }); + } - const response_type = Math.floor(status / 100); + for (const [dependency_path, result] of dependencies) { + const { status, headers } = result.response; - const is_html = headers.get('content-type') === 'text/html'; + const response_type = Math.floor(status / 100); - const file = `${out}${output_filename(dependency_path, is_html)}`; - mkdirp(dirname(file)); + const is_html = headers.get('content-type') === 'text/html'; - writeFileSync( - file, - result.body === null ? new Uint8Array(await result.response.arrayBuffer()) : result.body - ); - paths.push(dependency_path); - - if (response_type === OK) { - log.info(`${status} ${dependency_path}`); - } else { - error({ - status, - path: dependency_path, - referrer: path, - referenceType: 'fetched' - }); - } - } + const file = `${out}${output_filename(dependency_path, is_html)}`; + mkdirp(dirname(file)); - if (is_html && config.kit.prerender.crawl) { - for (const href of crawl(text)) { - if (href.startsWith('data:') || href.startsWith('#')) continue; + writeFileSync( + file, + result.body === null ? new Uint8Array(await result.response.arrayBuffer()) : result.body + ); + paths.push(dependency_path); - const resolved = resolve(path, href); - if (!is_root_relative(resolved)) continue; + if (response_type === OK) { + log.info(`${status} ${dependency_path}`); + } else { + error({ + status, + path: dependency_path, + referrer: path, + referenceType: 'fetched' + }); + } + } - const parsed = new URL(resolved, 'http://localhost'); + if (is_html && config.kit.prerender.crawl) { + for (const href of crawl(text)) { + if (href.startsWith('data:') || href.startsWith('#')) continue; - let pathname = decodeURI(parsed.pathname); + const resolved = resolve(path, href); + if (!is_root_relative(resolved)) continue; - if (config.kit.paths.base) { - if (!pathname.startsWith(config.kit.paths.base)) continue; - pathname = pathname.slice(config.kit.paths.base.length) || '/'; - } + const parsed = new URL(resolved, 'http://localhost'); - const file = pathname.slice(1); - if (files.has(file)) continue; + let pathname = decodeURI(parsed.pathname); - if (parsed.search) { - // TODO warn that query strings have no effect on statically-exported pages - } + if (config.kit.paths.base) { + if (!pathname.startsWith(config.kit.paths.base)) continue; + pathname = pathname.slice(config.kit.paths.base.length) || '/'; + } + + const file = pathname.slice(1); + if (files.has(file)) continue; - enqueue(pathname, path); + if (parsed.search) { + // TODO warn that query strings have no effect on statically-exported pages } + + enqueue(pathname, path); } } } @@ -267,10 +269,10 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a for (const entry of config.kit.prerender.entries) { if (entry === '*') { for (const entry of build_data.entries) { - enqueue(entry, null); + enqueue(config.kit.paths.base + entry, null); } } else { - enqueue(entry, null); + enqueue(config.kit.paths.base + entry, null); } } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 8eae572ecdb4..9258bdaa0ee0 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -85,7 +85,7 @@ const get_defaults = (prefix = '') => ({ prerender: { concurrency: 1, crawl: true, - createIndexFiles: true, + createIndexFiles: undefined, enabled: true, entries: ['*'], force: undefined, diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 0f8fa9d2cc6c..d4188c17d5fe 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -186,7 +186,9 @@ const options = object( prerender: object({ concurrency: number(1), crawl: boolean(true), - createIndexFiles: boolean(true), + createIndexFiles: error( + (keypath) => `${keypath} has been removed — it is now controlled by the trailingSlash option. See https://kit.svelte.dev/docs/configuration#trailingslash` + ), enabled: boolean(true), entries: validate(['*'], (input, keypath) => { if (!Array.isArray(input) || !input.every((page) => typeof page === 'string')) { diff --git a/packages/kit/test/prerendering/basics/test/test.js b/packages/kit/test/prerendering/basics/test/test.js index 969affe5adad..22434abafeda 100644 --- a/packages/kit/test/prerendering/basics/test/test.js +++ b/packages/kit/test/prerendering/basics/test/test.js @@ -14,7 +14,7 @@ test('prerenders /', () => { }); test('renders a redirect', () => { - const content = read('redirect/index.html'); + const content = read('redirect.html'); assert.equal( content, '' @@ -22,7 +22,7 @@ test('renders a redirect', () => { }); test('does not double-encode redirect locations', () => { - const content = read('redirect-encoded/index.html'); + const content = read('redirect-encoded.html'); assert.equal( content, '' @@ -30,7 +30,7 @@ test('does not double-encode redirect locations', () => { }); test('escapes characters in redirect', () => { - const content = read('redirect-malicious/index.html'); + const content = read('redirect-malicious.html'); assert.equal( content, '' @@ -38,12 +38,12 @@ test('escapes characters in redirect', () => { }); test('inserts http-equiv tag for cache-control headers', () => { - const content = read('max-age/index.html'); + const content = read('max-age.html'); assert.ok(content.includes('')); }); test('renders page with data from endpoint', () => { - const content = read('fetch-endpoint/buffered/index.html'); + const content = read('fetch-endpoint/buffered.html'); assert.ok(content.includes('

the answer is 42

')); const json = read('fetch-endpoint/buffered.json'); @@ -51,7 +51,7 @@ test('renders page with data from endpoint', () => { }); test('renders page with unbuffered data from endpoint', () => { - const content = read('fetch-endpoint/not-buffered/index.html'); + const content = read('fetch-endpoint/not-buffered.html'); assert.ok(content.includes('

content-type: application/json; charset=utf-8

'), content); const json = read('fetch-endpoint/not-buffered.json'); @@ -59,7 +59,7 @@ test('renders page with unbuffered data from endpoint', () => { }); test('loads a file with spaces in the filename', () => { - const content = read('load-file-with-spaces/index.html'); + const content = read('load-file-with-spaces.html'); assert.ok(content.includes('

answer: 42

'), content); }); @@ -69,7 +69,7 @@ test('generates __data.json file for shadow endpoint', () => { }); test('does not prerender page with shadow endpoint with non-GET handler', () => { - assert.ok(!fs.existsSync(`${build}/shadowed-post/index.html`)); + assert.ok(!fs.existsSync(`${build}/shadowed-post.html`)); assert.ok(!fs.existsSync(`${build}/shadowed-post/__data.json`)); }); diff --git a/packages/kit/test/prerendering/options/svelte.config.js b/packages/kit/test/prerendering/options/svelte.config.js index 28e2e0b4fc16..558a6c06f4e3 100644 --- a/packages/kit/test/prerendering/options/svelte.config.js +++ b/packages/kit/test/prerendering/options/svelte.config.js @@ -17,9 +17,7 @@ const config = { assets: 'https://cdn.example.com/stuff' }, - prerender: { - createIndexFiles: false - }, + trailingSlash: 'always', vite: { build: { diff --git a/packages/kit/test/prerendering/options/test/test.js b/packages/kit/test/prerendering/options/test/test.js index edf18c2aa0d3..7d08e436ad69 100644 --- a/packages/kit/test/prerendering/options/test/test.js +++ b/packages/kit/test/prerendering/options/test/test.js @@ -15,7 +15,7 @@ test('prerenders /path-base', () => { }); test('prerenders nested /path-base', () => { - const content = read('/nested.html'); + const content = read('nested/index.html'); assert.ok(content.includes('

nested hello

')); assert.ok(content.includes('http://sveltekit-prerender/path-base/nested')); }); diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index 18cb8e32d507..067680d31097 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -153,7 +153,6 @@ export interface Config { prerender?: { concurrency?: number; crawl?: boolean; - createIndexFiles?: boolean; enabled?: boolean; entries?: string[]; onError?: PrerenderOnErrorValue;