Documentation Updates (#79)
* Docs: Added Shadcn sidebar; added algorithm to automatically infer parents without needing to specify it. * Extracted Markdoc compilation in a separate file * Site Navigation: simplify nav by removing the border * Docs Navigation: added TOC; improved layout on mobile
This commit is contained in:
committed by
GitHub
parent
6490102e9f
commit
9615d1a4bb
@@ -1,3 +1,3 @@
|
||||
export function KeystaticContentRenderer(props: { content: unknown }) {
|
||||
return <div>{props.content as React.ReactNode}</div>;
|
||||
return props.content as React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Cms, CmsClient } from '@kit/cms-types';
|
||||
|
||||
import { createKeystaticReader } from './create-reader';
|
||||
import {
|
||||
CustomMarkdocComponents,
|
||||
CustomMarkdocTags,
|
||||
} from './custom-components';
|
||||
import { PostEntryProps } from './keystatic.config';
|
||||
import { renderMarkdoc } from './markdoc';
|
||||
|
||||
export function createKeystaticClient() {
|
||||
return new KeystaticClient();
|
||||
@@ -85,12 +80,78 @@ class KeystaticClient implements CmsClient {
|
||||
return right - left;
|
||||
});
|
||||
|
||||
const items = await Promise.all(
|
||||
filtered.slice(startOffset, endOffset).map(async (item) => {
|
||||
const children = docs.filter((item) => item.entry.parent === item.slug);
|
||||
function processItems(items: typeof docs) {
|
||||
const result: typeof docs = [...items];
|
||||
|
||||
return this.mapPost(item, children);
|
||||
}),
|
||||
const indexFiles = items.filter((item) => {
|
||||
const parts = item.slug.split('/');
|
||||
|
||||
return (
|
||||
parts.length > 1 &&
|
||||
parts[parts.length - 1] === parts[parts.length - 2]
|
||||
);
|
||||
});
|
||||
|
||||
function findParentIndex(pathParts: string[]): string | null {
|
||||
// Try each level up from the current path until we find an index file
|
||||
for (let i = pathParts.length - 1; i > 0; i--) {
|
||||
const currentPath = pathParts.slice(0, i).join('/');
|
||||
|
||||
const possibleParent = indexFiles.find((indexFile) => {
|
||||
const indexParts = indexFile.slug.split('/');
|
||||
const indexFolderPath = indexParts.slice(0, -1).join('/');
|
||||
|
||||
return indexFolderPath === currentPath;
|
||||
});
|
||||
|
||||
if (possibleParent) {
|
||||
return possibleParent.slug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
result.forEach((item) => {
|
||||
const pathParts = item.slug.split('/');
|
||||
|
||||
// Skip if this is a root level index file (e.g., "authentication/authentication")
|
||||
if (pathParts.length === 2 && pathParts[0] === pathParts[1]) {
|
||||
item.entry.parent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if current item is an index file
|
||||
const isIndexFile =
|
||||
pathParts[pathParts.length - 1] === pathParts[pathParts.length - 2];
|
||||
|
||||
if (isIndexFile) {
|
||||
// For index files, find parent in the level above
|
||||
const parentPath = pathParts.slice(0, -2);
|
||||
if (parentPath.length > 0) {
|
||||
item.entry.parent = findParentIndex(
|
||||
parentPath.concat(parentPath[parentPath.length - 1]!),
|
||||
);
|
||||
} else {
|
||||
item.entry.parent = null;
|
||||
}
|
||||
} else {
|
||||
// For regular files, find parent in the current folder
|
||||
item.entry.parent = findParentIndex(pathParts);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const itemsWithParents = processItems(filtered);
|
||||
|
||||
const items = await Promise.all(
|
||||
itemsWithParents
|
||||
.slice(startOffset, endOffset)
|
||||
.sort((a, b) => {
|
||||
return (a.entry.order ?? 0) - (b.entry.order ?? 0);
|
||||
})
|
||||
.map((item) => this.mapPost(item)),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -157,25 +218,17 @@ class KeystaticClient implements CmsClient {
|
||||
slug: string;
|
||||
},
|
||||
>(item: Type, children: Type[] = []): Promise<Cms.ContentItem> {
|
||||
const { transform, renderers } = await import('@markdoc/markdoc');
|
||||
|
||||
const publishedAt = item.entry.publishedAt
|
||||
? new Date(item.entry.publishedAt)
|
||||
: new Date();
|
||||
|
||||
const markdoc = await item.entry.content();
|
||||
|
||||
const content = transform(markdoc.node, {
|
||||
tags: CustomMarkdocTags,
|
||||
});
|
||||
|
||||
const html = renderers.react(content, React, {
|
||||
components: CustomMarkdocComponents,
|
||||
});
|
||||
const content = await item.entry.content();
|
||||
const html = await renderMarkdoc(content.node);
|
||||
|
||||
return {
|
||||
id: item.slug,
|
||||
title: item.entry.title,
|
||||
label: item.entry.label,
|
||||
url: item.slug,
|
||||
slug: item.slug,
|
||||
description: item.entry.description,
|
||||
@@ -201,7 +254,7 @@ class KeystaticClient implements CmsClient {
|
||||
parentId: item.entry.parent ?? undefined,
|
||||
order: item.entry.order ?? 1,
|
||||
children: await Promise.all(
|
||||
children.map(async (child) => this.mapPost(child, [])),
|
||||
children.map((child) => this.mapPost(child, [])),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ function getKeystaticCollections(path: string) {
|
||||
format: { contentField: 'content' },
|
||||
schema: {
|
||||
title: fields.slug({ name: { label: 'Title' } }),
|
||||
label: fields.text({ label: 'Label', validation: { isRequired: false } }),
|
||||
image: fields.image({
|
||||
label: 'Image',
|
||||
directory: 'public/site/images',
|
||||
@@ -101,6 +102,7 @@ function getKeystaticCollections(path: string) {
|
||||
format: { contentField: 'content' },
|
||||
schema: {
|
||||
title: fields.slug({ name: { label: 'Title' } }),
|
||||
label: fields.text({ label: 'Label', validation: { isRequired: false } }),
|
||||
content: getContentField(),
|
||||
image: fields.image({
|
||||
label: 'Image',
|
||||
|
||||
42
packages/cms/keystatic/src/markdoc-nodes.ts
Normal file
42
packages/cms/keystatic/src/markdoc-nodes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Or replace this with your own function
|
||||
import { Config, Node, RenderableTreeNode, Tag } from '@markdoc/markdoc';
|
||||
|
||||
function generateID(
|
||||
children: Array<RenderableTreeNode>,
|
||||
attributes: Record<string, unknown>,
|
||||
) {
|
||||
if (attributes.id && typeof attributes.id === 'string') {
|
||||
return attributes.id;
|
||||
}
|
||||
|
||||
return children
|
||||
.filter((child) => typeof child === 'string')
|
||||
.join(' ')
|
||||
.replace(/[?]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const heading = {
|
||||
children: ['inline'],
|
||||
attributes: {
|
||||
id: { type: String },
|
||||
level: { type: Number, required: true, default: 1 },
|
||||
},
|
||||
transform(node: Node, config: Config) {
|
||||
const attributes = node.transformAttributes(config);
|
||||
const children = node.transformChildren(config);
|
||||
|
||||
const id = generateID(children, attributes);
|
||||
|
||||
return new Tag(
|
||||
`h${node.attributes.level}`,
|
||||
{ ...attributes, id },
|
||||
children,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MarkdocNodes = {
|
||||
heading,
|
||||
};
|
||||
30
packages/cms/keystatic/src/markdoc.tsx
Normal file
30
packages/cms/keystatic/src/markdoc.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Node } from '@markdoc/markdoc';
|
||||
|
||||
import {
|
||||
CustomMarkdocComponents,
|
||||
CustomMarkdocTags,
|
||||
} from './custom-components';
|
||||
import { MarkdocNodes } from './markdoc-nodes';
|
||||
|
||||
/**
|
||||
* @name renderMarkdoc
|
||||
* @description Renders a Markdoc tree to React
|
||||
*/
|
||||
export async function renderMarkdoc(node: Node) {
|
||||
const { transform, renderers } = await import('@markdoc/markdoc');
|
||||
|
||||
const content = transform(node, {
|
||||
tags: {
|
||||
...CustomMarkdocTags,
|
||||
},
|
||||
nodes: {
|
||||
...MarkdocNodes,
|
||||
},
|
||||
});
|
||||
|
||||
return renderers.react(content, React, {
|
||||
components: CustomMarkdocComponents,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export namespace Cms {
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
title: string;
|
||||
label: string | undefined;
|
||||
url: string;
|
||||
description: string | undefined;
|
||||
content: unknown;
|
||||
|
||||
@@ -139,6 +139,7 @@ class WordpressClient implements CmsClient {
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
title: item.title.rendered,
|
||||
label: item.title.rendered,
|
||||
content: item.content.rendered,
|
||||
description: item.excerpt.rendered,
|
||||
image,
|
||||
@@ -217,6 +218,7 @@ class WordpressClient implements CmsClient {
|
||||
description: item.excerpt.rendered,
|
||||
children: [],
|
||||
title: item.title.rendered,
|
||||
label: item.title.rendered,
|
||||
content: item.content.rendered,
|
||||
slug: item.slug,
|
||||
publishedAt: item.date,
|
||||
|
||||
@@ -209,7 +209,7 @@ const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
variant?: 'sidebar' | 'floating' | 'inset' | 'ghost';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}
|
||||
>(
|
||||
@@ -285,7 +285,13 @@ const Sidebar = React.forwardRef<
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
className={cn(
|
||||
'w-[--sidebar-width] p-0 text-sidebar-foreground [&>button]:hidden',
|
||||
{
|
||||
'bg-background': variant === 'ghost',
|
||||
'bg-sidebar': variant !== 'ghost',
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
@@ -313,12 +319,15 @@ const Sidebar = React.forwardRef<
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]',
|
||||
{
|
||||
'h-svh': variant !== 'ghost',
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
@@ -337,7 +346,12 @@ const Sidebar = React.forwardRef<
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow',
|
||||
{
|
||||
'bg-background': variant === 'ghost',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -862,11 +876,7 @@ export function SidebarNavigation({
|
||||
|
||||
const ContentContainer = (props: React.PropsWithChildren) => {
|
||||
if (item.collapsible) {
|
||||
return (
|
||||
<CollapsibleContent>
|
||||
{props.children}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
return <CollapsibleContent>{props.children}</CollapsibleContent>;
|
||||
}
|
||||
|
||||
return props.children;
|
||||
|
||||
Reference in New Issue
Block a user