From aff1c20831bc70fbe37d821640629f5f1c6ee339 Mon Sep 17 00:00:00 2001 From: JMG Studios <214383483+JMGstudios@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:10:45 +0200 Subject: [PATCH 1/4] Updated developing guide to a fully working setup --- .../hydrodactyl/local-development/lerd.mdx | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/content/docs/hydrodactyl/local-development/lerd.mdx b/content/docs/hydrodactyl/local-development/lerd.mdx index a4bc472..eaa3496 100644 --- a/content/docs/hydrodactyl/local-development/lerd.mdx +++ b/content/docs/hydrodactyl/local-development/lerd.mdx @@ -5,12 +5,8 @@ description: Setup a local development environment using Lerd - The dev env currently is not 100% automated, some manual setup is required for the wings daemon + The dev env currently is not 100% automated, some manual setup is required for the wings daemon. See "installing wings" - - These docs are not finished yet, if you find something to fix or improve, make a pr! - - ## Prerequisites @@ -19,7 +15,6 @@ description: Setup a local development environment using Lerd - [pnpm](https://pnpm.io/) ## Initial setup - Note: Running a development environment on the same host as a production environment is not supported and will result in an error when connecting to port 80. @@ -28,11 +23,64 @@ description: Setup a local development environment using Lerd `git clone https://github.com/BlueprintFramework/hydrodactyl.git` 1. Cd into the Hydrodactyl directory + `cd hydrodactyl` + + 1. Run the dev env `pnpm dev:setup` 1. Access Hydrodactyl at your new url [`http://hydrodactyl.test`](http://hydrodactyl.test) + + Your basic dev setup is now completed and you can login with **username** 'dev' and **password** 'dev'. Follow the guide below to setup wings and install a server. + + +## Installing wings + +1. Follow along [`https://pterodactyl.io/wings/1.0/installing.html`](https://pterodactyl.io/wings/1.0/installing.html) to install wings on your local machine. + +1. As we are using https, we have to install local certificates: + + ```bash + mkcert hydrodactyl.test + ``` + +1. The certificates have been created in your home directory. + Create the certificate directory: + + ```bash + mkdir -p /etc/pterodactyl/certs + ``` + +1. Copy the certificates to the certs directory: + + ```bash + cp ./hydrodactyl.test.pem \ + /etc/pterodactyl/certs/fullchain.pem + + cp ./hydrodactyl.test-key.pem \ + /etc/pterodactyl/certs/privkey.pem + ``` + +1. Run ```nano /etc/pterodactyl/config.yml```, search and enter the correct certificate paths: + + ```yaml + ssl: + enabled: true + cert: /etc/pterodactyl/certs/fullchain.pem + key: /etc/pterodactyl/certs/privkey.pem + ``` +1. Start wings: + + ```bash + wings --debug + ``` + +1. Now you can add nodes and servers and start developing! + + + The error "Could not communicate with the machine running this server" means that Hydrodactyl has issues communicating with Wings. Check your Wings setup again! + \ No newline at end of file From eb4739f3d8b5ad52a22940fe594b72549ac041c2 Mon Sep 17 00:00:00 2001 From: JMG Studios <214383483+JMGstudios@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:17:54 +0200 Subject: [PATCH 2/4] Added an automatically updating changelog --- content/docs/hydrodactyl/changelog/index.mdx | 10 + content/docs/hydrodactyl/changelog/meta.json | 4 + .../{changelog.mdx => changelog/v5.mdx} | 4 +- src/components/ui/Changelog.tsx | 478 ++++++++++++++++++ 4 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 content/docs/hydrodactyl/changelog/index.mdx create mode 100644 content/docs/hydrodactyl/changelog/meta.json rename content/docs/hydrodactyl/{changelog.mdx => changelog/v5.mdx} (98%) create mode 100644 src/components/ui/Changelog.tsx diff --git a/content/docs/hydrodactyl/changelog/index.mdx b/content/docs/hydrodactyl/changelog/index.mdx new file mode 100644 index 0000000..a4e6ac4 --- /dev/null +++ b/content/docs/hydrodactyl/changelog/index.mdx @@ -0,0 +1,10 @@ +--- +title: Changelog +description: Hydrodactyl Changelog +--- + +import Changelog from '@/components/ui/Changelog'; + +## Hydrodactyl Release Notes + + diff --git a/content/docs/hydrodactyl/changelog/meta.json b/content/docs/hydrodactyl/changelog/meta.json new file mode 100644 index 0000000..0a4eb57 --- /dev/null +++ b/content/docs/hydrodactyl/changelog/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Changelog", + "pages": ["index"] +} diff --git a/content/docs/hydrodactyl/changelog.mdx b/content/docs/hydrodactyl/changelog/v5.mdx similarity index 98% rename from content/docs/hydrodactyl/changelog.mdx rename to content/docs/hydrodactyl/changelog/v5.mdx index 2ed30f1..9d32335 100644 --- a/content/docs/hydrodactyl/changelog.mdx +++ b/content/docs/hydrodactyl/changelog/v5.mdx @@ -1,6 +1,6 @@ --- -title: Changelog -description: Hydrodactyl Changelog +title: Changelog (v5 and earlier) +description: Hydrodactyl Changelog for v5 and earlier releases --- This file is a running track of new features and fixes to each version of the panel released starting with `v4.0.0`. diff --git a/src/components/ui/Changelog.tsx b/src/components/ui/Changelog.tsx new file mode 100644 index 0000000..813119c --- /dev/null +++ b/src/components/ui/Changelog.tsx @@ -0,0 +1,478 @@ +'use client'; + +import Link from 'next/link'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; + +const PAGE_SIZE = 10; // this defines how many releases will be loaded at once +const RELEASES_URL = + 'https://api.github.com/repos/blueprintframework/hydrodactyl/releases'; //should be clear what this is + +type ReleaseSummary = { + tagName: string; + publishedAt: string | null; + body: string; +}; + +type Block = + | { type: 'heading'; level: number; text: string } + | { type: 'list'; ordered: boolean; items: string[] } + | { type: 'paragraph'; text: string }; + +function parseVersion(tagName: string) { //function to parse the version number + return tagName + .replace(/^v/i, '') //remove the 'v' in front + .split(/[.-]/) //split the version number (1.1.1 to 1 1 1) + .slice(0, 3) //only use the top three numbers ('1 1 1 beta' to '1 1 1') + .map((p) => parseInt(p, 10) || 0); +} + +function isV6AndLater(tagName: string) { //sort out versions lower than 6.0.0 (if you go that far w/ pagination) + const [major = 0] = parseVersion(tagName); + return major >= 6; +} + +function parseBlocks(body: string): Block[] { + const blocks: Block[] = []; + const lines = body.split(/\r?\n/); //split text on newline + + let paragraphLines: string[] = []; + let listItems: string[] = []; + let listOrdered = false; + + const flushParagraph = () => { //save the paragraph and flush buffer + if (paragraphLines.length) { + blocks.push({ + type: 'paragraph', + text: paragraphLines.join(' '), + }); + paragraphLines = []; + } + }; + + const flushList = () => { //save the list and flush buffer + if (listItems.length) { + blocks.push({ + type: 'list', + ordered: listOrdered, + items: listItems, + }); + + listItems = []; + listOrdered = false; + } + }; + + for (const rawLine of lines) { + const line = rawLine.trim(); //remove empty spaces or wtv these are called ' ' + + /* + *An empty line marks the end of a paragraph/list! + */ + if (!line) { //check if the line is empty + flushParagraph(); + flushList(); + continue; + } //save the paragraph/list, done. + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); //check if the line is a heading (starts with #) + + if (headingMatch) { + flushParagraph(); + flushList(); //save preceded paragraphs/lists + + blocks.push({ //save the heading + type: 'heading', + level: headingMatch[1].length, //aka 1 - 6 # + text: headingMatch[2].trim(), //aka the content of the heading + }); + + continue; + } + + const bulletMatch = line.match(/^[-*+]\s+(.*)$/); //detect markdown lists with -, * and + (unordered) + const orderedMatch = line.match(/^\d+\.\s+(.*)$/); //detect numeric lists (ordered) + + if (bulletMatch || orderedMatch) { + flushParagraph(); //save preceded paragraphs + + if (!listItems.length) { + listOrdered = Boolean(orderedMatch); //remember if the list is ordered or unordered (via the first item) + } + + listItems.push((bulletMatch ?? orderedMatch)![1].trim()); /* + *1. Take bulletMatch if existing, else take orderedMatch + *2. Take the content of the list item + *3. Remove empty spaces at the start and end + */ + + continue; + } + + flushList(); /*if the line is none of the previous cases: + *- Save any potential lists and*/ + paragraphLines.push(line); //- Add the line to the current paragraph buffer + } + + flushParagraph(); //obviously still have to save the paragraph/list if nothing special happened and they are not ended through an empty line + flushList(); + + return blocks; +} + +function linkProps(href: string) { //helper to display and open links properly + return { + href, + target: '_blank' as const, + rel: 'noreferrer noopener', + className: 'text-fd-accent hover:underline break-all', + }; +} + +function shortenRepoReference(url: string): string | null { //helper to display long links as simple issue or PR hashtags, as you're used to from Github + const match = url.match( + /^https?:\/\/github\.com\/BlueprintFramework\/hydrodactyl\/(?:pull|issues)\/(\d+)(?:\/.*)?(?:[?#].*)?$/i + ); + + return match ? `#${match[1]}` : null; +} + +function linkLabel(url: string, explicitLabel?: string) { //helper to display links + if (explicitLabel && explicitLabel !== url) { + return explicitLabel; + } + + return shortenRepoReference(url) ?? explicitLabel ?? url; +} + +function splitTrailingPunctuation(url: string) { //helper to decide between the actual link and trailing punctuation (a '.' after the link) + const match = url.match(/^(.*?)([.,;:!?)\]}>]+)$/); + + if (!match) { + return { url, trailing: '' }; //if no trailing was found, return the url back and obv an empty trailing + } + + let cleaned = match[1]; + let trailing = match[2]; + + while (trailing.startsWith(')') && cleaned.includes('(')) { //keep balanced trailing ) if an opening ( exists in the URL + const open = (cleaned.match(/\(/g) ?? []).length; + const close = (cleaned.match(/\)/g) ?? []).length; + + if (open > close) { + cleaned += ')'; + trailing = trailing.slice(1); + } else { + break; + } + } + + return { url: cleaned, trailing }; +} + + +//==========================================MARKDOWN RENDERER===================================================================== +function renderInline( + text: string, + keyPrefix: string +): ReactNode[] { + const nodes: ReactNode[] = []; + + const regex = + //searching for a bunch of stuff that matters for inline rendering + /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*|@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\[[^\]]+\])?)|(https?:\/\/[^\s<]+)/g; + + let lastIndex = 0; + let match: RegExpExecArray | null; + let i = 0; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + if (match[1]) { + nodes.push( + + {linkLabel(match[2], match[1])} + + ); + } else if (match[3]) { + nodes.push( + + {match[3]} + + ); + } else if (match[4]) { + nodes.push( + + {match[4]} + + ); + } else if (match[5]) { + nodes.push( + + {match[5]} + + ); + } else if (match[0].startsWith('@')) { + const username = match[0].slice(1).replace(/\[bot\]$/i, ''); + + nodes.push( + + {match[0]} + + ); + } else if (match[6]) { + const { url, trailing } = splitTrailingPunctuation(match[6]); + + nodes.push( + + {linkLabel(url)} + + ); + + if (trailing) { + nodes.push(trailing); + } + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes; +} + +function renderBlocks(blocks: Block[]): ReactNode { + return blocks.map((block, index) => { + const key = `block-${index}`; + + if (block.type === 'heading') { + return ( +

