diff --git a/documentation/notes/wu.resource-pages.md b/documentation/notes/wu.resource-pages.md index 8be9510..e1eb9f0 100644 --- a/documentation/notes/wu.resource-pages.md +++ b/documentation/notes/wu.resource-pages.md @@ -34,6 +34,8 @@ A ResourcePage is assembled from three layers: Runtime code owns graph discovery and source resolution. Templates and stylesheets should arrange already-resolved document and panel data; they should not read RDF graphs, local files, remote URLs, mesh inventories, or config sources themselves. +Repository-backed payload pages show Repository Source instead of a duplicate Working File row. For a safe GitHub floating repository locator, Weave renders the repository URL and repository-relative path as one `blob/HEAD/` browse link. `HEAD` reflects the floating working source; exact release identity remains on the relevant HistoricalState. + ## Built-In Panels The default Semantic Site presentation currently supports these generated panels: diff --git a/src/core/weave/resource_page_models.ts b/src/core/weave/resource_page_models.ts index 369badd..0958608 100644 --- a/src/core/weave/resource_page_models.ts +++ b/src/core/weave/resource_page_models.ts @@ -144,12 +144,18 @@ export interface ResourcePageDocumentModel { stylesheetHrefs?: readonly string[]; title: string; summary?: string; + summaryLink?: ResourcePageSummaryLinkModel; rdfClasses: readonly ResourcePageRdfClassModel[]; breadcrumbs: readonly ResourcePageBreadcrumbModel[]; metadata: readonly ResourcePageMetadataModel[]; panels: readonly ResourcePagePanelModel[]; } +export interface ResourcePageSummaryLinkModel { + label: string; + href: string; +} + export interface ResourcePageBreadcrumbModel { label: string; href?: string; diff --git a/src/runtime/weave/page_model_assembly.ts b/src/runtime/weave/page_model_assembly.ts index 16e226a..98e25d6 100644 --- a/src/runtime/weave/page_model_assembly.ts +++ b/src/runtime/weave/page_model_assembly.ts @@ -456,9 +456,8 @@ function describeSemanticFlowResource( } const stateHistory = findHistoryForState(resourcePath, historyGroups); if (stateHistory) { - return `Historical state for the ${ - toLastPathSegment(stateHistory.path) - } artifact history`; + const historyLabel = toLastPathSegment(stateHistory.path); + return `Historical state for the ${historyLabel} artifact history`; } if (historyGroups.some((group) => group.path === resourcePath)) { const ownerResourcePath = dirname(resourcePath); diff --git a/src/runtime/weave/pages.ts b/src/runtime/weave/pages.ts index df5da2c..90c0f65 100644 --- a/src/runtime/weave/pages.ts +++ b/src/runtime/weave/pages.ts @@ -18,6 +18,7 @@ import type { ResourcePageReferenceLinkModel, ResourcePageReferenceTargetLinkModel, ResourcePageSectionModel, + ResourcePageSummaryLinkModel, } from "../../core/weave/resource_page_models.ts"; import { Parser, type Quad, type Term } from "n3"; import { codeToHtml } from "shiki"; @@ -46,6 +47,7 @@ import { SFLO_NAMESPACE, SFLO_PREFIX, } from "../../core/rdf/namespaces.ts"; +import { findHistoryForState } from "../../core/weave/resource_page_history_groups.ts"; interface ResourcePageRenderInput { meshLabel: string; @@ -62,6 +64,7 @@ interface ResourcePageRenderInput { title: string; breadcrumbs: readonly ResourcePageBreadcrumb[]; summary?: string; + summaryLink?: ResourcePageSummaryLinkModel; rdfClasses: readonly ResourcePageRdfClass[]; metadataRows: readonly ResourcePageMetadataRow[]; childrenRows: readonly ResourcePageMetadataRow[]; @@ -832,6 +835,10 @@ function toDefaultResourcePageDocumentModel( ? rdfFacts.classes : [classifyResourcePage(resourcePath, page.historyGroups ?? [])]; const resourcePathArtifactRole = artifactRoleForResourcePath(resourcePath); + const stateHistory = findHistoryForState( + resourcePath, + page.historyGroups ?? [], + ); return { kind: "simple", @@ -853,6 +860,14 @@ function toDefaultResourcePageDocumentModel( resourcePath, ), summary: page.description, + ...(stateHistory + ? { + summaryLink: { + label: toLastPathSegment(stateHistory.path), + href: toMeshResourceHref(meshRootHref, stateHistory.path), + }, + } + : {}), rdfClasses, metadata: [ { label: "Canonical IRI", value: canonical }, @@ -1131,6 +1146,7 @@ function toResourcePageRenderInput( title: document.title, breadcrumbs: document.breadcrumbs, summary: document.summary, + summaryLink: document.summaryLink, rdfClasses: document.rdfClasses, metadataRows: toRenderMetadataRows( document.meshRootHref, @@ -1237,6 +1253,21 @@ function toRenderMetadataRow( if (row.kind === "repositorySource") { const repositoryUrl = row.repositorySource.repositoryUrl; const repositoryPathFromRoot = row.repositorySource.repositoryPathFromRoot; + const repositoryFileUrl = toGitHubRepositoryFileUrl( + repositoryUrl, + repositoryPathFromRoot, + ); + if (repositoryFileUrl) { + return { + label: row.label, + value: repositoryFileUrl, + html: `${ + escapeHtml(repositoryFileUrl) + }`, + }; + } const repositoryUrlHtml = isSafeHttpUrl(repositoryUrl) ? `${ + escapeHtml(link.label) + }${escapeHtml(after)}`; +} + async function renderDefaultResourcePage( input: ResourcePageRenderInput, ): Promise { @@ -1387,7 +1436,9 @@ async function renderDefaultResourcePage( ? ` \n` : ""; const summary = input.summary - ? `

