From 89a5de6260e7fc73cdbe0d0ea299dc8c1a542860 Mon Sep 17 00:00:00 2001 From: Dan Willoughby Date: Tue, 4 Jun 2024 11:24:31 -0600 Subject: [PATCH] Add blog --- .../finding-goroutine-leaks-in-tests/page.md | 403 ++++++++++++++++++ app/(blog)/blog/page.js | 139 ++++++ app/(blog)/layout.js | 17 + app/layout.js | 15 +- next.config.mjs | 1 + package-lock.json | 76 ++++ package.json | 1 + public/images/misc_logos/hackernews.svg | 1 + public/images/misc_logos/indie-hackers.svg | 9 + src/components/BlogLayout.jsx | 109 +++++ src/components/MarkdownLayout.jsx | 5 +- src/components/Share.js | 91 ++++ src/components/Spaces.jsx | 1 + src/components/TableOfContents.jsx | 4 +- src/components/bio.js | 30 ++ src/markdoc/navigation.mjs | 8 +- src/markdoc/nodes.js | 9 + src/markdoc/search.mjs | 8 +- src/markdoc/tags.js | 4 + tailwind.config.js | 1 + 20 files changed, 909 insertions(+), 23 deletions(-) create mode 100644 app/(blog)/blog/finding-goroutine-leaks-in-tests/page.md create mode 100644 app/(blog)/blog/page.js create mode 100644 app/(blog)/layout.js create mode 100644 public/images/misc_logos/hackernews.svg create mode 100644 public/images/misc_logos/indie-hackers.svg create mode 100644 src/components/BlogLayout.jsx create mode 100644 src/components/Share.js create mode 100644 src/components/bio.js diff --git a/app/(blog)/blog/finding-goroutine-leaks-in-tests/page.md b/app/(blog)/blog/finding-goroutine-leaks-in-tests/page.md new file mode 100644 index 000000000..fdae92417 --- /dev/null +++ b/app/(blog)/blog/finding-goroutine-leaks-in-tests/page.md @@ -0,0 +1,403 @@ +--- +layout: blog +title: Finding Goroutine Leaks in Tests +date: 2022-03-07 +author: + name: Egon Elbre + title: Software Engineer +hackernews: https://news.ycombinator.com/item?id=30610952 +metadata: + title: Automated Tracking of Goroutine Leaks in Go Testing + description: + A comprehensive guide on finding and tracking resource leaks (e.g., + file or connection leakages) in Go testing by automating the process using runtime + callers for efficient resource management. +--- + +Forgetting to close a file, a connection, or some other resource is a rather common issue in Go. Usually you can spot them with good code review practices, but what if you wanted to automate it and you don't have a suitable linter at hand? + +How do we track and figure out those leaks? + +Fortunately, there's an approach to finding common resource leaks that we’ll explore below. + +## Problem: Connection Leak + +Let's take a simple example that involves a TCP client. Of course, it applies to other protocols, such as GRPC, database, or HTTP. We'll omit the communication implementation because it's irrelevant to the problem. + +```go +type Client struct { + conn net.Conn +} + +func Dial(ctx context.Context, address string) (*Client, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) + if err != nil { + return nil, fmt.Errorf("failed to dial: %w", err) + } + + return &Client{conn: conn}, nil +} + +func (client *Client) Close() error { + return client.conn.Close() +} +``` + +It's easy to put the defer in the wrong place or forget to call Close altogether. + +```go +func ExampleDial(ctx context.Context) error { + source, err := Dial(ctx, "127.0.0.1:1000") + if err != nil { + return err + } + + destination, err := Dial(ctx, "127.0.0.1:1001") + if err != nil { + return err + } + + defer source.Close() + defer destination.Close() + + data, err := source.Recv(ctx) + if err != nil { + return fmt.Errorf("recv failed: %w", err) + } + + err = destination.Send(ctx, data) + if err != nil { + return fmt.Errorf("send failed: %w", err) + } + + return nil +} +``` + +Notice if we fail to dial the second client, we have forgotten to close the source connection. + +## Problem: File Leak + +Another common resource management mistake is a file leak. + +```go +func ExampleFile(ctx context.Context, fs fs.FS) error { + file, err := fs.Open("data.csv") + if err != nil { + return fmt.Errorf("open failed: %w", err) + } + + stat, err := fs.Stat() + if err != nil { + return fmt.Errorf("stat failed: %w", err) + } + + fmt.Println(stat.Name()) + + _ = file.Close() + return nil +} +``` + +## Tracking Resources + +How do we track and figure out those leaks? One thing we can do is to keep track of every single open file and connection and ensure that everything is closed when the tests finish. + +We need to build something that keeps a list of all open things and tracks where we started using a resource. + +To figure out where our "leak" comes from, we can use [`runtime.Callers`](https://pkg.go.dev/runtime#Callers). You can look at the [Frames example](https://pkg.go.dev/runtime#example-Frames) to learn how to use it. Let's call the struct we use to hold this information a `Tag`. + +```go +// Tag is used to keep track of things we consider open. +type Tag struct { + owner *Tracker // we'll explain this below + caller [5]uintptr +} + +// newTag creates a new tracking tag. +func newTag(owner *Tracker, skip int) *Tag { + tag := &Tag{owner: owner} + // highlight + runtime.Callers(skip+1, tag.caller[:]) + return tag +} + +// String converts a caller frames to a string. +func (tag *Tag) String() string { + var s strings.Builder + // highlight + frames := runtime.CallersFrames(tag.caller[:]) + for { + frame, more := frames.Next() + if strings.Contains(frame.File, "runtime/") { + break + } + fmt.Fprintf(&s, "%s\n", frame.Function) + fmt.Fprintf(&s, "\t%s:%d\n", frame.File, frame.Line) + if !more { + break + } + } + return s.String() +} + +// Close marks the tag as being properly deallocated. +func (tag *Tag) Close() { + tag.owner.Remove(tag) +} +``` + +Of course, we need something to keep the list of all open trackers: + +```go +// Tracker keeps track of all open tags. +type Tracker struct { + mu sync.Mutex + closed bool + open map[*Tag]struct{} +} + +// NewTracker creates an empty tracker. +func NewTracker() *Tracker { + return &Tracker{open: map[*Tag]struct{}{}} +} + +// Create creates a new tag, which needs to be closed. +func (tracker *Tracker) Create() *Tag { + tag := newTag(tracker, 2) + + tracker.mu.Lock() + defer tracker.mu.Unlock() + + // We don't want to allow creating a new tag, when we stopped tracking. + if tracker.closed { + panic("creating a tag after tracker has been closed") + } + tracker.open[tag] = struct{}{} + + return tag +} + +// Remove stops tracking tag. +func (tracker *Tracker) Remove(tag *Tag) { + tracker.mu.Lock() + defer tracker.mu.Unlock() + delete(tracker.open, tag) +} + +// Close checks that none of the tags are still open. +func (tracker *Tracker) Close() error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + + tracker.closed = true + if len(tracker.open) > 0 { + return errors.New(tracker.openResources()) + } + return nil +} + +// openResources returns a string describing all the open resources. +func (tracker *Tracker) openResources() string { + var s strings.Builder + fmt.Fprintf(&s, "%d open resources\n", len(tracker.open)) + + for tag := range tracker.open { + fmt.Fprintf(&s, "---\n%s\n", tag) + } + + return s.String() +} +``` + +Let's look at how it works: + +```go +func TestTracker(t *testing.T) { + tracker := NewTracker() + defer func() { + if err := tracker.Close(); err != nil { + t.Fatal(err) + } + }() + + tag := tracker.Create() + // if we forget to call Close, then the test fails. + // tag.Close() +} +``` + +You can test it over at . + +## Hooking up the tracker to a `fs.FS` + +We need to integrate it into the initially problematic code. We can create a wrapper for `fs.FS` that creates a tag for each opened file. + +```go +type TrackedFS struct { + tracker *Tracker + fs fs.FS +} + +func TrackFS(fs fs.FS) *TrackedFS { + return &TrackedFS{ + tracker: NewTracker(), + fs: fs, + } +} + +func (fs *TrackedFS) Open(name string) (fs.File, error) { + file, err := fs.fs.Open(name) + if err != nil { + return file, err + } + + tag := fs.tracker.Create() + return &trackedFile{ + File: file, + tag: tag, + }, nil +} + +func (fs *TrackedFS) Close() error { return fs.tracker.Close() } + +type trackedFile struct { + fs.File + tag *Tag +} + +func (file *trackedFile) Close() error { + file.tag.Close() + return file.File.Close() +} +``` + +Finally, we can use this wrapper in a test and get some actual issues resolved: + +```go +func TestFS(t *testing.T) { + // We'll use `fstest` package here, but you can also replace this with + // `os.DirFS` or similar. + dir := fstest.MapFS{ + "data.csv": &fstest.MapFile{Data: []byte("hello")}, + } + + fs := TrackFS(dir) + defer func() { + if err := fs.Close(); err != nil { + t.Fatal(err) + } + }() + + file, err := fs.Open("data.csv") + if err != nil { + t.Fatal(err) + } + + stat, err := file.Stat() + if err != nil { + t.Fatal(err) + } + + t.Log(stat.Name()) +} +``` + +You can play around with it here . + +## Hooking up the tracker via a `Context` + +Passing this `tracker` everywhere would be rather cumbersome. However, we can write some helpers to put the tracker inside a `Context`. + +```go +type trackerKey struct{} + +func WithTracker(ctx context.Context) (*Tracker, context.Context) { + tracker := NewTracker() + return tracker, context.WithValue(ctx, trackerKey{}, tracker) +} + +func TrackerFromContext(ctx context.Context) *Tracker { + value := ctx.Value(trackerKey{}) + return value.(*Tracker) +} +``` + +Of course, we need to adjust our `Client` implementation as well: + +```go +type Client struct { + conn net.Conn + tag *Tag +} + +func Dial(ctx context.Context, address string) (*Client, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) + if err != nil { + return nil, fmt.Errorf("failed to dial: %w", err) + } + + tracker := TrackerFromContext(ctx) + return &Client{conn: conn, tag: tracker.Create()}, nil +} + +func (client *Client) Close() error { + client.tag.Close() + return client.conn.Close() +} +``` + +To make our testing code even shorter, we can make a tiny helper: + +```go +func TestingTracker(ctx context.Context, tb testing.TB) context.Context { + tracker, ctx := WithTracker(ctx) + tb.Cleanup(func() { + if err := tracker.Close(); err != nil { + tb.Fatal(err) + } + }) + return ctx +} +``` + +Finally, we can put it all together: + +```go +func TestClient(t *testing.T) { + ctx := TestingTracker(context.Background(), t) + + addr := startTestServer(t) + + client, err := Dial(ctx, addr) + if err != nil { + t.Fatal(err) + } + + // if we forget to close, then the test will fail + // client.Close + _ = client +} +``` + +You can see it working over here . + +## Making it zero cost for production + +Now, all of this `runtime.Callers` calling comes with a high cost. However, we can reduce it by conditionally compiling the code. Luckily we can use tags to only compile it only for testing. I like to use the `race` tag for it because it is added any time you run your tests with `-race`. + +``` +//go:build race + +package tracker +``` + +The implementations are left as an exercise for the reader. :) + +## Conclusion + +This is probably not a final solution for your problem, but hopefully, it is a good starting point. You can add more helpers, maybe track the filename inside a `Tag`, or only print unique caller frames in the test failure. Maybe try implementing this for SQL driver and track each thing separately -- you can take a peek [at our implementation](https://github.com/storj/private/tree/main/tagsql), if you get stuck. + +May all your resource leaks be discovered. + +This is a continuation of our series of finding leaks in Golang. In case you missed it, in a previous post we covered [finding leaked goroutines](https://www.storj.io/blog/finding-goroutine-leaks-in-tests). diff --git a/app/(blog)/blog/page.js b/app/(blog)/blog/page.js new file mode 100644 index 000000000..d8db7243f --- /dev/null +++ b/app/(blog)/blog/page.js @@ -0,0 +1,139 @@ +import React from 'react' +import { Link } from 'next/link' +import * as path from 'path' +import * as fs from 'fs' +import Markdoc from '@markdoc/markdoc' +import yaml from 'js-yaml' + +function getFrontmatter(filepath) { + const md = fs.readFileSync(filepath, 'utf8') + const ast = Markdoc.parse(md) + const frontmatter = ast.attributes.frontmatter + ? yaml.load(ast.attributes.frontmatter) + : {} + return { + title: frontmatter.title || path.basename(filepath), + frontmatter, + redirects: frontmatter.redirects, + docId: frontmatter.docId, + weight: frontmatter.weight, + } +} + +function sortByDateThenTitle(arr) { + arr.sort((a, b) => { + if (a.date !== b.date) { + return new Date(b.date) - new Date(a.date) + } else { + return a.title.localeCompare(b.title) + } + }) +} + +let dir = path.resolve('./app/(blog)') +function walkDirRec(dir, space) { + let results = [] + const list = fs.readdirSync(dir) + + list.forEach((file) => { + const filepath = path.join(dir, file) + const stat = fs.statSync(filepath) + const relativePath = path.join(file) + + if (stat && stat.isDirectory()) { + let pageFilepath = path.join(filepath, 'page.md') + // For directories that don't have an page.md + let title = file.charAt(0).toUpperCase() + file.slice(1) + let fm = null + if (fs.existsSync(pageFilepath)) { + fm = getFrontmatter(pageFilepath) + } + let entry = { + type: file, + title, + } + if (fm) { + entry = Object.assign(entry, fm) + } + if (fs.existsSync(pageFilepath)) { + entry.href = `/${space}/${relativePath}` + } + results.push(entry) + } + }) + + return results +} + +let posts = walkDirRec(`${dir}/blog`, 'blog') + +export default function BlogIndex() { + return ( + <> +
+
+
+