+ {renderInline(block.text, key)} +

+ ); + } + + if (block.type === 'list') { + const ListTag = block.ordered ? 'ol' : 'ul'; + + return ( + + {block.items.map((item, i) => ( +
  • + {renderInline(item, `${key}-${i}`)} +
  • + ))} +
    + ); + } + + return ( +

    + {renderInline(block.text, key)} +

    + ); + }); +} +//==========================================END OF MARKDOWN RENDERER===================================================================== + +function mapReleases( + data: Array> +): ReleaseSummary[] { + return data + .filter( + (release) => typeof release.tag_name === 'string' //only using valid tag names + ) + .filter((release) => isV6AndLater(release.tag_name)) //only using releases above v6.0.0 + .map((release) => ({ + tagName: release.tag_name, + publishedAt: release.published_at ?? null, + body: release.body ?? '', //mapping GitHub's values to proper standardized values + })) + .sort((a, b) => { //sort releases by date + const aDate = a.publishedAt + ? Date.parse(a.publishedAt) + : 0; + + const bDate = b.publishedAt + ? Date.parse(b.publishedAt) + : 0; + + return bDate - aDate; + }); +} + +export default function Changelog() { + const [releases, setReleases] = useState([]); //save all loaded releases + const [page, setPage] = useState(1); //check which GitHub page is being loaded + const [hasMore, setHasMore] = useState(false); //decide if the pages button should be displayed + const [status, setStatus] = + useState<'idle' | 'loading' | 'loading-more' | 'error'>('idle'); //different cases + + const fetchPage = useCallback(async (nextPage: number, append: boolean) => { //fetching the page from github + setStatus(append ? 'loading-more' : 'loading'); + + const res = await fetch( + `${RELEASES_URL}?per_page=${PAGE_SIZE}&page=${nextPage}`, //API request + { + headers: { + Accept: 'application/vnd.github+json', + }, + } + ); + + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status}`); //resolve errors + } + + const data: Array> = await res.json(); //save the data + const parsed = mapReleases(data); //map the releases (surprise) + + setReleases((prev) => (append ? [...prev, ...parsed] : parsed)); //save the releases in proper order + setPage(nextPage); + setHasMore(data.length === PAGE_SIZE); + setStatus('idle'); + }, []); + + useEffect(() => { + let active = true; + + fetchPage(1, false).catch(() => { //initial page fetching + if (!active) return; + setStatus('error'); + }); + + return () => { + active = false; + }; + }, [fetchPage]); + + const loadMore = () => { //this is the "Load More" button at the bottom + if (status !== 'idle' || !hasMore) return; + + fetchPage(page + 1, true).catch(() => { + setStatus('error'); + }); + }; + + return ( +
    +
    +

    + Hydrodactyl Release Notes (v6+) +

    + +

    + View our latest releases! +

    +
    + + {status === 'loading' ? ( +
    + Fetching data... +
    + ) : status === 'error' && !releases.length ? ( +
    + Failed to load release notes. +
    + ) : ( +
    + {releases.map((release) => ( //one article for each release +
    +
    +

    + {release.publishedAt + ? new Date( + release.publishedAt + ).toLocaleDateString() + : 'Unknown date'} +

    + +

    + {release.tagName} +

    +
    + +
    + {release.body ? ( + renderBlocks( //all the markdown stuff + parseBlocks(release.body) + ) + ) : ( +

    + No details available. +

    + )} +
    +
    + ))} + + {!releases.length && status === 'idle' ? ( +
    + Error: No releases found. +
    + ) : null} + +
    //da button + {hasMore ? ( + + ) : null} + + + Older releases (before v6) + +
    + + {status === 'error' ? ( +

    + Failed to load more releases. +

    + ) : null} +
    + )} +
    + ); +} From c1749db8649c6eec986f774c34f3a5925ac23996 Mon Sep 17 00:00:00 2001 From: Naterfute <106989409+naterfute@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:51:11 -0700 Subject: [PATCH 3/4] fix(changelog): "Da Button" --- src/components/ui/Changelog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/Changelog.tsx b/src/components/ui/Changelog.tsx index 813119c..a748bcc 100644 --- a/src/components/ui/Changelog.tsx +++ b/src/components/ui/Changelog.tsx @@ -444,7 +444,7 @@ export default function Changelog() { ) : null} -
    //da button +
    {hasMore ? ( - ) : null} - - - Older releases (before v6) - -
    - - {status === 'error' ? ( -

    - Failed to load more releases. -

    - ) : null} -
    - )} - - ); +export default async function Changelog() { + const releases = await fetchAllReleases(); + return ; } diff --git a/src/components/ui/changelog/ChangelogList.tsx b/src/components/ui/changelog/ChangelogList.tsx new file mode 100644 index 0000000..63a70fc --- /dev/null +++ b/src/components/ui/changelog/ChangelogList.tsx @@ -0,0 +1,58 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { parseBlocks, renderBlocks } from './markdown'; +import type { ReleaseSummary } from '@/lib/github-releases'; + + +export default function ChangelogList({ releases }: { releases: ReleaseSummary[] }) { + + const visible = releases; + + return ( +
    +
    +

    Hydrodactyl Release Notes (v6+)

    +

    View our latest releases!

    +
    + + {visible.length ? ( +
    + {visible.map((release) => ( +
    +
    +

    + {release.publishedAt ? new Date(release.publishedAt).toLocaleDateString() : 'Unknown date'} +

    +

    {release.tagName}

    +
    + +
    + {release.body ? ( + renderBlocks(parseBlocks(release.body)) + ) : ( +

    No details available.

    + )} +
    +
    + ))} + +
    + + Older releases (before v6) + +
    +
    + ) : ( +
    + No releases found. +
    + ) + } +
    + ); +} diff --git a/src/components/ui/changelog/changelog-toc.ts b/src/components/ui/changelog/changelog-toc.ts new file mode 100644 index 0000000..b7e0f6b --- /dev/null +++ b/src/components/ui/changelog/changelog-toc.ts @@ -0,0 +1,44 @@ +import { parseBlocks, type Block } from '@/components/ui/changelog/markdown'; +import type { ReleaseSummary } from './github-releases'; + +export type TocEntry = { title: string; url: string; depth: number }; +export type AnnotatedRelease = { + release: ReleaseSummary; + id: string; + blocks: Array; +}; + +function slugify(text: string) { + return ( + text.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-') || + 'section' + ); +} + +export function buildChangelogToc(releases: ReleaseSummary[]) { + const usedIds = new Set(); + const uniqueId = (raw: string) => { + let id = slugify(raw); + let n = 1; + while (usedIds.has(id)) id = `${slugify(raw)}-${n++}`; + usedIds.add(id); + return id; + }; + + const toc: TocEntry[] = []; + const annotated: AnnotatedRelease[] = releases.map((release) => { + const releaseId = uniqueId(release.tagName); + toc.push({ title: release.tagName, url: `#${releaseId}`, depth: 2 }); + + const blocks = parseBlocks(release.body).map((block) => { + if (block.type !== 'heading') return block; + const id = uniqueId(`${release.tagName}-${block.text}`); + toc.push({ title: block.text, url: `#${id}`, depth: Math.min(block.level + 2, 6) }); + return { ...block, id }; + }); + + return { release, id: releaseId, blocks }; + }); + + return { annotated, toc }; +} diff --git a/src/components/ui/changelog/markdown.tsx b/src/components/ui/changelog/markdown.tsx new file mode 100644 index 0000000..c053372 --- /dev/null +++ b/src/components/ui/changelog/markdown.tsx @@ -0,0 +1,217 @@ +import type { ReactNode } from 'react'; + +export type Block = + | { type: 'heading'; level: number; text: string } + | { type: 'list'; ordered: boolean; items: string[] } + | { type: 'paragraph'; text: string }; + +export function parseBlocks(body: string): Block[] { + const blocks: Block[] = []; + const lines = body.split(/\r?\n/); + + let paragraphLines: string[] = []; + let listItems: string[] = []; + let listOrdered = false; + + const flushParagraph = () => { + if (paragraphLines.length) { + blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') }); + paragraphLines = []; + } + }; + + const flushList = () => { + if (listItems.length) { + blocks.push({ type: 'list', ordered: listOrdered, items: listItems }); + listItems = []; + listOrdered = false; + } + }; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (!line) { + flushParagraph(); + flushList(); + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + flushParagraph(); + flushList(); + blocks.push({ + type: 'heading', + level: headingMatch[1].length, + text: headingMatch[2].trim(), + }); + continue; + } + + const bulletMatch = line.match(/^[-*+]\s+(.*)$/); + const orderedMatch = line.match(/^\d+\.\s+(.*)$/); + + if (bulletMatch || orderedMatch) { + flushParagraph(); + if (!listItems.length) { + listOrdered = Boolean(orderedMatch); + } + listItems.push((bulletMatch ?? orderedMatch)![1].trim()); + continue; + } + + flushList(); + paragraphLines.push(line); + } + + flushParagraph(); + flushList(); + + return blocks; +} + +function linkProps(href: string) { + return { + href, + target: '_blank' as const, + rel: 'noreferrer noopener', + className: 'text-fd-accent hover:underline break-all', + }; +} + +function shortenRepoReference(url: string): string | null { + const match = url.match( + /^https?:\/\/github\.com\/BlueprintFramework\/hydrodactyl\/(?:pull|issues)\/(\d+)(?:\/.*)?(?:[?#].*)?$/i + ); + return match ? `#${match[1]}` : null; +} + +function linkLabel(url: string, explicitLabel?: string) { + if (explicitLabel && explicitLabel !== url) { + return explicitLabel; + } + return shortenRepoReference(url) ?? explicitLabel ?? url; +} + +function splitTrailingPunctuation(url: string) { + const match = url.match(/^(.*?)([.,;:!?)\]}>]+)$/); + if (!match) { + return { url, trailing: '' }; + } + + let cleaned = match[1]; + let trailing = match[2]; + + while (trailing.startsWith(')') && cleaned.includes('(')) { + const open = (cleaned.match(/\(/g) ?? []).length; + const close = (cleaned.match(/\)/g) ?? []).length; + if (open > close) { + cleaned += ')'; + trailing = trailing.slice(1); + } else { + break; + } + } + + return { url: cleaned, trailing }; +} + +function renderInline(text: string, keyPrefix: string): ReactNode[] { + const nodes: ReactNode[] = []; + const regex = + /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*|@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\[[^\]]+\])?)|(https?:\/\/[^\s<]+)/g; + + let lastIndex = 0; + let match: RegExpExecArray | null; + let i = 0; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + if (match[1]) { + nodes.push( + + {linkLabel(match[2], match[1])} + + ); + } else if (match[3]) { + nodes.push({match[3]}); + } else if (match[4]) { + nodes.push( + + {match[4]} + + ); + } else if (match[5]) { + nodes.push({match[5]}); + } else if (match[0].startsWith('@')) { + const username = match[0].slice(1).replace(/\[bot\]$/i, ''); + nodes.push( + + {match[0]} + + ); + } else if (match[6]) { + const { url, trailing } = splitTrailingPunctuation(match[6]); + nodes.push( + + {linkLabel(url)} + + ); + if (trailing) { + nodes.push(trailing); + } + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes; +} + +export function renderBlocks(blocks: Array): ReactNode { + return blocks.map((block, index) => { + const key = `block-${index}`; + + if (block.type === 'heading') { + const level = Math.min(Math.max(block.level, 2), 6); + const Tag = `h${level}` as keyof JSX.IntrinsicElements; + return ( + + {renderInline(block.text, key)} + + ); + } + + if (block.type === 'list') { + const ListTag = block.ordered ? 'ol' : 'ul'; + return ( + + {block.items.map((item, i) => ( +
  • {renderInline(item, `${key}-${i}`)}
  • + ))} +
    + ); + } + + return ( +

    + {renderInline(block.text, key)} +

    + ); + }); +} diff --git a/src/lib/github-releases.ts b/src/lib/github-releases.ts new file mode 100644 index 0000000..6cbb597 --- /dev/null +++ b/src/lib/github-releases.ts @@ -0,0 +1,71 @@ +const OWNER = 'blueprintframework'; +const REPO = 'hydrodactyl'; + +export type ReleaseSummary = { + tagName: string; + publishedAt: string | null; + body: string; +}; + +function parseVersion(tagName: string) { + return tagName + .replace(/^v/i, '') + .split(/[.-]/) + .slice(0, 3) + .map((p) => parseInt(p, 10) || 0); +} + +function isV6AndLater(tagName: string) { + const [major = 0] = parseVersion(tagName); + return major >= 6; +} + +function mapReleases(data: Array>): ReleaseSummary[] { + return data + .filter((release) => typeof release.tag_name === 'string') + .filter((release) => isV6AndLater(release.tag_name)) + .map((release) => ({ + tagName: release.tag_name, + publishedAt: release.published_at ?? null, + body: release.body ?? '', + })); +} + +// Fetches every release page-by-page until GitHub returns a short page +// (i.e. we've hit the end). This only ever runs during `next build`, +// on your CI runner — never in a visitor's browser. +export async function fetchAllReleases(): Promise { + const all: ReleaseSummary[] = []; + let page = 1; + const perPage = 100; // max GitHub allows per page, minimizes request count + + while (true) { + const res = await fetch( + `https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=${perPage}&page=${page}`, + { + headers: { + Accept: 'application/vnd.github+json', + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }), + }, + } + ); + + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status}`); + } + + const data: Array> = await res.json(); + all.push(...mapReleases(data)); + + if (data.length < perPage) break; + page += 1; + } + + return all.sort((a, b) => { + const aDate = a.publishedAt ? Date.parse(a.publishedAt) : 0; + const bDate = b.publishedAt ? Date.parse(b.publishedAt) : 0; + return bDate - aDate; + }); +}