Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ In the simplest case, a page declares a `default` action:

```js
/// file: src/routes/login/+page.server.js
/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
default: async (event) => {
// TODO log the user in
Expand Down Expand Up @@ -56,7 +56,7 @@ Instead of one `default` action, a page can have as many named actions as it nee

```js
/// file: src/routes/login/+page.server.js
/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
--- default: async (event) => {---
+++ login: async (event) => {+++
Expand Down Expand Up @@ -119,7 +119,7 @@ export async function load({ cookies }) {
return { user };
}

/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
login: async ({ cookies, request }) => {
const data = await request.formData();
Expand Down Expand Up @@ -168,7 +168,7 @@ declare module '$lib/server/db';
+++import { fail } from '@sveltejs/kit';+++
import * as db from '$lib/server/db';

/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
login: async ({ cookies, request }) => {
const data = await request.formData();
Expand Down Expand Up @@ -232,7 +232,7 @@ declare module '$lib/server/db';
import { fail, +++redirect+++ } from '@sveltejs/kit';
import * as db from '$lib/server/db';

/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
login: async ({ cookies, request, +++url+++ }) => {
const data = await request.formData();
Expand Down Expand Up @@ -317,7 +317,7 @@ export function load(event) {
};
}

/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
logout: async (event) => {
event.cookies.delete('sessionid', { path: '/' });
Expand Down Expand Up @@ -507,7 +507,7 @@ Some forms don't need to `POST` data to the server — search inputs, for exampl
<form action="/search">
<label>
Search
<input name="q">
<input name="q" />
</label>
</form>
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function load() {
return { user };
}

/** @type {import('./$types').Actions} */
/** @satisfies {import('./$types').Actions} */
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
Expand Down
232 changes: 118 additions & 114 deletions packages/site-kit/src/lib/markdown/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,91 +368,112 @@ async function convert_to_ts(js_code: string, indent = '', offset = '') {

async function walk(node: ts.Node) {
const jsdoc = get_jsdoc(node);

if (jsdoc) {
for (const comment of jsdoc) {
let modified = false;

let count = 0;
for (const tag of comment.tags ?? []) {
if (ts.isJSDocTypeTag(tag)) {
const [name, generics] = await get_type_info(tag);

if (ts.isFunctionDeclaration(node)) {
const is_export = node.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
)
? 'export '
: '';
const is_async = node.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword
);

const type = generics !== undefined ? `${name}<${generics}>` : name;

if (node.name && node.body) {
code.overwrite(
node.getStart(),
node.name.getEnd(),
`${is_export ? 'export ' : ''}const ${node.name.getText()}: ${type} = (${
is_async ? 'async ' : ''
}`
);

code.appendLeft(node.body.getStart(), '=> ');
code.appendLeft(node.body.getEnd(), ');');

modified = true;
}
} else if (
ts.isVariableStatement(node) &&
node.declarationList.declarations.length === 1
) {
const variable_statement = node.declarationList.declarations[0];

if (variable_statement.name.getText() === 'actions') {
let i = variable_statement.getEnd();
while (code.original[i - 1] !== '}') i -= 1;
code.appendLeft(i, ` satisfies ${name}`);
} else {
code.appendLeft(
variable_statement.name.getEnd(),
`: ${name}${generics ? `<${generics}>` : ''}`
);
}

modified = true;
} else {
throw new Error('Unhandled @type JsDoc->TS conversion: ' + js_code);
}
} else if (ts.isJSDocParameterTag(tag) && ts.isFunctionDeclaration(node)) {
const sanitised_param = tag
.getFullText()
.replace(/\s+/g, '')
.replace(/(^\*|\*$)/g, '');

const [, param_type] = /@param{(.+)}(.+)/.exec(sanitised_param) ?? [];

let param_count = 0;
for (const param of node.parameters) {
if (count !== param_count) {
param_count++;
continue;
}

code.appendLeft(param.getEnd(), `:${param_type}`);

param_count++;
}

modified = true;
// this isn't an exhaustive list of tags we could potentially encounter (no `@template` etc)
// but it's good enough to cover what's actually in the docs right now
let type: string | null = null;
let params: string[] = [];
let returns: string | null = null;
let satisfies: string | null = null;

if (jsdoc.length > 1) {
throw new Error('woah nelly');
}

const { comment, tags = [] } = jsdoc[0];

for (const tag of tags) {
if (ts.isJSDocTypeTag(tag)) {
type = get_type_info(tag.typeExpression);
} else if (ts.isJSDocParameterTag(tag)) {
params.push(get_type_info(tag.typeExpression!));
} else if (ts.isJSDocReturnTag(tag)) {
returns = get_type_info(tag.typeExpression!);
} else if (ts.isJSDocSatisfiesTag(tag)) {
satisfies = get_type_info(tag.typeExpression!);
} else {
throw new Error('Unhandled tag');
}

let start = tag.getStart();
let end = tag.getEnd();

while (start > 0 && code.original[start] !== '\n') start -= 1;
while (end > 0 && code.original[end] !== '\n') end -= 1;
code.remove(start, end);
}

if (type && satisfies) {
throw new Error('Cannot combine @type and @satisfies');
}

if (ts.isFunctionDeclaration(node)) {
// convert function to a `const`
if (type || satisfies) {
const is_export = node.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
);

const is_async = node.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword
);

code.overwrite(
node.getStart(),
node.name!.getStart(),
is_export ? `export const ` : `const `
);

const modifier = is_async ? 'async ' : '';
code.appendLeft(
node.name!.getEnd(),
type ? `: ${type} = ${modifier}` : ` = ${modifier}(`
);

code.prependRight(node.body!.getStart(), '=> ');

code.appendLeft(node.getEnd(), satisfies ? `) satisfies ${satisfies};` : ';');
}

for (let i = 0; i < node.parameters.length; i += 1) {
if (params[i] !== undefined) {
code.appendLeft(node.parameters[i].getEnd(), `: ${params[i]}`);
}
}

count++;
if (returns) {
let start = node.body!.getStart();
while (code.original[start - 1] !== ')') start -= 1;
code.appendLeft(start, `: ${returns}`);
}
} else if (ts.isVariableStatement(node) && node.declarationList.declarations.length === 1) {
if (params.length > 0 || returns) {
throw new Error('TODO handle @params and @returns in variable declarations');
}

const declaration = node.declarationList.declarations[0];

if (modified) {
code.overwrite(comment.getStart(), comment.getEnd(), '');
if (type) {
code.appendLeft(declaration.name.getEnd(), `: ${type}`);
}

if (satisfies) {
let end = declaration.getEnd();
if (code.original[end - 1] === ';') end -= 1;
code.appendLeft(end, ` satisfies ${satisfies}`);
}
} else {
throw new Error('Unhandled @type JsDoc->TS conversion: ' + js_code);
}

if (!comment) {
// remove the whole thing
let start = jsdoc[0].getStart();
let end = jsdoc[0].getEnd();

while (start > 0 && code.original[start] !== '\n') start -= 1;
code.overwrite(start, end, '');
}
}

Expand Down Expand Up @@ -487,42 +508,25 @@ async function convert_to_ts(js_code: string, indent = '', offset = '') {

let transformed = code.toString();

return transformed === js_code ? undefined : transformed.replace(/\n\s*\n\s*\n/g, '\n\n');

async function get_type_info(tag: ts.JSDocTypeTag | ts.JSDocParameterTag) {
const type_text = tag.typeExpression?.getText();
let name = type_text?.slice(1, -1); // remove { }

const single_line_name = (
await prettier.format(name ?? '', {
printWidth: 1000,
parser: 'typescript',
semi: false,
singleQuote: true
})
).replace('\n', '');
return transformed === js_code ? undefined : transformed;

function get_type_info(expression: ts.JSDocTypeExpression) {
const type = expression
?.getText()!
.slice(1, -1) // remove surrounding `{` and `}`
.replace(/ \* ?/gm, '')
.replace(/import\('(.+?)'\)\.(\w+)(?:(<.+>))?/gms, (_, source, name, args = '') => {
const existing = imports.get(source);
if (existing) {
existing.add(name);
} else {
imports.set(source, new Set([name]));
}

const import_match = /import\('(.+?)'\)\.(\w+)(?:<(.+)>)?$/s.exec(single_line_name);
return name + args;
});

if (import_match) {
const [, from, _name, generics] = import_match;
name = _name;
const existing = imports.get(from);
if (existing) {
existing.add(name);
} else {
imports.set(from, new Set([name]));
}
if (generics !== undefined) {
return [
name,
generics
.replaceAll('*', '') // get rid of JSDoc asterisks
.replace(' }>', '}>') // unindent closing brace
];
}
}
return [name];
return type;
}
}

Expand Down Expand Up @@ -690,7 +694,7 @@ async function syntax_highlight({

// munge shiki output: put whitespace outside `<span>` elements, so that
// highlight delimiters fall outside tokens
html = html.replace(/(<span[^<]+?>)(\s+)/g, '$2$1').replace(/(\s+)(<\/span>)/g, '$2$1');
html = html.replace(/(<span[^>]+?>)(\s+)/g, '$2$1').replace(/(\s+)(<\/span>)/g, '$2$1');

html = html
.replace(/ {13}([^ ][^]+?) {13}/g, (_, content) => {
Expand Down
Loading