/** * Publish artifact types and builders for gno.sh export. * * @module src/publish/artifact */ import type { EgressLineage } from "../core/egress-provenance"; import type { DocumentRow } from "../store/types"; import type { KnownPublishRequiredCapability, PublishArtifactAsset, } from "./artifact-assets"; import { deriveDocid } from "../app/constants"; import { contextCapsuleEvidenceIdentity, sha256Text, } from "../core/context-capsule-validation"; import { egressLineageSchema, legacyLocalOnlyEgressLineage, } from "../core/egress-provenance"; import { stripFrontmatter } from "../ingestion/frontmatter"; import { MAX_PUBLISH_SLUG_LENGTH, requirePublishDateTime, validateAndProjectEncryptedPublishInput, validateAndProjectPublishSpaceInput, } from "./artifact-validation"; export { MAX_PUBLISH_SLUG_LENGTH } from "./artifact-validation"; export { buildExportedMetadata } from "./metadata"; export type { PublishArtifactAsset, PublishArtifactAssetReference, SupportedRasterMediaType, } from "./artifact-assets"; export { BUNDLED_RASTER_ASSETS_CAPABILITY, GNO_ASSET_SENTINEL_PATTERN, MAX_PUBLISH_UPLOAD_BYTES, PUBLISH_ASSET_DIAGNOSTIC_CODES, PUBLISH_ASSET_LIFECYCLE_TERMINALS, PUBLISH_ASSET_VISIBILITY, SUPPORTED_RASTER_MEDIA_TYPES, formatGnoAssetSentinel, measureArtifactUploadBytes, measureSerializedUploadBytes, parseGnoAssetSentinel, serializePublishArtifact, sniffRasterMediaType, validatePublishAssetContract, } from "./artifact-assets"; export { attachAssetsToV1Artifact, buildDeterministicAssets, emptyAssetEgressSummary, summarizeAssetEgress, } from "./attachment-resolver"; export type { AttachmentDiagnostic, PublishAssetEgressSummary, } from "./attachment-resolver"; export type PublishVisibility = | "encrypted" | "invite-only" | "public" | "secret-link"; export interface PublishArtifactNote { id?: string; markdown: string; metadata?: Record; slug: string; summary: string; title: string; } export const PUBLIC_PUBLISH_MANIFEST_SCHEMA_VERSION = "1.0" as const; export const PUBLIC_PUBLISH_CAPABILITIES = { capsuleEvidence: true, exactLineCitations: true, llmsTxt: true, markdownDocuments: true, } as const; export interface PublicPublishEvidence { docid: string; endLine: number; evidenceId: string; locator: string; mirrorHash: string; passageHash: string; sourceHash: string; startLine: number; uri: string; } export interface PublicPublishDocument { byteLength: number; contentHash: string; evidence: PublicPublishEvidence; lineCount: number; markdownPath: string; slug: string; summary: string; title: string; } export interface PublicPublishManifest { capabilities: typeof PUBLIC_PUBLISH_CAPABILITIES; documents: PublicPublishDocument[]; egressLineage: EgressLineage; generatedAt: string; projectionRevision: string; schemaVersion: typeof PUBLIC_PUBLISH_MANIFEST_SCHEMA_VERSION; visibility: "public"; } interface PublishArtifactSpaceBase { homeNoteSlug?: string; notes: PublishArtifactNote[]; routeSlug: string; sourceType: "note" | "collection"; summary: string; title: string; } export interface PublicPublishArtifactSpace extends PublishArtifactSpaceBase { manifest: PublicPublishManifest; visibility: "public"; } export interface RestrictedPublishArtifactSpace extends PublishArtifactSpaceBase { manifest?: never; visibility: "invite-only" | "secret-link"; } export type PublishArtifactSpace = | PublicPublishArtifactSpace | RestrictedPublishArtifactSpace; export interface EncryptedArtifactPayload { ciphertext: string; iterations: number; iv: string; salt: string; } export interface EncryptedPublishArtifactSpace { noteIds?: string[]; encryptedPayload: EncryptedArtifactPayload; routeSlug: string; secretToken: string; sourceType: "note" | "collection"; visibility: "encrypted"; } export interface PublishArtifactV1 { assets?: PublishArtifactAsset[]; egressLineage: EgressLineage; exportedAt: string; requiredCapabilities?: KnownPublishRequiredCapability[]; source: string; spaces: PublishArtifactSpace[]; version: 1; } export interface PublishArtifactV2 { egressLineage: EgressLineage; exportedAt: string; requiredCapabilities?: KnownPublishRequiredCapability[]; source: string; spaces: EncryptedPublishArtifactSpace[]; version: 2; } export type PublishArtifact = PublishArtifactV1 | PublishArtifactV2; export const PUBLISH_VISIBILITY_VALUES = [ "public", "secret-link", "invite-only", "encrypted", ] as const; export const isPublishVisibility = ( value: unknown ): value is PublishVisibility => typeof value === "string" && PUBLISH_VISIBILITY_VALUES.includes(value as PublishVisibility); export const slugify = (value: string) => value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); function toPublishSlugCandidate(value: string): string { return slugify(value).slice(0, MAX_PUBLISH_SLUG_LENGTH).replace(/-+$/g, ""); } export function derivePublishSlug( candidates: Array, fallback = "untitled" ): string { for (const candidate of candidates) { const slug = toPublishSlugCandidate(candidate); if (slug.length > 0) { return slug; } } return fallback; } export const normalizePublishSlug = (value: string, fallback?: string) => derivePublishSlug([value], fallback); const basenameWithoutExt = (value: string) => value .split("/") .pop() ?.replace(/\.[^.]+$/, "") ?? value; export const deriveExportedTitle = ( doc: Pick ) => doc.title?.trim() || basenameWithoutExt(doc.relPath); export const deriveExportedSlug = ( doc: Pick ) => derivePublishSlug([ deriveExportedTitle(doc), doc.relPath.replace(/\.[^.]+$/, "").replaceAll("/", "-"), ]); export const deriveExportedSummary = ( markdown: string, metadata: Record ) => { const metadataSummary = typeof metadata.description === "string" ? metadata.description : typeof metadata.summary === "string" ? metadata.summary : null; if (metadataSummary?.trim()) { return metadataSummary.trim(); } const plain = stripFrontmatter(markdown) .split("\n") .map((line) => line.trim()) .filter( (line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!") && !line.startsWith("```") ) .join(" ") .replace(/\s+/g, " ") .trim(); return plain.slice(0, 200).trim(); }; const compareCodeUnits = (left: string, right: string): number => { if (left < right) return -1; if (left > right) return 1; return 0; }; const canonicalizeJsonValue = (value: unknown): unknown => { if (Array.isArray(value)) { return value.map(canonicalizeJsonValue); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value) .sort(([left], [right]) => compareCodeUnits(left, right)) .map(([key, entry]) => [key, canonicalizeJsonValue(entry)]) ); } return value; }; const canonicalJson = (value: unknown): string => JSON.stringify(canonicalizeJsonValue(value)); const buildPublicPublishDocument = ( routeSlug: string, note: PublishArtifactNote ): PublicPublishDocument => { const contentHash = sha256Text(note.markdown); const docid = deriveDocid(contentHash); const lineCount = note.markdown.split("\n").length; const markdownPath = `./${note.slug}.md`; const uri = `gno://public/${routeSlug}/${note.slug}.md`; const evidenceBase = { uri, docid, startLine: 1, endLine: lineCount, sourceHash: contentHash, mirrorHash: contentHash, passageHash: contentHash, }; return { byteLength: new TextEncoder().encode(note.markdown).byteLength, contentHash, evidence: { ...evidenceBase, evidenceId: contextCapsuleEvidenceIdentity(evidenceBase), locator: `${markdownPath}#L1-L${lineCount}`, }, lineCount, markdownPath, slug: note.slug, summary: note.summary, title: note.title, }; }; export const buildPublicPublishManifest = (input: { egressLineage?: EgressLineage; exportedAt: string; homeNoteSlug?: string; notes: PublishArtifactNote[]; routeSlug: string; sourceType: "note" | "collection"; summary: string; title: string; visibility: "public"; }): PublicPublishManifest => { const { egressLineage: providedLineage, ...spaceInput } = input; const validated = validateAndProjectPublishSpaceInput(spaceInput); const egressLineage = egressLineageSchema.parse( providedLineage ?? legacyLocalOnlyEgressLineage("legacy") ); if (validated.visibility !== "public") { throw new Error("Public publish manifests require public visibility"); } const exportedAt = requirePublishDateTime(input.exportedAt); const documents = validated.notes .map((note) => buildPublicPublishDocument(validated.routeSlug, note)) .sort( (left, right) => compareCodeUnits(left.slug, right.slug) || compareCodeUnits(left.title, right.title) || compareCodeUnits(left.contentHash, right.contentHash) ); const revisionProjection = { capabilities: PUBLIC_PUBLISH_CAPABILITIES, documents, egressLineage, homeNoteSlug: validated.homeNoteSlug ?? null, notes: validated.notes .map((note) => ({ markdown: note.markdown, metadata: note.metadata ?? {}, slug: note.slug, summary: note.summary, title: note.title, })) .sort((left, right) => compareCodeUnits(left.slug, right.slug)), routeSlug: validated.routeSlug, sourceType: validated.sourceType, summary: validated.summary, title: validated.title, visibility: validated.visibility, }; return { capabilities: { ...PUBLIC_PUBLISH_CAPABILITIES }, documents, egressLineage, generatedAt: exportedAt, projectionRevision: sha256Text(canonicalJson(revisionProjection)), schemaVersion: PUBLIC_PUBLISH_MANIFEST_SCHEMA_VERSION, visibility: validated.visibility, }; }; export const buildPublishArtifact = (input: { egressLineage?: EgressLineage; homeNoteSlug?: string; notes: PublishArtifactNote[]; routeSlug: string; sourceType: "note" | "collection"; summary: string; title: string; visibility: Exclude; }): PublishArtifactV1 => { const { egressLineage: providedLineage, ...spaceInput } = input; const validated = validateAndProjectPublishSpaceInput(spaceInput); const egressLineage = egressLineageSchema.parse( providedLineage ?? legacyLocalOnlyEgressLineage("legacy") ); const exportedAt = new Date().toISOString(); const base = { ...(validated.homeNoteSlug === undefined ? {} : { homeNoteSlug: validated.homeNoteSlug }), notes: validated.notes, routeSlug: validated.routeSlug, sourceType: validated.sourceType, summary: validated.summary, title: validated.title, }; const space: PublishArtifactSpace = validated.visibility === "public" ? { ...base, manifest: buildPublicPublishManifest({ egressLineage, exportedAt, homeNoteSlug: validated.homeNoteSlug, notes: validated.notes, routeSlug: validated.routeSlug, sourceType: validated.sourceType, summary: validated.summary, title: validated.title, visibility: validated.visibility, }), visibility: validated.visibility, } : { ...base, visibility: validated.visibility, }; return { egressLineage, exportedAt, source: validated.routeSlug, spaces: [space], version: 1, }; }; export const buildEncryptedPublishArtifact = (input: { noteIds?: string[]; egressLineage?: EgressLineage; encryptedPayload: EncryptedArtifactPayload; requiredCapabilities?: KnownPublishRequiredCapability[]; routeSlug: string; secretToken: string; sourceType: "note" | "collection"; }): PublishArtifactV2 => { const { egressLineage: providedLineage, requiredCapabilities, ...encryptedInput } = input; const validated = validateAndProjectEncryptedPublishInput(encryptedInput); const egressLineage = egressLineageSchema.parse( providedLineage ?? legacyLocalOnlyEgressLineage("legacy") ); const exportedAt = requirePublishDateTime(new Date().toISOString()); const artifact: PublishArtifactV2 = { egressLineage, exportedAt, source: validated.routeSlug, spaces: [ { ...(validated.noteIds === undefined ? {} : { noteIds: validated.noteIds }), encryptedPayload: validated.encryptedPayload, routeSlug: validated.routeSlug, secretToken: validated.secretToken, sourceType: validated.sourceType, visibility: "encrypted", }, ], version: 2, }; // Capability is declared on the outer envelope; plaintext assets stay inside ciphertext. if (requiredCapabilities && requiredCapabilities.length > 0) { artifact.requiredCapabilities = [...requiredCapabilities]; } return artifact; }; export const derivePublishArtifactFilename = (artifact: PublishArtifact) => { const routeSlug = artifact.spaces[0]?.routeSlug.trim() || normalizePublishSlug(artifact.source, "publish-artifact"); return `${routeSlug || "publish-artifact"}.json`; };