diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1ef9173 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + filter: blob:none + - uses: actions/setup-node@v6 + with: + node-version: "24" + - uses: nubjs/setup-nub@v0 + with: + nub-version: ^0.9.0 + cache: true + cache-dependency-path: | + nub.lock + package.json + - uses: moonrepo/setup-toolchain@v0 + with: + moon-version: "2.5.5" + - run: nub install --frozen-lockfile + - run: nubx playwright install --with-deps chromium + - name: Check affected tasks + id: ci + run: moon ci + - uses: moonrepo/run-report-action@v1 + if: "!cancelled() && steps.ci.outcome != 'skipped'" + with: + access-token: ${{ secrets.GITHUB_TOKEN }} + skip-comment: true + - uses: actions/upload-artifact@v7 + if: always() + with: + name: moon-reports-${{ github.run_attempt }} + path: | + .moon/cache/ciReport.json + .moon/cache/runReport.json + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 + - uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-${{ github.run_attempt }} + path: test-results/ + if-no-files-found: ignore + retention-days: 1 diff --git a/.gitignore b/.gitignore index 7b516de..918bb69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.task/ +.moon/cache/ node_modules/ dist/ .npmrc diff --git a/.moon/tasks/all.yml b/.moon/tasks/all.yml new file mode 100644 index 0000000..fc0627b --- /dev/null +++ b/.moon/tasks/all.yml @@ -0,0 +1,6 @@ +implicitInputs: + - moon.yml + - package.json + - nub.lock + - /.moon/**/*.yml + - /.github/workflows/ci.yaml diff --git a/.moon/workspace.yml b/.moon/workspace.yml new file mode 100644 index 0000000..132e9ee --- /dev/null +++ b/.moon/workspace.yml @@ -0,0 +1,11 @@ +defaultProject: solid-static +projects: + solid-static: . +versionConstraint: ">=2.5.4 <3.0.0" +vcs: + client: git + defaultBranch: master +pipeline: + installDependencies: false + syncProjects: false + syncWorkspace: false diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..d4831a8 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,3 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json" +} diff --git a/AGENTS.md b/AGENTS.md index edaae81..dfc3470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ ## Verification -- after significant changes run `task check` +- after significant changes run `moon run solid-static:check` ## Releases -- agent can do `task publish` if human asks for it explicitly. Verbs: "publish"/"release" +- agent can do `moon run solid-static:publish` if human asks for it explicitly. Verbs: "publish"/"release" diff --git a/README.md b/README.md index ba0e528..2632878 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,29 @@ staticSite({ Add `.tsx`, `.md`, or `.mdx` pages under `src/pages`. The directory structure determines each page's route. Markdown pages must declare a SolidJS layout in their frontmatter. +### Markdown headings and tables of contents + +`createHtmlMarkdownProcessor()` assigns stable, GitHub-style IDs to headings. +Repeated headings receive unique suffixes, with numbering scoped to each document. +Markdown and MDX pages using `solidMarkdown()` also receive heading IDs. + +Rendered content collection entries expose `rendered.html` and +`rendered.headings`. Each heading contains `depth` (1–6), `slug` (the HTML ID), +and plain `text`. Build a table of contents from this metadata instead of parsing +the HTML a second time: + +```tsx + +
+``` + +Custom Markdown processors can provide the same metadata through their result's +`data.headings`. Results without heading metadata produce an empty headings list. + ### Client islands Import a self-mounting browser entry with the `?island` query, then reference the returned URL from a module script. The page remains static HTML; only the named entry and its imports are compiled for the browser. @@ -212,6 +235,33 @@ export default function Page() { When `src` contains image metadata, specifying only `width` or `height` infers the other dimension while preserving the aspect ratio. The returned promise resolves to a `GetImageResult` containing the generated `src`, inferred `attributes`, normalized `options`, original `rawOptions`, and an Astro-compatible `srcSet` object. Generated URLs work in both the Vite development server and production builds. `getImage()` throws if called in the browser. +## Development and CI + +Install Node.js 24, Nub 0.9, and Moon 2.5.5, then run: + +```sh +nub install --frozen-lockfile +nubx playwright install chromium +moon run solid-static:check +``` + +`moon.yml` owns the build, lint, typecheck, unit, and browser tasks. Tests depend +on the package build because Vite fixtures resolve the package's exported runtime +from `dist`. A clean checkout does not need prebuilt artifacts. + +The standalone GitHub workflow follows [Moon's CI guide](https://moonrepo.dev/docs/guides/ci): +full Git history, dependency installation, then `moon ci` to select affected +tasks and run their dependencies. It uploads native reports and keeps +publication out of CI. There is no separate CI-only task graph or persisted +Moon workspace cache. + +The same project tasks can be registered as `solid-static` in a parent Moon +workspace. Consumers should depend on `solid-static:build` and use a workspace +package dependency. The parent owns dependency and browser installation; the +submodule's standalone workflow does not run inside the parent's workflow. + +To publish an explicitly approved release, use `moon run solid-static:publish`. + ## Documentation Dedicated documentation is not available yet. For the concepts and intended behavior, see the corresponding Astro guides: diff --git a/Taskfile.yaml b/Taskfile.yaml deleted file mode 100644 index e75cb57..0000000 --- a/Taskfile.yaml +++ /dev/null @@ -1,91 +0,0 @@ -version: "3" - -tasks: - lint: - run: once - cmd: nubx oxlint src e2e playwright.config.ts - sources: - - nub.lock - - package.json - - playwright.config.ts - - tsconfig.json - - e2e/**/* - - src/**/* - - typecheck: - run: once - cmd: nubx tsc - sources: - - nub.lock - - package.json - - tsconfig.json - - src/**/* - - typecheck:playwright: - run: once - cmd: nubx tsc -p tsconfig.playwright.json - sources: - - e2e/**/* - - nub.lock - - package.json - - playwright.config.ts - - tsconfig.json - - tsconfig.playwright.json - - test:unit: - run: once - cmd: nubx vitest run - sources: - - nub.lock - - package.json - - tsconfig.json - - vitest.config.ts - - src/**/* - - playwright:install: - run: once - cmd: nubx playwright install chromium - sources: - - nub.lock - - test:playwright: - run: once - deps: - - playwright:install - cmd: nubx playwright test - sources: - - e2e/**/* - - nub.lock - - package.json - - playwright.config.ts - - src/**/* - - test: - deps: - - test:unit - - test:playwright - - build: - run: once - cmd: nubx tsc -p tsconfig.build.json - sources: - - nub.lock - - package.json - - tsconfig.json - - tsconfig.build.json - - src/**/* - generates: - - dist/**/* - - check: - deps: - - lint - - typecheck - - typecheck:playwright - - test - - publish: - deps: - - check - - build - cmd: nub publish --access public diff --git a/moon.yml b/moon.yml new file mode 100644 index 0000000..14f6730 --- /dev/null +++ b/moon.yml @@ -0,0 +1,79 @@ +toolchains: + default: system +tasks: + lint: + command: nubx oxlint --config .oxlintrc.json src e2e playwright.config.ts + inputs: + - .oxlintrc.json + - src/**/* + - e2e/**/* + - playwright.config.ts + - package.json + - /nub.lock + build: + command: nubx tsc -p tsconfig.build.json + inputs: + - src/**/* + - package.json + - tsconfig.json + - tsconfig.build.json + - /nub.lock + outputs: + - dist/**/* + typecheck: + command: nubx tsc + inputs: + - src/**/* + - package.json + - tsconfig.json + - /nub.lock + typecheck-playwright: + command: nubx tsc -p tsconfig.playwright.json + inputs: + - src/**/* + - e2e/**/* + - package.json + - playwright.config.ts + - tsconfig.json + - tsconfig.playwright.json + - /nub.lock + test-unit: + command: nubx vitest run + deps: + - ~:build + inputs: + - src/**/* + - package.json + - tsconfig.json + - vitest.config.ts + - /nub.lock + test-e2e: + command: nubx playwright test + deps: + - ~:build + inputs: + - src/**/* + - e2e/**/* + - playwright.config.ts + - tsconfig.playwright.json + - package.json + - /nub.lock + check: + deps: + - ~:lint + - ~:typecheck + - ~:typecheck-playwright + - ~:test-unit + - ~:test-e2e + options: + runInCI: false + inputs: [] + publish: + command: nub publish --access public + deps: + - ~:check + - ~:build + options: + cache: false + runInCI: false + inputs: [] diff --git a/nub.lock b/nub.lock index 5f2d34f..031b7c2 100644 --- a/nub.lock +++ b/nub.lock @@ -11,6 +11,12 @@ importers: '@mdx-js/rollup': specifier: ^3.1.1 version: 3.1.1(rollup@4.62.4) + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 + hast-util-to-string: + specifier: ^3.0.1 + version: 3.0.1 js-yaml: specifier: ^5.2.3 version: 5.3.0 @@ -44,6 +50,12 @@ importers: unified: specifier: 11.0.5 version: 11.0.5 + unist-util-visit: + specifier: ^5.1.0 + version: 5.1.0 + vfile: + specifier: ^6.0.3 + version: 6.0.3 vite-plugin-solid: specifier: ^2.11.14 version: 2.11.14(solid-js@1.9.15)(vite@8.2.1(@types/node@26.2.0)(yaml@2.9.0)) @@ -51,6 +63,9 @@ importers: '@playwright/test': specifier: ^1.62.1 version: 1.62.1 + '@types/hast': + specifier: ^3.0.5 + version: 3.0.5 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -1130,6 +1145,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -1139,6 +1157,9 @@ packages: hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -1753,7 +1774,6 @@ packages: optional: true jsdom: optional: true - vite: {} why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} @@ -2574,6 +2594,8 @@ snapshots: gensync@1.0.0-beta.2: {} + github-slugger@2.0.0: {} + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.9 @@ -2629,6 +2651,10 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 diff --git a/package.json b/package.json index fee6e21..0c99ac8 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ }, "dependencies": { "@mdx-js/rollup": "^3.1.1", + "github-slugger": "^2.0.0", + "hast-util-to-string": "^3.0.1", "js-yaml": "^5.2.3", "mdast-util-from-markdown": "^2.0.3", "rehype-stringify": "^10.0.1", @@ -52,10 +54,13 @@ "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", "unified": "11.0.5", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3", "vite-plugin-solid": "^2.11.14" }, "devDependencies": { "@playwright/test": "^1.62.1", + "@types/hast": "^3.0.5", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "@types/node": "^26.2.0", diff --git a/src/content.test.ts b/src/content.test.ts index 8dac06f..0367350 100644 --- a/src/content.test.ts +++ b/src/content.test.ts @@ -3,8 +3,29 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, test } from "vitest" import { loadCollections } from "./content.ts" +import { createHtmlMarkdownProcessor } from "./markdown.ts" describe("content collections", () => { + test("carries rendered heading anchors into collection entries", async () => { + const directory = await mkdtemp(join(tmpdir(), "solid-static-headings-")) + try { + await writeFile(join(directory, "guide.md"), "---\ntitle: Guide\n---\n## Listen\n### Share\n") + const collections = await loadCollections( + { guides: { directory, pattern: /\.md$/u } }, + createHtmlMarkdownProcessor(), + ) + expect(collections.guides?.[0]?.rendered).toEqual({ + html: '

Listen

\n

Share

', + headings: [ + { depth: 2, slug: "listen", text: "Listen" }, + { depth: 3, slug: "share", text: "Share" }, + ], + }) + } finally { + await rm(directory, { force: true, recursive: true }) + } + }) + test("parses YAML timestamps as dates", async () => { const directory = await mkdtemp(join(tmpdir(), "solid-static-content-")) diff --git a/src/content.ts b/src/content.ts index 9eff4d7..e45e5bb 100644 --- a/src/content.ts +++ b/src/content.ts @@ -4,8 +4,20 @@ import { CORE_SCHEMA, load, timestampTag } from "js-yaml" const yamlSchema = CORE_SCHEMA.withTags(timestampTag) +export interface MarkdownHeading { + depth: number + slug: string + text: string +} + +export interface RenderedMarkdown { + html: string + headings: MarkdownHeading[] +} + export interface MarkdownProcessor { process(file: { path: string; value: string }): Promise<{ + data?: { headings?: MarkdownHeading[] | undefined } | undefined toString(): string }> } @@ -23,11 +35,7 @@ export interface CollectionEntry { body?: string | undefined data: TData id: string - rendered?: - | { - html: string - } - | undefined + rendered?: RenderedMarkdown | undefined } export type CollectionEntryFor = @@ -40,9 +48,7 @@ export type RenderedCollectionEntryFor< TName extends keyof TDefinitions, > = CollectionEntryFor & { body: string - rendered: { - html: string - } + rendered: RenderedMarkdown } export type LoadedCollectionEntry = CollectionEntry @@ -123,21 +129,21 @@ const loadCollection = async ( const id = definition.id?.(relativePath) ?? defaultId(relativePath) const parsed = parseSource(await readFile(filePath, "utf8"), relativePath) const data = definition.schema?.(parsed.data, relativePath) ?? parsed.data + const rendered = + parsed.renderable && markdownProcessor !== undefined + ? await markdownProcessor.process({ path: filePath, value: parsed.body }) + : undefined return { ...(parsed.renderable ? { body: parsed.body, - ...(markdownProcessor === undefined + ...(rendered === undefined ? {} : { rendered: { - html: String( - await markdownProcessor.process({ - path: filePath, - value: parsed.body, - }), - ), + html: String(rendered), + headings: rendered.data?.headings ?? [], }, }), } diff --git a/src/markdown.test.ts b/src/markdown.test.ts new file mode 100644 index 0000000..0bc9986 --- /dev/null +++ b/src/markdown.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest" +import { createHtmlMarkdownProcessor } from "./markdown.ts" + +describe("Markdown headings", () => { + test("renders unique anchors and matching metadata for formatted and repeated headings", async () => { + const result = await createHtmlMarkdownProcessor().process( + "## Start **here**\n### Audio `RSS` & café\n## Start here\n## Start here-1\n## Start here\n## 日本語\n##\n", + ) + + expect(result.data.headings).toEqual([ + { depth: 2, slug: "start-here", text: "Start here" }, + { depth: 3, slug: "audio-rss--café", text: "Audio RSS & café" }, + { depth: 2, slug: "start-here-1", text: "Start here" }, + { depth: 2, slug: "start-here-1-1", text: "Start here-1" }, + { depth: 2, slug: "start-here-2", text: "Start here" }, + { depth: 2, slug: "日本語", text: "日本語" }, + { depth: 2, slug: "section", text: "" }, + ]) + expect(String(result)).toContain('

Start here

') + for (const heading of result.data.headings ?? []) { + expect(String(result)).toContain(``) + } + }) + + test("keeps numbering local to each document when a processor is reused concurrently", async () => { + const processor = createHtmlMarkdownProcessor() + const files = await Promise.all([ + processor.process("## Audio\n## Audio"), + processor.process("## Audio"), + processor.process("No headings."), + ]) + + expect(files.map(file => file.data.headings)).toEqual([ + [ + { depth: 2, slug: "audio", text: "Audio" }, + { depth: 2, slug: "audio-1", text: "Audio" }, + ], + [{ depth: 2, slug: "audio", text: "Audio" }], + [], + ]) + }) +}) diff --git a/src/markdown.ts b/src/markdown.ts index ce0a6ee..02290eb 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,4 +1,7 @@ import mdx from "@mdx-js/rollup" +import GithubSlugger, { slug as githubSlug } from "github-slugger" +import type { Root as HastRoot } from "hast" +import { toString } from "hast-util-to-string" import type { Root as MdastRoot } from "mdast" import { fromMarkdown } from "mdast-util-from-markdown" import type { Options as RemarkParseOptions } from "remark-parse" @@ -7,7 +10,47 @@ import remarkMdxFrontmatter from "remark-mdx-frontmatter" import remarkRehype from "remark-rehype" import rehypeStringify from "rehype-stringify" import { type Plugin, unified } from "unified" +import { visit } from "unist-util-visit" import type { PluginOption } from "vite" +import type { MarkdownHeading } from "./content.ts" + +declare module "vfile" { + interface DataMap { + headings: MarkdownHeading[] + } +} + +const rehypeHeadings: Plugin<[], HastRoot> = function () { + return function (tree, file) { + const slugger = new GithubSlugger() + const reserved = new Set() + const headings: MarkdownHeading[] = [] + + visit(tree, "element", function (node) { + if (typeof node.properties.id === "string") { + reserved.add(node.properties.id) + } + }) + + visit(tree, "element", function (node) { + if (!/^h[1-6]$/u.test(node.tagName)) return + + const text = toString(node) + let slug = node.properties.id + if (typeof slug !== "string" || slug === "") { + const base = githubSlug(text.trim()) === "" ? "section" : text.trim() + do { + slug = slugger.slug(base) + } while (reserved.has(slug)) + reserved.add(slug) + node.properties.id = slug + } + headings.push({ depth: Number(node.tagName.slice(1)), slug, text }) + }) + + file.data.headings = headings + } +} const remarkParsePlugin: Plugin< [(Readonly | null | undefined)?], @@ -18,7 +61,7 @@ const remarkParsePlugin: Plugin< } export const createMarkdownProcessor = () => - unified().use(remarkParsePlugin).use(remarkRehype) + unified().use(remarkParsePlugin).use(remarkRehype).use(rehypeHeadings) export const createHtmlMarkdownProcessor = () => createMarkdownProcessor().use(rehypeStringify) @@ -26,6 +69,7 @@ export const createHtmlMarkdownProcessor = () => export const solidMarkdown = (): PluginOption => mdx({ jsxImportSource: "solid-jsx", + rehypePlugins: [rehypeHeadings], remarkPlugins: [ remarkFrontmatter, [remarkMdxFrontmatter, { name: "frontmatter" }],