feat: more tweaks and features

This commit is contained in:
DecDuck
2025-08-10 17:04:52 +10:00
parent 1587e9601b
commit 5911dd96a0
14 changed files with 191 additions and 61 deletions

View File

@@ -1,26 +1,49 @@
<template>
<ul class="space-y-3">
<li v-for="[path, methods] in collapsedAPINav.entries()">
<span class="font-display text-zinc-100 font-semibold">{{ path }}</span>
<ul class="mt-1 space-y-1">
<li v-for="method in methods">
<NuxtLink
class="transition hover:bg-zinc-900 px-1 py-0.5 rounded ml-1 text-zinc-400 grid grid-cols-4 gap-x-2"
:href="method.path">
<span
:class="[apiMethodColours[method.method], 'text-center text-xs font-bold ring-1 rounded-full px-1 py-0.5']">{{
method.method }}</span>
<div class="col-span-3 text-sm relative whitespace-nowrap overflow-hidden"><span
class="absolute right-0">{{ method.methodlessName }}</span></div>
<div>
<div v-if="guidesNavigation">
<h1 class="font-display font-bold text-zinc-100">{{ guidesNavigation.title }}</h1>
<ul class="ml-3 mt-1 space-y-2">
<li v-for="guide in guidesNavigation.children">
<NuxtLink :href="guide.path" class="transition text-sm text-zinc-300 hover:text-zinc-100">
<span class="text-blue-400">+ {{ " " }}</span>{{ guide.title }}
</NuxtLink>
</li>
</ul>
</li>
</ul>
</div>
<div class="mt-8">
<div class="grid grid-cols-1">
<input type="email" name="email" id="email"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-950 py-1.5 pr-3 pl-10 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
placeholder="/api/v1/..." v-model="query" />
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-gray-400 sm:size-4"
aria-hidden="true" />
</div>
</div>
<ul class="mt-4 space-y-3">
<li v-for="[path, methods] in filteredCollapsedNav.entries()">
<span class="font-display text-zinc-100 font-semibold">{{ path }}</span>
<ul class="mt-1 space-y-1">
<li v-for="method in methods">
<NuxtLink
class="transition hover:bg-zinc-900 px-1 py-0.5 rounded ml-1 text-zinc-400 grid grid-cols-4 gap-x-2"
:href="method.path">
<span
:class="[apiMethodColours[method.method], 'text-center text-xs font-bold ring-1 rounded-full px-1 py-0.5']">{{
method.method }}</span>
<div class="col-span-3 text-sm relative whitespace-nowrap overflow-hidden"><span
class="absolute right-0">{{ method.methodlessName }}</span></div>
</NuxtLink>
</li>
</ul>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from '@heroicons/vue/16/solid'
import type { ContentNavigationItem } from '@nuxt/content';
const rawAPINav = await queryCollectionNavigation("docs");
@@ -66,4 +89,20 @@ collapsedAPINav.value = new Map([...collapsedAPINav.value].sort((a, b) => a[0].l
return v;
}))
const query = ref("");
const filteredCollapsedNav = computed(() => {
if(!query.value) return collapsedAPINav.value;
const raw = [...collapsedAPINav.value];
const filtered = raw.map((e) => [e[0], e[1].filter((v) => v.title.toLowerCase().includes(query.value.toLowerCase()))] as const);
const stripped = filtered.filter((e) => e[1].length > 0);
return new Map(stripped);
});
const guidesNavigation = (await queryCollectionNavigation("guides")).at(0);
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="not-prose rounded-md bg-yellow-600/10 p-4 my-4">
<div class="flex">
<div class="shrink-0">
<ExclamationTriangleIcon class="size-5 text-yellow-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="-mt-1 text-sm font-medium text-yellow-400">Deprecated</h3>
<div class="mt-2 text-sm text-yellow-500">
<p>This route has been deprecated, and is not recommended for new projects.</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid'
</script>

View File

@@ -0,0 +1,3 @@
<template>
<h1 class="not-prose text-zinc-100 text-3xl font-bold font-display"><slot /></h1>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-zinc-950 h-screen w-full">
<div class="h-screen w-full">
<div class="h-full lg:ml-72 xl:ml-80">
<div class="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex">
<div
@@ -10,7 +10,7 @@
</NuxtLink>
</div>
<Header />
<Navigation class="hidden lg:mt-10 lg:block" />
<Navigation class="hidden lg:mt-6 lg:block" />
</div>
</div>
<div class="relative overflow-y-auto flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">

View File

