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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
"react": ">=19.0.0",
"react-dom": ">=19.0.0",
"regex-utilities": "^2.3.0",
"zudoku": "^0.86.0"
"zudoku": "^0.88.0"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated because bug in v86

},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
Comment on lines +22 to +23

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed for the react component of the Navbar

"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.14.0"
Expand Down
21 changes: 19 additions & 2 deletions pages/guides/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,31 @@
title: "Guides Overview"
---

This page lists all available guides for deploying applications and services on Unikraft Cloud, grouped by category.
These guides mimic the [examples](https://github.com/unikraft-cloud/examples) repository, and are constantly updated with new content.
This page lists all available guides for Unikraft Cloud.
The guides are in two groups:

* **Tutorials** explain a platform topic that applies to any app.
Two examples are root filesystem formats and environment variables.
* **Example apps** show how to deploy one specific app or service.
They mimic the [examples](https://github.com/unikraft-cloud/examples) repository, and are constantly updated with new content.

:::note
Unikraft Cloud can run any workload—define it in a `Dockerfile` and it will run it.
These guides are here to make that journey as fast as possible for you.
:::

## Tutorials

- [Docker To Unikraft Cloud](/tutorials/docker-to-ukc)
- [KraftKit To Unikraft](/tutorials/kraftkit-to-unikraft)
- [Environment Variables](/tutorials/environment-variables)
- [Rootfs Formats](/tutorials/rootfs-formats)
- [Rootfs Compression](/tutorials/rootfs-compression)
- [Rootfses, Volumes and ROMs](/tutorials/rootfs-volumes-roms)
- [Scale To Zero Triggers](/tutorials/scale-to-zero-triggers)

The sections below list the example apps, grouped by category.

{/* vale off */}

## HTTP Servers
Expand Down
2 changes: 1 addition & 1 deletion pages/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ New: GPU support in enterprise preview - read more [here](/platform/instances#gp

## Quick start

<Stepper>
<Stepper toc={false}>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed so that zudoku v88 doesn't add counters to the table of content for the steps


1. [Create a free account](https://console.unikraft.cloud/signup).

Expand Down
507 changes: 203 additions & 304 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

55 changes: 27 additions & 28 deletions scripts/update_zudoku_guides.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
#!/usr/bin/env python3
"""
Rewrite the guides list in zudoku.config.tsx based on the MDX files in pages/guides/.
Rewrite the example apps list in zudoku.config.tsx based on the MDX files in pages/guides/.

It replaces everything between two sentinel comments in zudoku.config.tsx:

// AUTO-GENERATED:GUIDES-START
...
// AUTO-GENERATED:GUIDES-END

For each MDX file (except overview.mdx) the title is read from the YAML front-matter.
Entries are sorted alphabetically by title. The overview entry is always first.
Entries are sorted alphabetically by title.

Usage: update_zudoku_guides.py GUIDES_DIR ZUDOKU_CONFIG
"""
Expand All @@ -16,12 +22,10 @@

FRONT_MATTER_TITLE = re.compile(r'^title:\s*["\']?(.+?)["\']?\s*$', re.MULTILINE)

# Matches the entire items array inside the "Guides" navigation category.
# Captures the indentation of the first item so we can reproduce it.
GUIDES_ITEMS_PATTERN = re.compile(
r'(label:\s*"Guides"[^[]*items:\s*\[)' # up to and including "items: ["
r'(.*?)' # the current list content (group 2)
r'(\s*\])', # closing "]" with optional whitespace
SENTINEL_PATTERN = re.compile(
r'([ \t]*)// AUTO-GENERATED:GUIDES-START\n'
r'.*?'
r'([ \t]*)// AUTO-GENERATED:GUIDES-END',
re.DOTALL,
)

Expand All @@ -46,41 +50,36 @@ def build_items_block(guides_dir: Path, indent: str) -> str:

entries.sort(key=lambda t: t[0].casefold())

lines: list[str] = []
lines.append(f'{indent}//TODO: Please keep this list sorted by titles, not filenames !!')
lines.append(f'{indent}"/guides/overview", // Guides Overview')
for title, slug in entries:
lines.append(f'{indent}"/guides/{slug}", // {title}')

return "\n".join(lines) + "\n"
lines = [f'{indent}"/guides/{slug}", // {title}' for title, slug in entries]
return "\n".join(lines)


def update_config(guides_dir: Path, config_path: Path) -> None:
content = config_path.read_text(encoding="utf-8")

match = GUIDES_ITEMS_PATTERN.search(content)
match = SENTINEL_PATTERN.search(content)
if not match:
print("❌ Could not locate the Guides items list in zudoku.config.tsx", file=sys.stderr)
print(
"❌ Could not locate AUTO-GENERATED:GUIDES-START/END sentinels "
f"in {config_path}",
file=sys.stderr,
)
sys.exit(1)

# Detect indentation from the first non-empty line inside the current block
current_block = match.group(2)
indent_match = re.search(r'\n(\s+)"/', current_block)
indent = indent_match.group(1) if indent_match else " "

indent = match.group(1)
new_block = build_items_block(guides_dir, indent)

new_content = (
content[: match.start(2)]
+ "\n"
+ new_block
+ " ]" # closing "]" with fixed indentation, replacing the captured \s*\]
+ content[match.end(3):]
content[: match.start()]
+ f'{indent}// AUTO-GENERATED:GUIDES-START\n'
+ new_block + "\n"
+ f'{indent}// AUTO-GENERATED:GUIDES-END'
+ content[match.end():]
)

config_path.write_text(new_content, encoding="utf-8")
print(f" ✅ Updated guides list in {config_path.name} "
f"({len(new_block.splitlines()) - 2} guide entries)")
f"({len(new_block.splitlines())} guide entries)")


def main() -> None:
Expand Down
223 changes: 223 additions & 0 deletions src/TopNavMenus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/// <reference types="zudoku/client" />
import { useMemo } from "react";
import type { NavigationItem } from "zudoku";
import { useZudoku } from "zudoku/hooks";
import type { LucideIcon } from "zudoku/icons";
import { Link, useHref } from "zudoku/router";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "zudoku/ui/NavigationMenu";

export type TopNavMenu = {
label: string;
icon?: LucideIcon;
/**
* Labels of top-level `navigation` items. The menu links to each one, and
* shows in the tab row where the first one was.
*/
tabs: string[];
};

type Tab = {
label: string;
icon?: LucideIcon;
to: string;
href: string;
paths: string[];
};

type ResolvedMenu = TopNavMenu & { items: Tab[]; position: number };

const isExternal = (to: string) => /^[a-z][a-z\d+.-]*:/i.test(to);

const withSlash = (path: string) => (path.startsWith("/") ? path : `/${path}`);

// Keep in step with getFirstMatchingPath() in Zudoku, because the tab row
// links each tab to this path, and the CSS finds the tab by that href.
const firstPath = (item: NavigationItem): string | undefined => {
switch (item.type) {
case "doc":
case "custom-page":
return withSlash(item.path);
case "link":
return item.to;
case "category": {
if (item.link) {
return item.link.type === "doc" ? withSlash(item.link.path) : item.link.to;
}
const pageIn = (items: NavigationItem[]): string | undefined => {
for (const child of items) {
const path =
child.type === "category" ? pageIn(child.items) : firstPath(child);
if (path) return path;
}
};
return pageIn(item.items);
}
default:
return undefined;
}
};

const allPaths = (item: NavigationItem): string[] => {
switch (item.type) {
case "doc":
case "custom-page":
return [withSlash(item.path)];
case "link":
return isExternal(item.to) ? [] : [item.to];
case "category":
return [
...(item.link ? [firstPath({ ...item, items: [] })!] : []),
...item.items.flatMap(allPaths),
];
default:
return [];
}
};

// A link item such as the Platform API also owns the pages under its path.
const isOn = (pathname: string, paths: string[]) =>
paths.some((path) => pathname === path || pathname.startsWith(`${path}/`));

const tabSelector = (href: string) =>
`div:has(> [data-top-nav-menu]) > nav:not([data-top-nav-menu]) li:has(> a[href=${JSON.stringify(href)}])`;


const buildCss = (menus: ResolvedMenu[]) => {
const rules = [
`div:has(> [data-top-nav-menu]) { justify-content: flex-start; column-gap: 2rem; }`,
`div:has(> [data-top-nav-menu]) > nav:not([data-top-nav-menu]),
div:has(> [data-top-nav-menu]) > nav:not([data-top-nav-menu]) > ul { display: contents; }`,
`div:has(> [data-top-nav-menu]) > :not(nav) { order: 9999; margin-inline-start: auto; }`,
`.top-nav-menu-trigger[data-active]::after {
content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 2px;
background: var(--color-primary);
}`,
];

menus.forEach((menu, index) => {
const first = tabSelector(menu.items[0]!.href);
rules.push(`${first}, ${first} ~ li { order: ${2 * (index + 1)}; }`);
rules.push(
`${menu.items.map((tab) => tabSelector(tab.href)).join(",\n")} { display: none; }`,
);
});

return rules.join("\n");
};

/**
* Groups top-level navigation tabs into drop-down menus in the desktop tab row.
* Use the return value as the "top-navigation-side" slot.
*
* The tabs stay in `navigation`, because they give their pages a sidebar and
* they are the entries in the mobile menu. Only the desktop tab row hides them.
*/
export const topNavMenus = (menus: TopNavMenu[]) => {
const TopNavMenus = ({ location }: { location: { pathname: string } }) => {
const { options } = useZudoku();
const base = useHref("/").replace(/\/$/, "");

const resolved = useMemo(() => {
const navigation = options.navigation ?? [];

return menus
.flatMap((menu): ResolvedMenu[] => {
const items = menu.tabs.flatMap((label): Tab[] => {
const item = navigation.find((entry) => entry.label === label);
const to = item && firstPath(item);
if (!item || !to) {
if (import.meta.env.DEV) {
console.warn(
`[topNavMenus] "${menu.label}": no top-level navigation item has the label "${label}".`,
);
}
return [];
}
const icon = "icon" in item ? item.icon : undefined;
return [
{
label,
icon: typeof icon === "string" ? undefined : icon,
to,
href: isExternal(to) ? to : `${base}${to}`,
paths: allPaths(item),
},
];
});
if (items.length === 0) return [];

const position = navigation.findIndex(
(entry) => entry.label === items[0]!.label,
);
return [{ ...menu, items, position }];
})
.sort((a, b) => a.position - b.position);
}, [options.navigation, base]);

const css = useMemo(() => buildCss(resolved), [resolved]);

return (
<>
<style dangerouslySetInnerHTML={{ __html: css }} />
{resolved.map((menu, index) => (
<NavigationMenu
key={menu.label}
data-top-nav-menu=""
className="flex-none"
style={{ order: 2 * index + 1 }}
>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger
data-active={
menu.items.some((tab) => isOn(location.pathname, tab.paths)) ||
undefined
}
className="top-nav-menu-trigger gap-2 [&>svg:last-child]:ml-0 h-auto rounded-none bg-transparent px-0 py-3.5 -mb-px relative font-medium text-foreground/75 hover:bg-transparent hover:text-foreground focus:bg-transparent data-[state=open]:bg-transparent data-[state=open]:hover:bg-transparent data-[state=open]:focus:bg-transparent data-[state=open]:text-foreground data-active:text-foreground"
>
{menu.icon && (
<menu.icon size={16} className="align-[-0.125em]" />
)}
{menu.label}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="flex w-max min-w-[200px] flex-col gap-1 p-1">
{menu.items.map((tab) => (
<li key={tab.label}>
{/* The classes go on NavigationMenuLink, not on Link, so
that cn() replaces its default "flex-col". */}
<NavigationMenuLink
asChild
className="flex-row items-center justify-start gap-2 select-none rounded-md p-3 text-sm font-medium leading-none no-underline"
>
<Link to={tab.to}>
{tab.icon && (
<tab.icon
size={16}
className="shrink-0 text-muted-foreground"
/>
)}
{tab.label}
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
))}
</>
);
};

return TopNavMenus;
};
Loading
Loading