+ Storj Engineering Blog +

+

+ Learn about the latest developments in the Storj network and the + technology that powers it. +

+
+ {posts.map((post) => { + let frontmatter = post.frontmatter + return ( + + ) + })} +
+
+
+
+ + ) +} diff --git a/app/(blog)/layout.js b/app/(blog)/layout.js new file mode 100644 index 000000000..673947553 --- /dev/null +++ b/app/(blog)/layout.js @@ -0,0 +1,17 @@ +import '@/styles/tailwind.css' + +export const metadata = { + title: { + template: '%s | Storj blog', + default: 'Storj blog', + }, + description: 'Make the world your data center', +} + +export default function RootLayout({ + // Layouts must accept a children prop. + // This will be populated with nested layouts or pages + children, +}) { + return children +} diff --git a/app/layout.js b/app/layout.js index beee82b4a..d154945ce 100644 --- a/app/layout.js +++ b/app/layout.js @@ -43,20 +43,7 @@ export default function RootLayout({ - - - -
-
-
-
-
-
- -
-
- {children} -
+ {children} diff --git a/next.config.mjs b/next.config.mjs index fe6c2cd4e..f80ed0f6f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -13,6 +13,7 @@ const nextConfig = { distDir: 'dist', env: { SITE_URL: 'https://docs.storj.io', + NEXT_PUBLIC_SITE_URL: 'https://docs.storj.io', }, experimental: { scrollRestoration: true, diff --git a/package-lock.json b/package-lock.json index 6af5e53b4..9ab0c4247 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", + "react-share": "^5.1.0", "react-youtube": "^10.1.0", "simple-functional-loader": "^1.2.1", "tailwindcss": "^3.3.3", @@ -1397,6 +1398,11 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3299,6 +3305,27 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.2.1.tgz", + "integrity": "sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==", + "dependencies": { + "debug": "^2.1.3" + } + }, + "node_modules/jsonp/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/jsonp/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -4267,6 +4294,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-share": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.1.0.tgz", + "integrity": "sha512-OvyfMtj/0UzH1wi90OdHhZVJ6WUC/+IeWvBwppeZozwIGyAjQgyR0QXlHOrxVHVECqnGvcpBaFTXVrqouTieaw==", + "dependencies": { + "classnames": "^2.3.2", + "jsonp": "^0.2.1" + }, + "peerDependencies": { + "react": "^17 || ^18" + } + }, "node_modules/react-youtube": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz", @@ -6435,6 +6474,11 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true }, + "classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -7830,6 +7874,29 @@ "minimist": "^1.2.0" } }, + "jsonp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.2.1.tgz", + "integrity": "sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==", + "requires": { + "debug": "^2.1.3" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8462,6 +8529,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-share": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.1.0.tgz", + "integrity": "sha512-OvyfMtj/0UzH1wi90OdHhZVJ6WUC/+IeWvBwppeZozwIGyAjQgyR0QXlHOrxVHVECqnGvcpBaFTXVrqouTieaw==", + "requires": { + "classnames": "^2.3.2", + "jsonp": "^0.2.1" + } + }, "react-youtube": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz", diff --git a/package.json b/package.json index c6610779b..dad031316 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", + "react-share": "^5.1.0", "react-youtube": "^10.1.0", "simple-functional-loader": "^1.2.1", "tailwindcss": "^3.3.3", diff --git a/public/images/misc_logos/hackernews.svg b/public/images/misc_logos/hackernews.svg new file mode 100644 index 000000000..f672830e0 --- /dev/null +++ b/public/images/misc_logos/hackernews.svg @@ -0,0 +1 @@ + diff --git a/public/images/misc_logos/indie-hackers.svg b/public/images/misc_logos/indie-hackers.svg new file mode 100644 index 000000000..e8172f478 --- /dev/null +++ b/public/images/misc_logos/indie-hackers.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/components/BlogLayout.jsx b/src/components/BlogLayout.jsx new file mode 100644 index 000000000..1a078856b --- /dev/null +++ b/src/components/BlogLayout.jsx @@ -0,0 +1,109 @@ +import { slugifyWithCounter } from '@sindresorhus/slugify' + +import { Prose } from '@/components/Prose' +import TableOfContents from './TableOfContents' +import { PrevNextLinks } from '@/components/PrevNextLinks' +import clsx from 'clsx' +import ShareButtons from '@/components/Share' +import Bio from '@/components/bio' + +function getNodeText(node) { + let text = '' + for (let child of node.children ?? []) { + if (child.type === 'text') { + text += child.attributes.content + } + text += getNodeText(child) + } + return text +} + +function collectHeadings(nodes, slugify = slugifyWithCounter()) { + let sections = [] + + for (let node of nodes) { + if (node.type === 'heading' && node.attributes.level <= 3) { + let title = getNodeText(node) + if (!title) { + continue + } + + let id = slugify(title) + node.attributes.id = id + if ( + sections.length > 0 && + sections[sections.length - 1].level < node.attributes.level + ) { + sections[sections.length - 1].children.push({ + ...node.attributes, + title, + }) + } else { + sections.push({ ...node.attributes, title, children: [] }) + } + } + + sections.push(...collectHeadings(node.children ?? [], slugify)) + } + + return sections +} + +export default async function DocsLayout({ children, href, frontmatter, ast }) { + let tableOfContents = collectHeadings(ast.children) + let { + title, + hideTitle, + date, + author: { name }, + } = frontmatter + return ( +
+
+
+
+ +
+
+ {title && !hideTitle && ( +

+ {title} +

+ )} + +
+ {children} + +
+ + +
+
+
+
+ +
+
+
+ ) +} diff --git a/src/components/MarkdownLayout.jsx b/src/components/MarkdownLayout.jsx index ba6852af8..049110f6a 100644 --- a/src/components/MarkdownLayout.jsx +++ b/src/components/MarkdownLayout.jsx @@ -79,7 +79,10 @@ export default function DocsLayout({
- +
) diff --git a/src/components/Share.js b/src/components/Share.js new file mode 100644 index 000000000..628c7f3fd --- /dev/null +++ b/src/components/Share.js @@ -0,0 +1,91 @@ +'use client' +import React from 'react' +import { usePathname } from 'next/navigation' + +import { + FacebookShareButton, + FacebookIcon, + LinkedinShareButton, + LinkedinIcon, + TwitterShareButton, + TwitterIcon, + RedditIcon, +} from 'react-share' + +const ShareButtons = ({ + title, + twitterHandle, + tags, + reddit, + hackernews, + indiehackers, + linkedin, +}) => { + const pathname = usePathname() + let url = `${process.env.NEXT_PUBLIC_SITE_URL}${pathname}` + + let discuss = true + if (!linkedin && !reddit && !hackernews && !indiehackers) { + discuss = false + } + + return ( +
+
+

Like this post? Share it

+ + + + + + + + + +
+ {discuss && ( +
+

Discuss on

+ +
+ {reddit && ( + + + + )} + {hackernews && ( + + hackernews link + + )} + {indiehackers && ( + + indiehackers link + + )} + {linkedin && ( + + + + )} +
+
+ )} +
+ ) +} +export default ShareButtons diff --git a/src/components/Spaces.jsx b/src/components/Spaces.jsx index c4194fef9..5080f5231 100644 --- a/src/components/Spaces.jsx +++ b/src/components/Spaces.jsx @@ -8,6 +8,7 @@ const spaces = [ { name: 'Learn', href: '/learn' }, { name: 'Node', href: '/node' }, { name: 'Help Center', href: '/support' }, + { name: 'Blog', href: '/blog' }, ] export function TopLevelLink({ href, className, current, children }) { diff --git a/src/components/TableOfContents.jsx b/src/components/TableOfContents.jsx index 32d6f6d09..d1b9a1c22 100644 --- a/src/components/TableOfContents.jsx +++ b/src/components/TableOfContents.jsx @@ -50,7 +50,7 @@ function useTableOfContents(tableOfContents) { return currentSection } -export default function TableOfContents({ tableOfContents }) { +export default function TableOfContents({ tableOfContents, routeGroup }) { const pathname = usePathname() let currentSection = useTableOfContents(tableOfContents) @@ -120,7 +120,7 @@ export default function TableOfContents({ tableOfContents }) { target="_blank" rel="noreferrer" className="text-sm text-slate-500 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-300" - href={`${GITHUB_EDIT_URL}${pathname}/page.md`} + href={`${GITHUB_EDIT_URL}/${routeGroup}{pathname}/page.md`} > Edit this page on GitHub → diff --git a/src/components/bio.js b/src/components/bio.js new file mode 100644 index 000000000..020353544 --- /dev/null +++ b/src/components/bio.js @@ -0,0 +1,30 @@ +/** + * Bio component that queries for data + * with Gatsby's useStaticQuery component + * + * See: https://www.gatsbyjs.com/docs/use-static-query/ + */ + +import Link from 'next/link' + +export default async function Bio({ name = 'Dan Willoughby', image, summary }) { + return ( +
+ {image && ( +
+ {name} +
+ )} +
+

+ Written by {name} +

+ {summary &&

{summary}

} +
+
+ ) +} diff --git a/src/markdoc/navigation.mjs b/src/markdoc/navigation.mjs index abc283ca2..4eec4fe65 100644 --- a/src/markdoc/navigation.mjs +++ b/src/markdoc/navigation.mjs @@ -170,13 +170,13 @@ export default function (nextConfig = {}) { let dir = path.resolve('./app') this.addContextDependency(dir) - let dcs = walkDir(`${dir}/dcs`, 'dcs') + let dcs = walkDir(`${dir}/\(docs\)/dcs`, 'dcs') sortByWeightThenTitle(dcs) - let node = walkDir(`${dir}/node`, 'node') + let node = walkDir(`${dir}/\(docs\)/node`, 'node') sortByWeightThenTitle(node) - let learn = walkDir(`${dir}/learn`, 'learn') + let learn = walkDir(`${dir}/\(docs\)/learn`, 'learn') sortByWeightThenTitle(learn) - let support = walkDir(`${dir}/support`, 'support') + let support = walkDir(`${dir}/\(docs\)/support`, 'support') sortByWeightThenTitle(support) let getRedirects = (space) => { diff --git a/src/markdoc/nodes.js b/src/markdoc/nodes.js index b17c00624..e48817f12 100644 --- a/src/markdoc/nodes.js +++ b/src/markdoc/nodes.js @@ -44,6 +44,15 @@ const nodes = { render: MarkdownLayout, async transform(node, config) { documentSlugifyMap.set(config, slugifyWithCounter()) + let frontmatter = yaml.load(node.attributes.frontmatter) + + if (frontmatter.layout === 'blog') { + return new Tag( + 'Blog', + { frontmatter, ast: node }, + await node.transformChildren(config) + ) + } return new Tag( this.render, diff --git a/src/markdoc/search.mjs b/src/markdoc/search.mjs index 400c41cb7..4b0086114 100644 --- a/src/markdoc/search.mjs +++ b/src/markdoc/search.mjs @@ -57,10 +57,14 @@ export default function (nextConfig = {}) { let files = glob.sync('**/*.md', { cwd: dir }) let data = files.map((file) => { let url = null + let re = /((\(docs\)|\(blog\))\/)/g if (file.endsWith('page.md')) { - url = `/${file.replace(/page\.md$/, '')}`.slice(0, -1) // remove trailing slash + url = `/${file.replace(re, '').replace(/page\.md$/, '')}`.slice( + 0, + -1 + ) // remove trailing slash } else { - url = `/${file.replace(/\.md$/, '')}` + url = `/${file.replace(re, '').replace(/\.md$/, '')}` } let md = fs.readFileSync(path.join(dir, file), 'utf8') diff --git a/src/markdoc/tags.js b/src/markdoc/tags.js index 4c519baf6..9f8c6f3cd 100644 --- a/src/markdoc/tags.js +++ b/src/markdoc/tags.js @@ -8,8 +8,12 @@ import { TagLinks } from '@/components/TagLinks' import PartnerIntegration from '@/components/PartnerIntegration' import YouTubeEmbed from '@/components/YouTubeEmbed' import { RegistrationToken } from '@/components/RegistrationToken' +import BlogLayout from '@/components/BlogLayout' const tags = { + blog: { + render: BlogLayout, + }, callout: { attributes: { title: { type: String }, diff --git a/tailwind.config.js b/tailwind.config.js index 4ef9a1331..521836cfd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -35,6 +35,7 @@ module.exports = { 'only-content': 'auto', 'sidebar-content': '20rem auto', 'sidebar-content-toc': '20rem auto 20rem', + 'content-toc': 'auto 20rem', }, colors: { 'storj-blue': {