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
27 changes: 27 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ jobs:
- name: List produced packages
run: ls -la nupkgs/

# ─── Pre-push safety: refuse if the version is already on nuget.org ───
# NuGet locks version numbers permanently after first publish. Without
# this check, `--skip-duplicate` in the push step would silently no-op
# the upload of a fresh binary when the version already exists — burned
# us once (see CHANGELOG 0.1.0-alpha → 0.1.1-alpha). Fails loud so the
# human bumps Directory.Build.props before re-tagging.
- name: Verify version is not already published
if: github.event_name == 'push' || inputs.dry_run == false
run: |
version=$(grep -oE '<Version>[^<]+' Directory.Build.props | head -1 | sed 's/<Version>//')
echo "Directory.Build.props version: $version"
if [ -z "$version" ]; then
echo "ERROR: could not read Version from Directory.Build.props"
exit 1
fi
for pkg in ShellDocs.CLI ShellDocs.Components ShellDocs.Core ShellDocs.Markdown ShellDocs.Templates ShellDocs.Tokens; do
lower=$(echo "$pkg" | tr '[:upper:]' '[:lower:]')
existing=$(curl -sf "https://api.nuget.org/v3-flatcontainer/${lower}/index.json" 2>/dev/null | grep -oE '"[^"]*"' | tr -d '"' || echo "")
if echo "$existing" | grep -Fxq "$version"; then
echo "ERROR: $pkg $version is already published on nuget.org."
echo "NuGet does not allow overwriting a published version."
echo "Bump <Version> in Directory.Build.props and re-tag."
exit 1
fi
done
echo "OK — $version is not yet on nuget.org for any of the 6 packages. Proceeding."

# Runs immediately before push — the temp API key is valid only 1 hour.
# `user` is the nuget.org profile name (NOT email, NOT the GH org name),
# kept as a secret so it never lives in the workflow file.
Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht

## [Unreleased]

## [0.1.1-alpha] — 2026-07-25

First point-release after the dogfood smoke of `0.1.0-alpha`. Three consumer-blocking fixes plus release-workflow hardening.

### Fixed

- **`NavigationGraphBuilder` now auto-includes `.md` files not referenced in `meta.json`.** Previously, when `meta.json` existed, ONLY the entries in its `pages` array made it into the nav — every other file on disk was silently dropped. `shelldocs add component Button` created `content/docs/components/button.md` on disk but the URL 404'd and the page never appeared in the sidebar until the consumer hand-edited `meta.json`. Fix: `meta.json` now controls ORDERING of explicitly-listed items; presence is driven by the file tree. Unreferenced files/folders get appended alphabetically after the explicit ordering. Backward-compatible — consumers who list everything explicitly get their exact ordering preserved verbatim before the auto-appended tail.
- **`shelldocs init` scaffold no longer emits a broken `<Callout Text=...>` example.** The intro-page template referenced a `Text` prop that doesn't exist on `<Callout>`; the current API is `Variant` + `Title` + `ChildContent`. Every new consumer running `dotnet run` on their fresh scaffold saw an empty callout as the first thing on their site. Template updated to `<Callout Variant="info" Title="Live component">body content</Callout>`.
- **`shelldocs init` now inserts a Content Update itemgroup so `dotnet publish` copies the markdown corpus.** Previously worked on `dotnet run` (resolves ContentRoot to source) but silently broke first deploy — the published output had zero markdown, so every `/docs/*` route 404'd. New `AddContentCopyIfMissing` helper adds `<Content Update="content/**/*.md;content/**/meta.json" CopyToOutputDirectory="PreserveNewest" />` to the consumer's csproj. Idempotent, runs in both CREATE and ATTACH modes.

### Hardened (release infrastructure)

- **Release workflow pre-push existence check.** New step queries `nuget.org/v3-flatcontainer` for each of the 6 package IDs at the tag's version before invoking `dotnet nuget push`. If any version already exists on nuget.org, the workflow **fails loud** with a "bump `Directory.Build.props` and re-tag" message. `--skip-duplicate` stays in the push step (still useful for resuming a workflow re-run that partially completed), but the pre-check catches the "you forgot to bump the version number" case explicitly instead of silently no-op'ing.

## [0.1.0-alpha] — 2026-07-25

First public release. The whole Phase 1 target is shipped, plus most of Phase 2's primitives + consumer DX polish. See [ROADMAP.md](docs/ROADMAP.md).
Expand Down Expand Up @@ -95,5 +109,6 @@ Published to NuGet:
- `<TypeTable>` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4)
- No `<DocsBreadcrumb>` opt-out — currently hides when the trail has ≤ 1 node, otherwise always renders