${escapeHtml(input.summary)}

\n` + ? `

${ + renderSummary(input.summary, input.summaryLink) + }

\n` : ""; const classes = input.rdfClasses.length > 0 ? `

a ${ @@ -1861,6 +1912,44 @@ function isSafeHttpUrl(value: string): boolean { } } +function toGitHubRepositoryFileUrl( + repositoryUrl: string, + repositoryPathFromRoot: string, +): string | undefined { + if (!isSafeHttpUrl(repositoryUrl)) { + return undefined; + } + const url = new URL(repositoryUrl); + if ( + url.hostname.toLowerCase() !== "github.com" || + url.username || + url.password || + url.search || + url.hash + ) { + return undefined; + } + const repositorySegments = url.pathname.split("/").filter(Boolean); + if (repositorySegments.length !== 2) { + return undefined; + } + const [owner, repositoryWithSuffix] = repositorySegments; + const repository = repositoryWithSuffix!.replace(/\.git$/i, ""); + const fileSegments = repositoryPathFromRoot.split("/"); + if ( + !owner || !repository || fileSegments.length === 0 || + fileSegments.some((segment) => + !segment || segment === "." || segment === ".." || segment.includes("\\") + ) + ) { + return undefined; + } + const encodedPath = fileSegments.map(encodeURIComponent).join("/"); + return `https://github.com/${encodeURIComponent(owner)}/${ + encodeURIComponent(repository) + }/blob/HEAD/${encodedPath}`; +} + function toExtractionSourceMetadataRows( meshRootHref: string, meshLabel: string, diff --git a/src/runtime/weave/pages_test.ts b/src/runtime/weave/pages_test.ts index 72edaae..83183ee 100644 --- a/src/runtime/weave/pages_test.ts +++ b/src/runtime/weave/pages_test.ts @@ -327,7 +327,7 @@ Deno.test("renderResourcePage renders URL and floating repository working locato ); assertStringIncludes( html, - 'Repository Sourcehttps://github.com/semantic-flow/sflo.gitsemantic-flow-core-ontology.ttl', + 'Repository Sourcehttps://github.com/semantic-flow/sflo/blob/HEAD/semantic-flow-core-ontology.ttl', ); assertFalse( html.includes( diff --git a/tests/integration/weave_test.ts b/tests/integration/weave_test.ts index 1ca0f83..a5cd61a 100644 --- a/tests/integration/weave_test.ts +++ b/tests/integration/weave_test.ts @@ -2880,6 +2880,10 @@ Deno.test("executeGenerate lists every sidecar payload history with working hist releaseStatePage, '

a sflo:HistoricalState

', ); + assertStringIncludes( + releaseStatePage, + '

Historical state for the releases artifact history

', + ); assertStringIncludes( releaseStatePage, "Manifestations", @@ -3107,7 +3111,7 @@ Deno.test("executeGenerate renders working URL and floating repository source lo ); assertStringIncludes( page, - 'Repository Sourcehttps://github.com/semantic-flow/mesh-sidecar-fantasy-rules.gitontology/fantasy-rules-ontology.ttl', + 'Repository Sourcehttps://github.com/semantic-flow/mesh-sidecar-fantasy-rules/blob/HEAD/ontology/fantasy-rules-ontology.ttl', ); assertFalse( page.includes(