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
3 changes: 3 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"ignores": ["src/pages/docs/api/**"]
}
10 changes: 9 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { attributeMarkdown, wrapTables } from '/src/themes/octopus/utilities/cus
import llmMdEmitter from './src/integrations/llm-md-emitter.ts';
import pruneDist from './src/integrations/prune-dist.ts';
import satteriHeadingId from './src/plugins/satteri-heading-id.js';
import satteriApiExamples, { apiExampleDirective } from './src/plugins/satteri-api-examples.js';
import { endpointDirective } from './src/plugins/satteri-endpoint.js';
import satteriWbr from './src/plugins/satteri-wbr.js';
import shikiCodeBlock from './src/plugins/shiki-code-block.js';

Expand Down Expand Up @@ -52,10 +54,16 @@ export default defineConfig({
mdastPlugins: [
satteriHeadingId,
attributeMarkdown,
// After attributeMarkdown, whose generic handler would otherwise
// render `:::api-example` as an <api-example> tag, and
// `:endpoint` as an <endpoint> one
apiExampleDirective,
endpointDirective,
wrapTables
],
hastPlugins: [
satteriWbr
satteriWbr,
satteriApiExamples
],
}),
},
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
".octopus/**",
".vscode/**",
".github/**",
"src/pages/docs/api/**",
"src/pages/report/**",
"src/fallback/**",
"src/scripts/**",
Expand Down
134 changes: 134 additions & 0 deletions src/components/ApiNavigation.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
import { accelerator } from '@lib/accelerator';
import { Translations, Lang } from '@util/Languages';
import { apiMenu } from '@lib/apiNavigation';

const stats = new accelerator.statistics('components/ApiNavigation.astro');
stats.start();

type Props = {
lang: string;
headings: { depth: number; slug: string; text: string }[];
// One entry per endpoint, in heading order, left on the frontmatter by
// plugins/satteri-api-examples.js from the `:endpoint` directive under each
// heading. Sections without one — and pages that never reach that plugin —
// simply have none.
apiMethods?:
| { text: string; method: string | null; deprecated?: boolean }[]
| null;
};
const { lang, headings, apiMethods } = Astro.props satisfies Props;

// The four the API badges in api.css cover. Title case because the badge is an
// image to a screen reader, and reads as a word rather than as shouting.
const METHOD_LABELS: Record<string, string> = {
get: 'Get',
post: 'Post',
put: 'Put',
delete: 'Delete',
};