[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.0-alpha...HEAD
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.1-alpha...HEAD
[0.1.1-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.1-alpha
[0.1.0-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.0-alpha
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.0-alpha</Version>
<Version>0.1.1-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
5 changes: 4 additions & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ Once the one-time setup is done, cutting a release is three commands.

### 1. Bump the version

Edit `Directory.Build.props` → `<Version>0.X.Y[-suffix]</Version>`. That propagates to every packable project via the shared props file.
Bump **two** places to the same value:

- `Directory.Build.props` → `<Version>0.X.Y[-suffix]</Version>` — propagates to every packable project via the shared props file
- `src/ShellDocs.CLI/Commands/InitCommand.cs` → `private const string ShellDocsVersion = "0.X.Y[-suffix]";` — determines which `ShellDocs.*` package versions the CLI's `shelldocs init` scaffold references. If left stale, consumers scaffolding via the new CLI get old packages that lack the fresh CLI's fixes.

For a prerelease bump: `0.1.0-alpha` → `0.1.1-alpha` (patch) or `0.2.0-alpha` (minor).
For the first stable: strip the `-alpha` suffix → `1.0.0`.
Expand Down
28 changes: 27 additions & 1 deletion src/ShellDocs.CLI/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ SHELLDOCS_SETUP.md with copy-paste snippets instead.
Both modes are idempotent. */
internal static class InitCommand
{
private const string ShellDocsVersion = "0.1.0-alpha";
// Bump with <Version> in Directory.Build.props on every release. Determines
// which ShellDocs.* versions the scaffold references. If stale, consumers
// scaffolding via a new CLI get old packages that lack the fresh CLI's fixes.
private const string ShellDocsVersion = "0.1.1-alpha";

public static int Run(string? path, string dir, bool attach, bool yes, string theme)
{
Expand Down Expand Up @@ -252,6 +255,29 @@ private static void ScaffoldPackages(string csproj, List<string> changes)
{
AddPackageIfMissing(csproj, "ShellDocs.Components", ShellDocsVersion, changes);
AddPackageIfMissing(csproj, "ShellDocs.Tokens", ShellDocsVersion, changes);
AddContentCopyIfMissing(csproj, changes);
}

// Adds a Content Update item for content markdown/meta.json so
// `dotnet publish` copies the markdown corpus into the publish output.
// Without this, ContentRoot resolves fine under `dotnet run` (source dir)
// but the published site has no content to render.
internal static void AddContentCopyIfMissing(string csproj, List<string> changes)
{
var xml = File.ReadAllText(csproj);
if (new Regex(@"<Content\s+Update=""content/", RegexOptions.IgnoreCase).IsMatch(xml))
return;

var closing = xml.LastIndexOf("</Project>", StringComparison.OrdinalIgnoreCase);
if (closing < 0) return;

var block =
$" <ItemGroup>{Environment.NewLine}" +
$" <Content Update=\"content/**/*.md;content/**/meta.json\" CopyToOutputDirectory=\"PreserveNewest\" />{Environment.NewLine}" +
$" </ItemGroup>{Environment.NewLine}{Environment.NewLine}";

File.WriteAllText(csproj, xml.Insert(closing, block));
changes.Add($"added [cyan]content copy-to-output[/] to {Path.GetFileName(csproj)}");
}

private static void ScaffoldContent(string root, List<string> changes)
Expand Down
44 changes: 40 additions & 4 deletions src/ShellDocs.Core/NavigationGraphBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,40 @@ private static List<NavigationNode> BuildFolder(string folder, string root, stri
}

var result = new List<NavigationNode>();
var consumedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var consumedFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in meta.Pages)
{
var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix);
var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix, consumedSlugs, consumedFolders);
if (node is not null) result.Add(node);
}

/* meta.json controls ORDERING for anything it lists; presence is
driven by the file tree. Files or subfolders the meta didn't
mention get appended at the end (alphabetically) so a page dropped
via `shelldocs add` or by hand appears immediately, without the
consumer touching meta.json. */
foreach (var (name, tuple) in folderNameToChildren.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
{
if (consumedFolders.Contains(name)) continue;
var section = new NavigationNode
{
Title = TitleFromFolderName(name),
Kind = NodeKind.Section,
Path = tuple.folderPath
};
LinkChildren(section, tuple.children);
result.Add(section);
}
foreach (var page in slugToNode
.Where(kv => !consumedSlugs.Contains(kv.Key))
.Select(kv => kv.Value)
.OrderBy(n => n.Order)
.ThenBy(n => n.Title, StringComparer.OrdinalIgnoreCase))
{
result.Add(page);
}

return result;
}

Expand All @@ -72,14 +101,21 @@ private static List<NavigationNode> BuildFolder(string folder, string root, stri
Dictionary<string, NavigationNode> slugToNode,
Dictionary<string, (string folderPath, List<NavigationNode> children)> folderNameToChildren,
string root,
string urlPrefix)
string urlPrefix,
HashSet<string> consumedSlugs,
HashSet<string> consumedFolders)
{
switch (entry)
{
case MetaJsonPageRef pageRef:
if (slugToNode.TryGetValue(pageRef.Slug, out var page)) return page;
if (slugToNode.TryGetValue(pageRef.Slug, out var page))
{
consumedSlugs.Add(pageRef.Slug);
return page;
}
if (folderNameToChildren.TryGetValue(pageRef.Slug, out var folder))
{
consumedFolders.Add(pageRef.Slug);
var section = new NavigationNode
{
Title = TitleFromFolderName(pageRef.Slug),
Expand All @@ -103,7 +139,7 @@ private static List<NavigationNode> BuildFolder(string folder, string root, stri
var subChildren = new List<NavigationNode>();
foreach (var e in sub.Pages)
{
var child = ResolveEntry(e, slugToNode, folderNameToChildren, root, urlPrefix);
var child = ResolveEntry(e, slugToNode, folderNameToChildren, root, urlPrefix, consumedSlugs, consumedFolders);
if (child is not null) subChildren.Add(child);
}
LinkChildren(subNode, subChildren);
Expand Down
4 changes: 3 additions & 1 deletion src/ShellDocs.Templates/ScaffoldTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ public static class ScaffoldTemplates
## Live components

```razor:preview
<Callout Title="Nice" Text="This block is a live Blazor component rendered from markdown." />
<Callout Variant="info" Title="Live component">
This block is a live Blazor component rendered from markdown.
</Callout>
```
""";

Expand Down
61 changes: 61 additions & 0 deletions tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,67 @@ public void Build_UnknownSlugInMetaJson_IsSilentlySkipped()
Assert.Equal(new[] { "Real" }, titles);
}

[Fact]
public void Build_MdFileNotInMetaJson_IsAppendedAfterExplicitOrdering()
{
// Meta lists only `alpha`, but `bravo.md` exists on disk.
// Bravo should surface at the end, not silently disappear (the
// `shelldocs add` DX gap fix).
WriteMd("alpha.md", "Alpha");
WriteMd("bravo.md", "Bravo");
WriteMeta("", """{ "pages": ["alpha"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

Assert.Equal(new[] { "Alpha", "Bravo" }, titles);
}

[Fact]
public void Build_SubfolderNotInMetaJson_AppearsAsSectionAfterExplicitOrdering()
{
WriteMd("intro.md", "Intro");
WriteMd("components/button.md", "Button");
WriteMeta("", """{ "pages": ["intro"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

Assert.Equal(new[] { "Intro", "Components" }, titles);
}

[Fact]
public void Build_UnreferencedItems_AreAlphabetical()
{
WriteMd("first.md", "First"); // in meta
WriteMd("charlie.md", "Charlie"); // not in meta
WriteMd("alpha.md", "Alpha"); // not in meta
WriteMd("bravo.md", "Bravo"); // not in meta
WriteMeta("", """{ "pages": ["first"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

Assert.Equal(new[] { "First", "Alpha", "Bravo", "Charlie" }, titles);
}

[Fact]
public void Build_ExplicitOrderingIsPreserved_ForItemsInMetaJson()
{
// Verify the auto-append doesn't break the existing "meta.json controls
// ordering for explicitly-listed items" contract.
WriteMd("alpha.md", "Alpha");
WriteMd("bravo.md", "Bravo");
WriteMd("charlie.md", "Charlie");
WriteMeta("", """{ "pages": ["charlie", "alpha"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

// Charlie + Alpha in the meta-specified order, THEN Bravo appended.
Assert.Equal(new[] { "Charlie", "Alpha", "Bravo" }, titles);
}

[Fact]
public void Build_ThrowsOnMissingContentRoot()
{
Expand Down
Loading