@@ -3,8 +3,6 @@ const route = useRoute();
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
page.value?.body.toc
useSeoMeta({
title: page.value?.title,
description: page.value?.description
@@ -13,8 +11,8 @@ useSeoMeta({
<template>
<div v-if="page" class="flex flex-row gap-8">
<ContentRenderer class="grow prose prose-invert prose-blue max-w-none" :value="page" />
<div class="sticky top-0 w-96 h-min" v-if="page.body.toc">
<ContentRenderer class="grow prose prose-invert prose-blue max-w-none" :value="page" :prose="true" />
<div class="hidden lg:block sticky top-0 w-96 h-min" v-if="page.body.toc">
<h1 class="text-zinc-400">On this page</h1>
<ul class="mt-1 ">
<li v-for="link in page.body.toc.links">
@@ -26,5 +24,4 @@ useSeoMeta({
</div>
</div>
<div v-else>Page not found</div>
</template>

View File

@@ -10,6 +10,9 @@ useSeoMeta({
</script>
<template>
<ContentRenderer v-if="page" :value="page" />
<div v-if="page" class="prose prose-invert prose-blue">
<ContentRenderer :value="page" />
</div>
<div v-else>Page not found</div>
</template>

2
drop

Submodule drop updated: 29fdfcbdd4...7c234067a5

View File

@@ -1 +0,0 @@
# How to create an API token

View File

@@ -0,0 +1,11 @@
# Generating an API token
API tokens can be manually created by a user, or you can provide a convenient URL that auto-selects all the ACLs you want. Of course, users can edit these ACLs, but it is still a nice-to-have if you are developing a third-party application.
## How it works
The API token generation mimics OAuth2/SSO flow. At a high level, this is how it works:
1. Third party application (you) generates an "authorization" URL. This specifies the callback URL, required ACLs, and application metadata.
2. Third party application opens authorization URL in browser. The user, if signed in, can approve, modify, or deny the request.
3. If approved, Drop generates the API token, and redirects the URL to the callback URL, with the token in tow.

View File

@@ -12,10 +12,20 @@ export default defineNuxtConfig({
css: ["~/assets/css/core.scss", "~/assets/css/tailwind.css"],
hooks: {
"build:before": async () => {
"modules:before": async () => {
// Dynamic import so I can ignore errors. (I'm lazy)
const build = (await import("./tools/index")).default;
await build();
},
},
content: {
build: {
markdown: {
highlight: {
theme: "aurora-x",
},
},
},
},
});

View File

@@ -10,6 +10,8 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@nuxt/content": "^3.6.3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",

View File

@@ -8,6 +8,27 @@ function encouragePrettierLinebreaks(rawType: string) {
: rawType;
}
async function renderType(metadata: RouteMetadata, type: ts.Type) {
const expandedType = metadata.checker.typeToTypeNode(
type,
metadata.sourceFile,
ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.InTypeAlias
);
const rawType = metadata.checker.typeToString(
type,
undefined,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias
);
const newLinedType = encouragePrettierLinebreaks(rawType);
const pretty = await prettier.format(`type Body = ${newLinedType}`, {
parser: "typescript",
});
return pretty;
}
export default async function generateDocsPage(
route: Route,
metadata: RouteMetadata
@@ -26,17 +47,7 @@ Request type: \`application/json\`. Body type (TypeScript definition):
`.trimStart();
bodyText += "```typescript\n";
const rawType = metadata.checker.typeToString(
metadata.body,
undefined,
ts.TypeFormatFlags.NoTruncation
);
const newLinedType = encouragePrettierLinebreaks(rawType);
const pretty = await prettier.format(`type Body = ${newLinedType}`, {
parser: "typescript",
});
bodyText += pretty;
bodyText += await renderType(metadata, metadata.body);
bodyText += "\n```";
} else if (metadata.bodyComment) {
bodyText = `
@@ -59,9 +70,10 @@ ${metadata.bodyComment ?? ""}`;
if (metadata.query) {
const queryProperties = metadata.query.getProperties();
if (queryProperties.length > 0) {
queryTable = "## Query Parameters\n\n| Name | Type |\n| ---- | ---- |\n";
queryTable =
"## Query Parameters\n\n| Name | Parser |\n| ---- | ---- |\n";
for (const property of queryProperties) {
queryTable += `| ${property.escapedName} | string |\n`;
queryTable += `| ${property.escapedName} | ${property.declarations?.at(0)?.getLastToken()?.getText()?.slice(1, -1) ?? "string"} |\n`;
}
}
}
@@ -77,9 +89,9 @@ ${metadata.acls.map((e) => ` - \`${e}\``).join("\n")}
`.trimStart();
}
// If ACL is checked, but no specific ones are required
// Means authenticated, but no specific ACL is required
// Means authenticated, but only sessions
else if (metadata.acls) {
routeAuthentication = `You can access this route with any \`${metadata.aclMode}\` token.`;
routeAuthentication = `Only \`${metadata.aclMode}\` **sessions** can access this route, not API tokens.`;
}
// ===== Client Warning =====
@@ -104,17 +116,7 @@ Response definition:
`.trimStart();
responseText += "```typescript\n";
const rawType = metadata.checker.typeToString(
metadata.response,
undefined,
ts.TypeFormatFlags.NoTruncation
);
const newLinedType = encouragePrettierLinebreaks(rawType);
const pretty = await prettier.format(`type Response = ${newLinedType}`, {
parser: "typescript",
});
responseText += pretty;
responseText += await renderType(metadata, metadata.response);
responseText += "\n```";
} else if (metadata.responseComment) {
responseText = `
@@ -122,9 +124,16 @@ Response definition:
${metadata.responseComment}`.trim();
}
const deprecatedElement = `::deprecated\n::`;
const rawText = `
---
metaUrl: ${route.urlPath}
---
# ${route.method} \`${route.urlPath}\`
${metadata.deprecated ? deprecatedElement : ""}
## About this route
${metadata.routeDescription ?? "*No description provided.*"}

View File

@@ -67,6 +67,8 @@ async function transformRoutes(base: string, routes: string[]) {
export interface RouteMetadata {
checker: ts.TypeChecker;
sourceFile: ts.SourceFile;
deprecated?: boolean;
body?: ts.Type;
bodyComment?: string;
@@ -122,16 +124,20 @@ async function generateDocs(
if (!ts.isCallExpression(eventHandler)) continue;
// Check if this is defineClientHandler, and mark as a client route if so
const eventHandlerName = eventHandler.expression.getText();
const isClient = eventHandlerName.toLowerCase().includes("client");
const eventHandlerName = eventHandler.expression.getText().toLowerCase();
const isClient = eventHandlerName.includes("client");
const metadata: RouteMetadata = { checker, clientRoute: isClient };
const metadata: RouteMetadata = {
checker,
clientRoute: isClient,
sourceFile: routeTs,
};
// Extract the body type from the generic args
// Complicated and messy
// Only run on client/normal handlers
if (!eventHandlerName.toLowerCase().includes("websocket")) {
if (!eventHandlerName.includes("websocket")) {
const resolvedSignature = checker.getResolvedSignature(eventHandler);
if (!resolvedSignature)
throw "No resolved signature? Somehow an invalid node?";
@@ -144,12 +150,14 @@ async function generateDocs(
);
if (handlerType.isUnion()) handlerType = handlerType.types.at(0)!;
const handlerSignature = handlerType.getCallSignatures().at(0)!;
const typeArguments = (handlerType as any)[
"resolvedTypeArguments"
] as ts.Type[];
if (typeArguments) {
const requestConfigurationType = typeArguments.at(0)!;
const requestConfigurationType = typeArguments.at(isClient ? 1 : 0)!;
const properties = requestConfigurationType.getProperties();
for (const property of properties) {
@@ -162,9 +170,11 @@ async function generateDocs(
(metadata as any)[property.escapedName!] = type;
}
const responseType = typeArguments.at(1)!;
if (!(responseType.flags & ts.TypeFlags.Any))
metadata.response = checker.getAwaitedType(responseType);
const responseType = checker.getReturnTypeOfSignature(handlerSignature);
const awaited = checker.getAwaitedType(responseType);
if (awaited && !(awaited.flags & ts.TypeFlags.Any)) {
metadata.response = awaited;
}
}
} else {
// If it's websocket, change the "method"
@@ -239,6 +249,10 @@ async function generateDocs(
metadata.responseComment = tag.comment?.toString();
}
}
if (ts.isJSDocDeprecatedTag(tag)) {
metadata.deprecated = true;
}
}
}

View File

@@ -564,6 +564,18 @@
resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-3.1.1.tgz#af3aea7f1e52ec916d8b5c9dcc0f09d4c060a3fc"
integrity sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==
"@headlessui/vue@^1.7.23":
version "1.7.23"
resolved "https://registry.yarnpkg.com/@headlessui/vue/-/vue-1.7.23.tgz#7fe19dbeca35de9e6270c82c78c4864e6a6f7391"
integrity sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==
dependencies:
"@tanstack/vue-virtual" "^3.0.0-beta.60"
"@heroicons/vue@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@heroicons/vue/-/vue-2.2.0.tgz#d81f14eed448eec9859849ed63facd3f29bca2b3"
integrity sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==
"@ioredis/commands@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.3.0.tgz#4dc3ae9bfa7146b63baf27672a61db0ea86e35e5"
@@ -1891,6 +1903,18 @@
"@tailwindcss/oxide" "4.1.11"
tailwindcss "4.1.11"
"@tanstack/virtual-core@3.13.12":
version "3.13.12"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz#1dff176df9cc8f93c78c5e46bcea11079b397578"
integrity sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==
"@tanstack/vue-virtual@^3.0.0-beta.60":
version "3.13.12"
resolved "https://registry.yarnpkg.com/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz#a66daac9e6822ce4bcba76a3954937440697c264"
integrity sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==
dependencies:
"@tanstack/virtual-core" "3.13.12"
"@tybys/wasm-util@^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369"