From d50cf59cfad5f9aaca8dd5831ac806edc5add5d7 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 15:35:17 +0200 Subject: [PATCH] feat: mark products open-source vs proprietary with an optional repo link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets closed-source product docs be hosted and found on the landing page alongside open-source ones while being clearly distinguished. - ProductConfig gains Openness (ProductOpenness: Unspecified/OpenSource/ Proprietary, default Unspecified) and an optional RepositoryUrl. Both are display-only — orthogonal to Visibility and Restricted. Marten persists them with no schema change; the API projects them and Create/Update round-trip them (empty RepositoryUrl clears the link). - Product form (General tab): a Source select + Repository URL field; the product grid gains a Source column (persistColumnState v4). - Landing page: a colored openness badge (green Open Source / muted Proprietary, none when unspecified), a discreet "Source ↗" link when a repo URL is set, and Open Source / Proprietary filter chips (persisted in preferences). - Docs: CHANGELOG (2.2.0), CLAUDE.md, admin-ui / product-registration / configuration guides. Tests: 3 new round-trip tests (persist, default Unspecified, clear repo URL via empty string); full suite 128/128 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++ CLAUDE.md | 12 +- .../src/core/models/shelf.models.ts | 12 ++ .../src/stores/preferences.store.ts | 24 +++- .../src/views/LandingView.vue | 118 +++++++++++++++++- .../src/views/products/ProductFormModal.vue | 36 +++++- .../src/views/products/ProductListView.vue | 9 +- src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs | 17 ++- src/Cocoar.Shelf/Models/ProductConfig.cs | 13 ++ src/Cocoar.Shelf/Models/ProductOpenness.cs | 14 +++ src/Cocoar.Shelf/Models/ProductRequests.cs | 4 +- .../Integration/ApiProductsTests.cs | 76 +++++++++++ website/guide/admin-ui.md | 2 + website/guide/configuration.md | 2 + website/guide/product-registration.md | 15 +++ 15 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 src/Cocoar.Shelf/Models/ProductOpenness.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f46451..367b95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Shelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.2.0] — 2026-07-19 + +### Added + +- **Open-source vs proprietary marker** — a product can be tagged *Open Source* or *Proprietary* (or left unspecified). The marker shows as a colored badge on the landing page, so closed-source product docs can be hosted and found alongside open-source ones and be clearly told apart. The landing page gains a matching filter. Purely a display dimension — orthogonal to visibility and access control. +- **Optional repository URL** — a product can carry a source-repository link. When set, the landing card shows a discreet “Source ↗” link; leave it empty and closed-source docs carry no repo link. Set both on the product form (General tab). + ## [2.1.1] — 2026-07-19 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index b046789..fdb3318 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,8 +100,9 @@ Backend (`src/Cocoar.Shelf/`): - `ShelfOptions.cs` — all configuration (incl. `ModgudOptions`) - `Identity/` — ModgudUserProvisioning, ModgudClaimsTransformation, ClaimsSnapshot, RbacCookiePreservation, AdminCheck, MartenUserStore -- `Models/` — ProductConfig (+`Restricted`, `ReadPrincipals`), Group, Principal - (`IPrincipal`/`PrincipalRef`), UserDocument (+claims snapshot), AccessLogEntry (+lat/lng) +- `Models/` — ProductConfig (+`Restricted`, `ReadPrincipals`, `Openness`, `RepositoryUrl`), Group, + Principal (`IPrincipal`/`PrincipalRef`), ProductOpenness (enum), UserDocument (+claims snapshot), + AccessLogEntry (+lat/lng) - `Endpoints/` — ApiEndpoints (products/versions), AuthEndpoints, SettingsEndpoints, UserEndpoints, AnalyticsEndpoints, GroupEndpoints, PrincipalEndpoints, TestAuthEndpoints, ApiKeyFilter - `Middleware/DocsRoutingMiddleware.cs` — docs routing, rewriting, access-log recording @@ -118,7 +119,11 @@ Frontend (`src/Cocoar.Shelf.Client/src/`): - `layouts/AppLayout.vue` — one shared shell for landing + admin (header always; sidebar for logged-in admins everywhere); `views/LandingView.vue` renders inside it - `views/products/` — grid + tabbed edit modal (General / **Access** / Tags & API / Versions); - Access tab = Restricted toggle + principal picker (groups + users) + General tab = Openness (`ProductOpenness`) select + optional Repository URL; Access tab = + Restricted toggle + principal picker (groups + users) +- `views/LandingView.vue` — product cards show an **openness badge** (Open Source / Proprietary, + none when unspecified) + an optional "Source ↗" repo link; toolbar filters by tag AND openness + (`preferences.store` persists `opennessFilter`) - `views/groups/` — Groups grid + tabbed modal (General / Members / Auto-membership with `CoarScriptEditor` from `@cocoar/vue-script-editor` + dry-run) - `views/admin/` — GeneralSettings (master key), Users, Groups, AccessLog, GeoIP; @@ -150,3 +155,4 @@ Frontend (`src/Cocoar.Shelf.Client/src/`): - **Restricted → 404, not 403** for authed-no-grant (no existence leak); restricted products are fully invisible (no teaser) - **Analytics aggregation is server-side** — the browser only renders; country flags/names are derived client-side from the ISO code (display only) - **modgud owns identity, Shelf owns authorization** — groups/grants reference products, which only exist in Shelf, so revocation is immediate (per request, not next login) +- **Openness is a display marker, not access control** — `ProductConfig.Openness` (Open Source / Proprietary / Unspecified) only drives a landing badge + filter; proprietary docs are hosted publicly and findable, just clearly labeled. Hide docs with `Restricted`, not `Openness`. The repo link lives in the product's own VitePress docs; Shelf adds only the optional `RepositoryUrl` "Source ↗" link — omit it for closed source diff --git a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts index 6a9ced4..42aea2a 100644 --- a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts +++ b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts @@ -1,3 +1,6 @@ +/** Whether a product's source is public — display-only, drives a landing-page badge. */ +export type ProductOpenness = 'Unspecified' | 'OpenSource' | 'Proprietary'; + /** A principal (group or user) that a product's read access can be granted to. */ export type PrincipalKind = 'Group' | 'User'; @@ -26,6 +29,10 @@ export interface Product { readPrincipals: PrincipalRef[]; tags: string[]; showWhenEmpty: boolean; + /** Open-source vs proprietary marker (display-only). */ + openness: ProductOpenness; + /** Optional source-repository URL; when set the landing card shows a "Source ↗" link. */ + repositoryUrl: string | null; hasApiKey: boolean; latest: string | null; versions: string[]; @@ -47,6 +54,8 @@ export interface CreateProductRequest { showWhenEmpty?: boolean; restricted?: boolean; readPrincipals?: PrincipalRef[]; + openness?: ProductOpenness; + repositoryUrl?: string; /** Per-product upload key. Write-only: responses only carry hasApiKey. */ apiKey?: string; } @@ -60,6 +69,9 @@ export interface UpdateProductRequest { showWhenEmpty?: boolean; restricted?: boolean; readPrincipals?: PrincipalRef[]; + openness?: ProductOpenness; + /** undefined = keep, '' = remove the repo link, value = set. */ + repositoryUrl?: string; /** undefined = keep, '' = remove, value = replace. */ apiKey?: string; } diff --git a/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts b/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts index 1e0b6f9..97ad838 100644 --- a/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts +++ b/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts @@ -1,11 +1,16 @@ import { defineStore } from 'pinia'; import { ref, watch } from 'vue'; +import type { ProductOpenness } from '@/core/models/shelf.models'; const STORAGE_KEY = 'shelf:preferences'; +/** Landing-page filter for the open-source vs proprietary marker; null = show all. */ +export type OpennessFilter = 'OpenSource' | 'Proprietary' | null; + interface Preferences { showPreview: boolean; selectedTags: string[]; + opennessFilter: OpennessFilter; } function load(): Preferences { @@ -17,23 +22,26 @@ function load(): Preferences { } function defaults(): Preferences { - return { showPreview: false, selectedTags: [] }; + return { showPreview: false, selectedTags: [], opennessFilter: null }; } export const usePreferencesStore = defineStore('preferences', () => { const saved = load(); const showPreview = ref(saved.showPreview); const selectedTags = ref(saved.selectedTags); + const opennessFilter = ref(saved.opennessFilter); function persist() { localStorage.setItem(STORAGE_KEY, JSON.stringify({ showPreview: showPreview.value, selectedTags: selectedTags.value, + opennessFilter: opennessFilter.value, })); } watch(showPreview, persist); watch(selectedTags, persist, { deep: true }); + watch(opennessFilter, persist); function toggleTag(tag: string) { const idx = selectedTags.value.indexOf(tag); @@ -45,5 +53,17 @@ export const usePreferencesStore = defineStore('preferences', () => { selectedTags.value = []; } - return { showPreview, selectedTags, toggleTag, clearTags }; + // Click the active filter again to clear it (toggle behavior). + function toggleOpenness(value: Exclude) { + opennessFilter.value = opennessFilter.value === value ? null : value; + } + + return { showPreview, selectedTags, opennessFilter, toggleTag, clearTags, toggleOpenness }; }); + +/** Human label for an openness value (badge + filter). */ +export function opennessLabel(openness: ProductOpenness): string { + return openness === 'OpenSource' ? 'Open Source' + : openness === 'Proprietary' ? 'Proprietary' + : ''; +} diff --git a/src/Cocoar.Shelf.Client/src/views/LandingView.vue b/src/Cocoar.Shelf.Client/src/views/LandingView.vue index d07d6da..936483a 100644 --- a/src/Cocoar.Shelf.Client/src/views/LandingView.vue +++ b/src/Cocoar.Shelf.Client/src/views/LandingView.vue @@ -1,7 +1,7 @@ @@ -79,12 +118,12 @@ import { ref, computed, onMounted } from 'vue'; import { storeToRefs } from 'pinia'; import type { Product } from '@/core/models/shelf.models'; -import { usePreferencesStore } from '@/stores/preferences.store'; +import { usePreferencesStore, opennessLabel } from '@/stores/preferences.store'; import { useUI } from '@/composables/useUI'; const prefs = usePreferencesStore(); -const { showPreview, selectedTags } = storeToRefs(prefs); -const { toggleTag, clearTags } = prefs; +const { showPreview, selectedTags, opennessFilter } = storeToRefs(prefs); +const { toggleTag, clearTags, toggleOpenness } = prefs; const ui = useUI(); ui.set((ctx) => { @@ -125,6 +164,11 @@ const hasAnyPreviewContent = computed(() => ) ); +// Only surface the open-source/proprietary filter once at least one product is marked. +const hasAnyOpenness = computed(() => + eligibleProducts.value.some(p => p.openness && p.openness !== 'Unspecified') +); + // All unique tags across all registered products (sorted) const allTags = computed(() => { const set = new Set(); @@ -150,6 +194,10 @@ const visibleProducts = computed(() => { ); } + if (opennessFilter.value) { + products = products.filter(p => p.openness === opennessFilter.value); + } + return products; }); @@ -302,6 +350,66 @@ onMounted(async () => { border: 1px solid var(--coar-border-semantic-warning-subtle, #fde68a); } +.openness-badge { + font-size: 0.7em; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; +} + +.openness-badge--oss { + background: var(--coar-background-semantic-success-subtle, #dcfce7); + color: var(--coar-text-semantic-success, #166534); + border: 1px solid var(--coar-border-semantic-success-subtle, #bbf7d0); +} + +/* Proprietary = deliberately muted (no open source). */ +.openness-badge--prop { + background: var(--coar-background-neutral-secondary); + color: var(--coar-text-neutral-secondary); + border: 1px solid var(--coar-border-neutral-tertiary); +} + +.card-source { + margin-top: 12px; + align-self: flex-start; + font-size: 0.8em; + font-weight: 500; + color: var(--coar-text-neutral-tertiary); + text-decoration: none; +} + +.card-source:hover { + color: var(--coar-text-accent-primary); + text-decoration: underline; +} + +.toolbar-divider { + width: 1px; + align-self: stretch; + margin: 2px 4px; + background: var(--coar-border-neutral-tertiary); +} + +.openness-filter::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.openness-filter--oss::before { + background: var(--coar-text-semantic-success, #16a34a); +} + +.openness-filter--prop::before { + background: var(--coar-text-neutral-tertiary); +} + .card-desc { font-size: 0.9em; color: var(--coar-text-neutral-secondary); diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue index a6e0ffe..0ff1c99 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue @@ -9,7 +9,7 @@ import { useProductsStore } from '@/stores/products.store'; import { shelfApi } from '@/core/api/shelf-api'; import { ApiError } from '@/core/api/http'; import { generateApiKey, copyToClipboard } from '@/core/api-key-utils'; -import type { Principal, PrincipalRef } from '@/core/models/shelf.models'; +import type { Principal, PrincipalRef, ProductOpenness } from '@/core/models/shelf.models'; const props = defineProps<{ id: string @@ -29,12 +29,20 @@ const visibilityOptions = [ { value: 'preview', label: 'Preview' }, ]; +const opennessOptions = [ + { value: 'Unspecified', label: 'Unspecified' }, + { value: 'OpenSource', label: 'Open Source' }, + { value: 'Proprietary', label: 'Proprietary' }, +]; + const form = ref({ name: '', displayName: '', description: '', source: 'upload', visibility: 'public', + openness: 'Unspecified' as ProductOpenness, + repositoryUrl: '', restricted: false, readPrincipals: [] as PrincipalRef[], tags: [] as string[], @@ -120,6 +128,8 @@ async function loadProduct() { description: product.description ?? '', source: product.source, visibility: product.visibility, + openness: product.openness ?? 'Unspecified', + repositoryUrl: product.repositoryUrl ?? '', restricted: product.restricted ?? false, readPrincipals: [...(product.readPrincipals ?? [])], tags: [...(product.tags ?? [])], @@ -184,6 +194,9 @@ async function save() { description: form.value.description || undefined, source: form.value.source || undefined, visibility: form.value.visibility, + openness: form.value.openness, + // Always sent: '' clears the repo link, a value sets it. + repositoryUrl: form.value.repositoryUrl.trim(), restricted: form.value.restricted, readPrincipals: form.value.readPrincipals, tags: form.value.tags, @@ -294,6 +307,23 @@ async function onDeleteVersion(version: string) { +
+ + + + + + +
+ { const rowData = computed(() => productsStore.items); +const opennessLabels: Record = { + OpenSource: 'Open Source', + Proprietary: 'Proprietary', +}; + const builder = CoarGridBuilder.create() - .persistColumnState('shelf-products-v3') + .persistColumnState('shelf-products-v4') .option('getRowId', (p: any) => p.data.name) .rowDataRef(rowData) .searchHighlight() @@ -55,6 +60,8 @@ const builder = CoarGridBuilder.create() (col: any) => col.field('visibility').header('Visibility').width(110).option('minWidth', 100), (col: any) => col.field('restricted').header('Restricted').width(110).option('minWidth', 95) .option('valueGetter', (p: any) => (p.data?.restricted ? 'restricted' : '')), + (col: any) => col.field('openness').header('Source').width(120).option('minWidth', 100) + .option('valueGetter', (p: any) => opennessLabels[p.data?.openness] ?? ''), (col: any) => col.field('latest').header('Latest').width(110).option('minWidth', 90), (col: any) => col.field('versions').header('Versions').width(100).option('minWidth', 95) .option('valueGetter', (p: any) => p.data?.versions?.length ?? 0), diff --git a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs index 1c8df79..f961d3b 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs @@ -82,6 +82,8 @@ private static async Task GetProducts( config.ReadPrincipals, config.Tags, config.ShowWhenEmpty, + config.Openness, + config.RepositoryUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey), Latest = manifest?.Latest, Versions = manifest?.Versions ?? (IReadOnlyList)[] @@ -163,6 +165,8 @@ private static async Task GetProduct( config.ReadPrincipals, config.Tags, config.ShowWhenEmpty, + config.Openness, + config.RepositoryUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey), Latest = manifest?.Latest, Versions = manifest?.Versions ?? (IReadOnlyList)[] @@ -343,7 +347,9 @@ private static async Task CreateProduct( ShowWhenEmpty = request.ShowWhenEmpty ?? false, ApiKey = request.ApiKey, Restricted = request.Restricted ?? false, - ReadPrincipals = NormalizePrincipals(request.ReadPrincipals) + ReadPrincipals = NormalizePrincipals(request.ReadPrincipals), + Openness = request.Openness ?? ProductOpenness.Unspecified, + RepositoryUrl = NormalizeUrl(request.RepositoryUrl) }; await configService.CreateAsync(config); @@ -384,7 +390,10 @@ private static async Task UpdateProduct( ShowWhenEmpty = request.ShowWhenEmpty ?? existing.ShowWhenEmpty, ApiKey = request.ApiKey ?? existing.ApiKey, Restricted = request.Restricted ?? existing.Restricted, - ReadPrincipals = request.ReadPrincipals != null ? NormalizePrincipals(request.ReadPrincipals) : existing.ReadPrincipals + ReadPrincipals = request.ReadPrincipals != null ? NormalizePrincipals(request.ReadPrincipals) : existing.ReadPrincipals, + Openness = request.Openness ?? existing.Openness, + // null = keep, "" = clear the repo link, value = set. + RepositoryUrl = request.RepositoryUrl == null ? existing.RepositoryUrl : NormalizeUrl(request.RepositoryUrl) }; await configService.UpdateAsync(config); @@ -537,6 +546,10 @@ private static async Task DeleteVersion( private static IReadOnlyList NormalizeTags(IReadOnlyList? tags) => tags == null ? [] : tags.Select(t => t.Trim()).Where(t => t.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(); + // Trim; an empty/whitespace URL is stored as null (no repo link). + private static string? NormalizeUrl(string? url) => + string.IsNullOrWhiteSpace(url) ? null : url.Trim(); + // Trim, drop blanks, dedupe (user emails case-insensitively). private static IReadOnlyList NormalizePrincipals(IReadOnlyList? principals) => principals == null ? [] : principals diff --git a/src/Cocoar.Shelf/Models/ProductConfig.cs b/src/Cocoar.Shelf/Models/ProductConfig.cs index a2b87df..94d0996 100644 --- a/src/Cocoar.Shelf/Models/ProductConfig.cs +++ b/src/Cocoar.Shelf/Models/ProductConfig.cs @@ -30,5 +30,18 @@ public class ProductConfig public bool ShowWhenEmpty { get; set; } + /// + /// Whether this product's source is public. Display-only: drives a landing-page badge so + /// open-source and proprietary docs can be hosted side by side and clearly told apart. + /// Default ⇒ no badge. + /// + public ProductOpenness Openness { get; set; } = ProductOpenness.Unspecified; + + /// + /// Optional source-repository URL. When set, the landing card shows a "Source ↗" link — typically + /// only for open-source products; leave empty so proprietary docs carry no repo link. + /// + public string? RepositoryUrl { get; set; } + public string? ApiKey { get; set; } } diff --git a/src/Cocoar.Shelf/Models/ProductOpenness.cs b/src/Cocoar.Shelf/Models/ProductOpenness.cs new file mode 100644 index 0000000..a632cd5 --- /dev/null +++ b/src/Cocoar.Shelf/Models/ProductOpenness.cs @@ -0,0 +1,14 @@ +namespace Cocoar.Shelf.Models; + +/// +/// Whether a product's source code is publicly available. Purely a display/labeling dimension +/// (orthogonal to and ): +/// it drives a badge on the landing page so proprietary docs can be hosted alongside open-source ones +/// and be clearly distinguished. ⇒ no badge (unchanged behavior). +/// +public enum ProductOpenness +{ + Unspecified = 0, + OpenSource = 1, + Proprietary = 2, +} diff --git a/src/Cocoar.Shelf/Models/ProductRequests.cs b/src/Cocoar.Shelf/Models/ProductRequests.cs index 057ef54..0e520e5 100644 --- a/src/Cocoar.Shelf/Models/ProductRequests.cs +++ b/src/Cocoar.Shelf/Models/ProductRequests.cs @@ -1,5 +1,5 @@ namespace Cocoar.Shelf.Models; -public record CreateProductRequest(string Name, string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null, IReadOnlyList? ReadPrincipals = null); +public record CreateProductRequest(string Name, string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null, IReadOnlyList? ReadPrincipals = null, ProductOpenness? Openness = null, string? RepositoryUrl = null); -public record UpdateProductRequest(string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null, IReadOnlyList? ReadPrincipals = null); +public record UpdateProductRequest(string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null, IReadOnlyList? ReadPrincipals = null, ProductOpenness? Openness = null, string? RepositoryUrl = null); diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs index 8dbd6b4..8662b06 100644 --- a/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs @@ -32,6 +32,82 @@ public async Task GetProducts_ReturnsRegisteredProduct() Assert.Equal("Test Product", product.GetProperty("displayName").GetString()); } + [Fact] + public async Task CreateProduct_PersistsOpennessAndRepositoryUrl() + { + await CreateProductViaApi(new + { + name = "test-oss", + displayName = "OSS Product", + source = "upload", + openness = "OpenSource", + repositoryUrl = "https://github.com/cocoar-dev/example" + }); + + var product = await GetProduct("test-oss"); + Assert.Equal("OpenSource", product.GetProperty("openness").GetString()); + Assert.Equal("https://github.com/cocoar-dev/example", product.GetProperty("repositoryUrl").GetString()); + } + + [Fact] + public async Task CreateProduct_DefaultsToUnspecified_WithNoRepositoryUrl() + { + await CreateProductViaApi(new { name = "test-default-openness", source = "upload" }); + + var product = await GetProduct("test-default-openness"); + Assert.Equal("Unspecified", product.GetProperty("openness").GetString()); + Assert.Equal(JsonValueKind.Null, product.GetProperty("repositoryUrl").ValueKind); + } + + [Fact] + public async Task UpdateProduct_ClearsRepositoryUrl_WhenEmptyString() + { + await CreateProductViaApi(new + { + name = "test-clear-repo", + source = "upload", + openness = "Proprietary", + repositoryUrl = "https://example.com/private" + }); + + // Empty string clears the repo link; the openness marker is left untouched. + await UpdateProductViaApi("test-clear-repo", new { repositoryUrl = "" }); + + var product = await GetProduct("test-clear-repo"); + Assert.Equal(JsonValueKind.Null, product.GetProperty("repositoryUrl").ValueKind); + Assert.Equal("Proprietary", product.GetProperty("openness").GetString()); + } + + private async Task CreateProductViaApi(object body) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + var response = await _client.SendAsync(request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + private async Task UpdateProductViaApi(string name, object body) + { + var request = new HttpRequestMessage(HttpMethod.Put, $"/_api/products/{name}") + { + Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + var response = await _client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private async Task GetProduct(string name) + { + var response = await _client.GetAsync($"/_api/products/{name}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + return JsonDocument.Parse(json).RootElement.Clone(); + } + [Fact] public async Task GetVersions_Returns404_WhenProductNotRegistered() { diff --git a/website/guide/admin-ui.md b/website/guide/admin-ui.md index adce45f..1405f77 100644 --- a/website/guide/admin-ui.md +++ b/website/guide/admin-ui.md @@ -33,6 +33,8 @@ The product dialog is a modal with four tabs: - **Name** — product identifier used in URLs (e.g. `configuration`). Cannot be changed after creation. - **Visibility** — `public` or `preview` - **Display Name / Description** — shown on the landing page +- **Source** — mark the product *Open Source* or *Proprietary* (or leave *Unspecified*). This shows as a colored badge on the landing page so open-source and closed-source docs can sit side by side and be told apart at a glance. Display-only — it does not restrict access (use **Restricted** for that). +- **Repository URL** — optional source-repository link. When set, the landing card shows a discreet "Source ↗" link; leave it empty and the product carries no repo link (typical for proprietary products). - **Show when empty** — display on the landing page before any version is deployed ("Coming soon" teaser) ### Access diff --git a/website/guide/configuration.md b/website/guide/configuration.md index fb69c18..b637065 100644 --- a/website/guide/configuration.md +++ b/website/guide/configuration.md @@ -93,6 +93,8 @@ Shelf serves a Vue SPA as the landing page at the root URL (`/` or `{PathBase}/` All tags used across any registered product are automatically collected and displayed as clickable filter chips in a toolbar above the product grid. Clicking a tag narrows the visible products to those that carry that tag. Multiple tags can be active simultaneously (AND filter). The active selection is persisted in `localStorage` so visitors get the same view on their next visit. +Next to the tags, once any product is marked [Open Source or Proprietary](./product-registration.md#openness-repository-link), the toolbar also shows **Open Source** / **Proprietary** filter chips so visitors can narrow the list to one kind. This selection is persisted the same way. + ### Preview Toggle Products with `visibility: "preview"` and pre-release-only versions are hidden by default. A "Show preview" toggle reveals: diff --git a/website/guide/product-registration.md b/website/guide/product-registration.md index 003f1f3..119151f 100644 --- a/website/guide/product-registration.md +++ b/website/guide/product-registration.md @@ -106,6 +106,8 @@ Each JSON file describes one product: | `description` | No | Short description of the product | | `source` | No | Deployment source type. Default: `"upload"` | | `visibility` | No | `"public"` or `"preview"`. Default: `"public"` | +| `openness` | No | `"OpenSource"`, `"Proprietary"` or `"Unspecified"`. Drives a badge + filter on the landing page (display-only). Default: `"Unspecified"` | +| `repositoryUrl` | No | Optional source-repository link; when set, the landing card shows a "Source ↗" link | | `tags` | No | List of free-form labels (e.g. `["C#", "UI"]`). Used for filtering on the landing page | | `showWhenEmpty` | No | Show on landing page even without any deployed versions. Default: `false` | @@ -136,6 +138,19 @@ Tags are stored as an array of strings. Shelf normalises them automatically: lea } ``` +### Openness & Repository Link + +The `openness` field marks whether a product's source is public — `"OpenSource"`, `"Proprietary"`, or `"Unspecified"` (the default). Marked products get a colored badge on the landing page and can be filtered by it, so open-source and proprietary documentation can be hosted side by side and told apart at a glance. It is a **display marker only** — it does not restrict access. To actually hide a product, mark it [restricted](./admin-ui.md#access-control) instead. + +Set `repositoryUrl` to show a discreet "Source ↗" link on the product card — typically for open-source products. Leave it empty and the card carries no repository link, which is the usual choice for proprietary products. + +```json +{ + "openness": "OpenSource", + "repositoryUrl": "https://github.com/cocoar-dev/configuration" +} +``` + ### Show When Empty By default, a product only appears on the landing page once it has at least one deployed version. Set `showWhenEmpty: true` to display the product card immediately — even before any documentation is uploaded. The card is rendered in a distinct teaser style with a "Coming soon" indicator.