Skip to content

Commit

Permalink
feat(next): typed links
Browse files Browse the repository at this point in the history
  • Loading branch information
florian-lefebvre committed Sep 20, 2024
1 parent 325a57c commit f430225
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 2 deletions.
3 changes: 3 additions & 0 deletions examples/blog/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap()],
experimental: {
typedLinks: true
}
});
10 changes: 8 additions & 2 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
contentCollectionCache: false,
clientPrerender: false,
contentIntellisense: false,
typedLinks: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -522,6 +523,7 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
typedLinks: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.typedLinks),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`,
Expand Down Expand Up @@ -660,10 +662,14 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
// Handle `base` and `image.endpoint.route` trailing slash based on `trailingSlash` config
if (config.trailingSlash === 'never') {
config.base = prependForwardSlash(removeTrailingForwardSlash(config.base));
config.image.endpoint.route = prependForwardSlash(removeTrailingForwardSlash(config.image.endpoint.route));
config.image.endpoint.route = prependForwardSlash(
removeTrailingForwardSlash(config.image.endpoint.route),
);
} else if (config.trailingSlash === 'always') {
config.base = prependForwardSlash(appendForwardSlash(config.base));
config.image.endpoint.route = prependForwardSlash(appendForwardSlash(config.image.endpoint.route));
config.image.endpoint.route = prependForwardSlash(
appendForwardSlash(config.image.endpoint.route),
);
} else {
config.base = prependForwardSlash(config.base);
config.image.endpoint.route = prependForwardSlash(config.image.endpoint.route);
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { vitePluginMiddleware } from './middleware/vite-plugin.js';
import { joinPaths } from './path.js';
import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js';
import { isObject } from './util.js';
import { astroTypedLinks } from '../typed-links/vite-plugin-typed-links.js';

type CreateViteOptions = {
settings: AstroSettings;
Expand Down Expand Up @@ -166,6 +167,7 @@ export async function createVite(
vitePluginUserActions({ settings }),
vitePluginServerIslands({ settings }),
astroContainer(),
astroTypedLinks({ settings }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { Logger } from '../logger/core.js';
import { formatErrorMessage } from '../messages.js';
import { createRouteManifest } from '../routing/index.js';
import { ensureProcessNodeEnv } from '../util.js';
import { syncTypedLinks } from '../../typed-links/sync.js';

export type SyncOptions = {
/**
Expand Down Expand Up @@ -151,6 +152,7 @@ export async function syncInternal({
});
}
syncAstroEnv(settings);
syncTypedLinks(settings, manifest);

writeInjectedTypes(settings, fs);
logger.info('types', `Generated ${dim(getTimeStat(timerStart, performance.now()))}`);
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/typed-links/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const VIRTUAL_MODULE_ID = 'astro:link';
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;

const PKG_BASE = new URL('../../', import.meta.url);
export const MODULE_TEMPLATE_URL = new URL('templates/typed-links/module.mjs', PKG_BASE);
export const TYPES_TEMPLATE_URL = new URL('templates/typed-links/types.d.ts', PKG_BASE);
export const TYPES_FILE = 'typed-links.d.ts';
55 changes: 55 additions & 0 deletions packages/astro/src/typed-links/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs';
import type { AstroSettings, ManifestData } from '../types/astro.js';
import { TYPES_FILE, TYPES_TEMPLATE_URL } from './constants.js';
import { removeTrailingForwardSlash, appendForwardSlash } from '@astrojs/internal-helpers/path';

export function syncTypedLinks(settings: AstroSettings, manifest: ManifestData): void {
if (!settings.config.experimental.typedLinks) {
return;
}

const data: Array<{ route: string; params: Array<string> }> = [];

const { base, trailingSlash } = settings.config;
for (const { route: _route, params } of manifest.routes) {
const route = `${removeTrailingForwardSlash(base)}${_route}`;
if (trailingSlash === 'always') {
data.push({ route: appendForwardSlash(route), params });
} else if (trailingSlash === 'never') {
data.push({ route, params });
} else {
const r = appendForwardSlash(route);
data.push({ route, params });
if (route !== r) {
data.push({ route: r, params });
}
}
}

let types = '';
for (let i = 0; i < data.length; i++) {
const { route, params } = data[i];

if (i > 0) {
types += ' ';
}

types += `"${route}": ${
params.length === 0
? 'never'
: `{${params
.map((key) => `"${key}": ${key.startsWith('...') ? 'string | undefined' : 'string'}`)
.join('; ')}}`
};`;

if (i !== data.length - 1) {
types += '\n';
}
}

const content = readFileSync(TYPES_TEMPLATE_URL, 'utf-8').replace('// @@LINKS@@', types);
settings.injectedTypes.push({
filename: TYPES_FILE,
content,
});
}
31 changes: 31 additions & 0 deletions packages/astro/src/typed-links/vite-plugin-typed-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Plugin } from 'vite';
import type { AstroSettings } from '../types/astro.js';
import { MODULE_TEMPLATE_URL, RESOLVED_VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID } from './constants.js';
import { readFileSync } from 'node:fs';

interface TypedLinksPluginParams {
settings: AstroSettings;
}

export function astroTypedLinks({ settings }: TypedLinksPluginParams): Plugin | undefined {
if (!settings.config.experimental.typedLinks) {
return;
}

const module = readFileSync(MODULE_TEMPLATE_URL, 'utf-8');

return {
name: 'astro-typed-links-plugin',
enforce: 'pre',
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
return RESOLVED_VIRTUAL_MODULE_ID;
}
},
load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
return module;
}
},
};
}
3 changes: 3 additions & 0 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,9 @@ export interface AstroUserConfig {
* To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature.
*/
contentIntellisense?: boolean;

/** TODO: */
typedLinks?: boolean;
};
}

Expand Down
25 changes: 25 additions & 0 deletions packages/astro/templates/typed-links/module.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @ts-check

/**
* @param {string} path
* @param {{ params?: Record<string, string | undefined>; searchParams?: Record<string, string> | URLSearchParams; hash?: string; }} opts
*/
export const link = (path, opts = {}) => {
let newPath = path;
if (opts.params) {
for (const [key, value] of Object.entries(opts.params)) {
newPath = newPath.replace(`[${key}]`, value ?? '');
}
}
if (opts.searchParams) {
const searchParams =
opts.searchParams instanceof URLSearchParams
? opts.searchParams
: new URLSearchParams(opts.searchParams);
newPath += `?${searchParams.toString()}`;
}
if (opts.hash) {
newPath += `#${opts.hash}`;
}
return newPath;
};
29 changes: 29 additions & 0 deletions packages/astro/templates/typed-links/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
declare module 'astro:link' {
interface Links {
// @@LINKS@@
}

type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

type Opts<T> = Prettify<
([T] extends [never]
? {
params?: never;
}
: {
params: T;
}) & {
searchParams?: Record<string, string> | URLSearchParams;
hash?: string;
}
>;

export function link<TPath extends keyof Links>(
path: TPath,
...[opts]: Links[TPath] extends never ? [opts?: Opts<Links[TPath]>] : [opts: Opts<Links[TPath]>]
): string;
}

// TODO: routePattern

0 comments on commit f430225

Please sign in to comment.