diff --git a/catalog-app/listing/listing.gts b/catalog-app/listing/listing.gts index ed518456..52d22084 100644 --- a/catalog-app/listing/listing.gts +++ b/catalog-app/listing/listing.gts @@ -934,6 +934,8 @@ export class Listing extends CardDef { @field license = linksTo(() => License, { searchable: true }); @field images = linksToMany(ImageDef, { searchable: true }); @field examples = linksToMany(() => CardDef); + // installed alongside examples but never rendered in the listing detail view + @field supportingCards = linksToMany(() => CardDef); @field skills = linksToMany(() => Skill); @field cardTitle = contains(StringField, { diff --git a/commands/collect-submission-files.ts b/commands/collect-submission-files.ts index e50890ef..1ff8476c 100644 --- a/commands/collect-submission-files.ts +++ b/commands/collect-submission-files.ts @@ -109,10 +109,13 @@ export default class CollectSubmissionFilesCommand extends Command< ); } - let examplesToSnapshot = listing.examples; + let examplesToSnapshot = [ + ...(listing.examples ?? []), + ...(listing.supportingCards ?? []), + ]; let fileDefUrls = new Set(); - if (listing.examples?.length) { - let expanded = await this.expandInstances(listing.examples); + if (examplesToSnapshot.length) { + let expanded = await this.expandInstances(examplesToSnapshot); examplesToSnapshot = expanded.instances; fileDefUrls = expanded.fileDefUrls; } diff --git a/commands/listing-create.ts b/commands/listing-create.ts index c72361cc..8f0f7ed8 100644 --- a/commands/listing-create.ts +++ b/commands/listing-create.ts @@ -96,6 +96,9 @@ export default class ListingCreateCommand extends Command< input: BaseCommandModule.ListingCreateInput, ): Promise { let { openCardIds, codeRef, targetRealm } = input; + // tolerate a base ListingCreateInput that predates supportingCardIds + let explicitSupportingCardIds = (input as { supportingCardIds?: string[] }) + .supportingCardIds; if (!codeRef) { throw new Error('codeRef is required'); @@ -118,12 +121,24 @@ export default class ListingCreateCommand extends Command< const displayName = (cardDef as any)?.displayName ?? codeRef.name; const catalogRealm = await this.getCatalogRealm(); + const classified = await this.classifyOpenCards(openCardIds, codeRef); + const exampleCardIds = classified.exampleCardIds; + // explicit supporting cards are taken verbatim (no type check); a card + // also picked as an example stays an example + const supportingCardIds = [ + ...new Set([ + ...classified.supportingCardIds, + ...(explicitSupportingCardIds ?? []), + ]), + ].filter((id) => !exampleCardIds.includes(id)); + let relationships: Record = {}; - if (openCardIds && openCardIds.length > 0) { - openCardIds.forEach((id, index) => { - relationships[`examples.${index}`] = { links: { self: id } }; - }); - } + exampleCardIds.forEach((id, index) => { + relationships[`examples.${index}`] = { links: { self: id } }; + }); + supportingCardIds.forEach((id, index) => { + relationships[`supportingCards.${index}`] = { links: { self: id } }; + }); const listingDoc: LooseSingleCardDocument = { data: { @@ -144,12 +159,12 @@ export default class ListingCreateCommand extends Command< const commandModule = await loadCommandModule(this.commandContext); const listingCard = listing as CardAPI.CardDef; - const firstOpenCardId = openCardIds?.[0]; + const firstExampleCardId = exampleCardIds[0]; const examplePromise = this.autoLinkExample( listingCard, codeRef, - openCardIds, + exampleCardIds, ); const backgroundTasks = [ @@ -190,7 +205,7 @@ export default class ListingCreateCommand extends Command< promise: this.linkSpecs( listingCard, targetRealm, - firstOpenCardId ?? codeRef?.module, + firstExampleCardId ?? codeRef?.module, codeRef.module, codeRef, ), @@ -216,6 +231,69 @@ export default class ListingCreateCommand extends Command< return result; } + // Open cards that are instances of the listed type become examples; other + // open cards (e.g. data a query-based example depends on) become + // supportingCards, which install but don't render in the listing detail. + private async classifyOpenCards( + openCardIds: string[] | undefined, + codeRef: ResolvedCodeRef, + ): Promise<{ exampleCardIds: string[]; supportingCardIds: string[] }> { + const classified = await Promise.all( + (openCardIds ?? []).map(async (id) => { + try { + const instance = await new GetCardCommand( + this.commandContext, + ).execute({ cardId: id }); + return { + id, + isExample: + isCardInstance(instance) && + this.isInstanceOfCodeRef(instance as CardAPI.CardDef, codeRef), + }; + } catch { + return { id, isExample: true }; + } + }), + ); + return { + exampleCardIds: classified.filter((c) => c.isExample).map((c) => c.id), + supportingCardIds: classified + .filter((c) => !c.isExample) + .map((c) => c.id), + }; + } + + private isInstanceOfCodeRef( + instance: CardAPI.CardDef, + codeRef: ResolvedCodeRef, + ): boolean { + let targetModule: string; + try { + targetModule = trimExecutableExtension(rri(new URL(codeRef.module).href)); + } catch { + return false; + } + let current: CardAPI.BaseDefConstructor | undefined = + instance.constructor as CardAPI.BaseDefConstructor; + while (current) { + const ref = identifyCard(current); + if (ref && !('type' in ref) && ref.name === codeRef.name) { + try { + if ( + trimExecutableExtension(rri(new URL(ref.module).href)) === + targetModule + ) { + return true; + } + } catch { + // unresolvable ancestor module; keep walking + } + } + current = getAncestor(current) ?? undefined; + } + return false; + } + private async guessListingType( codeRef: ResolvedCodeRef, cardDef: CardAPI.BaseDefConstructor | undefined, diff --git a/commands/listing-install.ts b/commands/listing-install.ts index 1deea2d8..efa35c99 100644 --- a/commands/listing-install.ts +++ b/commands/listing-install.ts @@ -98,9 +98,14 @@ export default class ListingInstallCommand extends Command< // this is intentionally to type because base command cannot interpret Listing type from catalog const listing = listingInput as Listing; - let examplesToInstall = listing.examples; - if (listing.examples?.length) { - examplesToInstall = await this.expandInstances(listing.examples); + // seed examples first so the primary example stays the first planned instance + let hasPrimaryExamples = (listing.examples?.length ?? 0) > 0; + let examplesToInstall = [ + ...(listing.examples ?? []), + ...(listing.supportingCards ?? []), + ]; + if (examplesToInstall.length) { + examplesToInstall = await this.expandInstances(examplesToInstall); } // side-effects @@ -125,9 +130,11 @@ export default class ListingInstallCommand extends Command< resolver, virtualNetwork, ); - let firstInstance = r.instancesCopy[0]; - exampleCardId = join(realmUrl, firstInstance.lid); - selectedCodeRef = firstInstance.targetCodeRef; + if (hasPrimaryExamples) { + let firstInstance = r.instancesCopy[0]; + exampleCardId = join(realmUrl, firstInstance.lid); + selectedCodeRef = firstInstance.targetCodeRef; + } return r; }) .addIf(listing.skills?.length > 0, (resolver: ListingPathResolver) => { diff --git a/commands/listing-use.ts b/commands/listing-use.ts index def8b1a5..8b306b53 100644 --- a/commands/listing-use.ts +++ b/commands/listing-use.ts @@ -78,17 +78,20 @@ export default class ListingUseCommand extends Command< }); } - if (listing.examples) { - const sourceCards = (listing.examples as CardAPI.CardDef[]).map( - (example) => example, - ); - for (const card of sourceCards) { - await new CopyCardToRealmCommand(this.commandContext).execute({ - sourceCard: card, - targetRealm: realmUrl, - localDir, - }); - } + const sourceCards = [ + ...new Map( + [ + ...((listing.examples ?? []) as CardAPI.CardDef[]), + ...((listing.supportingCards ?? []) as CardAPI.CardDef[]), + ].map((card) => [card.id ?? card, card]), + ).values(), + ]; + for (const card of sourceCards) { + await new CopyCardToRealmCommand(this.commandContext).execute({ + sourceCard: card, + targetRealm: realmUrl, + localDir, + }); } if ('skills' in listing && Array.isArray(listing.skills)) {