// Astro escapes a brace in heading text as `${`, so a title that is a route —
// "GET /api/{spaceId}/environments" — survives being read as an expression. The
// methods are collected before that happens, so the two are compared unescaped.
function headingText(text: string): string {
return text.replace(/\$\{/g, '{').trim();
}

const _ = Lang(lang);

// Only the page the reader is on lists its endpoints. Expanding every page
// would put thousands of anchors in the markup of all ~100 of them, and it is
// the rule the site nav follows too: the branch you are inside is the one that
// opens.
const currentPath = Astro.url.pathname.replace(/\/$/, '');

// An endpoint per H2, which is the first level of content on a generated API
// page.
//
// The methods are in the same order, an endpoint each, so they pair off by
// position — two endpoints on a page can share a title, which is what rules out
// pairing them by it. The title is checked all the same: if the two lists have
// drifted apart, the nav goes without badges rather than hanging the wrong one
// on a row.
const endpoints = (headings ?? [])
.filter((heading) => heading.depth === 2)
.map((heading, index) => {
const endpoint = apiMethods?.[index];
const matches = endpoint?.text === headingText(heading.text);
const method = matches ? (endpoint.method ?? '') : '';

return {
...heading,
method: method in METHOD_LABELS ? method : null,
deprecated: matches && endpoint.deprecated === true,
};
});

const pages = apiMenu().map((page) => ({
...page,
isCurrent: page.url.replace(/\/$/, '') === currentPath,
}));

stats.stop();
---

<nav
class="site-nav"
id="site-nav"
aria-label={_(Translations.aria.site_navigation)}
>
<h2 class="site-nav-title">{_(Translations.navigation.title)}</h2>
<ul class="site-nav__list">
{
pages.map((page) =>
page.isCurrent && endpoints.length > 0 ? (
<li class="site-nav__list-item">
<details class="site-nav__group" open>
{/* The summary is the page the reader is already on, so it
labels the group rather than linking back to itself. */}
<summary class="site-nav__link" aria-current="page">
<span class="site-nav__label">{page.title}</span>
</summary>
<ul class="site-nav__list">
{endpoints.map((heading) => (
<li class="site-nav__list-item">
<a
class:list={[
'site-nav__link',
'site-nav__link--heading',
heading.deprecated && 'site-nav__link--deprecated',
]}
href={`#${heading.slug}`}
>
{/* The badge is the method: an icon in the nav, where
there is no room for the word beside the title. */}
{heading.method && (
<span
class={`api-${heading.method} api-icon-only`}
role="img"
aria-label={METHOD_LABELS[heading.method]}
/>
)}
<span class="site-nav__label">{heading.text}</span>
</a>
</li>
))}
</ul>
</details>
</li>
) : (
<li class="site-nav__list-item">
<a
class="site-nav__link"
href={accelerator.urlFormatter.formatAddress(page.url)}
aria-current={page.isCurrent ? 'page' : null}
>
<span class="site-nav__label">{page.title}</span>
</a>
</li>
)
)
}
</ul>
</nav>
123 changes: 123 additions & 0 deletions src/layouts/Api.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
import { accelerator } from '@lib/accelerator';
import { PostFiltering } from 'astro-accelerator-utils';
import type { Frontmatter as OriginalFrontmatter } from 'astro-accelerator-utils/types/Frontmatter';
import { SITE } from '@config';
import { buildApiCrumbs } from '@lib/apiNavigation';
import type { Crumb } from '@util/breadcrumbs';

// Theme components
import Head from '@components/HtmlHead.astro';
import SkipLinks from '@components/SkipLinks.astro';
import Breadcrumbs from '@components/Breadcrumbs.astro';
import Authors from '@components/Authors.astro';
import Taxonomy from '@components/Taxonomy.astro';

// Custom components
import ApiNavigation from '../components/ApiNavigation.astro';
import ArticleHeader from '../components/ArticleHeader.astro';
import Feedback from '../components/Feedback.astro';
import Header from '../components/Header.astro';
import Plausible from 'src/components/Plausible.astro';
import Footer from 'src/components/Footer.astro';
import DocsSearch from '../components/DocsSearch.astro';

type Props = {
// `apiMethods` is not written by hand: plugins/satteri-api-examples.js leaves
// it on the frontmatter for the left nav, an entry per endpoint.
frontmatter: OriginalFrontmatter & {
apiMethods?: { text: string; method: string | null }[];
};
headings: { depth: number; slug: string; text: string }[];
breadcrumbs?: Crumb[] | null;
};
const { frontmatter, headings, breadcrumbs } = Astro.props satisfies Props;
Comment thread
borland marked this conversation as resolved.

// buildApiCrumbs, not buildCrumbs: the section has no page of its own at
// /docs/api for the generic walk to find, so it splices the crumb in.
const crumbs = buildApiCrumbs(Astro.url, breadcrumbs);

const lang = frontmatter.lang ?? SITE.default.lang;
const textDirection = frontmatter.dir ?? SITE.default.dir;

// Logic
const title = await accelerator.markdown.getInlineHtmlFrom(
frontmatter.title ?? SITE.title
);

const subtitle = frontmatter.subtitle
? await accelerator.markdown.getInlineHtmlFrom(frontmatter.subtitle)
: null;

const site_url = SITE.url;
const site_features = SITE.featureFlags;
const search =
accelerator.posts.all().filter(PostFiltering.isSearch).shift() ?? null;
const searchUrl = search && accelerator.urlFormatter.formatAddress(search.url);
const isSearchPage =
accelerator.urlFormatter.formatAddress(Astro.url.pathname) === searchUrl;

const showSearch = !isSearchPage;

// The footer prints this; JSON-LD carries it as dateModified.
const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
---

<html dir={textDirection} lang={lang} class="initial" data-theme="light">
<Head
frontmatter={frontmatter}
headings={headings}
lang={lang}
crumbs={crumbs}
/>
<body>
<SkipLinks frontmatter={frontmatter} headings={headings} lang={lang} />
<Header
frontmatter={frontmatter}
headings={headings}
lang={lang}
showSearch={showSearch}
/>
<!-- The article spans the table of contents column as well as its own: the
left nav lists this page's endpoints, so there is no table of contents
to show, and the width is what the examples column is built out of. -->
<div class="content-group content-group--api">
<main id="site-main">
<Breadcrumbs lang={lang} crumbs={crumbs} />
<article>
<ArticleHeader lang={lang} subtitle={subtitle} title={title} />
<div class="page-actions">
{
/* Copy as markdown temporarily disabled until we can get it working with the API docs. Deliberately no "Edit on GitHub" because these are generated and should not be hand edited */
}
Comment thread
borland marked this conversation as resolved.
</div>
<div class="page-content anim-show-parent">
<slot />
<Authors frontmatter={frontmatter} lang={lang} />
<Taxonomy frontmatter={frontmatter} lang={lang} />
</div>
</article>
<Feedback frontmatter={frontmatter} lang={lang} />
</main>
<ApiNavigation
headings={headings}
lang={lang}
apiMethods={frontmatter.apiMethods}
/>
<Footer lang={lang} lastUpdated={lastUpdated} />
</div>
{
/* The overlay the header's search field opens. Same single instance as
Default.astro, and not behind `showSearch` for the same reason. */
}
<DocsSearch />
<script define:vars={{ site_url, site_features }}>
window.site_url = site_url;
window.site_features = site_features;
</script>
<script>
import '../scripts/main.js';
</script>
<Plausible />
</body>
</html>
27 changes: 26 additions & 1 deletion src/lib/accelerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,38 @@ const posts = accelerator.posts;
// 1.1 MB of JSON is ~18 ms.
// Builds only. In dev the page set changes under you, so keep reading through.
const readAll = posts.all.bind(posts);

// Posts.all() globs every markdown file under src/pages, including the ones
// Astro does not route: anything with an underscore-prefixed path segment. Those
// are not pages, so they have no business in the nav, the breadcrumb trail, the
// taxonomy, or the /report pages - each of which would link to a URL that 404s.
// Astro's own routing rule is the filter.
const PAGES_ROOT = '/src/pages/';
const isRouted = (post: { file?: string }) => {
const file = post.file ?? '';
// Only the path below src/pages decides routing. In local development the
// generated API pages are symlinked in, so `file` resolves to wherever the
// generator wrote them - checking the whole absolute path could catch a
// directory of the developer's that happens to start with an underscore.
const index = file.lastIndexOf(PAGES_ROOT);
const routePath =
index === -1
? file.slice(file.lastIndexOf('/') + 1)
: file.slice(index + PAGES_ROOT.length);
return !routePath.split('/').some((part) => part.startsWith('_'));
};

const readRouted = () => readAll().filter(isRouted);

let allPosts: ReturnType<typeof readAll> | null = null;

if (import.meta.env.PROD) {
posts.all = () => {
if (allPosts === null) allPosts = readAll();
if (allPosts === null) allPosts = readRouted();
return allPosts.slice();
};
} else {
posts.all = readRouted;
}

const shadow = (key: string, value: unknown) =>
Expand Down
Loading