This commit is contained in:
eewing
2026-02-17 14:10:16 -06:00
parent 2bca5834c5
commit cf73cd3b4c
11246 changed files with 1690552 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
/**
* Removes nullish values from an array.
*
* @template T
* @param {Array<T>} arr
*/
export function compact(arr) {
return arr.filter(/** @returns {val is NonNullable<T>} */ (val) => val != null);
}
+210
View File
@@ -0,0 +1,210 @@
import MagicString from 'magic-string';
import * as svelte from 'svelte/compiler';
/** @typedef {ReturnType<typeof import('svelte/compiler').parseCss>['children']} StyleSheetChildren */
/** @typedef {{ property: string; value: string; start: number; end: number; type: 'Declaration' }} Declaration */
const parse = svelte.parseCss
? svelte.parseCss
: /** @param {string} css */
(css) => {
return /** @type {{ css: { children: StyleSheetChildren } }} */ (
svelte.parse(`<style>${css}</style>`)
).css;
};
const SKIP_PARSING_REGEX = /url\(/i;
/** Capture a single url(...) so we can process them one at a time */
const URL_FUNCTION_REGEX = /url\(\s*.*?\)/gi;
/** Captures the value inside a CSS url(...) */
const URL_PARAMETER_REGEX = /url\(\s*(['"]?)(.*?)\1\s*\)/i;
/** Splits the URL if there's a query string or hash fragment */
const HASH_OR_QUERY_REGEX = /[#?]/;
/**
* Assets handled by Vite that are referenced in the stylesheet always start
* with this prefix because Vite emits them into the same directory as the CSS file
*/
const VITE_ASSET_PREFIX = './';
const AST_OFFSET = '<style>'.length;
/**
* We need to fix the asset URLs in the CSS before we inline them into a document
* because they are now relative to the document instead of the CSS file.
* @param {{
* css: string;
* vite_assets: Set<string>;
* static_assets: Set<string>;
* paths_assets: string;
* base: string;
* static_asset_prefix: string;
* }} opts
* @returns {string}
*/
export function fix_css_urls({
css,
vite_assets,
static_assets,
paths_assets,
base,
static_asset_prefix
}) {
// skip parsing if there are no url(...) occurrences
if (!SKIP_PARSING_REGEX.test(css)) {
return css;
}
// safe guard in case of trailing slashes (but this should never happen)
if (paths_assets.endsWith('/')) {
paths_assets = paths_assets.slice(0, -1);
}
if (base.endsWith('/')) {
base = base.slice(0, -1);
}
const s = new MagicString(css);
const parsed = parse(css);
for (const child of parsed.children) {
find_declarations(child, (declaration) => {
if (!SKIP_PARSING_REGEX.test) return;
const cleaned = tippex_comments_and_strings(declaration.value);
/** @type {string} */
let new_value = declaration.value;
/** @type {RegExpExecArray | null} */
let url_function_found;
URL_FUNCTION_REGEX.lastIndex = 0;
while ((url_function_found = URL_FUNCTION_REGEX.exec(cleaned))) {
const [url_function] = url_function_found;
// After finding a legitimate url(...), we want to operate on the original
// that may have a string inside it
const original_url_function = declaration.value.slice(
url_function_found.index,
url_function_found.index + url_function.length
);
const url_parameter_found = URL_PARAMETER_REGEX.exec(original_url_function);
if (!url_parameter_found) continue;
const [, , url] = url_parameter_found;
const [url_without_hash_or_query] = url.split(HASH_OR_QUERY_REGEX);
/** @type {string | undefined} */
let new_prefix;
// Check if it's an asset processed by Vite...
let current_prefix = url_without_hash_or_query.slice(0, VITE_ASSET_PREFIX.length);
let filename = url_without_hash_or_query.slice(VITE_ASSET_PREFIX.length);
const decoded = decodeURIComponent(filename);
if (current_prefix === VITE_ASSET_PREFIX && vite_assets.has(decoded)) {
new_prefix = paths_assets;
} else {
// ...or if it's from the static directory
current_prefix = url_without_hash_or_query.slice(0, static_asset_prefix.length);
filename = url_without_hash_or_query.slice(static_asset_prefix.length);
const decoded = decodeURIComponent(filename);
if (current_prefix === static_asset_prefix && static_assets.has(decoded)) {
new_prefix = base;
}
}
if (!new_prefix) continue;
new_value = new_value.replace(`${current_prefix}${filename}`, `${new_prefix}/${filename}`);
}
if (declaration.value === new_value) return;
if (!svelte.parseCss) {
declaration.start = declaration.start - AST_OFFSET;
declaration.end = declaration.end - AST_OFFSET;
}
s.update(declaration.start, declaration.end, `${declaration.property}: ${new_value}`);
});
}
return s.toString();
}
/**
* @param {StyleSheetChildren[0]} rule
* @param {(declaration: Declaration) => void} callback
*/
function find_declarations(rule, callback) {
// Vite already inlines relative @import rules, so we don't need to handle them here
if (!rule.block) return;
for (const child of rule.block.children) {
if (child.type !== 'Declaration') {
find_declarations(child, callback);
continue;
}
callback(child);
}
}
/**
* Replaces comment and string contents with whitespace.
* @param {string} value
* @returns {string}
*/
export function tippex_comments_and_strings(value) {
let new_value = '';
let escaped = false;
let in_comment = false;
/** @type {null | '"' | "'"} */
let quote_mark = null;
let i = 0;
while (i < value.length) {
const char = value[i];
if (in_comment) {
if (char === '*' && value[i + 1] === '/') {
in_comment = false;
new_value += char;
} else {
new_value += ' ';
}
} else if (!quote_mark && char === '/' && value[i + 1] === '*') {
in_comment = true;
new_value += '/*';
i++; // skip the '*' since we already added it
} else if (escaped) {
new_value += ' ';
escaped = false;
} else if (quote_mark && char === '\\') {
escaped = true;
new_value += ' ';
} else if (char === quote_mark) {
quote_mark = null;
new_value += char;
} else if (quote_mark) {
new_value += ' ';
} else if (quote_mark === null && (char === '"' || char === "'")) {
quote_mark = char;
new_value += char;
} else {
new_value += char;
}
i++;
}
return new_value;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* @param {Record<string, string>} env
* @param {string} allowed
* @param {string} disallowed
* @returns {Record<string, string>}
*/
export function filter_env(env, allowed, disallowed) {
return Object.fromEntries(
Object.entries(env).filter(
([k]) => k.startsWith(allowed) && (disallowed === '' || !k.startsWith(disallowed))
)
);
}
+38
View File
@@ -0,0 +1,38 @@
import { HttpError, SvelteKitError } from '@sveltejs/kit/internal';
/**
* @param {unknown} err
* @return {Error}
*/
export function coalesce_to_error(err) {
return err instanceof Error ||
(err && /** @type {any} */ (err).name && /** @type {any} */ (err).message)
? /** @type {Error} */ (err)
: new Error(JSON.stringify(err));
}
/**
* This is an identity function that exists to make TypeScript less
* paranoid about people throwing things that aren't errors, which
* frankly is not something we should care about
* @param {unknown} error
*/
export function normalize_error(error) {
return /** @type {import('../exports/internal/index.js').Redirect | HttpError | SvelteKitError | Error} */ (
error
);
}
/**
* @param {unknown} error
*/
export function get_status(error) {
return error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500;
}
/**
* @param {unknown} error
*/
export function get_message(error) {
return error instanceof SvelteKitError ? error.text : 'Internal Error';
}
+62
View File
@@ -0,0 +1,62 @@
/**
* When inside a double-quoted attribute value, only `&` and `"` hold special meaning.
* @see https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(double-quoted)-state
* @type {Record<string, string>}
*/
const escape_html_attr_dict = {
'&': '&amp;',
'"': '&quot;'
// Svelte also escapes < because the escape function could be called inside a `noscript` there
// https://github.com/sveltejs/svelte/security/advisories/GHSA-8266-84wp-wv5c
// However, that doesn't apply in SvelteKit
};
/**
* @type {Record<string, string>}
*/
const escape_html_dict = {
'&': '&amp;',
'<': '&lt;'
};
const surrogates = // high surrogate without paired low surrogate
'[\\ud800-\\udbff](?![\\udc00-\\udfff])|' +
// a valid surrogate pair, the only match with 2 code units
// we match it so that we can match unpaired low surrogates in the same pass
// TODO: use lookbehind assertions once they are widely supported: (?<![\ud800-udbff])[\udc00-\udfff]
'[\\ud800-\\udbff][\\udc00-\\udfff]|' +
// unpaired low surrogate (see previous match)
'[\\udc00-\\udfff]';
const escape_html_attr_regex = new RegExp(
`[${Object.keys(escape_html_attr_dict).join('')}]|` + surrogates,
'g'
);
const escape_html_regex = new RegExp(
`[${Object.keys(escape_html_dict).join('')}]|` + surrogates,
'g'
);
/**
* Escapes unpaired surrogates (which are allowed in js strings but invalid in HTML) and
* escapes characters that are special.
*
* @param {string} str
* @param {boolean} [is_attr]
* @returns {string} escaped string
* @example const html = `<tag data-value="${escape_html('value', true)}">...</tag>`;
*/
export function escape_html(str, is_attr) {
const dict = is_attr ? escape_html_attr_dict : escape_html_dict;
const escaped_str = str.replace(is_attr ? escape_html_attr_regex : escape_html_regex, (match) => {
if (match.length === 2) {
// valid surrogate pair
return match;
}
return dict[match] ?? `&#${match.charCodeAt(0)};`;
});
return escaped_str;
}
+93
View File
@@ -0,0 +1,93 @@
/**
* @param {Set<string>} expected
*/
function validator(expected) {
/**
* @param {any} module
* @param {string} [file]
*/
function validate(module, file) {
if (!module) return;
for (const key in module) {
if (key[0] === '_' || expected.has(key)) continue; // key is valid in this module
const values = [...expected.values()];
const hint =
hint_for_supported_files(key, file?.slice(file.lastIndexOf('.'))) ??
`valid exports are ${values.join(', ')}, or anything with a '_' prefix`;
throw new Error(`Invalid export '${key}'${file ? ` in ${file}` : ''} (${hint})`);
}
}
return validate;
}
/**
* @param {string} key
* @param {string} ext
* @returns {string | void}
*/
function hint_for_supported_files(key, ext = '.js') {
const supported_files = [];
if (valid_layout_exports.has(key)) {
supported_files.push(`+layout${ext}`);
}
if (valid_page_exports.has(key)) {
supported_files.push(`+page${ext}`);
}
if (valid_layout_server_exports.has(key)) {
supported_files.push(`+layout.server${ext}`);
}
if (valid_page_server_exports.has(key)) {
supported_files.push(`+page.server${ext}`);
}
if (valid_server_exports.has(key)) {
supported_files.push(`+server${ext}`);
}
if (supported_files.length > 0) {
return `'${key}' is a valid export in ${supported_files.slice(0, -1).join(', ')}${
supported_files.length > 1 ? ' or ' : ''
}${supported_files.at(-1)}`;
}
}
const valid_layout_exports = new Set([
'load',
'prerender',
'csr',
'ssr',
'trailingSlash',
'config'
]);
const valid_page_exports = new Set([...valid_layout_exports, 'entries']);
const valid_layout_server_exports = new Set([...valid_layout_exports]);
const valid_page_server_exports = new Set([...valid_layout_server_exports, 'actions', 'entries']);
const valid_server_exports = new Set([
'GET',
'POST',
'PATCH',
'PUT',
'DELETE',
'OPTIONS',
'HEAD',
'fallback',
'prerender',
'trailingSlash',
'config',
'entries'
]);
export const validate_layout_exports = validator(valid_layout_exports);
export const validate_page_exports = validator(valid_page_exports);
export const validate_layout_server_exports = validator(valid_layout_server_exports);
export const validate_page_server_exports = validator(valid_page_server_exports);
export const validate_server_exports = validator(valid_server_exports);
+24
View File
@@ -0,0 +1,24 @@
/**
* @param {string} route_id
* @param {any} config
* @param {string} feature
* @param {import('@sveltejs/kit').Adapter | undefined} adapter
*/
export function check_feature(route_id, config, feature, adapter) {
if (!adapter) return;
switch (feature) {
case '$app/server:read': {
const supported = adapter.supports?.read?.({
route: { id: route_id },
config
});
if (!supported) {
throw new Error(
`Cannot use \`read\` from \`$app/server\` in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.`
);
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import fs from 'node:fs';
import path from 'node:path';
/** @param {string} dir */
export function mkdirp(dir) {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (/** @type {any} */ e) {
if (e.code === 'EEXIST') {
if (!fs.statSync(dir).isDirectory()) {
throw new Error(`Cannot create directory ${dir}, a file already exists at this position`);
}
return;
}
throw e;
}
}
/** @param {string} path */
export function rimraf(path) {
fs.rmSync(path, { force: true, recursive: true });
}
/**
* @param {string} source
* @param {string} target
* @param {{
* filter?: (basename: string) => boolean;
* replace?: Record<string, string>;
* }} opts
*/
export function copy(source, target, opts = {}) {
if (!fs.existsSync(source)) return [];
/** @type {string[]} */
const files = [];
const prefix = posixify(target) + '/';
const regex = opts.replace
? new RegExp(`\\b(${Object.keys(opts.replace).join('|')})\\b`, 'g')
: null;
/**
* @param {string} from
* @param {string} to
*/
function go(from, to) {
if (opts.filter && !opts.filter(path.basename(from))) return;
const stats = fs.statSync(from);
if (stats.isDirectory()) {
fs.readdirSync(from).forEach((file) => {
go(path.join(from, file), path.join(to, file));
});
} else {
mkdirp(path.dirname(to));
if (opts.replace) {
const data = fs.readFileSync(from, 'utf-8');
fs.writeFileSync(
to,
data.replace(
/** @type {RegExp} */ (regex),
(_match, key) => /** @type {Record<string, string>} */ (opts.replace)[key]
)
);
} else {
fs.copyFileSync(from, to);
}
files.push(to === target ? posixify(path.basename(to)) : posixify(to).replace(prefix, ''));
}
}
go(source, target);
return files;
}
/**
* Get a list of all files in a directory
* @param {string} cwd - the directory to walk
* @param {boolean} [dirs] - whether to include directories in the result
* @returns {string[]} a list of all found files (and possibly directories) relative to `cwd`
*/
export function walk(cwd, dirs = false) {
/** @type {string[]} */
const all_files = [];
/** @param {string} dir */
function walk_dir(dir) {
const files = fs.readdirSync(path.join(cwd, dir));
for (const file of files) {
const joined = path.join(dir, file);
const stats = fs.statSync(path.join(cwd, joined));
if (stats.isDirectory()) {
if (dirs) all_files.push(joined);
walk_dir(joined);
} else {
all_files.push(joined);
}
}
}
return (walk_dir(''), all_files);
}
/** @param {string} str */
export function posixify(str) {
return str.replace(/\\/g, '/');
}
/**
* Like `path.join`, but posixified and with a leading `./` if necessary
* @param {string[]} str
*/
export function join_relative(...str) {
let result = posixify(path.join(...str));
if (!result.startsWith('.')) {
result = `./${result}`;
}
return result;
}
/**
* Like `path.relative`, but always posixified and with a leading `./` if necessary.
* Useful for JS imports so the path can safely reside inside of `node_modules`.
* Otherwise paths could be falsely interpreted as package paths.
* @param {string} from
* @param {string} to
*/
export function relative_path(from, to) {
return join_relative(path.relative(from, to));
}
/**
* Prepend given path with `/@fs` prefix
* @param {string} str
*/
export function to_fs(str) {
str = posixify(str);
return `/@fs${
// Windows/Linux separation - Windows starts with a drive letter, we need a / in front there
str.startsWith('/') ? '' : '/'
}${str}`;
}
/**
* Removes `/@fs` prefix from given path and posixifies it
* @param {string} str
*/
export function from_fs(str) {
str = posixify(str);
if (!str.startsWith('/@fs')) return str;
str = str.slice(4);
// Windows/Linux separation - Windows starts with a drive letter, we need to strip the additional / here
return str[2] === ':' && /[A-Z]/.test(str[1]) ? str.slice(1) : str;
}
/**
* Given an entry point like [cwd]/src/hooks, returns a filename like [cwd]/src/hooks.js or [cwd]/src/hooks/index.js
* @param {string} entry
* @returns {string|null}
*/
export function resolve_entry(entry) {
if (fs.existsSync(entry)) {
const stats = fs.statSync(entry);
if (stats.isFile()) {
return entry;
}
const index = path.join(entry, 'index');
if (fs.existsSync(index + '.js') || fs.existsSync(index + '.ts')) {
return resolve_entry(index);
}
}
const dir = path.dirname(entry);
if (fs.existsSync(dir)) {
const base = path.basename(entry);
const files = fs.readdirSync(dir);
const found = files.find((file) => {
return file.replace(/\.(js|ts)$/, '') === base && fs.statSync(path.join(dir, file)).isFile();
});
if (found) return path.join(dir, found);
}
return null;
}
/** @param {string} file */
export function read(file) {
return fs.readFileSync(file, 'utf-8');
}
+70
View File
@@ -0,0 +1,70 @@
import { fileURLToPath } from 'node:url';
import { Worker, parentPort } from 'node:worker_threads';
import process from 'node:process';
/**
* Runs a task in a subprocess so any dangling stuff gets killed upon completion.
* The subprocess needs to be the file `forked` is called in, and `forked` needs to be called eagerly at the top level.
* @template T
* @template U
* @param {string} module `import.meta.url` of the file
* @param {(opts: T) => Promise<U>} callback The function that is invoked in the subprocess
* @returns {(opts: T) => Promise<U>} A function that when called starts the subprocess
*/
export function forked(module, callback) {
if (process.env.SVELTEKIT_FORK && parentPort) {
parentPort.on(
'message',
/** @param {any} data */ async (data) => {
if (data?.type === 'args' && data.module === module) {
parentPort?.postMessage({
type: 'result',
module,
payload: await callback(data.payload)
});
}
}
);
parentPort.postMessage({ type: 'ready', module });
}
/**
* @param {T} opts
* @returns {Promise<U>}
*/
return function (opts) {
return new Promise((fulfil, reject) => {
const worker = new Worker(fileURLToPath(module), {
env: {
...process.env,
SVELTEKIT_FORK: 'true'
}
});
worker.on(
'message',
/** @param {any} data */ (data) => {
if (data?.type === 'ready' && data.module === module) {
worker.postMessage({
type: 'args',
module,
payload: opts
});
}
if (data?.type === 'result' && data.module === module) {
worker.unref();
fulfil(data.payload);
}
}
);
worker.on('exit', (code) => {
if (code) {
reject(new Error(`Failed with code ${code}`));
}
});
});
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* @template T
* @param {() => T} fn
*/
export function once(fn) {
let done = false;
/** @type T */
let result;
return () => {
if (done) return result;
done = true;
return (result = fn());
};
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Hash using djb2
* @param {import('types').StrictBody[]} values
*/
export function hash(...values) {
let hash = 5381;
for (const value of values) {
if (typeof value === 'string') {
let i = value.length;
while (i) hash = (hash * 33) ^ value.charCodeAt(--i);
} else if (ArrayBuffer.isView(value)) {
const buffer = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
let i = buffer.length;
while (i) hash = (hash * 33) ^ buffer[--i];
} else {
throw new TypeError('value must be a string or TypedArray');
}
}
return (hash >>> 0).toString(36);
}
+82
View File
@@ -0,0 +1,82 @@
import { BINARY_FORM_CONTENT_TYPE } from '../runtime/form-utils.js';
/**
* Given an Accept header and a list of possible content types, pick
* the most suitable one to respond with
* @param {string} accept
* @param {string[]} types
*/
export function negotiate(accept, types) {
/** @type {Array<{ type: string, subtype: string, q: number, i: number }>} */
const parts = [];
accept.split(',').forEach((str, i) => {
const match = /([^/ \t]+)\/([^; \t]+)[ \t]*(?:;[ \t]*q=([0-9.]+))?/.exec(str);
// no match equals invalid header — ignore
if (match) {
const [, type, subtype, q = '1'] = match;
parts.push({ type, subtype, q: +q, i });
}
});
parts.sort((a, b) => {
if (a.q !== b.q) {
return b.q - a.q;
}
if ((a.subtype === '*') !== (b.subtype === '*')) {
return a.subtype === '*' ? 1 : -1;
}
if ((a.type === '*') !== (b.type === '*')) {
return a.type === '*' ? 1 : -1;
}
return a.i - b.i;
});
let accepted;
let min_priority = Infinity;
for (const mimetype of types) {
const [type, subtype] = mimetype.split('/');
const priority = parts.findIndex(
(part) =>
(part.type === type || part.type === '*') &&
(part.subtype === subtype || part.subtype === '*')
);
if (priority !== -1 && priority < min_priority) {
accepted = mimetype;
min_priority = priority;
}
}
return accepted;
}
/**
* Returns `true` if the request contains a `content-type` header with the given type
* @param {Request} request
* @param {...string} types
*/
function is_content_type(request, ...types) {
const type = request.headers.get('content-type')?.split(';', 1)[0].trim() ?? '';
return types.includes(type.toLowerCase());
}
/**
* @param {Request} request
*/
export function is_form_content_type(request) {
// These content types must be protected against CSRF
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype
return is_content_type(
request,
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
BINARY_FORM_CONTENT_TYPE
);
}
+52
View File
@@ -0,0 +1,52 @@
import process from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
/**
* Resolves a peer dependency relative to the current CWD. Duplicated with `packages/adapter-auto`
* @param {string} dependency
*/
function resolve_peer(dependency) {
let [name, ...parts] = dependency.split('/');
if (name[0] === '@') name += `/${parts.shift()}`;
let dir = process.cwd();
while (!fs.existsSync(`${dir}/node_modules/${name}/package.json`)) {
if (dir === (dir = path.dirname(dir))) {
throw new Error(
`Could not resolve peer dependency "${name}" relative to your project — please install it and try again.`
);
}
}
const pkg_dir = `${dir}/node_modules/${name}`;
const pkg = JSON.parse(fs.readFileSync(`${pkg_dir}/package.json`, 'utf-8'));
const subpackage = ['.', ...parts].join('/');
let exported = pkg.exports[subpackage];
while (typeof exported !== 'string') {
if (!exported) {
throw new Error(`Could not find valid "${subpackage}" export in ${name}/package.json`);
}
exported = exported['import'] ?? exported['default'];
}
return path.resolve(pkg_dir, exported);
}
/**
* Resolve a dependency relative to the current working directory,
* rather than relative to this package (but falls back to trying that, if necessary)
* @param {string} dependency
*/
export async function import_peer(dependency) {
try {
return await import(resolve_peer(dependency));
} catch {
return await import(dependency);
}
}
+1
View File
@@ -0,0 +1 @@
export const s = JSON.stringify;
+95
View File
@@ -0,0 +1,95 @@
import {
validate_layout_exports,
validate_layout_server_exports,
validate_page_exports,
validate_page_server_exports
} from './exports.js';
export class PageNodes {
data;
/**
* @param {Array<import('types').SSRNode | undefined>} nodes
*/
constructor(nodes) {
this.data = nodes;
}
layouts() {
return this.data.slice(0, -1);
}
page() {
return this.data.at(-1);
}
validate() {
for (const layout of this.layouts()) {
if (layout) {
validate_layout_server_exports(layout.server, /** @type {string} */ (layout.server_id));
validate_layout_exports(layout.universal, /** @type {string} */ (layout.universal_id));
}
}
const page = this.page();
if (page) {
validate_page_server_exports(page.server, /** @type {string} */ (page.server_id));
validate_page_exports(page.universal, /** @type {string} */ (page.universal_id));
}
}
/**
* @template {'prerender' | 'ssr' | 'csr' | 'trailingSlash'} Option
* @param {Option} option
* @returns {Value | undefined}
*/
#get_option(option) {
/** @typedef {(import('types').UniversalNode | import('types').ServerNode)[Option]} Value */
return this.data.reduce((value, node) => {
return node?.universal?.[option] ?? node?.server?.[option] ?? value;
}, /** @type {Value | undefined} */ (undefined));
}
csr() {
return this.#get_option('csr') ?? true;
}
ssr() {
return this.#get_option('ssr') ?? true;
}
prerender() {
return this.#get_option('prerender') ?? false;
}
trailing_slash() {
return this.#get_option('trailingSlash') ?? 'never';
}
get_config() {
/** @type {any} */
let current = {};
for (const node of this.data) {
if (!node?.universal?.config && !node?.server?.config) continue;
current = {
...current,
// TODO: should we override the server config value with the universal value similar to other page options?
...node?.universal?.config,
...node?.server?.config
};
}
// TODO 3.0 always return `current`? then we can get rid of `?? {}` in other places
return Object.keys(current).length ? current : undefined;
}
should_prerender_data() {
return this.data.some(
// prerender in case of trailingSlash because the client retrieves that value from the server
(node) => node?.server?.load || node?.server?.trailingSlash !== undefined
);
}
}
+29
View File
@@ -0,0 +1,29 @@
/** @see https://github.com/microsoft/TypeScript/blob/904e7dd97dc8da1352c8e05d70829dff17c73214/src/lib/es2024.promise.d.ts */
/**
* @template T
* @typedef {{
* promise: Promise<T>;
* resolve: (value: T | PromiseLike<T>) => void;
* reject: (reason?: any) => void;
* }} PromiseWithResolvers<T>
*/
/**
* TODO: Whenever Node >21 is minimum supported version, we can use `Promise.withResolvers` to avoid this ceremony
*
* @template T
* @returns {PromiseWithResolvers<T>}
*/
export function with_resolvers() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// @ts-expect-error `resolve` and `reject` are assigned!
return { promise, resolve, reject };
}
+307
View File
@@ -0,0 +1,307 @@
import { BROWSER } from 'esm-env';
import { decode_params } from './url.js';
const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;
/**
* Creates the regex pattern, extracts parameter names, and generates types for a route
* @param {string} id
*/
export function parse_route_id(id) {
/** @type {import('types').RouteParam[]} */
const params = [];
const pattern =
id === '/'
? /^\/$/
: new RegExp(
`^${get_route_segments(id)
.map((segment) => {
// special case — /[...rest]/ could contain zero segments
const rest_match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment);
if (rest_match) {
params.push({
name: rest_match[1],
matcher: rest_match[2],
optional: false,
rest: true,
chained: true
});
return '(?:/([^]*))?';
}
// special case — /[[optional]]/ could contain zero segments
const optional_match = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment);
if (optional_match) {
params.push({
name: optional_match[1],
matcher: optional_match[2],
optional: true,
rest: false,
chained: true
});
return '(?:/([^/]+))?';
}
if (!segment) {
return;
}
const parts = segment.split(/\[(.+?)\](?!\])/);
const result = parts
.map((content, i) => {
if (i % 2) {
if (content.startsWith('x+')) {
return escape(String.fromCharCode(parseInt(content.slice(2), 16)));
}
if (content.startsWith('u+')) {
return escape(
String.fromCharCode(
...content
.slice(2)
.split('-')
.map((code) => parseInt(code, 16))
)
);
}
// We know the match cannot be null in the browser because manifest generation
// would have invoked this during build and failed if we hit an invalid
// param/matcher name with non-alphanumeric character.
const match = /** @type {RegExpExecArray} */ (param_pattern.exec(content));
if (!BROWSER && !match) {
throw new Error(
`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`
);
}
const [, is_optional, is_rest, name, matcher] = match;
// It's assumed that the following invalid route id cases are already checked
// - unbalanced brackets
// - optional param following rest param
params.push({
name,
matcher,
optional: !!is_optional,
rest: !!is_rest,
chained: is_rest ? i === 1 && parts[0] === '' : false
});
return is_rest ? '([^]*?)' : is_optional ? '([^/]*)?' : '([^/]+?)';
}
return escape(content);
})
.join('');
return '/' + result;
})
.join('')}/?$`
);
return { pattern, params };
}
const optional_param_regex = /\/\[\[\w+?(?:=\w+)?\]\]/;
/**
* Removes optional params from a route ID.
* @param {string} id
* @returns The route id with optional params removed
*/
export function remove_optional_params(id) {
return id.replace(optional_param_regex, '');
}
/**
* Returns `false` for `(group)` segments
* @param {string} segment
*/
function affects_path(segment) {
return segment !== '' && !/^\([^)]+\)$/.test(segment);
}
/**
* Splits a route id into its segments, removing segments that
* don't affect the path (i.e. groups). The root route is represented by `/`
* and will be returned as `['']`.
* @param {string} route
* @returns string[]
*/
export function get_route_segments(route) {
return route.slice(1).split('/').filter(affects_path);
}
/**
* @param {RegExpMatchArray} match
* @param {import('types').RouteParam[]} params
* @param {Record<string, import('@sveltejs/kit').ParamMatcher>} matchers
*/
export function exec(match, params, matchers) {
/** @type {Record<string, string>} */
const result = {};
const values = match.slice(1);
const values_needing_match = values.filter((value) => value !== undefined);
let buffered = 0;
for (let i = 0; i < params.length; i += 1) {
const param = params[i];
let value = values[i - buffered];
// in the `[[a=b]]/.../[...rest]` case, if one or more optional parameters
// weren't matched, roll the skipped values into the rest
if (param.chained && param.rest && buffered) {
value = values
.slice(i - buffered, i + 1)
.filter((s) => s)
.join('/');
buffered = 0;
}
// if `value` is undefined, it means this is an optional or rest parameter
if (value === undefined) {
if (param.rest) {
// We need to allow the matcher to run so that it can decide if this optional rest param should be allowed to match
value = '';
} else {
continue;
}
}
if (!param.matcher || matchers[param.matcher](value)) {
result[param.name] = value;
// Now that the params match, reset the buffer if the next param isn't the [...rest]
// and the next value is defined, otherwise the buffer will cause us to skip values
const next_param = params[i + 1];
const next_value = values[i + 1];
if (next_param && !next_param.rest && next_param.optional && next_value && param.chained) {
buffered = 0;
}
// There are no more params and no more values, but all non-empty values have been matched
if (
!next_param &&
!next_value &&
Object.keys(result).length === values_needing_match.length
) {
buffered = 0;
}
continue;
}
// in the `/[[a=b]]/...` case, if the value didn't satisfy the matcher,
// keep track of the number of skipped optional parameters and continue
if (param.optional && param.chained) {
buffered++;
continue;
}
// otherwise, if the matcher returns `false`, the route did not match
return;
}
if (buffered) return;
return result;
}
/** @param {string} str */
function escape(str) {
return (
str
.normalize()
// escape [ and ] before escaping other characters, since they are used in the replacements
.replace(/[[\]]/g, '\\$&')
// replace %, /, ? and # with their encoded versions because decode_pathname leaves them untouched
.replace(/%/g, '%25')
.replace(/\//g, '%2[Ff]')
.replace(/\?/g, '%3[Ff]')
.replace(/#/g, '%23')
// escape characters that have special meaning in regex
.replace(/[.*+?^${}()|\\]/g, '\\$&')
);
}
const basic_param_pattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g;
/**
* Populate a route ID with params to resolve a pathname.
* @example
* ```js
* resolveRoute(
* `/blog/[slug]/[...somethingElse]`,
* {
* slug: 'hello-world',
* somethingElse: 'something/else'
* }
* ); // `/blog/hello-world/something/else`
* ```
* @param {string} id
* @param {Record<string, string | undefined>} params
* @returns {string}
*/
export function resolve_route(id, params) {
const segments = get_route_segments(id);
const has_id_trailing_slash = id != '/' && id.endsWith('/');
return (
'/' +
segments
.map((segment) =>
segment.replace(basic_param_pattern, (_, optional, rest, name) => {
const param_value = params[name];
// This is nested so TS correctly narrows the type
if (!param_value) {
if (optional) return '';
if (rest && param_value !== undefined) return '';
throw new Error(`Missing parameter '${name}' in route ${id}`);
}
if (param_value.startsWith('/') || param_value.endsWith('/'))
throw new Error(
`Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar`
);
return param_value;
})
)
.filter(Boolean)
.join('/') +
(has_id_trailing_slash ? '/' : '')
);
}
/**
* @param {import('types').SSRNode} node
* @returns {boolean}
*/
export function has_server_load(node) {
return node.server?.load !== undefined || node.server?.trailingSlash !== undefined;
}
/**
* Find the first route that matches the given path
* @template {{pattern: RegExp, params: import('types').RouteParam[]}} Route
* @param {string} path - The decoded pathname to match
* @param {Route[]} routes
* @param {Record<string, import('@sveltejs/kit').ParamMatcher>} matchers
* @returns {{ route: Route, params: Record<string, string> } | null}
*/
export function find_route(path, routes, matchers) {
for (const route of routes) {
const match = route.pattern.exec(path);
if (!match) continue;
const matched = exec(match, route.params, matchers);
if (matched) {
return {
route,
params: decode_params(matched)
};
}
}
return null;
}
+41
View File
@@ -0,0 +1,41 @@
import { with_resolvers } from './promise.js';
/**
* Create an async iterator and a function to push values into it
* @template T
* @returns {{
* iterate: (transform?: (input: T) => T) => AsyncIterable<T>;
* add: (promise: Promise<T>) => void;
* }}
*/
export function create_async_iterator() {
let resolved = -1;
let returned = -1;
/** @type {import('./promise.js').PromiseWithResolvers<T>[]} */
const deferred = [];
return {
iterate: (transform = (x) => x) => {
return {
[Symbol.asyncIterator]() {
return {
next: async () => {
const next = deferred[++returned];
if (!next) return { value: null, done: true };
const value = await next.promise;
return { value: transform(value), done: false };
}
};
}
};
},
add: (promise) => {
deferred.push(with_resolvers());
void promise.then((value) => {
deferred[++resolved].resolve(value);
});
}
};
}
+201
View File
@@ -0,0 +1,201 @@
import { BROWSER, DEV } from 'esm-env';
/**
* Matches a URI scheme. See https://www.rfc-editor.org/rfc/rfc3986#section-3.1
* @type {RegExp}
*/
export const SCHEME = /^[a-z][a-z\d+\-.]+:/i;
const internal = new URL('sveltekit-internal://');
/**
* @param {string} base
* @param {string} path
*/
export function resolve(base, path) {
// special case
if (path[0] === '/' && path[1] === '/') return path;
let url = new URL(base, internal);
url = new URL(path, url);
return url.protocol === internal.protocol ? url.pathname + url.search + url.hash : url.href;
}
/** @param {string} path */
export function is_root_relative(path) {
return path[0] === '/' && path[1] !== '/';
}
/**
* @param {string} path
* @param {import('types').TrailingSlash} trailing_slash
*/
export function normalize_path(path, trailing_slash) {
if (path === '/' || trailing_slash === 'ignore') return path;
if (trailing_slash === 'never') {
return path.endsWith('/') ? path.slice(0, -1) : path;
} else if (trailing_slash === 'always' && !path.endsWith('/')) {
return path + '/';
}
return path;
}
/**
* Decode pathname excluding %25 to prevent further double decoding of params
* @param {string} pathname
*/
export function decode_pathname(pathname) {
return pathname.split('%25').map(decodeURI).join('%25');
}
/** @param {Record<string, string>} params */
export function decode_params(params) {
for (const key in params) {
// input has already been decoded by decodeURI
// now handle the rest
params[key] = decodeURIComponent(params[key]);
}
return params;
}
/**
* The error when a URL is malformed is not very helpful, so we augment it with the URI
* @param {string} uri
*/
export function decode_uri(uri) {
try {
return decodeURI(uri);
} catch (e) {
if (e instanceof Error) {
e.message = `Failed to decode URI: ${uri}\n` + e.message;
}
throw e;
}
}
/**
* Returns everything up to the first `#` in a URL
* @param {{href: string}} url_like
*/
export function strip_hash({ href }) {
return href.split('#')[0];
}
/**
* @param {URL} url
* @param {() => void} callback
* @param {(search_param: string) => void} search_params_callback
* @param {boolean} [allow_hash]
*/
export function make_trackable(url, callback, search_params_callback, allow_hash = false) {
const tracked = new URL(url);
Object.defineProperty(tracked, 'searchParams', {
value: new Proxy(tracked.searchParams, {
get(obj, key) {
if (key === 'get' || key === 'getAll' || key === 'has') {
return (/** @type {string} */ param, /** @type {string[]} */ ...rest) => {
search_params_callback(param);
return obj[key](param, ...rest);
};
}
// if they try to access something different from what is in `tracked_search_params_properties`
// we track the whole url (entries, values, keys etc)
callback();
const value = Reflect.get(obj, key);
return typeof value === 'function' ? value.bind(obj) : value;
}
}),
enumerable: true,
configurable: true
});
/**
* URL properties that could change during the lifetime of the page,
* which excludes things like `origin`
*/
const tracked_url_properties = ['href', 'pathname', 'search', 'toString', 'toJSON'];
if (allow_hash) tracked_url_properties.push('hash');
for (const property of tracked_url_properties) {
Object.defineProperty(tracked, property, {
get() {
callback();
// @ts-expect-error
return url[property];
},
enumerable: true,
configurable: true
});
}
if (!BROWSER) {
// @ts-ignore
tracked[Symbol.for('nodejs.util.inspect.custom')] = (depth, opts, inspect) => {
return inspect(url, opts);
};
// @ts-ignore
tracked.searchParams[Symbol.for('nodejs.util.inspect.custom')] = (depth, opts, inspect) => {
return inspect(url.searchParams, opts);
};
}
if ((DEV || !BROWSER) && !allow_hash) {
disable_hash(tracked);
}
return tracked;
}
/**
* Disallow access to `url.hash` on the server and in `load`
* @param {URL} url
*/
function disable_hash(url) {
allow_nodejs_console_log(url);
Object.defineProperty(url, 'hash', {
get() {
throw new Error(
'Cannot access event.url.hash. Consider using `page.url.hash` inside a component instead'
);
}
});
}
/**
* Disallow access to `url.search` and `url.searchParams` during prerendering
* @param {URL} url
*/
export function disable_search(url) {
allow_nodejs_console_log(url);
for (const property of ['search', 'searchParams']) {
Object.defineProperty(url, property, {
get() {
throw new Error(`Cannot access url.${property} on a page with prerendering enabled`);
}
});
}
}
/**
* Allow URL to be console logged, bypassing disabled properties.
* @param {URL} url
*/
function allow_nodejs_console_log(url) {
if (!BROWSER) {
// @ts-ignore
url[Symbol.for('nodejs.util.inspect.custom')] = (depth, opts, inspect) => {
return inspect(new URL(url), opts);
};
}
}