Newer
Older
hello-programmer-world / src / components / Toc.astro
@h.sakamoto h.sakamoto 21 days ago 3 KB git
---
type PageItem = {
	url: string;
	title: string;
};

type Section = {
	title: string;
	items: PageItem[];
};

async function getSectionPages(sectionPath: string): Promise<PageItem[]> {
	const allPages = await Astro.glob("../pages/**/*.mdx");

	return allPages
		.filter((page) => page.file.includes(`/pages/${sectionPath}/`))
		.map((page) => {
			const fileName = page.file.split("/").pop()?.replace(".mdx", "") || "";
			const url = `/${sectionPath}/${fileName}`;

			const [index, titlePart] = fileName.split("-");
			const title = (page as any).title || page.frontmatter?.title || titlePart;

			return { url, title, order: index };
		})
		.sort((a, b) => a.order - b.order);
}

const currentPath = Astro.url.pathname;

const sections: Section[] = [];

const sectionPaths = ["html", "js", "cli", "php", "sql", "git", "tips"];

for (const path of sectionPaths) {
	const pages = await getSectionPages(path);
	if (pages.length > 0) {
		sections.push({
			title: path.charAt(0).toUpperCase() + path.slice(1),
			items: pages,
		});
	}
}
---

<div class="toc-wrapper">
  <nav class="toc">
    <ul class="toc-list">
      <li class="toc-item">
        <a href="/" class="toc-link">Introduction</a>
      </li>

      {sections.map(section => {
      const isActive = section.items.some(item => currentPath.startsWith(item.url));
      return (
      <li class="toc-item">
        <details open={isActive}>
          <summary class="toc-section-title">{section.title}</summary>
          <ul class="toc-sublist">
            {section.items.map(item => (
            <li class="toc-subitem">
              <a href={item.url} class="toc-link">{item.title}</a>
            </li>
            ))}
          </ul>
        </details>
      </li>
      );
      })}
    </ul>
  </nav>
</div>

<style>
  .toc {
    font-size: 14px;
    position: sticky;
    top: 32px;
  }

  .toc-list {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .toc-item {
    margin: 0;
  }

  .toc-link {
    display: block;
    padding: 4px 8px;
    color: #0969da;
    text-decoration: none;
    border-radius: 6px;
  }

  .toc-link:hover {
    background-color: #f6f8fa;
    text-decoration: none;
  }

  details {
    margin: 4px 0;
  }

  summary {
    cursor: pointer;
    padding: 4px 8px;
    font-weight: 600;
    color: #1f2328;
    border-radius: 6px;
    list-style: none;
  }

  summary::-webkit-details-marker {
    display: none;
  }

  summary::before {
    content: '▶';
    display: inline-block;
    margin-right: 6px;
    font-size: 10px;
    transition: transform 0.2s;
  }

  details[open] summary::before {
    transform: rotate(90deg);
  }

  summary:hover {
    background-color: #f6f8fa;
  }

  .toc-sublist {
    list-style: none;
    padding-left: 16px;
    margin: 4px 0;
  }

  .toc-subitem {
    margin: 0;
  }

  @media (prefers-color-scheme: dark) {
    .toc-link {
      color: #4493f8;
    }

    .toc-link:hover {
      background-color: #161b22;
    }

    summary {
      color: #e6edf3;
    }

    summary:hover {
      background-color: #161b22;
    }
  }
</style>