From ca67177636cb9de2acc3573e6c2bb0b8c235ae3c Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 16 Jun 2026 18:49:14 -0400 Subject: [PATCH 01/12] Add PHP support --- codegen.schema.ts | 13 + package-lock.json | 1 + package.json | 4 +- schema.graphql | 557 +++++++++++++++++- src/application/api/workspace.ts | 1 + src/application/cli/command/slot/add.ts | 6 +- src/application/fs/fileSystem.ts | 2 + src/application/fs/localFilesystem.ts | 10 + src/application/model/platform.ts | 16 + .../project/code/generation/example.ts | 4 + .../generation/slot/bladeExampleGenerator.ts | 261 ++++++++ .../generation/slot/phpExampleGenerator.ts | 283 +++++++++ .../slot/plugPhpExampleGenerator.ts | 45 ++ .../generation/slot/twigExampleGenerator.ts | 271 +++++++++ .../transformation/neon/neonListCodemod.ts | 167 ++++++ .../php/drupalLocalSettingsCodemod.ts | 257 ++++++++ .../transformation/php/laravelRouteCodemod.ts | 125 ++++ .../php/symfonyBundleCodemod.ts | 40 ++ .../transformation/yml/yamlMappingCodemod.ts | 55 ++ src/application/project/example/example.ts | 85 +++ .../project/example/exampleLauncher.ts | 36 ++ .../project/example/exampleServer.ts | 115 ++++ .../packageManager/agent/composerAgent.ts | 49 ++ .../packageManager/composerPackageManager.ts | 315 ++++++++++ .../project/sdk/content/contentLoader.ts | 44 ++ .../sdk/content/workspaceContentLoader.ts | 228 +++++++ src/application/project/sdk/javasScriptSdk.ts | 247 ++------ src/application/project/sdk/lazySdk.ts | 4 + .../project/sdk/nuxtStoryblokPlugin.ts | 8 +- src/application/project/sdk/phpSdk.ts | 461 +++++++++++++++ src/application/project/sdk/plugDrupalSdk.ts | 527 +++++++++++++++++ src/application/project/sdk/plugJsSdk.ts | 18 +- src/application/project/sdk/plugLaravelSdk.ts | 116 ++++ src/application/project/sdk/plugNextSdk.ts | 18 +- src/application/project/sdk/plugNuxtSdk.ts | 14 +- src/application/project/sdk/plugPhpSdk.ts | 64 ++ src/application/project/sdk/plugReactSdk.ts | 7 +- src/application/project/sdk/plugSymfonySdk.ts | 196 ++++++ src/application/project/sdk/plugVueSdk.ts | 7 +- src/application/project/sdk/sdk.ts | 20 +- .../project/sdk/wrapperStoryblokPlugin.ts | 8 +- .../project/server/processServer.ts | 13 +- src/application/project/server/server.ts | 8 + .../application/api/graphql/workspace.ts | 16 +- src/infrastructure/application/cli/cli.ts | 207 +++++++ .../application/project/phpFormatter.ts | 75 +++ .../partialComposerLockValidator.ts | 19 + .../partialComposerManifestValidator.ts | 26 + .../absent-no-newline.php | 2 + .../fixtures/drupal-local-settings/absent.php | 3 + .../active-double-quote.php | 3 + .../active-require-once.php | 3 + .../drupal-local-settings/active-stock.php | 7 + .../active-uppercase.php | 3 + .../active-with-escaped-string.php | 4 + .../drupal-local-settings/block-commented.php | 5 + .../commented-after-code.php | 3 + .../commented-at-eof-no-newline.php | 2 + .../drupal-local-settings/commented-bare.php | 2 + .../commented-first-line.php | 2 + .../commented-if-no-closer-eof.php | 3 + .../commented-indented-no-space.php | 3 + .../commented-no-closer.php | 4 + .../commented-note-opener.php | 4 + .../drupal-local-settings/commented-slash.php | 5 + .../drupal-local-settings/commented-stock.php | 7 + .../inactive-keyword-in-string.php | 3 + .../inactive-var-named-include.php | 3 + .../laravel-route/already-registered.php | 9 + .../laravel-route/commented-routes.php | 9 + .../fixtures/laravel-route/double-quoted.php | 3 + .../fixtures/laravel-route/escaped-string.php | 5 + .../laravel-route/existing-routes.php | 7 + .../fixtures/laravel-route/no-routes.php | 3 + .../laravel-route/no-trailing-newline.php | 3 + .../fixtures/laravel-route/url-as-label.php | 7 + .../fixtures/neon-list/already-included.neon | 5 + .../fixtures/neon-list/commented-include.neon | 3 + .../fixtures/neon-list/double-quoted.neon | 5 + .../fixtures/neon-list/empty-includes.neon | 3 + .../fixtures/neon-list/empty.neon | 0 .../fixtures/neon-list/no-includes.neon | 4 + .../fixtures/neon-list/quoted-include.neon | 2 + .../fixtures/neon-list/trailing-includes.neon | 4 + .../fixtures/neon-list/with-includes.neon | 5 + .../symfony-bundles/already-registered.php | 6 + .../fixtures/symfony-bundles/empty-array.php | 3 + .../symfony-bundles/no-closing-array.php | 4 + .../fixtures/symfony-bundles/standard.php | 6 + .../yaml-mapping/already-configured.yaml | 3 + .../yaml-mapping/commented-croct.yaml | 2 + .../fixtures/yaml-mapping/empty.yaml | 0 .../yaml-mapping/no-trailing-newline.yaml | 2 + .../fixtures/yaml-mapping/other-config.yaml | 2 + .../neonListCodemod.test.ts.snap | 78 +++ .../neon/neonListCodemod.test.ts | 38 ++ .../drupalLocalSettingsCodemod.test.ts.snap | 174 ++++++ .../laravelRouteCodemod.test.ts.snap | 115 ++++ .../symfonyBundleCodemod.test.ts.snap | 38 ++ .../php/drupalLocalSettingsCodemod.test.ts | 32 + .../php/laravelRouteCodemod.test.ts | 45 ++ .../php/symfonyBundleCodemod.test.ts | 25 + .../yamlMappingCodemod.test.ts.snap | 45 ++ .../yml/yamlMappingCodemod.test.ts | 33 ++ 104 files changed, 5589 insertions(+), 279 deletions(-) create mode 100644 codegen.schema.ts create mode 100644 src/application/project/code/generation/slot/bladeExampleGenerator.ts create mode 100644 src/application/project/code/generation/slot/phpExampleGenerator.ts create mode 100644 src/application/project/code/generation/slot/plugPhpExampleGenerator.ts create mode 100644 src/application/project/code/generation/slot/twigExampleGenerator.ts create mode 100644 src/application/project/code/transformation/neon/neonListCodemod.ts create mode 100644 src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts create mode 100644 src/application/project/code/transformation/php/laravelRouteCodemod.ts create mode 100644 src/application/project/code/transformation/php/symfonyBundleCodemod.ts create mode 100644 src/application/project/code/transformation/yml/yamlMappingCodemod.ts create mode 100644 src/application/project/example/example.ts create mode 100644 src/application/project/example/exampleLauncher.ts create mode 100644 src/application/project/example/exampleServer.ts create mode 100644 src/application/project/packageManager/agent/composerAgent.ts create mode 100644 src/application/project/packageManager/composerPackageManager.ts create mode 100644 src/application/project/sdk/content/contentLoader.ts create mode 100644 src/application/project/sdk/content/workspaceContentLoader.ts create mode 100644 src/application/project/sdk/phpSdk.ts create mode 100644 src/application/project/sdk/plugDrupalSdk.ts create mode 100644 src/application/project/sdk/plugLaravelSdk.ts create mode 100644 src/application/project/sdk/plugPhpSdk.ts create mode 100644 src/application/project/sdk/plugSymfonySdk.ts create mode 100644 src/infrastructure/application/project/phpFormatter.ts create mode 100644 src/infrastructure/application/validation/partialComposerLockValidator.ts create mode 100644 src/infrastructure/application/validation/partialComposerManifestValidator.ts create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/absent-no-newline.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/absent.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/active-double-quote.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/active-require-once.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/active-stock.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/active-uppercase.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/active-with-escaped-string.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/block-commented.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-after-code.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-at-eof-no-newline.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-bare.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-first-line.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-if-no-closer-eof.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-indented-no-space.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-no-closer.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-note-opener.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-slash.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/commented-stock.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/inactive-keyword-in-string.php create mode 100644 test/application/project/code/transformation/fixtures/drupal-local-settings/inactive-var-named-include.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/already-registered.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/commented-routes.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/double-quoted.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/escaped-string.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/existing-routes.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/no-routes.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/no-trailing-newline.php create mode 100644 test/application/project/code/transformation/fixtures/laravel-route/url-as-label.php create mode 100644 test/application/project/code/transformation/fixtures/neon-list/already-included.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/commented-include.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/double-quoted.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/empty-includes.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/empty.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/no-includes.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/quoted-include.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/trailing-includes.neon create mode 100644 test/application/project/code/transformation/fixtures/neon-list/with-includes.neon create mode 100644 test/application/project/code/transformation/fixtures/symfony-bundles/already-registered.php create mode 100644 test/application/project/code/transformation/fixtures/symfony-bundles/empty-array.php create mode 100644 test/application/project/code/transformation/fixtures/symfony-bundles/no-closing-array.php create mode 100644 test/application/project/code/transformation/fixtures/symfony-bundles/standard.php create mode 100644 test/application/project/code/transformation/fixtures/yaml-mapping/already-configured.yaml create mode 100644 test/application/project/code/transformation/fixtures/yaml-mapping/commented-croct.yaml create mode 100644 test/application/project/code/transformation/fixtures/yaml-mapping/empty.yaml create mode 100644 test/application/project/code/transformation/fixtures/yaml-mapping/no-trailing-newline.yaml create mode 100644 test/application/project/code/transformation/fixtures/yaml-mapping/other-config.yaml create mode 100644 test/application/project/code/transformation/neon/__snapshots__/neonListCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/neon/neonListCodemod.test.ts create mode 100644 test/application/project/code/transformation/php/__snapshots__/drupalLocalSettingsCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/php/__snapshots__/laravelRouteCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts create mode 100644 test/application/project/code/transformation/php/laravelRouteCodemod.test.ts create mode 100644 test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts create mode 100644 test/application/project/code/transformation/yml/__snapshots__/yamlMappingCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/yml/yamlMappingCodemod.test.ts diff --git a/codegen.schema.ts b/codegen.schema.ts new file mode 100644 index 00000000..6a2c3100 --- /dev/null +++ b/codegen.schema.ts @@ -0,0 +1,13 @@ +import type {CodegenConfig} from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: 'https://app.croct.com/graphql', + generates: { + './schema.graphql': { + plugins: ['schema-ast'], + }, + }, +}; + +// eslint-disable-next-line import-x/no-default-export -- Must be default export +export default config; diff --git a/package-lock.json b/package-lock.json index 636a95b2..46127d60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "devDependencies": { "@croct/eslint-plugin": "^0.8.3", "@graphql-codegen/cli": "^5.0.7", + "@graphql-codegen/schema-ast": "^4.1.0", "@swc/jest": "^0.2.39", "@types/ini": "^4.1.1", "@types/jest": "^29.5.14", diff --git a/package.json b/package.json index f625641d..ca32c3b3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test": "jest -c jest.config.js --coverage", "validate": "tsc --noEmit", "build": "tsup", - "graphql-codegen": "graphql-codegen --config codegen.ts" + "graphql-codegen": "graphql-codegen --config codegen.ts", + "graphql-schema": "graphql-codegen --config codegen.schema.ts" }, "dependencies": { "@babel/core": "^7.28.5", @@ -79,6 +80,7 @@ "devDependencies": { "@croct/eslint-plugin": "^0.8.3", "@graphql-codegen/cli": "^5.0.7", + "@graphql-codegen/schema-ast": "^4.1.0", "@swc/jest": "^0.2.39", "@types/ini": "^4.1.1", "@types/jest": "^29.5.14", diff --git a/schema.graphql b/schema.graphql index 659ccaa9..86bf209d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,7 +1,3 @@ -### This file was generated by Nexus Schema -### Do not make changes to this file directly - - type AbsoluteTimeWindow { end: LocalDate! start: LocalDate! @@ -27,6 +23,15 @@ type AddonItem { total: Int! } +type Address { + city: String + country: String + district: String + postalCode: String + region: String + street: String +} + type AllowedFileTypes { contentType: String! extensions: [String!]! @@ -162,6 +167,7 @@ enum ApiPermission { workspace_slot_view workspace_updateSettings workspace_view + workspace_viewEndUserProfile workspace_viewExperience workspace_viewSettings } @@ -175,7 +181,7 @@ interface Application implements Node { first: Int! searchTerm: String ): ApiKeyConnection! - applicationStatus: ApplicationTrafficStatus! + applicationStatus: TrafficStatus! capabilities: Capabilities! creationTime: Instant! environment: ApplicationEnvironment! @@ -250,17 +256,6 @@ input ApplicationSettingsInput { weekends: [Weekday!]! } -enum ApplicationTrafficStatus { - """No traffic received yet""" - NEVER_RECEIVED_TRAFFIC - - """No traffic received in the past 24 hours""" - NOT_RECEIVING_TRAFFIC - - """Received traffic in the past 24 hours""" - RECEIVING_TRAFFIC -} - type Asset implements Node { contentType: String! creationTime: Instant! @@ -284,6 +279,7 @@ type Audience implements Node { experienceCount(inTest: Boolean): Int! highestExperiencePriority: Int id: ID! + lastEditor: User lastUpdateTime: Instant! name: String! self: Audience! @@ -309,6 +305,16 @@ type AudienceEdge { node: Audience } +type AudienceEstimate { + confidenceLevel: Float! + higherBoundEstimate: Float! + lowerBoundEstimate: Float! + matchedUsers: [EndUser!]! + sampleSize: Int! + totalMatches: Int! + totalUsers: Int! +} + """The ID that uniquely identifies the audience.""" scalar AudienceId @@ -357,6 +363,9 @@ type BarChart implements Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -370,6 +379,7 @@ type BarChart implements Chart { divisionOrder: [String!] groupMode: BarGroupMode hideAxis: Boolean! + hint: Tooltip id: ID! legendPlacement: ChartLegendPlacement queryId: QueryId! @@ -429,6 +439,13 @@ type BillingSubscription implements Node { trialEnd: Instant } +type Browser { + code: String + name: String + type: String + version: String +} + type BuiltinComponentDefinition { description: String directReferences: [String!]! @@ -441,6 +458,14 @@ type BuiltinComponentDefinition { type: String! } +type Campaign { + content: String + medium: String + name: String + source: String + term: String +} + type CancelScheduledSubscriptionChangesResult { organization: Organization! subscription: BillingSubscription! @@ -478,6 +503,9 @@ interface Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -487,6 +515,7 @@ interface Chart { workspaceId: WorkspaceId workspaceSlug: ReadableId ): ChartData! + hint: Tooltip id: ID! legendPlacement: ChartLegendPlacement queryId: QueryId! @@ -536,6 +565,9 @@ type ChartWidget implements DashboardFilterable & Node & Widget { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -549,9 +581,15 @@ type ChartWidget implements DashboardFilterable & Node & Widget { id: ID! metrics: [Metric!]! pollInterval: Int! + relatedEvents: [EventType!]! title: String! } +type Client { + browser: Browser! + device: Device! +} + """ Session was linked to an authentication process. The access token can be used on following requests to be authenticated as the user. """ @@ -617,6 +655,9 @@ type ColumnsChart implements Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -630,6 +671,7 @@ type ColumnsChart implements Chart { divisionOrder: [String!] groupMode: BarGroupMode hideAxis: Boolean! + hint: Tooltip id: ID! legendPlacement: ChartLegendPlacement queryId: QueryId! @@ -637,6 +679,14 @@ type ColumnsChart implements Chart { yAxisFormat: NumberFormat! } +enum ComparisonOperation { + EQUALS + GREATER_THAN + GREATER_THAN_OR_EQUAL + LESS_THAN + LESS_THAN_OR_EQUAL +} + input CompleteUploadPayload { token: String! } @@ -653,6 +703,7 @@ type Component implements Node { dependentComponentCount: Int! description: String id: ID! + lastEditor: User lastUpdateTime: Instant! """ @@ -800,6 +851,7 @@ type ContentAudience { } type ContentConstraints { + maximumListItemLabelLength: Int! maximumListLength: Int! maximumNodes: Int! maximumStringLength: ContentMaximumStringLength! @@ -993,6 +1045,12 @@ type CurrencyNumberFormat implements NumberFormat { currencyLabel: String! maximumFractionDigits: Int! minimumFractionDigits: Int! + type: String! +} + +type CustomerExportOutput { + exportedData: String! + title: String! } type Dashboard implements DashboardFilterable & Node { @@ -1009,6 +1067,9 @@ type Dashboard implements DashboardFilterable & Node { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -1030,6 +1091,7 @@ type DashboardContext { experimentId: ExperimentId id: ID! organizationId: OrganizationId! + refreshing: Boolean timeWindow: TimeWindow timeZone: String workspaceId: WorkspaceId! @@ -1037,6 +1099,8 @@ type DashboardContext { type DashboardFilter { defaultValue: String + format: FilterFormat + freeInput: Boolean! id: ID! label: String! loading: Boolean! @@ -1063,6 +1127,9 @@ interface DashboardFilterable implements Node { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -1122,6 +1189,14 @@ input DetachPaymentMethodPayload { paymentMethodId: PaymentMethodId! } +type Device { + category: String + code: String + name: String + os: OperatingSystem! + vendor: String +} + type DiscardExperienceDraftResult { """ The ID of the deleted resource. Refer either to an Experience or and ExperienceDraft. @@ -1162,6 +1237,9 @@ type DonutChart implements Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -1171,6 +1249,7 @@ type DonutChart implements Chart { workspaceId: WorkspaceId workspaceSlug: ReadableId ): ChartData! + hint: Tooltip id: ID! legendPlacement: ChartLegendPlacement numberFormat: PlainNumberFormat! @@ -1182,6 +1261,132 @@ input DuplicateExperiencePayload { experienceId: ExperienceId! } +"""A number representing a duration, in milliseconds.""" +type DurationNumberFormat implements NumberFormat { + maximumFractionDigits: Int! + minimumFractionDigits: Int! + type: String! +} + +type EndUser { + activities: [String!]! + address: Address! + alternateEmail: String + alternatePhone: String + avatar: String + birthDate: LocalDate + company: String + companyUrl: String + creationTime: Instant! + customAttributes: JSONObject! + email: String + endUserEvent(id: EndUserEventId!): EndUserEvent + endUserEvents( + """Returns the elements in the list that come after the specified cursor""" + after: String + + """Returns the elements in the list that come before the specified cursor""" + before: String + + """Returns the first n elements from the list.""" + first: Int + inclusive: Boolean + + """Returns the last n elements from the list.""" + last: Int + ): EndUserEventConnection! + externalUserId: String + firstName: String + gender: Gender! + id: EndUserId! + interests: [String!]! + jobTitle: String + lastActivity: Instant + lastModifiedTime: Instant! + lastName: String + lastSyncedTime: Instant! + organizationId: OrganizationId! + phone: String + stats: Stats! + tags: [String!] + webSessionSummaries( + """Returns the elements in the list that come after the specified cursor""" + after: String + + """Returns the first n elements from the list.""" + first: Int! + ): WebSessionSummaryConnection! + webSessionSummary(id: WebSessionSummaryId): WebSessionSummary + workspaceId: WorkspaceId! +} + +type EndUserEdge { + """https://facebook.github.io/relay/graphql/connections.htm#sec-Cursor""" + cursor: String! + + """https://facebook.github.io/relay/graphql/connections.htm#sec-Node""" + node: EndUser +} + +type EndUserEvent { + applicationId: ApplicationId! + clientId: String! + context: JSONObject! + id: EndUserEventId! + organizationId: OrganizationId! + originalTimestamp: Instant! + payload: JSONObject! + sessionId: WebSessionSummaryId! + timestamp: Instant! + traceId: String! + type: String! + userId: EndUserId! + visitorId: String! + workspaceId: WorkspaceId! +} + +type EndUserEventConnection { + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-Edge-Types + """ + edges: [EndUserEventEdge] + + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo + """ + pageInfo: PageInfo! +} + +type EndUserEventEdge { + """https://facebook.github.io/relay/graphql/connections.htm#sec-Cursor""" + cursor: String! + + """https://facebook.github.io/relay/graphql/connections.htm#sec-Node""" + node: EndUserEvent +} + +"""The ID that uniquely identifies the end-user event""" +scalar EndUserEventId + +"""The ID that uniquely identifies the end user""" +scalar EndUserId + +type EnvironmentTrackingTime { + developmentStatus: TrafficStatus! + developmentTime: Instant + productionStatus: TrafficStatus! + productionTime: Instant +} + +enum EventType { + GOAL_COMPLETED + INTEREST_SHOWN + LEAD_GENERATED + ORDER_PLACED + USER_PROFILE_CHANGED + USER_SIGNED_UP +} + input ExchangeTokenPayload { authenticator: ExternalUserAuthenticator! keyId: String! @@ -1205,8 +1410,8 @@ type Experience implements Node { ): ExperimentConnection! hasExperiments: Boolean! id: ID! - lastEditor(scheduled: Boolean! = false): User - lastUpdateTime(scheduled: Boolean! = false): Instant! + lastEditor: User + lastUpdateTime: Instant! name: String! priority: Int revision: String! @@ -1303,8 +1508,10 @@ input ExperienceOptionsInput { enum ExperiencePreviewScope { EXPERIENCE_DRAFT EXPERIMENT_DRAFT + FALLBACK_CONTENT PUBLISHED_CONTENT SLOT_DEFAULT_CONTENT + SLOT_TIMELINE } """An experience revision.""" @@ -1330,6 +1537,7 @@ type ExperienceSlot { slot: Slot slotContent: SlotContent! slotId: SlotId! + slotTraffic: EnvironmentTrackingTime! version: Version! } @@ -1458,14 +1666,18 @@ enum ExportFormat { } enum ExternalUserAuthenticator { + STORYBLOK VERCEL } enum Feature { API_DATA_EXPORT + BOT_EXCLUSION CROSS_DEVICE_EXPERIMENT DASHBOARD_DATA_DOWNLOAD + EVENT_HISTORY SCHEDULING + SERVICE_SUSPENSION } type FileMetadata { @@ -1476,6 +1688,10 @@ type FileMetadata { url: String! } +enum FilterFormat { + PATH_URL +} + enum FrameTimeWindowEnum { FULL } @@ -1490,6 +1706,13 @@ type FrameTimeWindowObject { window: FrameTimeWindowEnum! } +enum Gender { + FEMALE + MALE + NEUTRAL + UNKNOWN +} + input GenerateTypingPayload { components: [VersionSpecifier!]! slots: [VersionSpecifier!]! @@ -1593,6 +1816,7 @@ type Invoice implements Node { id: ID! invoiceDate: Instant! invoiceDocument: String! + invoiceReceipt: String lastPaymentFailureReason: String nextPaymentAttempt: Instant organizationId: OrganizationId! @@ -1624,7 +1848,7 @@ enum InvoiceStatus { UNCOLLECTIBLE } -type IssueExperiencePreviewTokenResult { +type IssueContentPreviewTokenResult { token: String! } @@ -1640,15 +1864,20 @@ input IssueTokenPayload { duration: Int! } +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + """ The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). """ -scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") +scalar JSONObject """ The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). """ -scalar JsonSchema @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") +scalar JsonSchema type KeyValue { key: String! @@ -1696,6 +1925,9 @@ type LineChart implements Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -1707,6 +1939,7 @@ type LineChart implements Chart { ): ChartData! filledArea: Boolean! hideAxis: Boolean! + hint: Tooltip id: ID! legendPlacement: ChartLegendPlacement queryId: QueryId! @@ -1778,6 +2011,26 @@ type LocalizedStaticContent { locale: String! } +type Location { + city: String + continent: String + country: String + currencyCode: String + currencyName: String + district: String + languages: [String!] + latitude: Float + longitude: Float + phoneCode: String + population: Int + postalCode: String + regionCode: String + regionName: String + source: String + tags: [String!] + timezone: String +} + input MarkNotificationsAsReadPayload { notifications: [NotificationId!]! } @@ -1796,6 +2049,9 @@ type Metric { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -1842,6 +2098,9 @@ type MetricWidget implements DashboardFilterable & Node & Widget { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -1856,6 +2115,7 @@ type MetricWidget implements DashboardFilterable & Node & Widget { metric: Metric! orientation: WidgetOrientation! pollInterval: Int! + relatedEvents: [EventType!]! title: String! } @@ -1920,13 +2180,16 @@ type Mutation { discardExperimentDraft(experienceId: ExperienceId!, revision: Int!, workspaceId: WorkspaceId!): DiscardExperimentDraftResult! duplicateExperience(payload: DuplicateExperiencePayload!, workspaceId: WorkspaceId!): Experience! duplicateExperiment(experienceId: ExperienceId!, experimentId: ExperimentId!, workspaceId: WorkspaceId!): Experiment! + endActiveSessions(startNewSession: Boolean): String exchangeToken(payload: ExchangeTokenPayload!): String! finishExperiment(experienceContent: VariantId, experienceId: ExperienceId!, experimentId: ExperimentId!, winnerVariant: VariantId, workspaceId: WorkspaceId!): Experiment! + finishWorkspaceSetup(workspaceId: WorkspaceId!): Workspace! generateTyping(payload: GenerateTypingPayload!, workspaceId: WorkspaceId!): String! inviteOrganizationMember(organizationId: OrganizationId!, payload: InviteOrganizationMemberPayload!): OrganizationMembership! inviteWorkspaceMember(payload: InviteWorkspaceMemberPayload!, workspaceId: WorkspaceId!): WorkspaceMembership! - issueExperiencePreviewToken(audienceId: AudienceId, experienceId: ExperienceId!, locale: String, previewMode: ExperiencePreviewScope!, publishTime: LocalDateTime, variantId: VariantId, workspaceId: WorkspaceId!): IssueExperiencePreviewTokenResult! + issueExperiencePreviewToken(audienceId: AudienceId, experienceId: ExperienceId!, locale: String, previewMode: ExperiencePreviewScope!, publishTime: LocalDateTime, slotId: SlotId, variantId: VariantId, workspaceId: WorkspaceId!): IssueContentPreviewTokenResult! issueInvoice(organizationId: OrganizationId!, payload: IssueInvoicePayload!): String! + issueSlotTimelinePreviewToken(audienceId: AudienceId, contentId: ID!, experienceId: ExperienceId, majorVersion: Int, slotId: SlotId!, workspaceId: WorkspaceId!): IssueContentPreviewTokenResult! issueToken(payload: IssueTokenPayload!): String! leaveOrganization(organizationId: OrganizationId!): LeaveOrganizationResult! markAllNotificationsAsRead: [Notification!]! @@ -1970,6 +2233,7 @@ type Mutation { signIn(payload: SignInPayload!): Viewer! signOut: Viewer! signUp(payload: SignUpPayload!): SignUpResult! + skipWorkspaceExperienceSetup(workspaceId: WorkspaceId!): Workspace! suspendOrganizationMembership(id: OrganizationMembershipId!, organizationId: OrganizationId!): OrganizationMembership! updateApplication(applicationId: ApplicationId!, payload: UpdateApplicationPayload!): Application! updateApplicationLogo(applicationId: ApplicationId!, logo: ImageData!): Application! @@ -2014,8 +2278,10 @@ interface Node { type NonConsumableQuotas { audiencesPerExperience: Int! dynamicAttributesPerContent: Int! + eventRetentionPeriod: Int! nodesPerContent: Int! nodesPerDefinition: Int! + userRetentionPeriod: Int! } """An arbitrary map of strings""" @@ -2056,6 +2322,7 @@ type NumberAxisFormat implements AxisFormat { interface NumberFormat { maximumFractionDigits: Int! minimumFractionDigits: Int! + type: String! } type NumericTableColumn implements TableColumn { @@ -2077,6 +2344,12 @@ enum OnboardingStatus { UNINITIALIZED } +type OperatingSystem { + code: String + name: String + version: String +} + type Organization implements Node { allowOverage: Boolean! canManage(permissions: [ApiPermission!], role: OrganizationRole!): Boolean! @@ -2104,6 +2377,7 @@ type Organization implements Node { name: String! paymentMethods: [PaymentMethod!]! quotas: OrganizationQuotas! + runningOperations(status: [RunningOperationStatus!], type: [RunningOperationType!]): [RunningOperation!]! self: Organization! slug: ReadableId! subscription: BillingSubscription! @@ -2209,9 +2483,11 @@ enum OrganizationMembershipStatus { } type OrganizationQuotas { + eventRetentionPeriod: Int! monthlyActiveUsers: Int! remainingMonthlyActiveUsers: Int! remainingWorkspaces: Int! + userRetentionPeriod: Int! workspace: Int! } @@ -2230,6 +2506,12 @@ enum OrganizationType { """The ID that uniquely identifies a trusted origin.""" scalar OriginId +type PageIndexing { + end: Int! + start: Int! + total: Int! +} + """ PageInfo cursor, as defined in https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo """ @@ -2308,11 +2590,13 @@ enum PaymentMethodTypeInput { type PercentageNumberFormat implements NumberFormat { maximumFractionDigits: Int! minimumFractionDigits: Int! + type: String! } type PlainNumberFormat implements NumberFormat { maximumFractionDigits: Int! minimumFractionDigits: Int! + type: String! } type PlanItem { @@ -2327,12 +2611,14 @@ type PlanQuota { audiencesPerWorkspace: Int! componentsPerWorkspace: Int! dynamicAttributesPerContent: Int! + eventRetentionPeriod: Int! experiencesPerWorkspace: Int! localesPerWorkspace: Int! monthlyActiveUsers: Int! nodesPerContent: Int! nodesPerDefinition: Int! slotsPerWorkspace: Int! + userRetentionPeriod: Int! workspacesPerOrganization: Int! } @@ -2341,9 +2627,16 @@ input PlanQuotaIntentInput { } enum Platform { + ASTRO + DRUPAL JAVASCRIPT + LARAVEL NEXT + NUXT + PHP REACT + SYMFONY + VUE } input PrepareUploadPayload { @@ -2444,6 +2737,8 @@ type Query { checkAvailability: AvailabilityCheck! contextOverview(payload: ContextOverviewPayload!): ContextData! dashboard(id: DashboardId!): Dashboard! + estimateAudience(criteria: String!, workspaceId: WorkspaceId!): AudienceEstimate! + exportCustomerData(applicationId: ApplicationId, applicationSlug: ReadableId, endUserEventId: EndUserEventId, endUserId: EndUserId, organizationId: OrganizationId, organizationSlug: ReadableId, webSessionId: WebSessionSummaryId, workspaceId: WorkspaceId, workspaceSlug: ReadableId): CustomerExportOutput! exportDashboardWidget( applicationId: ApplicationId applicationSlug: ReadableId @@ -2457,6 +2752,9 @@ type Query { format: ExportFormat! organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -2558,12 +2856,14 @@ enum Quota { AUDIENCES_PER_WORKSPACE COMPONENTS_PER_WORKSPACE DYNAMIC_ATTRIBUTES_PER_CONTENT + EVENT_RETENTION_PERIOD EXPERIENCES_PER_WORKSPACE LOCALES_PER_WORKSPACE MONTHLY_ACTIVE_USERS NODES_PER_CONTENT NODES_PER_DEFINITION SLOTS_PER_WORKSPACE + USER_RETENTION_PERIOD WORKSPACES_PER_ORGANIZATION } @@ -2573,6 +2873,7 @@ input QuotaIntentInput { audiencesPerWorkspace: Int componentsPerWorkspace: Int dynamicAttributesPerContent: Int + eventRetentionPeriod: Int experiencesPerWorkspace: Int experimentsPerWorkspace: Int localesPerWorkspace: Int @@ -2580,6 +2881,7 @@ input QuotaIntentInput { nodesPerContent: Int nodesPerDefinition: Int slotsPerWorkspace: Int + userRetentionPeriod: Int workspacesPerOrganization: Int } @@ -2589,12 +2891,14 @@ type QuotaMap { audiencesPerWorkspace: Int componentsPerWorkspace: Int dynamicAttributesPerContent: Int + eventRetentionPeriod: Int experiencesPerWorkspace: Int localesPerWorkspace: Int monthlyActiveUsers: Int nodesPerContent: Int nodesPerDefinition: Int slotsPerWorkspace: Int + userRetentionPeriod: Int workspacesPerOrganization: Int } @@ -2604,7 +2908,7 @@ type QuotaUsage { } """ -A URL-safe identifier matching the regular expression /^[A-Za-z]+(-?[A-Za-z0-9]+)*$/ +A URL-safe identifier matching the regular expression /^[A-Za-z]+([_-]?[A-Za-z0-9]+)*$/ """ scalar ReadableId @@ -2628,6 +2932,26 @@ type RetryMutation { retryUserEmailVerification: UserAccount! } +type RunningOperation { + creationTime: Instant! + id: String! + lastUpdateTime: Instant! + organizationId: OrganizationId! + response: JSON + status: RunningOperationStatus! + type: RunningOperationType! +} + +enum RunningOperationStatus { + FAILED + FINISHED + RUNNING +} + +enum RunningOperationType { + EVENTS_BACKFILL +} + input SaveExperienceDraftPayload { audiences: [ExperienceAudienceInput!]! content: ContentVariantInput! @@ -2716,6 +3040,9 @@ type SingleBarChart implements Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -2726,6 +3053,7 @@ type SingleBarChart implements Chart { workspaceSlug: ReadableId ): ChartData! divisionLabel: String! + hint: Tooltip id: ID! legendPlacement: ChartLegendPlacement numberFormat: PlainNumberFormat! @@ -2735,17 +3063,33 @@ type SingleBarChart implements Chart { type Slot implements Node { content(majorVersion: Int): SlotContent! + contents( + """Returns the elements in the list that come after the specified cursor""" + after: String + + """Returns the first n elements from the list.""" + first: Int! + pinnedVersions: [Int!] + ): SlotContentConnection! creationTime: Instant! customId: ReadableId! experienceCount: Int! id: ID! + lastEditor: User lastUpdateTime: Instant! name: String! + """The newest major slot content version that had personalized traffic""" + newestContentWithTraffic( + """The application environment to fetch the newest version with traffic""" + environment: ApplicationEnvironment! = PRODUCTION + ): SlotContent + """ Indication that the Slot is using an old version of the associated component. """ outdated: Boolean! + personalizationTraffic: EnvironmentTrackingTime! self: Slot! staticContent(majorVersion: Int): [LocalizedStaticContent!]! workspaceId: WorkspaceId! @@ -2774,9 +3118,30 @@ type SlotContent implements Node { Indication that the Slot Content is not valid for the most recent patch Component version compatible with it's linked version. """ invalidContents: [String!]! + personalizationTraffic: EnvironmentTrackingTime! version: Version! } +type SlotContentConnection { + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-Edge-Types + """ + edges: [SlotContentEdge] + + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo + """ + pageInfo: PageInfo! +} + +type SlotContentEdge { + """https://facebook.github.io/relay/graphql/connections.htm#sec-Cursor""" + cursor: String! + + """https://facebook.github.io/relay/graphql/connections.htm#sec-Node""" + node: SlotContent +} + type SlotEdge { """https://facebook.github.io/relay/graphql/connections.htm#sec-Cursor""" cursor: String! @@ -2800,6 +3165,17 @@ input SlotLocalizedContentInput { slotId: SlotId! } +type Statistics { + orders: Int! + pageViews: Int! + tabViews: Int! +} + +type Stats { + orders: Int! + sessions: Int! +} + """An arbitrary map of nullable strings""" scalar StringMap @@ -2858,6 +3234,9 @@ type SunburstChart implements Chart { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -2867,6 +3246,7 @@ type SunburstChart implements Chart { workspaceId: WorkspaceId workspaceSlug: ReadableId ): ChartData! + hint: Tooltip id: ID! layerLabels: [String!]! legendPlacement: ChartLegendPlacement @@ -2899,6 +3279,9 @@ type Table { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -2909,6 +3292,7 @@ type Table { workspaceSlug: ReadableId ): TableData! hideHeader: Boolean! + hint: Tooltip id: ID! minimumRowCount: Int queryId: QueryId! @@ -2957,6 +3341,9 @@ type TableWidget implements DashboardFilterable & Node & Widget { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -2969,6 +3356,7 @@ type TableWidget implements DashboardFilterable & Node & Widget { hideTitle: Boolean! id: ID! pollInterval: Int! + relatedEvents: [EventType!]! table: Table! tabs: [WidgetTab!] title: String! @@ -2976,6 +3364,7 @@ type TableWidget implements DashboardFilterable & Node & Widget { enum TargetSdk { PLUG_JS + PLUG_PHP } type TaxId { @@ -3154,6 +3543,7 @@ input TimeoutExpirationPolicyInput { } type Tooltip { + link: String message: String! type: TooltipType! } @@ -3163,6 +3553,17 @@ enum TooltipType { info } +enum TrafficStatus { + """No traffic received yet""" + NEVER_RECEIVED_TRAFFIC + + """No traffic received in the past 24 hours""" + NOT_RECEIVING_TRAFFIC + + """Received traffic in the past 24 hours""" + RECEIVING_TRAFFIC +} + enum TrendDirection { DOWN UP @@ -3388,8 +3789,10 @@ input UpdateWorkspaceMemberPermissionPayload { } input UpdateWorkspacePayload { + botExclusion: Boolean name: String slug: ReadableId + suspendedService: Boolean timeZone: TimeZone website: String } @@ -3474,6 +3877,11 @@ input ValidateSlotContentPayload { slotId: SlotId } +input ValueComparisonInput { + comparison: ComparisonOperation! + value: Int! +} + type Variant { allocation: Int! baseline: Boolean! @@ -3547,7 +3955,7 @@ type WebApplication implements Application & Node { first: Int! searchTerm: String ): ApiKeyConnection! - applicationStatus: ApplicationTrafficStatus! + applicationStatus: TrafficStatus! capabilities: Capabilities! creationTime: Instant! environment: ApplicationEnvironment! @@ -3584,6 +3992,54 @@ type WebApplicationSettings implements ApplicationSettings { weekends: [Weekday!]! } +type WebSessionSummary { + applicationId: ApplicationId! + attributes: JSONObject! + campaign: Campaign! + client: Client! + clientId: String! + closeTime: Instant! + expirationTime: Instant + externalUserId: String + id: WebSessionSummaryId! + landingPageUrl: String + lastActivity: Instant! + location: Location! + organizationId: OrganizationId! + parentId: WebSessionSummaryId + referrer: String + statistics: Statistics! + tokenId: String + userId: EndUserId! + visitorId: String! + windowEnd: Instant! + windowStart: Instant! + workspaceId: WorkspaceId! +} + +type WebSessionSummaryConnection { + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-Edge-Types + """ + edges: [WebSessionSummaryEdge] + + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo + """ + pageInfo: PageInfo! +} + +type WebSessionSummaryEdge { + """https://facebook.github.io/relay/graphql/connections.htm#sec-Cursor""" + cursor: String! + + """https://facebook.github.io/relay/graphql/connections.htm#sec-Node""" + node: WebSessionSummary +} + +"""The ID that uniquely identifies the web session summary""" +scalar WebSessionSummaryId + type WebsiteMetadata { description: String domain: String! @@ -3641,6 +4097,9 @@ interface Widget implements DashboardFilterable & Node { filters: StringMap organizationId: OrganizationId organizationSlug: ReadableId + + """Whether the query is refreshing.""" + refreshing: Boolean timeWindow: TimeWindowInput """ @@ -3653,6 +4112,7 @@ interface Widget implements DashboardFilterable & Node { hideTitle: Boolean! id: ID! pollInterval: Int! + relatedEvents: [EventType!]! title: String! } @@ -3717,6 +4177,7 @@ type Workspace implements Node { first: Int searchTerm: String ): AudienceConnection! + botExclusion: Boolean! canManage(permissions: [ApiPermission!], role: WorkspaceRole!): Boolean! capabilities: Capabilities! component(customId: ReadableId): Component @@ -3735,6 +4196,27 @@ type Workspace implements Node { creationTime: Instant! defaultLocale: String! earliestTraffic: Instant + endUser(id: EndUserId): EndUser + endUserCount: Int! + endUsers( + """Returns the elements in the list that come after the specified cursor""" + after: String + + """Returns the elements in the list that come before the specified cursor""" + before: String + endUserIds: [EndUserId!] + + """Returns the first n elements from the list.""" + first: Int + inclusive: Boolean + + """Returns the last n elements from the list.""" + last: Int + orders: ValueComparisonInput + searchTerm: String + sessions: ValueComparisonInput + tags: [String!] + ): WorkspaceEndUsers_Connection! experience(id: ExperienceId): Experience experienceCount: Int! experiences( @@ -3752,6 +4234,7 @@ type Workspace implements Node { slotId: SlotId status: [ExperienceStatus!] ): ExperienceConnection! + goalId(id: String): String goalIds( """Returns the elements in the list that come after the specified cursor""" after: String @@ -3787,8 +4270,9 @@ type Workspace implements Node { ): WorkspaceMembershipConnection! name: String! quotas: WorkspaceQuotas! - receivingTraffic: ApplicationTrafficStatus! + receivingTraffic: TrafficStatus! self: Workspace! + setupStatus: WorkspaceSetupStatus! slot(customId: ReadableId): Slot slotCount(componentCustomId: ReadableId, componentId: ComponentId, excludeLocale: String, searchTerm: String): Int! slots( @@ -3803,6 +4287,7 @@ type Workspace implements Node { searchTerm: String ): SlotConnection! slug: ReadableId! + suspendedService: Boolean! timeZone: TimeZone! website: String } @@ -3867,6 +4352,19 @@ type WorkspaceEdge { node: Workspace } +type WorkspaceEndUsers_Connection { + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-Edge-Types + """ + edges: [EndUserEdge] + pageIndexing: PageIndexing + + """ + https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo + """ + pageInfo: PageInfo! +} + """The ID that uniquely identifies the workspace.""" scalar WorkspaceId @@ -3970,4 +4468,13 @@ enum WorkspaceRole { MAINTAINER MANAGER OWNER +} + +type WorkspaceSetupStatus { + component: Boolean! + experience: Boolean! + experienceSkipped: Boolean! + finished: Boolean! + implementation: Boolean! + slot: Boolean! } \ No newline at end of file diff --git a/src/application/api/workspace.ts b/src/application/api/workspace.ts index f1d40920..a1b332ae 100644 --- a/src/application/api/workspace.ts +++ b/src/application/api/workspace.ts @@ -35,6 +35,7 @@ export type NewApplication = WorkspacePath & Omit { const notifier = output.notify('Generating example'); try { - await Promise.all(addedSlots.map(([slot]) => sdk.generateSlotExample(slot, installation))); + for (const [slot] of addedSlots) { + await sdk.generateSlotExample(slot, installation); + } } catch (error) { notifier.stop(); @@ -95,6 +97,8 @@ export class AddSlotCommand implements Command { } notifier.confirm('Example generated'); + + await sdk.presentExamples?.(addedSlots.map(([slot]) => slot), installation); } } diff --git a/src/application/fs/fileSystem.ts b/src/application/fs/fileSystem.ts index 0f3af985..8775cca9 100644 --- a/src/application/fs/fileSystem.ts +++ b/src/application/fs/fileSystem.ts @@ -57,6 +57,8 @@ export interface FileSystem { move(source: string, destination: string, options?: MoveOptions): Promise; readTextFile(path: string): Promise; writeTextFile(path: string, data: string, options?: FileWritingOptions): Promise; + getPermissions(path: string): Promise; + setPermissions(path: string, mode: number): Promise; isAbsolutePath(path: string): boolean; isSubPath(parent: string, path: string): boolean; joinPaths(...paths: string[]): string; diff --git a/src/application/fs/localFilesystem.ts b/src/application/fs/localFilesystem.ts index 0e72a2cd..0fe11032 100644 --- a/src/application/fs/localFilesystem.ts +++ b/src/application/fs/localFilesystem.ts @@ -1,4 +1,5 @@ import { + chmod, cp, rename, link, @@ -8,6 +9,7 @@ import { readFile, realpath, rm, + stat, symlink, writeFile, mkdtemp, @@ -290,6 +292,14 @@ export class LocalFilesystem implements FileSystem { ); } + public getPermissions(path: string): Promise { + return this.execute(async () => (await stat(this.resolvePath(path))).mode & 0o777); + } + + public setPermissions(path: string, mode: number): Promise { + return this.execute(() => chmod(this.resolvePath(path), mode)); + } + public async createDirectory(path: string, options?: DirectoryCreationOptions): Promise { await this.execute( () => mkdir(this.resolvePath(path), { diff --git a/src/application/model/platform.ts b/src/application/model/platform.ts index 67a38c5a..4ae72c89 100644 --- a/src/application/model/platform.ts +++ b/src/application/model/platform.ts @@ -4,6 +4,10 @@ export enum Platform { REACT = 'react', VUE = 'vue', JAVASCRIPT = 'javascript', + LARAVEL = 'laravel', + SYMFONY = 'symfony', + DRUPAL = 'drupal', + PHP = 'php', } export namespace Platform { @@ -23,6 +27,18 @@ export namespace Platform { case Platform.JAVASCRIPT: return 'JavaScript'; + + case Platform.LARAVEL: + return 'Laravel'; + + case Platform.SYMFONY: + return 'Symfony'; + + case Platform.DRUPAL: + return 'Drupal'; + + case Platform.PHP: + return 'PHP'; } } } diff --git a/src/application/project/code/generation/example.ts b/src/application/project/code/generation/example.ts index 2d891d28..6af82930 100644 --- a/src/application/project/code/generation/example.ts +++ b/src/application/project/code/generation/example.ts @@ -5,6 +5,10 @@ export enum CodeLanguage { TYPESCRIPT = 'typescript', TYPESCRIPT_XML = 'tsx', VUE = 'vue', + PHP = 'php', + BLADE = 'blade', + TWIG = 'twig', + YAML = 'yaml', } export type ExampleFile = { diff --git a/src/application/project/code/generation/slot/bladeExampleGenerator.ts b/src/application/project/code/generation/slot/bladeExampleGenerator.ts new file mode 100644 index 00000000..38f282a8 --- /dev/null +++ b/src/application/project/code/generation/slot/bladeExampleGenerator.ts @@ -0,0 +1,261 @@ +import type { + AttributeDefinition, + ContentDefinition, + RootDefinition, +} from '@croct/content-model/definition/definition'; +import type {SlotDefinition, SlotExampleGenerator} from './slotExampleGenerator'; +import type {CodeExample} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {CodeWriter} from '@/application/project/code/generation/codeWritter'; +import {formatLabel, formatSlug, sortAttributes} from '@/application/project/code/generation/utils'; +import {formatName} from '@/application/project/utils/formatName'; + +export type Configuration = { + indentationSize?: number, + contentVariable: string, + filePath: string, +}; + +type Attribute = AttributeDefinition & { + name: string, +}; + +/** + * Generates a Blade view that renders a slot's content. + * + * Walks the slot's resolved definition and renders each field by array key, + * relying on the typed content provided by the controller. The plug.js script + * is injected by the Croct middleware, so the view only renders content. + */ +export class BladeExampleGenerator implements SlotExampleGenerator { + private readonly options: Configuration; + + public constructor(options: Configuration) { + this.options = options; + } + + public generate(definition: SlotDefinition): CodeExample { + const path = BladeExampleGenerator.replaceVariables(this.options.filePath, definition.id); + const writer = new CodeWriter(this.options.indentationSize); + const variable = `$${this.options.contentVariable}`; + const title = BladeExampleGenerator.escapeEntities(BladeExampleGenerator.formatTitle(definition.id)); + + writer.write('') + .write('') + .write('') + .indent() + .write('') + .write(`${title}`) + .outdent() + .write('') + .write('') + .indent(); + + this.writeRoot(writer, definition.definition, variable); + + writer.outdent() + .write('') + .write('', false); + + return { + files: [ + { + path: path, + language: CodeLanguage.BLADE, + code: writer.toString(), + }, + ], + }; + } + + private writeRoot(writer: CodeWriter, definition: RootDefinition, path: string): void { + if (definition.type === 'union') { + this.writeUnion(writer, definition, path); + + return; + } + + writer.write('
    ') + .indent(); + + this.writeStructureAttributes(writer, definition, path); + + writer.outdent() + .write('
'); + } + + private writeStructureAttributes( + writer: CodeWriter, + definition: ContentDefinition<'structure'>, + path: string, + ): void { + for (const [name, attribute] of sortAttributes(definition.attributes)) { + if (attribute.private === true) { + continue; + } + + this.writeAttribute(writer, {name: name, ...attribute}, path); + } + } + + private writeAttribute(writer: CodeWriter, attribute: Attribute, parentPath: string): void { + const path = `${parentPath}['${attribute.name}']`; + const definition = attribute.type; + const label = BladeExampleGenerator.escapeEntities(attribute.label ?? formatLabel(attribute.name)); + const optional = attribute.optional === true; + + if (optional) { + writer.write(`@isset(${path})`) + .indent(); + } + + switch (definition.type) { + case 'boolean': + case 'text': + case 'number': + writer.write(`
  • ${label}: `, false); + + this.writeFragment(writer, definition, path); + + writer.append('
  • ') + .newLine(); + + break; + + default: + writer.write('
  • ') + .indent() + .write(`${label}`); + + this.writeFragment(writer, definition, path); + + writer.outdent() + .write('
  • '); + + break; + } + + if (optional) { + writer.outdent() + .write('@endisset'); + } + } + + private writeFragment(writer: CodeWriter, definition: ContentDefinition, path: string): void { + switch (definition.type) { + case 'text': + case 'number': + writer.append(`{{ ${path} }}`); + + break; + + case 'boolean': + writer.append(BladeExampleGenerator.formatBoolean(definition, path)); + + break; + + case 'list': + this.writeList(writer, definition, path); + + break; + + case 'structure': + writer.write('
      ') + .indent(); + + this.writeStructureAttributes(writer, definition, path); + + writer.outdent() + .write('
    '); + + break; + + case 'union': + this.writeUnion(writer, definition, path); + + break; + } + } + + private writeList(writer: CodeWriter, definition: ContentDefinition<'list'>, path: string): void { + const variable = definition.itemLabel !== undefined + ? formatName(definition.itemLabel) + : 'item'; + const itemPath = `$${variable}`; + + writer.write('
      ') + .indent() + .write(`@foreach (${path} as ${itemPath})`) + .indent(); + + if (BladeExampleGenerator.isInline(definition.items)) { + writer.write('
    1. ', false); + + this.writeFragment(writer, definition.items, itemPath); + + writer.append('
    2. ') + .newLine(); + } else { + writer.write('
    3. ') + .indent(); + + this.writeFragment(writer, definition.items, itemPath); + + writer.outdent() + .write('
    4. '); + } + + writer.outdent() + .write('@endforeach') + .outdent() + .write('
    '); + } + + private writeUnion(writer: CodeWriter, definition: ContentDefinition<'union'>, path: string): void { + for (const [id, variant] of Object.entries(definition.types)) { + writer.write(`@if (${path}['_type'] === '${BladeExampleGenerator.escapeString(id)}')`) + .indent(); + + this.writeFragment(writer, variant, path); + + writer.outdent() + .write('@endif'); + } + } + + private static formatBoolean(definition: ContentDefinition<'boolean'>, path: string): string { + const trueLabel = BladeExampleGenerator.escapeString(definition.label?.true ?? 'Yes'); + const falseLabel = BladeExampleGenerator.escapeString(definition.label?.false ?? 'No'); + + return `{{ ${path} ? '${trueLabel}' : '${falseLabel}' }}`; + } + + private static isInline(definition: ContentDefinition): boolean { + return ['number', 'text', 'boolean'].includes(definition.type); + } + + private static escapeString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'"); + } + + private static escapeEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + private static formatTitle(id: string): string { + return formatSlug(id) + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + private static replaceVariables(path: string, id: string): string { + return path.replace(/%slug%/g, formatSlug(id)); + } +} diff --git a/src/application/project/code/generation/slot/phpExampleGenerator.ts b/src/application/project/code/generation/slot/phpExampleGenerator.ts new file mode 100644 index 00000000..c2f14498 --- /dev/null +++ b/src/application/project/code/generation/slot/phpExampleGenerator.ts @@ -0,0 +1,283 @@ +import type { + AttributeDefinition, + ContentDefinition, + RootDefinition, +} from '@croct/content-model/definition/definition'; +import type {SlotDefinition, SlotExampleGenerator} from './slotExampleGenerator'; +import type {CodeExample} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {CodeWriter} from '@/application/project/code/generation/codeWritter'; +import {formatLabel, formatSlug, sortAttributes} from '@/application/project/code/generation/utils'; +import {formatName} from '@/application/project/utils/formatName'; + +export type Configuration = { + indentationSize?: number, + contentVariable: string, + filePath: string, +}; + +type Attribute = AttributeDefinition & { + name: string, +}; + +/** + * Generates a runnable PHP page that renders a slot's content. + * + * Walks the slot's resolved definition and renders each field by array key, + * relying on the generated typing stub so the content is statically typed and + * needs no casts. Subclasses provide the bootstrap that fetches the content. + */ +export abstract class PhpExampleGenerator implements SlotExampleGenerator { + protected readonly options: Configuration; + + public constructor(options: Configuration) { + this.options = options; + } + + public generate(definition: SlotDefinition): CodeExample { + const path = PhpExampleGenerator.replaceVariables(this.options.filePath, definition.id); + const writer = new CodeWriter(this.options.indentationSize); + const variable = `$${this.options.contentVariable}`; + const title = PhpExampleGenerator.escapeEntities(PhpExampleGenerator.formatTitle(definition.id)); + + this.writeScript(writer, definition); + + writer.newLine() + .write('') + .write('') + .write('') + .indent() + .write('') + .write(`${title}`) + .outdent() + .write('') + .write('') + .indent(); + + this.writeRoot(writer, definition.definition, variable); + + writer.newLine(); + + this.writeHandoff(writer); + + writer.outdent() + .write('') + .write('', false); + + return { + files: [ + { + path: path, + language: CodeLanguage.PHP, + code: writer.toString(), + }, + ], + }; + } + + protected abstract writeScript(writer: CodeWriter, definition: SlotDefinition): void; + + protected writeHandoff(writer: CodeWriter): void { + writer.write('') + .write(''); + } + + private writeRoot(writer: CodeWriter, definition: RootDefinition, path: string): void { + if (definition.type === 'union') { + this.writeUnion(writer, definition, path); + + return; + } + + writer.write('
      ') + .indent(); + + this.writeStructureAttributes(writer, definition, path); + + writer.outdent() + .write('
    '); + } + + private writeStructureAttributes( + writer: CodeWriter, + definition: ContentDefinition<'structure'>, + path: string, + ): void { + for (const [name, attribute] of sortAttributes(definition.attributes)) { + if (attribute.private === true) { + continue; + } + + this.writeAttribute(writer, {name: name, ...attribute}, path); + } + } + + private writeAttribute(writer: CodeWriter, attribute: Attribute, parentPath: string): void { + const path = `${parentPath}['${attribute.name}']`; + const definition = attribute.type; + const label = PhpExampleGenerator.escapeEntities(attribute.label ?? formatLabel(attribute.name)); + const optional = attribute.optional === true; + + if (optional) { + writer.write(``) + .indent(); + } + + switch (definition.type) { + case 'boolean': + case 'text': + case 'number': + writer.write(`
  • ${label}: `, false); + + this.writeFragment(writer, definition, path); + + writer.append('
  • ') + .newLine(); + + break; + + default: + writer.write('
  • ') + .indent() + .write(`${label}`); + + this.writeFragment(writer, definition, path); + + writer.outdent() + .write('
  • '); + + break; + } + + if (optional) { + writer.outdent() + .write(''); + } + } + + private writeFragment(writer: CodeWriter, definition: ContentDefinition, path: string): void { + switch (definition.type) { + case 'text': + writer.append(``); + + break; + + case 'number': + writer.append(``); + + break; + + case 'boolean': + writer.append(PhpExampleGenerator.formatBoolean(definition, path)); + + break; + + case 'list': + this.writeList(writer, definition, path); + + break; + + case 'structure': + writer.write('
      ') + .indent(); + + this.writeStructureAttributes(writer, definition, path); + + writer.outdent() + .write('
    '); + + break; + + case 'union': + this.writeUnion(writer, definition, path); + + break; + } + } + + private writeList(writer: CodeWriter, definition: ContentDefinition<'list'>, path: string): void { + const variable = definition.itemLabel !== undefined + ? formatName(definition.itemLabel) + : 'item'; + const itemPath = `$${variable}`; + + writer.write('
      ') + .indent() + .write(``) + .indent(); + + if (PhpExampleGenerator.isInline(definition.items)) { + writer.write('
    1. ', false); + + this.writeFragment(writer, definition.items, itemPath); + + writer.append('
    2. ') + .newLine(); + } else { + writer.write('
    3. ') + .indent(); + + this.writeFragment(writer, definition.items, itemPath); + + writer.outdent() + .write('
    4. '); + } + + writer.outdent() + .write('') + .outdent() + .write('
    '); + } + + private writeUnion(writer: CodeWriter, definition: ContentDefinition<'union'>, path: string): void { + for (const [id, variant] of Object.entries(definition.types)) { + writer.write(``) + .indent(); + + this.writeFragment(writer, variant, path); + + writer.outdent() + .write(''); + } + } + + private static formatBoolean(definition: ContentDefinition<'boolean'>, path: string): string { + const trueLabel = PhpExampleGenerator.escapeString(definition.label?.true ?? 'Yes'); + const falseLabel = PhpExampleGenerator.escapeString(definition.label?.false ?? 'No'); + + return ``; + } + + private static isInline(definition: ContentDefinition): boolean { + return ['number', 'text', 'boolean'].includes(definition.type); + } + + protected static escapeString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'"); + } + + private static escapeEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + private static formatTitle(id: string): string { + return formatSlug(id) + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + private static replaceVariables(path: string, id: string): string { + return path.replace(/%slug%/g, formatSlug(id)); + } +} diff --git a/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts b/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts new file mode 100644 index 00000000..eb27aac1 --- /dev/null +++ b/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts @@ -0,0 +1,45 @@ +import type {Configuration as PhpExampleConfiguration} from './phpExampleGenerator'; +import {PhpExampleGenerator} from './phpExampleGenerator'; +import type {SlotDefinition} from './slotExampleGenerator'; +import type {CodeWriter} from '@/application/project/code/generation/codeWritter'; + +export type Configuration = PhpExampleConfiguration & { + autoloadPath: string, +}; + +/** + * Generates a framework-agnostic PHP example page for a slot. + * + * Bootstraps the SDK from the environment, fetches the slot content, and emits + * the session cookies before rendering the page. + */ +export class PlugPhpExampleGenerator extends PhpExampleGenerator { + private readonly autoloadPath: string; + + public constructor(configuration: Configuration) { + super(configuration); + + this.autoloadPath = configuration.autoloadPath; + } + + protected writeScript(writer: CodeWriter, definition: SlotDefinition): void { + const slotId = PlugPhpExampleGenerator.escapeString(definition.id); + const rootPath = this.autoloadPath.replace(/\/?vendor\/autoload\.php$/, '') || '.'; + + writer.write('fetchContent('${slotId}')->getContent();`) + .newLine() + .write('Croct::emitCookies();') + .newLine() + .write('?>', false); + } +} diff --git a/src/application/project/code/generation/slot/twigExampleGenerator.ts b/src/application/project/code/generation/slot/twigExampleGenerator.ts new file mode 100644 index 00000000..b7cdb75b --- /dev/null +++ b/src/application/project/code/generation/slot/twigExampleGenerator.ts @@ -0,0 +1,271 @@ +import type { + AttributeDefinition, + ContentDefinition, + RootDefinition, +} from '@croct/content-model/definition/definition'; +import type {SlotDefinition, SlotExampleGenerator} from './slotExampleGenerator'; +import type {CodeExample} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {CodeWriter} from '@/application/project/code/generation/codeWritter'; +import {formatLabel, formatSlug, sortAttributes} from '@/application/project/code/generation/utils'; +import {formatName} from '@/application/project/utils/formatName'; + +export type Configuration = { + indentationSize?: number, + contentVariable: string, + filePath: string, + /** + * Whether to wrap the content in a full HTML page or just render the fragment. + * + * Defaults to true. + */ + page?: boolean, +}; + +type Attribute = AttributeDefinition & { + name: string, +}; + +/** + * Generates a Twig template that renders a slot's content. + * + * Walks the slot's resolved definition and renders each field by key, relying + * on the typed content passed by the controller. The plug.js script is injected + * by the Croct bundle, so the template only renders content. + */ +export class TwigExampleGenerator implements SlotExampleGenerator { + private readonly options: Configuration; + + public constructor(options: Configuration) { + this.options = options; + } + + public generate(definition: SlotDefinition): CodeExample { + const path = TwigExampleGenerator.replaceVariables(this.options.filePath, definition.id); + const writer = new CodeWriter(this.options.indentationSize); + const variable = this.options.contentVariable; + + if (this.options.page === false) { + this.writeRoot(writer, definition.definition, variable); + } else { + const title = TwigExampleGenerator.escapeEntities(TwigExampleGenerator.formatTitle(definition.id)); + + writer.write('') + .write('') + .write('') + .indent() + .write('') + .write(`${title}`) + .outdent() + .write('') + .write('') + .indent(); + + this.writeRoot(writer, definition.definition, variable); + + writer.outdent() + .write('') + .write('', false); + } + + return { + files: [ + { + path: path, + language: CodeLanguage.TWIG, + code: writer.toString(), + }, + ], + }; + } + + private writeRoot(writer: CodeWriter, definition: RootDefinition, path: string): void { + if (definition.type === 'union') { + this.writeUnion(writer, definition, path); + + return; + } + + writer.write('
      ') + .indent(); + + this.writeStructureAttributes(writer, definition, path); + + writer.outdent() + .write('
    '); + } + + private writeStructureAttributes( + writer: CodeWriter, + definition: ContentDefinition<'structure'>, + path: string, + ): void { + for (const [name, attribute] of sortAttributes(definition.attributes)) { + if (attribute.private === true) { + continue; + } + + this.writeAttribute(writer, {name: name, ...attribute}, path); + } + } + + private writeAttribute(writer: CodeWriter, attribute: Attribute, parentPath: string): void { + const path = `${parentPath}.${attribute.name}`; + const definition = attribute.type; + const label = TwigExampleGenerator.escapeEntities(attribute.label ?? formatLabel(attribute.name)); + const optional = attribute.optional === true; + + if (optional) { + writer.write(`{% if ${path} is defined %}`) + .indent(); + } + + switch (definition.type) { + case 'boolean': + case 'text': + case 'number': + writer.write(`
  • ${label}: `, false); + + this.writeFragment(writer, definition, path); + + writer.append('
  • ') + .newLine(); + + break; + + default: + writer.write('
  • ') + .indent() + .write(`${label}`); + + this.writeFragment(writer, definition, path); + + writer.outdent() + .write('
  • '); + + break; + } + + if (optional) { + writer.outdent() + .write('{% endif %}'); + } + } + + private writeFragment(writer: CodeWriter, definition: ContentDefinition, path: string): void { + switch (definition.type) { + case 'text': + case 'number': + writer.append(`{{ ${path} }}`); + + break; + + case 'boolean': + writer.append(TwigExampleGenerator.formatBoolean(definition, path)); + + break; + + case 'list': + this.writeList(writer, definition, path); + + break; + + case 'structure': + writer.write('
      ') + .indent(); + + this.writeStructureAttributes(writer, definition, path); + + writer.outdent() + .write('
    '); + + break; + + case 'union': + this.writeUnion(writer, definition, path); + + break; + } + } + + private writeList(writer: CodeWriter, definition: ContentDefinition<'list'>, path: string): void { + const itemPath = definition.itemLabel !== undefined + ? formatName(definition.itemLabel) + : 'item'; + + writer.write('
      ') + .indent() + .write(`{% for ${itemPath} in ${path} %}`) + .indent(); + + if (TwigExampleGenerator.isInline(definition.items)) { + writer.write('
    1. ', false); + + this.writeFragment(writer, definition.items, itemPath); + + writer.append('
    2. ') + .newLine(); + } else { + writer.write('
    3. ') + .indent(); + + this.writeFragment(writer, definition.items, itemPath); + + writer.outdent() + .write('
    4. '); + } + + writer.outdent() + .write('{% endfor %}') + .outdent() + .write('
    '); + } + + private writeUnion(writer: CodeWriter, definition: ContentDefinition<'union'>, path: string): void { + for (const [id, variant] of Object.entries(definition.types)) { + writer.write(`{% if ${path}._type == '${TwigExampleGenerator.escapeString(id)}' %}`) + .indent(); + + this.writeFragment(writer, variant, path); + + writer.outdent() + .write('{% endif %}'); + } + } + + private static formatBoolean(definition: ContentDefinition<'boolean'>, path: string): string { + const trueLabel = TwigExampleGenerator.escapeString(definition.label?.true ?? 'Yes'); + const falseLabel = TwigExampleGenerator.escapeString(definition.label?.false ?? 'No'); + + return `{{ ${path} ? '${trueLabel}' : '${falseLabel}' }}`; + } + + private static isInline(definition: ContentDefinition): boolean { + return ['number', 'text', 'boolean'].includes(definition.type); + } + + private static escapeString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'"); + } + + private static escapeEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + private static formatTitle(id: string): string { + return formatSlug(id) + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + private static replaceVariables(path: string, id: string): string { + return path.replace(/%slug%/g, formatSlug(id)); + } +} diff --git a/src/application/project/code/transformation/neon/neonListCodemod.ts b/src/application/project/code/transformation/neon/neonListCodemod.ts new file mode 100644 index 00000000..be1e1e60 --- /dev/null +++ b/src/application/project/code/transformation/neon/neonListCodemod.ts @@ -0,0 +1,167 @@ +import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; + +export type NeonListOptions = { + /** + * The top-level key whose list the value is added to. + */ + key: string, + + /** + * The value to add to the list. + */ + value: string, +}; + +type ParsedLine = { + indentation: string, + content: string, +}; + +type BlockSection = { + type: 'block', + keyIndex: number, + values: string[], + indentation: string | null, +}; + +type InlineSection = { + type: 'inline', +}; + +type ListSection = BlockSection | InlineSection; + +/** + * Adds a value to a top-level list in a NEON document (e.g. PHPStan's `includes`). + */ +export class NeonListCodemod implements Codemod { + public apply(input: string, options: NeonListOptions): Promise> { + const lines = input.split('\n'); + const section = NeonListCodemod.findSection(lines, options.key); + + if (section !== null && section.type === 'inline') { + throw new CodemodError(`Cannot add a value to the inline \`${options.key}\` list.`); + } + + if (section !== null && section.values.includes(options.value)) { + return Promise.resolve({modified: false, result: input}); + } + + return Promise.resolve({ + modified: true, + result: NeonListCodemod.addValue(lines, section, options), + }); + } + + private static addValue( + lines: string[], + section: BlockSection | null, + {key, value}: NeonListOptions, + ): string { + if (section === null) { + const block = `${key}:\n\t- ${value}`; + const rest = lines.join('\n'); + + return rest.trim() === '' ? `${block}\n` : `${block}\n\n${rest}`; + } + + const indentation = section.indentation ?? '\t'; + + lines.splice(section.keyIndex + 1, 0, `${indentation}- ${value}`); + + return lines.join('\n'); + } + + private static findSection(lines: string[], key: string): ListSection | null { + const marker = `${key}:`; + + for (let index = 0; index < lines.length; index++) { + const {indentation, content} = NeonListCodemod.parseLine(lines[index]); + + if (indentation !== '' || !content.startsWith(marker)) { + continue; + } + + if (content !== marker) { + return {type: 'inline'}; + } + + return NeonListCodemod.collectItems(lines, index); + } + + return null; + } + + private static collectItems(lines: string[], keyIndex: number): BlockSection { + const values: string[] = []; + let indentation: string | null = null; + + for (let index = keyIndex + 1; index < lines.length; index++) { + const line = NeonListCodemod.parseLine(lines[index]); + + if (line.content === '') { + // Blank or comment-only line: still part of the block. + continue; + } + + if (line.indentation === '') { + // Dedented to the next top-level key: the block has ended. + break; + } + + if (line.content.startsWith('-')) { + indentation ??= line.indentation; + values.push(NeonListCodemod.parseValue(line.content.slice(1).trim())); + } + } + + return {type: 'block', keyIndex: keyIndex, values: values, indentation: indentation}; + } + + private static parseLine(line: string): ParsedLine { + const code = NeonListCodemod.stripComment(line); + + return { + indentation: code.slice(0, code.length - code.trimStart().length), + content: code.trim(), + }; + } + + private static stripComment(line: string): string { + let index = 0; + + while (index < line.length) { + const char = line[index]; + + if (char === "'" || char === '"') { + index = NeonListCodemod.skipString(line, index); + } else if (char === '#' && (index === 0 || line[index - 1] === ' ' || line[index - 1] === '\t')) { + return line.slice(0, index); + } else { + index++; + } + } + + return line; + } + + private static skipString(line: string, start: number): number { + const quote = line[start]; + + for (let index = start + 1; index < line.length; index++) { + if (line[index] === quote) { + return index + 1; + } + } + + return line.length; + } + + private static parseValue(value: string): string { + if (value.length >= 2 && (value[0] === "'" || value[0] === '"') && value[value.length - 1] === value[0]) { + return value.slice(1, -1); + } + + return value; + } +} diff --git a/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts b/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts new file mode 100644 index 00000000..720041b5 --- /dev/null +++ b/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts @@ -0,0 +1,257 @@ +import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; + +export type Configuration = { + /** + * The local settings filename that `settings.php` should include. + */ + file: string, +}; + +type Scan = { + /** + * Whether an active (non-commented) include of the file was found. + */ + included: boolean, + + /** + * The index of the line holding a commented-out include, or -1 if none. + */ + commentIndex: number, +}; + +/** + * Enables Drupal's local settings include in `settings.php`. + */ +export class DrupalLocalSettingsCodemod implements Codemod { + private static readonly INCLUDE_KEYWORDS = ['include_once', 'require_once', 'include', 'require']; + + private static readonly WORD_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789_'; + + private readonly file: string; + + public constructor({file}: Configuration) { + this.file = file; + } + + public apply(input: string): Promise> { + if (input.trim() === '') { + return Promise.resolve({modified: false, result: input}); + } + + const result = DrupalLocalSettingsCodemod.scan(input, this.file); + + if (result.included) { + return Promise.resolve({modified: false, result: input}); + } + + if (result.commentIndex >= 0) { + const uncommented = DrupalLocalSettingsCodemod.uncomment(input, result.commentIndex); + + // Uncommenting in place only applies to a full-line comment; if it made + // no change (the include is commented after code on the line), append. + if (uncommented !== input) { + return Promise.resolve({modified: true, result: uncommented}); + } + } + + return Promise.resolve({modified: true, result: DrupalLocalSettingsCodemod.append(input, this.file)}); + } + + /** + * Walks the source once and reports whether the file is already included. + */ + private static scan(input: string, file: string): Scan { + let mode: 'code' | 'string' | 'line' | 'block' = 'code'; + let quote = ''; + let statement = ''; // code of the current statement (reset on `;`) + let buffer = ''; // current string or comment contents + let line = 0; + let bufferLine = 0; // line on which the current comment started + let commentIndex = -1; // first commented-out include, or -1 + let index = 0; + + while (index < input.length) { + const char = input[index]; + const next = input[index + 1] ?? ''; + + if (mode === 'code') { + if (char === "'" || char === '"') { + mode = 'string'; + quote = char; + buffer = ''; + } else if (char === '/' && next === '*') { + mode = 'block'; + index += 2; + + continue; + } else if ((char === '/' && next === '/') || char === '#') { + mode = 'line'; + buffer = ''; + bufferLine = line; + index += char === '#' ? 1 : 2; + + continue; + } else if (char === ';') { + statement = ''; + } else { + statement += char; + } + } else if (mode === 'string') { + if (char === '\\') { + buffer += char + next; + index += 2; + + continue; + } + + if (char === quote) { + mode = 'code'; + + if (buffer.includes(file) && DrupalLocalSettingsCodemod.hasKeyword(statement)) { + return {included: true, commentIndex: -1}; + } + } else { + buffer += char; + } + } else if (mode === 'line') { + if (char === '\n') { + mode = 'code'; + + // Record the first commented include but keep scanning: a later + // active include must override it. + if (commentIndex < 0 && DrupalLocalSettingsCodemod.isCommentedInclude(buffer, file)) { + commentIndex = bufferLine; + } + } else { + buffer += char; + } + } else if (char === '*' && next === '/') { + // Reached only in block-comment mode. A block comment is skipped + // wholesale: a block-commented include is neither active nor + // line-uncommentable, so it falls through to append. + mode = 'code'; + index += 2; + + continue; + } + + if (char === '\n') { + line += 1; + } + + index += 1; + } + + // A line comment may run to the end of the file without a trailing newline. + if (commentIndex < 0 && mode === 'line' && DrupalLocalSettingsCodemod.isCommentedInclude(buffer, file)) { + commentIndex = bufferLine; + } + + return {included: false, commentIndex: commentIndex}; + } + + private static isCommentedInclude(comment: string, file: string): boolean { + return comment.includes(file) && DrupalLocalSettingsCodemod.hasKeyword(comment); + } + + private static uncomment(input: string, target: number): string { + const lines = input.split('\n'); + + lines[target] = DrupalLocalSettingsCodemod.stripMarker(lines[target]); + + // When the include sits in a commented `if (...) {`, uncomment the opener + // (directly above) and its closing brace (directly below) so the block stays + // balanced. Drupal's stock block is exactly these three lines. + const opener = target > 0 ? lines[target - 1] : ''; + + if ( + DrupalLocalSettingsCodemod.isComment(opener) + && DrupalLocalSettingsCodemod.isIfOpener(DrupalLocalSettingsCodemod.stripMarker(opener)) + ) { + lines[target - 1] = DrupalLocalSettingsCodemod.stripMarker(opener); + + const closer = target + 1 < lines.length ? lines[target + 1] : ''; + + if ( + DrupalLocalSettingsCodemod.isComment(closer) + && DrupalLocalSettingsCodemod.stripMarker(closer) + .trimStart() + .startsWith('}') + ) { + lines[target + 1] = DrupalLocalSettingsCodemod.stripMarker(closer); + } + } + + return lines.join('\n'); + } + + private static append(input: string, file: string): string { + const path = `$app_root . '/' . $site_path . '/${file}'`; + const block = [ + `if (file_exists(${path})) {`, + ` include ${path};`, + '}', + '', + ].join('\n'); + + const base = input.endsWith('\n') ? input : `${input}\n`; + + return `${base}\n${block}`; + } + + private static hasKeyword(text: string): boolean { + const lower = text.toLowerCase(); + + return DrupalLocalSettingsCodemod.INCLUDE_KEYWORDS.some(keyword => { + for (let at = lower.indexOf(keyword); at !== -1; at = lower.indexOf(keyword, at + 1)) { + if ( + !DrupalLocalSettingsCodemod.isWordChar(lower[at - 1]) + && !DrupalLocalSettingsCodemod.isWordChar(lower[at + keyword.length]) + ) { + return true; + } + } + + return false; + }); + } + + private static isWordChar(char: string | undefined): boolean { + // Called with lowercased characters, so uppercase need not be considered. + return char !== undefined && DrupalLocalSettingsCodemod.WORD_CHARS.includes(char); + } + + private static isComment(line: string): boolean { + const trimmed = line.trimStart(); + + return trimmed.startsWith('#') || trimmed.startsWith('//'); + } + + private static isIfOpener(code: string): boolean { + const trimmed = code.trim(); + + return (trimmed.startsWith('if ') || trimmed.startsWith('if(')) && trimmed.endsWith('{'); + } + + private static stripMarker(line: string): string { + let start = 0; + + while (line[start] === ' ' || line[start] === '\t') { + start += 1; + } + + const indent = line.slice(0, start); + + let rest = line.slice(start); + + if (rest.startsWith('//')) { + rest = rest.slice(2); + } else if (rest.startsWith('#')) { + rest = rest.slice(1); + } else { + return line; + } + + return indent + (rest.startsWith(' ') ? rest.slice(1) : rest); + } +} diff --git a/src/application/project/code/transformation/php/laravelRouteCodemod.ts b/src/application/project/code/transformation/php/laravelRouteCodemod.ts new file mode 100644 index 00000000..512e788e --- /dev/null +++ b/src/application/project/code/transformation/php/laravelRouteCodemod.ts @@ -0,0 +1,125 @@ +import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; + +export type RouteOptions = { + /** + * The slot ID fetched by the route. + */ + slot: string, + + /** + * The route URL, which also serves as the idempotency key. + */ + url: string, + + /** + * The Blade view that renders the slot content. + */ + view: string, +}; + +/** + * Registers a Laravel route in `routes/web.php`. + * + * The route is appended to the file. Registration is idempotent on the URL: if a + * route for the URL is already defined in active code, the file is left unchanged. + */ +export class LaravelRouteCodemod implements Codemod { + public apply(input: string, options?: RouteOptions): Promise> { + if ( + options === undefined + || input.trim() === '' + || LaravelRouteCodemod.isRegistered(input, options.url) + ) { + return Promise.resolve({modified: false, result: input}); + } + + return Promise.resolve({modified: true, result: LaravelRouteCodemod.append(input, options)}); + } + + /** + * Checks whether the given URL is already registered in the given source code. + */ + private static isRegistered(input: string, url: string): boolean { + let mode: 'code' | 'string' | 'line' | 'block' = 'code'; + let quote = ''; + let statement = ''; // code of the current statement (reset on `;`) + let buffer = ''; // current string contents + let index = 0; + + while (index < input.length) { + const char = input[index]; + const next = input[index + 1] ?? ''; + + if (mode === 'code') { + if (char === "'" || char === '"') { + mode = 'string'; + quote = char; + buffer = ''; + } else if (char === '/' && next === '*') { + mode = 'block'; + index += 2; + + continue; + } else if ((char === '/' && next === '/') || char === '#') { + mode = 'line'; + index += char === '#' ? 1 : 2; + + continue; + } else if (char === ';') { + statement = ''; + } else { + statement += char; + } + } else if (mode === 'string') { + if (char === '\\') { + buffer += char + next; + index += 2; + + continue; + } + + if (char === quote) { + mode = 'code'; + + // The URL counts only as the argument of a `Route::` call, not as + // an unrelated string literal. + if (buffer === url && statement.includes('Route::')) { + return true; + } + } else { + buffer += char; + } + } else if (mode === 'line') { + if (char === '\n') { + mode = 'code'; + } + } else if (char === '*' && next === '/') { + // Reached only in block-comment mode. + mode = 'code'; + index += 2; + + continue; + } + + index += 1; + } + + return false; + } + + private static append(input: string, options: RouteOptions): string { + const block = [ + '', + `Route::get('${options.url}', static function (\\Croct\\Plug\\Plug $croct) {`, + ` return view('${options.view}', [`, + ` 'content' => $croct->fetchContent('${options.slot}')->getContent(),`, + ' ]);', + '});', + '', + ].join('\n'); + + const base = input.endsWith('\n') ? input : `${input}\n`; + + return `${base}${block}`; + } +} diff --git a/src/application/project/code/transformation/php/symfonyBundleCodemod.ts b/src/application/project/code/transformation/php/symfonyBundleCodemod.ts new file mode 100644 index 00000000..d27315de --- /dev/null +++ b/src/application/project/code/transformation/php/symfonyBundleCodemod.ts @@ -0,0 +1,40 @@ +import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; + +export type Configuration = { + /** + * The fully-qualified bundle class to register (without a leading backslash). + */ + bundle: string, +}; + +/** + * Registers a Symfony bundle in `config/bundles.php`. + */ +export class SymfonyBundleCodemod implements Codemod { + private readonly bundle: string; + + public constructor({bundle}: Configuration) { + this.bundle = bundle; + } + + public apply(input: string): Promise> { + if (input.includes(this.bundle)) { + return Promise.resolve({modified: false, result: input}); + } + + // Bundle configs use `]` (e.g. `['all' => true]`), so the array's `];` is + // the only one — its last occurrence is the insertion point. + const closing = input.lastIndexOf('];'); + + if (closing === -1) { + return Promise.resolve({modified: false, result: input}); + } + + const entry = ` ${this.bundle}::class => ['all' => true],\n`; + + return Promise.resolve({ + modified: true, + result: `${input.slice(0, closing)}${entry}${input.slice(closing)}`, + }); + } +} diff --git a/src/application/project/code/transformation/yml/yamlMappingCodemod.ts b/src/application/project/code/transformation/yml/yamlMappingCodemod.ts new file mode 100644 index 00000000..485691b1 --- /dev/null +++ b/src/application/project/code/transformation/yml/yamlMappingCodemod.ts @@ -0,0 +1,55 @@ +import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; + +export type YamlMappingOptions = { + /** + * The top-level mapping key. + */ + key: string, + + /** + * The nested `name: value` entries. + * + * Values are written verbatim, so the caller controls quoting + * and any framework-specific syntax (e.g. Symfony `%env()%`). + */ + entries: Record, +}; + +/** + * Ensures a top-level YAML mapping exists in a configuration file. + * + * Appends `:` with the given entries when the key is absent, creating the + * file from empty input, and is a no-op when a top-level `:` is already + * present. + */ +export class YamlMappingCodemod implements Codemod { + public apply(input: string, options: YamlMappingOptions): Promise> { + if (YamlMappingCodemod.hasKey(input, options.key)) { + return Promise.resolve({modified: false, result: input}); + } + + return Promise.resolve({modified: true, result: YamlMappingCodemod.append(input, options)}); + } + + private static hasKey(input: string, key: string): boolean { + const marker = `${key}:`; + + return input.split('\n').some(line => line.startsWith(marker)); + } + + private static append(input: string, {key, entries}: YamlMappingOptions): string { + const block = [ + `${key}:`, + ...Object.entries(entries).map(([name, value]) => ` ${name}: ${value}`), + '', + ].join('\n'); + + if (input.trim() === '') { + return block; + } + + const base = input.endsWith('\n') ? input : `${input}\n`; + + return `${base}\n${block}`; + } +} diff --git a/src/application/project/example/example.ts b/src/application/project/example/example.ts new file mode 100644 index 00000000..d550ffe6 --- /dev/null +++ b/src/application/project/example/example.ts @@ -0,0 +1,85 @@ +import type {Input} from '@/application/cli/io/input'; +import type {Output} from '@/application/cli/io/output'; +import type {ExampleServer} from '@/application/project/example/exampleServer'; + +/** + * What an example uses to present itself. + */ +export type ExampleContext = { + input?: Input, + output: Output, + server: ExampleServer, +}; + +/** + * A generated slot example that knows how to point the developer at itself. + */ +export abstract class Example { + protected readonly name: string; + + protected constructor(name: string) { + this.name = name; + } + + /** + * Opens the example or tells the developer how to reach it. + */ + public abstract present(context: ExampleContext): Promise; +} + +/** + * An example served by the application at a URL (e.g. `/croct/home-hero`). + */ +export class UrlExample extends Example { + private readonly path: string; + + public constructor(name: string, path: string) { + super(name); + + this.path = path; + } + + public present({server}: ExampleContext): Promise { + return server.open(this.name, this.path); + } +} + +/** + * An example opened directly as a file (e.g. a generated `index.html`). + */ +export class FileExample extends Example { + private readonly path: string; + + public constructor(name: string, path: string) { + super(name); + + this.path = path; + } + + public async present({input, output}: ExampleContext): Promise { + output.inform(`View the '${this.name}' example by opening \`${this.path}\` in your browser.`); + + if (input !== undefined && await input.confirm({message: 'Open it now?', default: true})) { + await output.open(`file://${this.path}`); + } + } +} + +/** + * An example the developer wires up by following an instruction (e.g. importing a component). + */ +export class InstructionExample extends Example { + private readonly instruction: string; + + public constructor(name: string, instruction: string) { + super(name); + + this.instruction = instruction; + } + + public present({output}: ExampleContext): Promise { + output.inform(this.instruction); + + return Promise.resolve(); + } +} diff --git a/src/application/project/example/exampleLauncher.ts b/src/application/project/example/exampleLauncher.ts new file mode 100644 index 00000000..32ab240c --- /dev/null +++ b/src/application/project/example/exampleLauncher.ts @@ -0,0 +1,36 @@ +import type {Input} from '@/application/cli/io/input'; +import type {Output} from '@/application/cli/io/output'; +import type {Provider} from '@/application/provider/provider'; +import type {Server} from '@/application/project/server/server'; +import type {Example} from '@/application/project/example/example'; +import {ExampleServer} from '@/application/project/example/exampleServer'; + +export type Presentation = { + examples: Example[], + input?: Input, + output: Output, +}; + +/** + * Presents the generated examples, owning the dev-server lifecycle. + * + * Builds the {@link ExampleServer} each example presents against, runs every example, then shuts + * down a server it started. + */ +export class ExampleLauncher { + private readonly serverProvider: Provider; + + public constructor(serverProvider: Provider) { + this.serverProvider = serverProvider; + } + + public async launch({examples, input, output}: Presentation): Promise { + const server = new ExampleServer(this.serverProvider, input, output); + + for (const example of examples) { + await example.present({input: input, output: output, server: server}); + } + + await server.close(); + } +} diff --git a/src/application/project/example/exampleServer.ts b/src/application/project/example/exampleServer.ts new file mode 100644 index 00000000..9a4a21e8 --- /dev/null +++ b/src/application/project/example/exampleServer.ts @@ -0,0 +1,115 @@ +import type {Input} from '@/application/cli/io/input'; +import type {Output} from '@/application/cli/io/output'; +import type {Provider} from '@/application/provider/provider'; +import type {Server} from '@/application/project/server/server'; +import {TaskProgressLogger} from '@/infrastructure/application/cli/io/taskProgressLogger'; + +/** + * The dev server seen by examples while they present themselves. + * + * The detected server is resolved lazily, the first time an example asks to open a URL: if it is + * running the developer is offered to open it; if it is stopped, to start it. A server started here + * is kept alive ({@link close}) until the developer stops it with Ctrl+C. The example URL is always + * reported for reference, whether or not it is opened. + */ +export class ExampleServer { + private readonly provider: Provider; + + private readonly input: Input | undefined; + + private readonly output: Output; + + private resolved = false; + + private base?: URL; + + private shouldOpen = false; + + private owned?: Server; + + public constructor(provider: Provider, input: Input | undefined, output: Output) { + this.provider = provider; + this.input = input; + this.output = output; + } + + public async open(name: string, path: string): Promise { + await this.resolve(); + + if (this.base === undefined) { + this.output.inform(`Start your dev server, then open \`${path}\` to view the '${name}' example.`); + + return; + } + + const url = new URL(path, this.base).toString(); + + this.output.inform(`View the '${name}' example at ${url}`); + + if (this.shouldOpen) { + await this.output.open(url); + } + } + + public async close(): Promise { + if (this.owned !== undefined) { + this.output.inform(`Server running at ${this.base?.toString() ?? ''}. Press Ctrl+C to stop.`); + + // Keep the CLI in the foreground while the server it started serves requests; Ctrl+C + // stops the server (and the CLI) the way any dev server is stopped. + await this.owned.wait(); + } + } + + private async resolve(): Promise { + if (this.resolved) { + return; + } + + this.resolved = true; + + const server = await this.provider.get(); + const status = await server?.getStatus(); + + if (status?.running === true) { + this.base = status.url; + this.shouldOpen = this.input !== undefined + && await this.input.confirm({message: 'Open the example in your browser?', default: true}); + + return; + } + + if ( + server !== null + && this.input !== undefined + && await this.input.confirm({message: 'Start the dev server and open the example?', default: true}) + ) { + const url = await this.start(server); + + if (url !== undefined) { + this.base = url; + this.shouldOpen = true; + this.owned = server; + } + } + } + + private async start(server: Server): Promise { + const notifier = this.output.notify('Starting the dev server'); + + try { + const url = await server.start({ + logger: new TaskProgressLogger({status: 'Starting the dev server', notifier: notifier}), + }); + + notifier.confirm('Dev server started'); + + return url; + } catch { + notifier.stop(); + this.output.warn('Could not start the dev server. Start it manually, then open the example.'); + + return undefined; + } + } +} diff --git a/src/application/project/packageManager/agent/composerAgent.ts b/src/application/project/packageManager/agent/composerAgent.ts new file mode 100644 index 00000000..0f0744fa --- /dev/null +++ b/src/application/project/packageManager/agent/composerAgent.ts @@ -0,0 +1,49 @@ +import {ExecutableAgent} from '@/application/project/packageManager/agent/executableAgent'; +import type {Command} from '@/application/system/process/command'; + +/** + * Package manager agent backed by the Composer executable. + * + * Builds the Composer commands for installing, updating, and running packages + * and scripts in a PHP project. + */ +export class ComposerAgent extends ExecutableAgent { + protected getCommandName(): string { + return 'composer'; + } + + protected createPackageCommand(packageName: string, args: string[] = []): Promise { + return Promise.resolve( + this.getCommand('composer', ['exec', packageName, ...(args.length > 0 ? ['--', ...args] : [])]), + ); + } + + protected createScriptCommand(script: string, args: string[] = []): Promise { + return Promise.resolve( + this.getCommand('composer', ['run-script', script, ...(args.length > 0 ? ['--', ...args] : [])]), + ); + } + + protected createAddDependencyCommand(dependencies: string[], dev: boolean): Promise { + return Promise.resolve( + this.getCommand('composer', ['require', ...(dev ? ['--dev'] : []), ...dependencies]), + ); + } + + protected createInstallDependenciesCommand(): Promise { + return Promise.resolve(this.getCommand('composer', ['install'])); + } + + protected createPackageUpdateCommand(packageName: string, global = false): Promise { + return Promise.resolve( + this.getCommand('composer', [...(global ? ['global'] : []), 'update', packageName]), + ); + } + + private getCommand(command: string, args: string[] = []): Command { + return { + name: command, + arguments: args, + }; + } +} diff --git a/src/application/project/packageManager/composerPackageManager.ts b/src/application/project/packageManager/composerPackageManager.ts new file mode 100644 index 00000000..27dcf378 --- /dev/null +++ b/src/application/project/packageManager/composerPackageManager.ts @@ -0,0 +1,315 @@ +import semver from 'semver'; +import {JsonObjectNode, JsonParser} from '@croct/json5-parser'; +import type { + AddDependencyOptions, + Dependency, + InstallDependenciesOptions, + PackageManager, + UpdateCommandOptions, + UpdatePackageOptions, +} from '@/application/project/packageManager/packageManager'; +import {PackageManagerError} from '@/application/project/packageManager/packageManager'; +import type {FileSystem} from '@/application/fs/fileSystem'; +import type {Validator} from '@/application/validation'; +import {ErrorReason} from '@/application/error'; +import type {WorkingDirectory} from '@/application/fs/workingDirectory/workingDirectory'; +import type {PackageManagerAgent} from '@/application/project/packageManager/agent/packageManagerAgent'; +import type {Command} from '@/application/system/process/command'; + +export type Configuration = { + projectDirectory: WorkingDirectory, + packageValidator: Validator, + lockValidator: Validator, + fileSystem: FileSystem, + agent: PackageManagerAgent, +}; + +export type PartialComposerManifest = { + name?: string, + version?: string, + type?: string, + require?: Record, + 'require-dev'?: Record, + scripts?: Record, + autoload?: { + 'psr-4'?: Record, + }, + bin?: string | string[], + extra?: Record, +}; + +export type ComposerLock = { + packages?: Array<{provide?: Record}>, + 'packages-dev'?: Array<{provide?: Record}>, +}; + +/** + * Composer-backed implementation of the package manager. + * + * Reads dependencies from the `composer.json` manifest and resolves installed + * packages under the `vendor` directory, delegating command execution to a + * Composer agent. + */ +export class ComposerPackageManager implements PackageManager { + /** + * Default providers for PSR virtual implementation packages, chosen to add the + * fewest dependencies from a single vendor: `guzzlehttp/guzzle` provides the + * PSR-18 client and pulls `guzzlehttp/psr7` (PSR-17 factory + PSR-7 message). + */ + private static readonly DEFAULT_PROVIDERS: Record = { + 'psr/http-client-implementation': 'guzzlehttp/guzzle', + 'psr/http-factory-implementation': 'guzzlehttp/guzzle', + 'psr/http-message-implementation': 'guzzlehttp/guzzle', + }; + + private readonly projectDirectory: WorkingDirectory; + + private readonly fileSystem: FileSystem; + + private readonly agent: PackageManagerAgent; + + private readonly packageValidator: Validator; + + private readonly lockValidator: Validator; + + public constructor(configuration: Configuration) { + this.projectDirectory = configuration.projectDirectory; + this.fileSystem = configuration.fileSystem; + this.agent = configuration.agent; + this.packageValidator = configuration.packageValidator; + this.lockValidator = configuration.lockValidator; + } + + public getName(): Promise { + return this.agent.getName(); + } + + public isInstalled(): Promise { + return this.agent.isInstalled(); + } + + public isProject(): Promise { + return this.fileSystem.exists(this.getProjectManifestPath()); + } + + public async addDependencies(dependencies: string[], options?: AddDependencyOptions): Promise { + const resolved = await this.resolveImplementations(dependencies); + + if (resolved.length > 0) { + await this.agent.addDependencies(resolved, options); + } + } + + /** + * Resolves PSR virtual implementation packages to concrete providers. + * + * A virtual package (e.g. `psr/http-client-implementation`) cannot be required + * directly: it is dropped when an installed package already provides it, and + * otherwise replaced by a default provider. Real package names pass through. + */ + private async resolveImplementations(dependencies: string[]): Promise { + const resolved: string[] = []; + + for (const dependency of dependencies) { + const provider = ComposerPackageManager.DEFAULT_PROVIDERS[dependency]; + + if (provider === undefined) { + resolved.push(dependency); + } else if (!await this.hasDependency(dependency)) { + resolved.push(provider); + } + } + + return [...new Set(resolved)]; + } + + public installDependencies(options?: InstallDependenciesOptions): Promise { + return this.agent.installDependencies(options); + } + + public updatePackage(packageName: string, options?: UpdatePackageOptions): Promise { + return this.agent.updatePackage(packageName, options); + } + + public getPackageCommand(packageName: string, args: string[] = []): Promise { + return this.agent.getPackageCommand(packageName, args); + } + + public getScriptCommand(script: string, args: string[] = []): Promise { + return this.agent.getScriptCommand(script, args); + } + + public getPackageUpdateCommand(packageName: string, options?: UpdateCommandOptions): Promise { + return this.agent.getPackageUpdateCommand(packageName, options); + } + + public async hasDirectDependency(name: string, version?: string): Promise { + const manifest = await this.readManifest(this.getProjectManifestPath()); + + if (manifest?.require?.[name] === undefined && manifest?.['require-dev']?.[name] === undefined) { + return false; + } + + if (version === undefined) { + return true; + } + + return this.hasDependency(name, version); + } + + public async hasDependency(name: string, version?: string): Promise { + const info = await this.getDependency(name); + + if (info !== null) { + if (version === undefined || info.version === null) { + return version === undefined; + } + + return semver.satisfies(info.version, version); + } + + // Virtual packages (e.g. `psr/http-client-implementation`) have no `vendor/` + // entry; check whether any installed package declares them under `provide`. + return this.isProvided(name); + } + + private async isProvided(name: string): Promise { + const lock = await this.readLock(); + + return [...(lock.packages ?? []), ...(lock['packages-dev'] ?? [])] + .some(entry => entry.provide?.[name] !== undefined); + } + + private async readLock(): Promise { + const path = this.fileSystem.joinPaths(this.projectDirectory.get(), 'composer.lock'); + + if (!await this.fileSystem.exists(path)) { + return {}; + } + + let data: unknown; + + try { + data = JSON.parse(await this.fileSystem.readTextFile(path)); + } catch { + return {}; + } + + const result = await this.lockValidator.validate(data); + + return result.valid ? result.data : {}; + } + + public async getDependency(name: string): Promise { + const manifestPath = this.getVendorManifestPath(name); + const manifest = await this.readManifest(manifestPath); + + if (manifest === null) { + return null; + } + + return { + name: manifest.name ?? name, + version: manifest.version ?? null, + directory: this.fileSystem.getDirectoryName(manifestPath), + metadata: manifest, + }; + } + + public async getScripts(): Promise> { + const manifest = await this.readManifest(this.getProjectManifestPath()); + + if (manifest?.scripts === undefined) { + return {}; + } + + const scripts: Record = {}; + + for (const [name, value] of Object.entries(manifest.scripts)) { + if (typeof value === 'string') { + scripts[name] = value; + } else if (Array.isArray(value) && value.every((item): item is string => typeof item === 'string')) { + scripts[name] = value.join(' && '); + } + } + + return scripts; + } + + public async addScript(name: string, script: string): Promise { + const manifestFile = this.getProjectManifestPath(); + + if (!await this.fileSystem.exists(manifestFile)) { + throw new PackageManagerError('Composer manifest not found in the project.', { + reason: ErrorReason.PRECONDITION, + details: [ + `File: ${manifestFile}`, + ], + }); + } + + const manifest = JsonParser.parse(await this.fileSystem.readTextFile(manifestFile), JsonObjectNode); + + if (!manifest.has('scripts')) { + manifest.set('scripts', {}); + } + + const scripts = manifest.get('scripts', JsonObjectNode); + + if (scripts.has(name)) { + const current = scripts.get(name).toJSON(); + + if (typeof current === 'string') { + if (current === script) { + // The script is already registered. + return; + } + + scripts.set(name, [current, script]); + } else if (Array.isArray(current)) { + if (current.includes(script)) { + // The script is already registered. + return; + } + + scripts.set(name, [...current, script]); + } else { + scripts.set(name, script); + } + } else { + scripts.set(name, script); + } + + await this.fileSystem.writeTextFile(manifestFile, manifest.toString(), {overwrite: true}); + } + + private getProjectManifestPath(): string { + return this.fileSystem.joinPaths(this.projectDirectory.get(), 'composer.json'); + } + + private getVendorManifestPath(name: string): string { + return this.fileSystem.joinPaths(this.projectDirectory.get(), 'vendor', name, 'composer.json'); + } + + private async readManifest(path: string): Promise { + if (!await this.fileSystem.exists(path)) { + return null; + } + + let data: unknown; + + try { + data = JSON.parse(await this.fileSystem.readTextFile(path)); + } catch { + return null; + } + + const result = await this.packageValidator.validate(data); + + if (!result.valid) { + return null; + } + + return result.data; + } +} diff --git a/src/application/project/sdk/content/contentLoader.ts b/src/application/project/sdk/content/contentLoader.ts new file mode 100644 index 00000000..10f6ade9 --- /dev/null +++ b/src/application/project/sdk/content/contentLoader.ts @@ -0,0 +1,44 @@ +import type {Help} from '@/application/error'; +import {HelpfulError} from '@/application/error'; +import type {ProjectConfiguration} from '@/application/project/configuration/projectConfiguration'; +import type {LocalizedContentMap} from '@/application/model/experience'; +import type {TargetSdk} from '@/application/api/workspace'; + +export type VersionedContent = { + version: number, + content: LocalizedContentMap, +}; + +export type VersionedContentMap = Record; + +export class ContentError extends HelpfulError { + public constructor(message: string, help?: Help) { + super(message, help); + + Object.setPrototypeOf(this, ContentError.prototype); + } +} + +export interface ContentLoader { + /** + * Downloads the slot content from the workspace and caches it. + * + * Keeps the cached content when it already exists, unless `refresh` is true, in + * which case it always fetches the content from the workspace and updates the cache. + * + * @throws ContentError If the content cannot be downloaded. + */ + downloadContent(configuration: ProjectConfiguration, refresh?: boolean): Promise; + + /** + * Gets the slot content for the project, downloading it first when necessary. + * + * @throws ContentError If the content cannot be downloaded or read. + */ + loadContent(configuration: ProjectConfiguration, refresh?: boolean): Promise; + + /** + * Gets the typing source for the given target SDK from the workspace. + */ + loadTypes(configuration: ProjectConfiguration, target: TargetSdk): Promise; +} diff --git a/src/application/project/sdk/content/workspaceContentLoader.ts b/src/application/project/sdk/content/workspaceContentLoader.ts new file mode 100644 index 00000000..e8631718 --- /dev/null +++ b/src/application/project/sdk/content/workspaceContentLoader.ts @@ -0,0 +1,228 @@ +import type {ProjectConfiguration} from '@/application/project/configuration/projectConfiguration'; +import type {WorkspaceApi, TargetSdk} from '@/application/api/workspace'; +import type {FileSystem} from '@/application/fs/fileSystem'; +import {Version} from '@/application/model/version'; +import {ErrorReason} from '@/application/error'; +import type { + ContentLoader, + VersionedContent, + VersionedContentMap, +} from '@/application/project/sdk/content/contentLoader'; +import {ContentError} from '@/application/project/sdk/content/contentLoader'; + +export type Configuration = { + workspaceApi: WorkspaceApi, + fileSystem: FileSystem, +}; + +/** + * Loads slot content and typing source from a Croct workspace. + * + * Resolves the configured version specifiers against the workspace, caches the + * fetched content as a `slots.json` file, and reuses it on subsequent loads + * unless a refresh is requested. + */ +export class WorkspaceContentLoader implements ContentLoader { + private readonly workspaceApi: WorkspaceApi; + + private readonly fileSystem: FileSystem; + + public constructor(configuration: Configuration) { + this.workspaceApi = configuration.workspaceApi; + this.fileSystem = configuration.fileSystem; + } + + public async downloadContent(configuration: ProjectConfiguration, refresh = false): Promise { + const filePath = this.getContentPath(configuration); + + if (!refresh && await this.fileSystem.exists(filePath)) { + return; + } + + await this.saveContent(await this.fetchContent(configuration), filePath); + } + + public async loadContent(configuration: ProjectConfiguration, refresh = false): Promise { + const filePath = this.getContentPath(configuration); + + if (!refresh && await this.fileSystem.exists(filePath)) { + return this.readContent(filePath); + } + + const content = await this.fetchContent(configuration); + + await this.saveContent(content, filePath); + + return content; + } + + public async loadTypes(configuration: ProjectConfiguration, target: TargetSdk): Promise { + const {organization, workspace, components, slots} = await this.resolveVersions(configuration); + + return this.workspaceApi.generateTypes({ + organizationSlug: organization, + workspaceSlug: workspace, + target: target, + components: Object.entries(components).map( + ([component, version]) => ({ + id: component, + version: version, + }), + ), + slots: Object.entries(slots).map( + ([slot, version]) => ({ + id: slot, + version: version, + }), + ), + }); + } + + private getContentPath(configuration: ProjectConfiguration): string { + return this.fileSystem.joinPaths(configuration.paths?.content ?? '.', 'slots.json'); + } + + private async saveContent(content: VersionedContentMap, path: string): Promise { + const directory = this.fileSystem.getDirectoryName(path); + + await this.fileSystem.createDirectory(directory, {recursive: true}); + + await this.fileSystem.writeTextFile( + path, + JSON.stringify(content, null, 2), + {overwrite: true}, + ); + } + + private async readContent(path: string): Promise { + let content: string; + + try { + content = await this.fileSystem.readTextFile(path); + } catch { + return {}; + } + + try { + return JSON.parse(content); + } catch (error) { + throw new ContentError('Failed to parse content file.', { + reason: ErrorReason.INVALID_INPUT, + cause: error, + details: [`File: ${path}`], + }); + } + } + + private async fetchContent(configuration: ProjectConfiguration): Promise { + const resolved = await this.resolveVersions(configuration); + + const slots = Object.entries(resolved.slots); + const slotVersions: Record = {}; + + for (const [slot, versionSpecifier] of slots) { + slotVersions[slot] = Version.parse(versionSpecifier).getVersions(); + } + + return Object.fromEntries( + await Promise.all( + slots.map( + async ([slot]) => [ + slot, + await Promise.all( + slotVersions[slot].map( + version => this.workspaceApi + .getSlotStaticContent( + { + organizationSlug: resolved.organization, + workspaceSlug: resolved.workspace, + slotSlug: slot, + }, + version, + ) + .then( + (versionedContent): VersionedContent => ({ + version: version, + content: Object.fromEntries( + versionedContent.map(({locale, content}) => [locale, content]), + ), + }), + ), + ), + ), + ] as const, + ), + ), + ); + } + + private async resolveVersions(configuration: ProjectConfiguration): Promise { + const listedComponents = Object.keys(configuration.components); + const listedSlots = Object.keys(configuration.slots); + + if (listedComponents.length === 0 && listedSlots.length === 0) { + return configuration; + } + + const [slots, components] = await Promise.all([ + Promise.all(listedSlots.map( + slot => ( + this.workspaceApi.getSlot({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + slotSlug: slot, + }) + ), + )).then(list => list.filter(slot => slot !== null)), + Promise.all(listedComponents.map( + component => ( + this.workspaceApi.getComponent({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + componentSlug: component, + }) + ), + )).then(list => list.filter(component => component !== null)), + ]); + + return { + ...configuration, + components: Object.fromEntries( + Object.entries(configuration.components).flatMap<[string, string]>(([slug, version]) => { + const versions = Version.parse(version) + .getVersions() + .filter( + major => components.some( + component => component.slug === slug + && major <= component.version.major, + ), + ); + + if (versions.length === 0) { + return []; + } + + return [[slug, Version.either(...versions).toString()]]; + }), + ), + slots: Object.fromEntries( + Object.entries(configuration.slots).flatMap<[string, string]>(([slug, version]) => { + const versions = Version.parse(version) + .getVersions() + .filter( + major => slots.some( + slot => slot.slug === slug + && major <= slot.version.major, + ), + ); + + if (versions.length === 0) { + return []; + } + + return [[slug, Version.either(...versions).toString()]]; + }), + ), + }; + } +} diff --git a/src/application/project/sdk/javasScriptSdk.ts b/src/application/project/sdk/javasScriptSdk.ts index cc46987c..1f802718 100644 --- a/src/application/project/sdk/javasScriptSdk.ts +++ b/src/application/project/sdk/javasScriptSdk.ts @@ -1,9 +1,10 @@ import {JsonArrayNode, JsonObjectNode, JsonParser} from '@croct/json5-parser/index.js'; import type { - UpdateOptions as BaseContentOptions, Installation, + InstallationPlan, Sdk, UpdateOptions, + UpdateOptions as BaseContentOptions, } from '@/application/project/sdk/sdk'; import {SdkError} from '@/application/project/sdk/sdk'; import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; @@ -12,23 +13,19 @@ import type {WorkspaceApi} from '@/application/api/workspace'; import {TargetSdk} from '@/application/api/workspace'; import type {ExampleFile} from '@/application/project/code/generation/example'; import type {CodeFormatter} from '@/application/project/code/formatting/formatter'; -import {Version} from '@/application/model/version'; import type {FileSystem} from '@/application/fs/fileSystem'; import type {Slot} from '@/application/model/slot'; -import type {LocalizedContentMap} from '@/application/model/experience'; -import {ErrorReason, HelpfulError} from '@/application/error'; +import type {ContentLoader} from '@/application/project/sdk/content/contentLoader'; +import {HelpfulError} from '@/application/error'; import type {Dependency, PackageManager} from '@/application/project/packageManager/packageManager'; import type {WorkingDirectory} from '@/application/fs/workingDirectory/workingDirectory'; import type {TsConfigLoader} from '@/application/project/import/tsConfigLoader'; import {multiline} from '@/utils/multiline'; import {formatName} from '@/application/project/utils/formatName'; import {TaskProgressLogger} from '@/infrastructure/application/cli/io/taskProgressLogger'; - -export type InstallationPlan = { - tasks: Task[], - dependencies: string[], - configuration: ProjectConfiguration, -}; +import type {ExampleLauncher} from '@/application/project/example/exampleLauncher'; +import type {Example} from '@/application/project/example/example'; +import {InstructionExample} from '@/application/project/example/example'; export type Configuration = { workspaceApi: WorkspaceApi, @@ -37,16 +34,11 @@ export type Configuration = { formatter: CodeFormatter, fileSystem: FileSystem, tsConfigLoader: TsConfigLoader, + contentLoader: ContentLoader, + exampleLauncher: ExampleLauncher, plugins?: JavaScriptSdkPlugin[], }; -type VersionedContent = { - version: number, - content: LocalizedContentMap, -}; - -type VersionedContentMap = Record; - type ContentOptions = BaseContentOptions & { notifier?: TaskNotifier, }; @@ -79,6 +71,10 @@ export abstract class JavaScriptSdk implements Sdk { private readonly importConfigLoader: TsConfigLoader; + private readonly contentLoader: ContentLoader; + + protected readonly exampleLauncher: ExampleLauncher; + private readonly plugins: JavaScriptSdkPlugin[]; protected constructor(configuration: Configuration) { @@ -88,6 +84,8 @@ export abstract class JavaScriptSdk implements Sdk { this.formatter = configuration.formatter; this.fileSystem = configuration.fileSystem; this.importConfigLoader = configuration.tsConfigLoader; + this.contentLoader = configuration.contentLoader; + this.exampleLauncher = configuration.exampleLauncher; this.plugins = configuration.plugins ?? []; } @@ -114,6 +112,29 @@ export abstract class JavaScriptSdk implements Sdk { protected abstract generateSlotExampleFiles(slot: Slot, installation: Installation): Promise; + public async presentExamples(slots: Slot[], installation: Installation): Promise { + await this.exampleLauncher.launch({ + examples: await Promise.all(slots.map(slot => this.createExample(slot, installation))), + input: installation.input, + output: installation.output, + }); + } + + /** + * Builds the object describing how the generated example for a slot is reached. + * + * Defaults to a component the developer imports; subclasses whose example is a route or a + * standalone file override this. + */ + protected async createExample(slot: Slot, installation: Installation): Promise { + const {examples} = await this.getPaths(installation.configuration); + + return new InstructionExample( + slot.name, + `Import the '${slot.name}' example from \`${examples}\` into a page to view it.`, + ); + } + public async setup(installation: Installation): Promise { const {input, output} = installation; @@ -372,7 +393,9 @@ export abstract class JavaScriptSdk implements Sdk { const contentImports: Record = {}; const contentVariableMap: Record> = {}; - for (const [slotId, versionedContent] of Object.entries(await this.loadContent(installation, options.clean))) { + const loadedContent = await this.contentLoader.loadContent(configuration, options.clean === true); + + for (const [slotId, versionedContent] of Object.entries(loadedContent)) { const latestVersion = Math.max(...versionedContent.map(({version}) => version)); for (const {version, content: localizedContent} of versionedContent) { @@ -543,100 +566,6 @@ export abstract class JavaScriptSdk implements Sdk { notifier.confirm('Content updated'); } - private async loadContent(installation: Installation, update = false): Promise { - const {configuration} = installation; - - if (configuration.paths?.content === undefined) { - return this.loadRemoteContent(installation); - } - - const filePath = this.fileSystem.joinPaths(configuration.paths.content, 'slots.json'); - - if (!update && await this.fileSystem.exists(filePath)) { - return this.loadLocalContent(filePath); - } - - const content = await this.loadRemoteContent(installation); - - await this.saveContent(content, filePath); - - return content; - } - - private async saveContent(content: VersionedContentMap, path: string): Promise { - const directory = this.fileSystem.getDirectoryName(path); - - await this.fileSystem.createDirectory(directory, {recursive: true}); - - await this.fileSystem.writeTextFile( - path, - JSON.stringify(content, null, 2), - {overwrite: true}, - ); - } - - private async loadLocalContent(path: string): Promise { - let content: string; - - try { - content = await this.fileSystem.readTextFile(path); - } catch { - return {}; - } - - try { - return JSON.parse(content); - } catch (error) { - throw new SdkError('Failed to parse content file.', { - reason: ErrorReason.INVALID_INPUT, - cause: error, - details: [`File: ${path}`], - }); - } - } - - private async loadRemoteContent(installation: Installation): Promise { - const configuration = await this.resolveVersions(installation.configuration); - - const slots = Object.entries(configuration.slots); - const slotVersions: Record = {}; - - for (const [slot, versionSpecifier] of slots) { - slotVersions[slot] = Version.parse(versionSpecifier).getVersions(); - } - - return Object.fromEntries( - await Promise.all( - slots.map( - async ([slot]) => [ - slot, - await Promise.all( - slotVersions[slot].map( - version => this.workspaceApi - .getSlotStaticContent( - { - organizationSlug: configuration.organization, - workspaceSlug: configuration.workspace, - slotSlug: slot, - }, - version, - ) - .then( - (versionedContent): VersionedContent => ({ - version: version, - content: Object.fromEntries( - versionedContent.map(({locale, content}) => [locale, content]), - ), - }), - ), - ), - ), - ] as const, - ), - ), - ); - } - private async updateTypes(installation: Installation, options: ContentOptions = {}): Promise { const {output, configuration} = installation; @@ -647,7 +576,7 @@ export abstract class JavaScriptSdk implements Sdk { let module = ''; if (Object.keys(configuration.slots).length > 0 || Object.keys(configuration.components).length > 0) { - module = `${await this.generateTypes(configuration)}`; + module = `${await this.contentLoader.loadTypes(configuration, TargetSdk.JAVASCRIPT)}`; } module = multiline` @@ -670,28 +599,6 @@ export abstract class JavaScriptSdk implements Sdk { notifier.confirm('Types updated'); } - private async generateTypes(configuration: ProjectConfiguration): Promise { - const {organization, workspace, components, slots} = await this.resolveVersions(configuration); - - return this.workspaceApi.generateTypes({ - organizationSlug: organization, - workspaceSlug: workspace, - target: TargetSdk.JAVASCRIPT, - components: Object.entries(components).map( - ([component, version]) => ({ - id: component, - version: version, - }), - ), - slots: Object.entries(slots).map( - ([slot, version]) => ({ - id: slot, - version: version, - }), - ), - }); - } - private async registerTypeFile(installation: Installation, notifier?: TaskNotifier): Promise { const paths = await this.getPaths(installation.configuration); @@ -751,76 +658,6 @@ export abstract class JavaScriptSdk implements Sdk { output.confirm('Type file registered'); } - private async resolveVersions(configuration: ProjectConfiguration): Promise { - const listedComponents = Object.keys(configuration.components); - const listedSlots = Object.keys(configuration.slots); - - if (listedComponents.length === 0 && listedSlots.length === 0) { - return configuration; - } - - const [slots, components] = await Promise.all([ - Promise.all(listedSlots.map( - slot => ( - this.workspaceApi.getSlot({ - organizationSlug: configuration.organization, - workspaceSlug: configuration.workspace, - slotSlug: slot, - }) - ), - )).then(list => list.filter(slot => slot !== null)), - Promise.all(listedComponents.map( - component => ( - this.workspaceApi.getComponent({ - organizationSlug: configuration.organization, - workspaceSlug: configuration.workspace, - componentSlug: component, - }) - ), - )).then(list => list.filter(component => component !== null)), - ]); - - return { - ...configuration, - components: Object.fromEntries( - Object.entries(configuration.components).flatMap<[string, string]>(([slug, version]) => { - const versions = Version.parse(version) - .getVersions() - .filter( - major => components.some( - component => component.slug === slug - && major <= component.version.major, - ), - ); - - if (versions.length === 0) { - return []; - } - - return [[slug, Version.either(...versions).toString()]]; - }), - ), - slots: Object.fromEntries( - Object.entries(configuration.slots).flatMap<[string, string]>(([slug, version]) => { - const versions = Version.parse(version) - .getVersions() - .filter( - major => slots.some( - slot => slot.slug === slug - && major <= slot.version.major, - ), - ); - - if (versions.length === 0) { - return []; - } - - return [[slug, Version.either(...versions).toString()]]; - }), - ), - }; - } - private async mountContentPackageFolder(): Promise { const packageInfo = await this.packageManager.getDependency(JavaScriptSdk.CONTENT_PACKAGE); diff --git a/src/application/project/sdk/lazySdk.ts b/src/application/project/sdk/lazySdk.ts index d90a3152..25f423e2 100644 --- a/src/application/project/sdk/lazySdk.ts +++ b/src/application/project/sdk/lazySdk.ts @@ -37,4 +37,8 @@ export class LazySdk implements Sdk { public async generateSlotExample(slot: Slot, installation: Installation): Promise { return (await this.sdk).generateSlotExample(slot, installation); } + + public async presentExamples(slots: Slot[], installation: Installation): Promise { + return (await this.sdk).presentExamples?.(slots, installation); + } } diff --git a/src/application/project/sdk/nuxtStoryblokPlugin.ts b/src/application/project/sdk/nuxtStoryblokPlugin.ts index 5c23f3b4..193bf80b 100644 --- a/src/application/project/sdk/nuxtStoryblokPlugin.ts +++ b/src/application/project/sdk/nuxtStoryblokPlugin.ts @@ -1,12 +1,8 @@ -import type { - InstallationPlan, - JavaScriptPluginContext, - JavaScriptSdkPlugin, -} from '@/application/project/sdk/javasScriptSdk'; +import type {JavaScriptPluginContext, JavaScriptSdkPlugin} from '@/application/project/sdk/javasScriptSdk'; import type {Task} from '@/application/cli/io/output'; import {HelpfulError} from '@/application/error'; import type {Codemod} from '@/application/project/code/transformation/codemod'; -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; export type Configuration = { storyblokPackage: string, diff --git a/src/application/project/sdk/phpSdk.ts b/src/application/project/sdk/phpSdk.ts new file mode 100644 index 00000000..c6b4b02f --- /dev/null +++ b/src/application/project/sdk/phpSdk.ts @@ -0,0 +1,461 @@ +import type {Installation, InstallationPlan, Sdk, UpdateOptions} from '@/application/project/sdk/sdk'; +import {SdkError} from '@/application/project/sdk/sdk'; +import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; +import type {Task, TaskNotifier} from '@/application/cli/io/output'; +import type {PackageManager} from '@/application/project/packageManager/packageManager'; +import type {WorkingDirectory} from '@/application/fs/workingDirectory/workingDirectory'; +import type {FileSystem} from '@/application/fs/fileSystem'; +import type {CodeFormatter} from '@/application/project/code/formatting/formatter'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import type {Slot} from '@/application/model/slot'; +import type {WorkspaceApi} from '@/application/api/workspace'; +import {TargetSdk} from '@/application/api/workspace'; +import type {UserApi} from '@/application/api/user'; +import type {ApplicationApi, GeneratedApiKey} from '@/application/api/application'; +import type {ContentLoader} from '@/application/project/sdk/content/contentLoader'; +import {EnvFile} from '@/application/project/code/envFile'; +import {ApiKeyPermission} from '@/application/model/application'; +import {ApiError} from '@/application/api/error'; +import {ErrorReason, HelpfulError} from '@/application/error'; +import {TaskProgressLogger} from '@/infrastructure/application/cli/io/taskProgressLogger'; +import type {CommandExecutor} from '@/application/system/process/executor'; +import type {Codemod} from '@/application/project/code/transformation/codemod'; +import type {NeonListOptions} from '@/application/project/code/transformation/neon/neonListCodemod'; +import type {ExampleLauncher} from '@/application/project/example/exampleLauncher'; +import type {Example} from '@/application/project/example/example'; + +export type Configuration = { + projectDirectory: WorkingDirectory, + packageManager: PackageManager, + fileSystem: FileSystem, + formatter: CodeFormatter, + commandExecutor: CommandExecutor, + exampleLauncher: ExampleLauncher, + contentLoader: ContentLoader, + workspaceApi: WorkspaceApi, + userApi: UserApi, + applicationApi: ApplicationApi, + phpstanIncludeCodemod: Codemod, +}; + +export enum PhpEnvVar { + API_KEY = 'CROCT_API_KEY', + APP_ID = 'CROCT_APP_ID', +} + +/** + * Base SDK for PHP projects. + * + * Installs the Croct dependencies through Composer and writes the credentials + * to the project's `.env` file. Framework-specific subclasses extend the + * installation plan with their own registration and example-generation steps. + */ +export abstract class PhpSdk implements Sdk { + private static readonly PHPSTAN_EXTENSION = 'vendor/croct/plug-php/extension.neon'; + + protected readonly projectDirectory: WorkingDirectory; + + protected readonly packageManager: PackageManager; + + protected readonly fileSystem: FileSystem; + + protected readonly formatter: CodeFormatter; + + protected readonly commandExecutor: CommandExecutor; + + protected readonly exampleLauncher: ExampleLauncher; + + private readonly phpstanIncludeCodemod: Codemod; + + private readonly contentLoader: ContentLoader; + + private readonly workspaceApi: WorkspaceApi; + + private readonly userApi: UserApi; + + private readonly applicationApi: ApplicationApi; + + public constructor(configuration: Configuration) { + this.projectDirectory = configuration.projectDirectory; + this.packageManager = configuration.packageManager; + this.fileSystem = configuration.fileSystem; + this.formatter = configuration.formatter; + this.commandExecutor = configuration.commandExecutor; + this.exampleLauncher = configuration.exampleLauncher; + this.phpstanIncludeCodemod = configuration.phpstanIncludeCodemod; + this.contentLoader = configuration.contentLoader; + this.workspaceApi = configuration.workspaceApi; + this.userApi = configuration.userApi; + this.applicationApi = configuration.applicationApi; + } + + public async setup(installation: Installation): Promise { + const {input, output} = installation; + + const plan = await this.getInstallationPlan(installation); + + const configuration: ProjectConfiguration = { + ...plan.configuration, + paths: { + content: '.', + ...await this.getPaths(installation.configuration), + ...plan.configuration.paths, + }, + }; + + const resolvedInstallation: Installation = { + ...installation, + configuration: { + ...installation.configuration, + ...configuration, + applications: installation.configuration.applications, + }, + }; + + const tasks: Task[] = []; + + tasks.push({ + title: 'Install dependencies', + task: async notifier => { + notifier.update('Installing dependencies'); + + const logger = new TaskProgressLogger({ + status: 'Installing dependencies', + notifier: notifier, + }); + + try { + await this.packageManager.addDependencies(plan.dependencies, {logger: logger}); + + notifier.confirm('Dependencies installed'); + } catch (error) { + notifier.alert('Failed to install dependencies', HelpfulError.formatMessage(error)); + } + }, + }); + + tasks.push(...plan.tasks); + + const usesPhpstan = await this.packageManager.hasDependency('phpstan/phpstan'); + const usesExtensionInstaller = await this.packageManager.hasDependency('phpstan/extension-installer'); + + if (usesPhpstan && !usesExtensionInstaller) { + // PHPStan auto-discovers the extension when phpstan/extension-installer is present; + // otherwise the extension must be added to the configuration manually. + tasks.push(this.getPhpstanTask()); + } + + if (await this.packageManager.hasDependency('vimeo/psalm')) { + tasks.push(this.getPsalmTask()); + } + + tasks.push({ + title: 'Set up credentials', + task: async notifier => { + notifier.update('Setting up credentials'); + + try { + await this.setUpCredentials({ + ...resolvedInstallation, + notifier: notifier, + }); + + notifier.confirm('Credentials configured'); + } catch (error) { + notifier.alert('Failed to set up credentials', HelpfulError.formatMessage(error)); + } + }, + }); + + tasks.push({ + title: 'Download content', + task: async notifier => { + notifier.update('Downloading content'); + + try { + await this.contentLoader.downloadContent(resolvedInstallation.configuration, true); + + notifier.confirm('Content downloaded'); + } catch (error) { + notifier.alert('Failed to download content', HelpfulError.formatMessage(error)); + } + }, + }); + + tasks.push({ + title: 'Generate types', + task: async notifier => { + notifier.update('Generating types'); + + try { + await this.updateTypes(resolvedInstallation, {clean: true}); + + notifier.confirm('Types generated'); + } catch (error) { + notifier.alert('Failed to generate types', HelpfulError.formatMessage(error)); + } + }, + }); + + if (input !== undefined) { + output.break(); + output.inform('**Installation plan**'); + + for (const {title} of tasks) { + output.log(` - ${title}`); + } + + output.break(); + + if (!await input.confirm({message: 'Proceed?', default: true})) { + return output.exit(); + } + } + + await output.monitor({tasks: tasks}); + + return configuration; + } + + public getPaths(configuration: ProjectConfiguration): Promise { + const source = configuration.paths?.source ?? 'src'; + + return Promise.resolve({ + ...configuration.paths, + source: source, + utilities: configuration.paths?.utilities ?? `${source}/utils`, + components: configuration.paths?.components ?? `${source}/components`, + examples: configuration.paths?.examples ?? 'examples', + }); + } + + public async update(installation: Installation, options: UpdateOptions = {}): Promise { + await this.contentLoader.downloadContent(installation.configuration, options.clean === true); + await this.updateTypes(installation, options); + } + + private async updateTypes(installation: Installation, options: UpdateOptions): Promise { + const {configuration} = installation; + // The `.stub` extension keeps PHPStan/Psalm from analysing the file as source + // (which would clash with the real Plug interface); the plug-php extension and + // Psalm plugin load it only as a type overlay. + const stubPath = this.fileSystem.joinPaths( + this.projectDirectory.get(), + configuration.paths?.content ?? '.', + 'slots.stub', + ); + + // The stub is committed, so it is regenerated only on a clean install or when missing. + if (options.clean !== true && await this.fileSystem.exists(stubPath)) { + return; + } + + if (Object.keys(configuration.slots).length === 0 && Object.keys(configuration.components).length === 0) { + return; + } + + const source = await this.contentLoader.loadTypes(configuration, TargetSdk.PHP); + + await this.fileSystem.createDirectory(this.fileSystem.getDirectoryName(stubPath), {recursive: true}); + await this.fileSystem.writeTextFile(stubPath, source, {overwrite: true}); + } + + public async generateSlotExample(slot: Slot, installation: Installation): Promise { + const rootPath = this.projectDirectory.get(); + const phpFiles: string[] = []; + + for (const file of await this.generateSlotExampleFiles(slot, installation)) { + const directory = this.fileSystem.joinPaths(rootPath, this.fileSystem.getDirectoryName(file.path)); + + await this.fileSystem + .createDirectory(directory, {recursive: true}) + .catch(() => null); + + const filePath = this.fileSystem.joinPaths(rootPath, file.path); + + await this.fileSystem.writeTextFile(filePath, file.code, {overwrite: true}); + + if (file.language === CodeLanguage.PHP) { + phpFiles.push(filePath); + } + } + + if (phpFiles.length > 0) { + await this.formatter.format(phpFiles); + } + } + + public async presentExamples(slots: Slot[], installation: Installation): Promise { + await this.exampleLauncher.launch({ + examples: await Promise.all(slots.map(slot => this.createExample(slot, installation))), + input: installation.input, + output: installation.output, + }); + } + + protected abstract getInstallationPlan(installation: Installation): Promise; + + protected abstract generateSlotExampleFiles(slot: Slot, installation: Installation): Promise; + + /** + * Builds the object describing where the generated example for a slot is reached. + * + * Each SDK derives this from the same configured paths it generates the example with. + */ + protected abstract createExample(slot: Slot, installation: Installation): Promise; + + private getPhpstanTask(): Task { + const instruction = `Add \`${PhpSdk.PHPSTAN_EXTENSION}\` to your PHPStan \`includes\`.`; + + return { + title: 'Enable PHPStan extension', + task: async notifier => { + notifier.update('Enabling PHPStan extension'); + + // PHPStan runs without a config file, so create one when absent: a file + // with only `includes` is valid and loads the extension. + const path = await this.findPhpstanConfig() + ?? this.fileSystem.joinPaths(this.projectDirectory.get(), 'phpstan.neon'); + + try { + const {modified} = await this.phpstanIncludeCodemod.apply(path, { + key: 'includes', + value: PhpSdk.PHPSTAN_EXTENSION, + }); + + notifier.confirm(modified ? 'PHPStan extension enabled' : 'PHPStan extension already enabled'); + } catch { + notifier.warn('Failed to enable the PHPStan extension', instruction); + } + }, + }; + } + + private getPsalmTask(): Task { + const instruction = 'Run `vendor/bin/psalm-plugin enable croct/plug-php` to enable the Croct plugin.'; + + return { + title: 'Enable Psalm plugin', + task: async notifier => { + notifier.update('Enabling Psalm plugin'); + + try { + const command = await this.packageManager.getPackageCommand( + 'psalm-plugin', + ['enable', 'croct/plug-php'], + ); + + const execution = await this.commandExecutor.run(command, { + workingDirectory: this.projectDirectory.get(), + }); + + const exitCode = await execution.wait(); + + if (exitCode === 0 || exitCode === 3) { + // Psalm's enable command returns 0 when it enables + // the plugin and 3 when it was already enabled. + notifier.confirm('Psalm plugin enabled'); + } else { + notifier.warn('Failed to enable the Psalm plugin', instruction); + } + } catch { + notifier.warn('Failed to enable the Psalm plugin', instruction); + } + }, + }; + } + + private async findPhpstanConfig(): Promise { + const root = this.projectDirectory.get(); + const paths = ['phpstan.neon', 'phpstan.neon.dist', 'phpstan.dist.neon'] + .map(name => this.fileSystem.joinPaths(root, name)); + + const existing = await Promise.all(paths.map(path => this.fileSystem.exists(path))); + + for (let index = 0; index < existing.length; index++) { + if (existing[index]) { + return paths[index]; + } + } + + return null; + } + + protected async setUpCredentials(installation: Installation & {notifier: TaskNotifier}): Promise { + const {configuration, notifier} = installation; + + notifier.update('Loading information'); + + const developmentApplication = await this.workspaceApi.getApplication({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + applicationSlug: configuration.applications.development, + }); + + if (developmentApplication === null) { + throw new SdkError( + `Development application \`${configuration.applications.development}\` not found.`, + {reason: ErrorReason.NOT_FOUND}, + ); + } + + if (!await this.hasApiKey() && installation.skipApiKeySetup !== true) { + const user = await this.userApi.getUser(); + + notifier.update('Creating API key'); + + let apiKey: GeneratedApiKey; + + try { + apiKey = await this.applicationApi.createApiKey({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + applicationSlug: developmentApplication.slug, + name: `${user.username} CLI`, + permissions: [ApiKeyPermission.ISSUE_TOKEN], + }); + } catch (error) { + if (error instanceof HelpfulError) { + throw new SdkError( + error instanceof ApiError && error.isAccessDenied() + ? 'Your user does not have permission to create an API key' + : error.message, + error.help, + ); + } + + throw error; + } + + await this.storeApiKey(apiKey.secret); + } + + await this.storeAppId(developmentApplication.publicId); + } + + /** + * Reports whether the API key credential is already present. + */ + protected hasApiKey(): Promise { + return this.getEnvFile().hasVariable(PhpEnvVar.API_KEY); + } + + /** + * Persists the API key credential. + */ + protected async storeApiKey(secret: string): Promise { + await this.getEnvFile().setVariables({[PhpEnvVar.API_KEY]: secret}); + } + + /** + * Persists the application ID credential. + */ + protected async storeAppId(publicId: string): Promise { + await this.getEnvFile().setVariables({[PhpEnvVar.APP_ID]: publicId}); + } + + private getEnvFile(): EnvFile { + return new EnvFile(this.fileSystem, this.fileSystem.joinPaths(this.projectDirectory.get(), '.env')); + } +} diff --git a/src/application/project/sdk/plugDrupalSdk.ts b/src/application/project/sdk/plugDrupalSdk.ts new file mode 100644 index 00000000..3c5824a3 --- /dev/null +++ b/src/application/project/sdk/plugDrupalSdk.ts @@ -0,0 +1,527 @@ +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; +import {SdkError} from '@/application/project/sdk/sdk'; +import type {Configuration as PhpSdkConfiguration} from '@/application/project/sdk/phpSdk'; +import {PhpSdk} from '@/application/project/sdk/phpSdk'; +import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; +import type {Output, Task, TaskNotifier} from '@/application/cli/io/output'; +import type {Codemod} from '@/application/project/code/transformation/codemod'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {TwigExampleGenerator} from '@/application/project/code/generation/slot/twigExampleGenerator'; +import {formatSlug} from '@/application/project/code/generation/utils'; +import {formatName} from '@/application/project/utils/formatName'; +import {UrlExample} from '@/application/project/example/example'; +import type {Example} from '@/application/project/example/example'; +import {ErrorReason} from '@/application/error'; +import type {Slot} from '@/application/model/slot'; + +export type Configuration = PhpSdkConfiguration & { + localSettingsFileCodemod: Codemod, +}; + +export class PlugDrupalSdk extends PhpSdk { + private static readonly MODULE_NAME = 'croct_example'; + + public static readonly LOCAL_SETTINGS_FILE = 'settings.local.php'; + + private static readonly SETTINGS = { + APP_ID: 'croct.app_id', + API_KEY: 'croct.api_key', + }; + + private readonly localSettingsFileCodemod: Codemod; + + public constructor(configuration: Configuration) { + super(configuration); + + this.localSettingsFileCodemod = configuration.localSettingsFileCodemod; + } + + protected getInstallationPlan(installation: Installation): Promise { + const tasks = [ + this.getModuleTask(), + this.getLocalSettingsTask(), + ]; + + return Promise.resolve({ + dependencies: ['croct/plug-drupal'], + tasks: tasks, + configuration: installation.configuration, + }); + } + + private getModuleTask(): Task { + return { + title: 'Enable the Croct module (`drush en croct`)', + task: async notifier => { + notifier.update('Enabling the Croct module with `drush en croct`'); + + if (await this.enableModule()) { + notifier.confirm('Croct module enabled'); + } else { + notifier.warn('Could not enable the Croct module', 'Run `drush en croct` to enable it manually.'); + } + }, + }; + } + + private getLocalSettingsTask(): Task { + const instruction = 'Add the `settings.local.php` include to `settings.php` so Croct can read the credentials.'; + + return { + title: 'Include settings.local.php in settings.php', + task: async notifier => { + notifier.update('Adding the `settings.local.php` include to `settings.php`'); + + switch (await this.includeLocalSettings()) { + case 'included': + return notifier.confirm('Added the `settings.local.php` include to `settings.php`'); + + case 'unchanged': + return notifier.confirm('`settings.php` already includes `settings.local.php`'); + + default: + return notifier.warn('Could not include the local settings', instruction); + } + }, + }; + } + + public async getPaths(configuration: ProjectConfiguration): Promise { + const modules = await this.resolveModulesDirectory(); + + return { + ...configuration.paths, + source: configuration.paths?.source ?? modules, + utilities: configuration.paths?.utilities ?? modules, + components: configuration.paths?.components ?? modules, + examples: configuration.paths?.examples ?? this.fileSystem.joinPaths(modules, PlugDrupalSdk.MODULE_NAME), + }; + } + + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { + const module = (await this.getPaths(installation.configuration)).examples; + + return [ + this.generateModuleInfo(module), + this.generateInstallHook(module), + this.generateBlock(slot, module), + ]; + } + + public async presentExamples(slots: Slot[], installation: Installation): Promise { + const {input, output} = installation; + + if (input === undefined) { + PlugDrupalSdk.printExampleSteps(output); + + return; + } + + const enable = await input.confirm({ + message: 'Enable the Croct example module now? It places the example block(s) in the Content region.', + default: true, + }); + + if (!enable) { + PlugDrupalSdk.printExampleSteps(output); + + return; + } + + const notifier = output.notify('Enabling the Croct example module'); + + if (!await this.enableExampleModule()) { + notifier.stop(); + output.warn('Could not enable the Croct example module.'); + PlugDrupalSdk.printExampleSteps(output); + + return; + } + + notifier.confirm('Croct example module enabled'); + + // Every block renders on the front page, so present it once for the first slot. + await this.exampleLauncher.launch({ + examples: [await this.createExample(slots[0])], + input: input, + output: output, + }); + } + + protected createExample(slot: Slot): Promise { + return Promise.resolve(new UrlExample(slot.name, '/')); + } + + protected async setUpCredentials(installation: Installation & {notifier: TaskNotifier}): Promise { + await super.setUpCredentials(installation); + + installation.notifier.update('Rebuilding the Drupal cache to apply the credentials'); + + // Drupal compiles settings.php values into the cached container at build time, so the + // credentials written above only take effect after a rebuild. Without it the module boots + // with an empty API key and every request throws a ConfigurationException. + if (!await this.rebuildCache()) { + installation.notifier.warn( + 'Could not rebuild the Drupal cache', + 'Run `drush cr` to apply the Croct credentials.', + ); + } + } + + protected async hasApiKey(): Promise { + const path = await this.resolveLocalSettingsFile(); + + if (path === null || !await this.fileSystem.exists(path)) { + return false; + } + + return (await this.fileSystem.readTextFile(path)).includes(`$settings['${PlugDrupalSdk.SETTINGS.API_KEY}']`); + } + + protected async storeApiKey(secret: string): Promise { + await this.writeSetting(PlugDrupalSdk.SETTINGS.API_KEY, secret); + } + + protected async storeAppId(publicId: string): Promise { + await this.writeSetting(PlugDrupalSdk.SETTINGS.APP_ID, publicId); + } + + private generateModuleInfo(module: string): ExampleFile { + return { + path: this.fileSystem.joinPaths(module, `${PlugDrupalSdk.MODULE_NAME}.info.yml`), + language: CodeLanguage.YAML, + code: [ + "name: 'Croct Example'", + 'type: module', + "description: 'Example blocks rendering Croct slots.'", + 'package: Croct', + 'core_version_requirement: ^10 || ^11', + 'dependencies:', + " - 'croct:croct'", + '', + ].join('\n'), + }; + } + + private generateInstallHook(module: string): ExampleFile { + const name = PlugDrupalSdk.MODULE_NAME; + + return { + path: this.fileSystem.joinPaths(module, `${name}.install`), + language: CodeLanguage.PHP, + code: [ + 'get('default');", + " $manager = \\Drupal::service('plugin.manager.block');", + '', + ' foreach ($manager->getDefinitions() as $id => $definition) {', + ` if (($definition['provider'] ?? '') !== '${name}') {`, + ' continue;', + ' }', + '', + " $blockId = $theme . '_' . $id;", + '', + ' if (Block::load($blockId) !== null) {', + ' continue;', + ' }', + '', + ' Block::create([', + " 'id' => $blockId,", + " 'plugin' => $id,", + " 'region' => 'content',", + " 'theme' => $theme,", + " 'weight' => -50,", + " 'settings' => [", + " 'id' => $id,", + " 'label' => (string) $definition['admin_label'],", + " 'label_display' => '0',", + ` 'provider' => '${name}',`, + ' ],', + ' ])->save();', + ' }', + '}', + '', + ].join('\n'), + }; + } + + private generateBlock(slot: Slot, module: string): ExampleFile { + const name = formatName(slot.slug).replace(/^./, character => character.toUpperCase()); + const id = `croct_${formatSlug(slot.slug).replace(/-/g, '_')}`; + const label = PlugDrupalSdk.formatTitle(slot.slug) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'"); + + const fragment = new TwigExampleGenerator({ + contentVariable: 'content', + filePath: '', + page: false, + }).generate({ + id: slot.slug, + version: slot.version.major, + definition: slot.resolvedDefinition, + }).files[0].code; + + // Indent the fragment under the heredoc body; PHP strips the closing marker's indentation. + const template = fragment + .trimEnd() + .split('\n') + .map(line => (line === '' ? '' : ` ${line}`)) + .join('\n'); + + const code = [ + 'get(Plug::class));', + ' }', + '', + ' /**', + ' * @return array', + ' */', + ' public function build(): array', + ' {', + ' return [', + " '#type' => 'inline_template',", + " '#template' => <<<'TWIG'", + template, + ' TWIG,', + " '#context' => [", + ` 'content' => $this->croct->fetchContent('${slot.slug}')->getContent(),`, + ' ],', + ' // Personalized per visitor, so vary the render cache by session.', + " '#cache' => [", + " 'contexts' => ['session'],", + ' ],', + ' ];', + ' }', + '}', + '', + ].join('\n'); + + return { + path: this.fileSystem.joinPaths(module, 'src', 'Plugin', 'Block', `${name}Block.php`), + language: CodeLanguage.PHP, + code: code, + }; + } + + private async writeSetting(key: string, value: string): Promise { + const path = await this.resolveLocalSettingsFile(); + + if (path === null) { + throw new SdkError('Could not locate the Drupal site directory to store the credentials.', { + reason: ErrorReason.NOT_FOUND, + }); + } + + const content = await this.fileSystem.exists(path) + ? await this.fileSystem.readTextFile(path) + : ' this.fileSystem.writeTextFile( + path, + `${content}${content.endsWith('\n') ? '' : '\n'}${line}`, + {overwrite: true}, + ), + ); + } + + /** + * Runs a write while the given paths are owner-writable, restoring their original modes after. + */ + private async runWritablePaths(paths: string[], action: () => Promise): Promise { + const restore: Array<[string, number]> = []; + + for (const path of paths) { + if (await this.fileSystem.exists(path)) { + const mode = await this.fileSystem.getPermissions(path); + + if ((mode & 0o200) === 0) { + await this.fileSystem.setPermissions(path, mode | 0o200); + restore.push([path, mode]); + } + } + } + + try { + return await action(); + } finally { + for (const [path, mode] of restore) { + await this.fileSystem.setPermissions(path, mode); + } + } + } + + private enableModule(): Promise { + return this.runDrush(['en', 'croct', '--yes']); + } + + private rebuildCache(): Promise { + return this.runDrush(['cache:rebuild']); + } + + private async enableExampleModule(): Promise { + if (!await this.runDrush(['en', PlugDrupalSdk.MODULE_NAME, '--yes'])) { + return false; + } + + // The install hook places the blocks, but only on first install, so re-run it to also + // cover the case where the module was already enabled (it is idempotent, keeping any + // block already placed). Clear the block definition cache before placing: the blocks + // were generated after the last rebuild, so a stale discovery cache would hide them and + // only some (or none) would be placed. + const module = PlugDrupalSdk.MODULE_NAME; + const place = `\\Drupal::moduleHandler()->loadInclude('${module}', 'install');` + + ` \\Drupal::service('plugin.manager.block')->clearCachedDefinitions(); ${module}_install();`; + + if (!await this.runDrush(['php:eval', place])) { + return false; + } + + // Rebuild so the freshly placed blocks render immediately on the front page. + return this.rebuildCache(); + } + + private async runDrush(args: string[]): Promise { + try { + // Resolved the same way as npm packages: the package manager runs the project's + // own binary (`composer exec drush ...`), so the path is never built by hand. + const command = await this.packageManager.getPackageCommand('drush', args); + + const execution = await this.commandExecutor.run(command, { + workingDirectory: this.projectDirectory.get(), + }); + + return await execution.wait() === 0; + } catch { + return false; + } + } + + private static printExampleSteps(output: Output): void { + output.inform( + [ + 'To view the example:', + `1. Enable the module: \`drush en ${PlugDrupalSdk.MODULE_NAME}\` (or via \`/admin/modules\`).`, + "2. Open your site's front page.", + '3. If a block is missing, place it at `/admin/structure/block` (search "Croct").', + ].join('\n'), + ); + } + + private async includeLocalSettings(): Promise<'included' | 'unchanged' | 'missing'> { + const path = await this.resolveSettingsFile(); + + if (path === null) { + return 'missing'; + } + + // The injected codemod reads/writes settings.php and style-fixes it by + // decoration; its `modified` flag tells whether the include was added. Drupal leaves + // settings.php read-only after install, so apply it under a temporary unlock. + const {modified} = await this.runWritablePaths([path], () => this.localSettingsFileCodemod.apply(path)); + + return modified ? 'included' : 'unchanged'; + } + + private async resolveSettingsFile(): Promise { + const directory = await this.resolveSettingsDirectory(); + + return directory === null ? null : this.fileSystem.joinPaths(directory, 'settings.php'); + } + + private async resolveLocalSettingsFile(): Promise { + const directory = await this.resolveSettingsDirectory(); + + return directory === null + ? null + : this.fileSystem.joinPaths(directory, PlugDrupalSdk.LOCAL_SETTINGS_FILE); + } + + private async resolveSettingsDirectory(): Promise { + const root = this.projectDirectory.get(); + const candidates = [ + ['web', 'sites', 'default'], + ['sites', 'default'], + ]; + + for (const segments of candidates) { + const directory = this.fileSystem.joinPaths(root, ...segments); + + if (await this.fileSystem.exists(this.fileSystem.joinPaths(directory, 'settings.php'))) { + return directory; + } + } + + return null; + } + + private async resolveModulesDirectory(): Promise { + const hasDocroot = await this.fileSystem.exists(this.fileSystem.joinPaths(this.projectDirectory.get(), 'web')); + + return hasDocroot ? 'web/modules/custom' : 'modules/custom'; + } + + private static formatTitle(slug: string): string { + return formatSlug(slug) + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } +} diff --git a/src/application/project/sdk/plugJsSdk.ts b/src/application/project/sdk/plugJsSdk.ts index d43afabd..61ddb536 100644 --- a/src/application/project/sdk/plugJsSdk.ts +++ b/src/application/project/sdk/plugJsSdk.ts @@ -1,12 +1,9 @@ import type {Content} from '@croct/content-model/content/content'; import type {JsonValue} from '@croct/json'; import type {ContentDefinition} from '@croct/content-model/definition/definition'; -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; import {SdkError} from '@/application/project/sdk/sdk'; -import type { - Configuration as JavaScriptSdkConfiguration, - InstallationPlan, -} from '@/application/project/sdk/javasScriptSdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; import {PlugJsExampleGenerator} from '@/application/project/code/generation/slot/plugJsExampleGenerator'; import type {ExampleFile} from '@/application/project/code/generation/example'; @@ -14,6 +11,8 @@ import {CodeLanguage} from '@/application/project/code/generation/example'; import type {Slot} from '@/application/model/slot'; import {sortAttributes} from '@/application/project/code/generation/utils'; import {ErrorReason} from '@/application/error'; +import type {Example} from '@/application/project/example/example'; +import {FileExample} from '@/application/project/example/example'; export type Configuration = JavaScriptSdkConfiguration & { bundlers: string[], @@ -28,6 +27,15 @@ export class PlugJsSdk extends JavaScriptSdk { this.bundlers = bundlers; } + protected async createExample(slot: Slot, installation: Installation): Promise { + const {examples} = await this.getPaths(installation.configuration); + + return new FileExample( + slot.name, + this.fileSystem.joinPaths(this.projectDirectory.get(), examples, slot.slug, 'index.html'), + ); + } + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { const {configuration} = installation; const [isTypeScript, bundler, application] = await Promise.all([ diff --git a/src/application/project/sdk/plugLaravelSdk.ts b/src/application/project/sdk/plugLaravelSdk.ts new file mode 100644 index 00000000..56369a3b --- /dev/null +++ b/src/application/project/sdk/plugLaravelSdk.ts @@ -0,0 +1,116 @@ +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; +import type {Configuration as PhpSdkConfiguration} from '@/application/project/sdk/phpSdk'; +import {PhpSdk} from '@/application/project/sdk/phpSdk'; +import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {BladeExampleGenerator} from '@/application/project/code/generation/slot/bladeExampleGenerator'; +import type {Codemod} from '@/application/project/code/transformation/codemod'; +import type {RouteOptions} from '@/application/project/code/transformation/php/laravelRouteCodemod'; +import {formatSlug} from '@/application/project/code/generation/utils'; +import type {Slot} from '@/application/model/slot'; +import {UrlExample} from '@/application/project/example/example'; +import type {Example} from '@/application/project/example/example'; + +export type Configuration = PhpSdkConfiguration & { + routeCodemod: Codemod, +}; + +export class PlugLaravelSdk extends PhpSdk { + private static readonly VIEWS_DIRECTORY = 'resources/views'; + + private readonly routeCodemod: Codemod; + + public constructor(configuration: Configuration) { + super(configuration); + + this.routeCodemod = configuration.routeCodemod; + } + + protected getInstallationPlan(installation: Installation): Promise { + return Promise.resolve({ + dependencies: ['croct/plug-laravel'], + tasks: [], + configuration: installation.configuration, + }); + } + + public getPaths(configuration: ProjectConfiguration): Promise { + return Promise.resolve({ + ...configuration.paths, + source: configuration.paths?.source ?? 'app', + utilities: configuration.paths?.utilities ?? 'app/Support', + components: configuration.paths?.components ?? 'app/View/Components', + examples: configuration.paths?.examples ?? `${PlugLaravelSdk.VIEWS_DIRECTORY}/croct`, + }); + } + + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { + const paths = await this.getPaths(installation.configuration); + + const generator = new BladeExampleGenerator({ + contentVariable: 'content', + filePath: this.fileSystem.joinPaths(paths.examples, '%slug%.blade.php'), + }); + + const {files} = generator.generate({ + id: slot.slug, + version: slot.version.major, + definition: slot.resolvedDefinition, + }); + + const route = await this.generateRouteFile(slot.slug, this.resolveViewName(paths.examples, slot.slug)); + + if (route !== null) { + return [...files, route]; + } + + return files; + } + + protected createExample(slot: Slot): Promise { + return Promise.resolve(new UrlExample(slot.name, PlugLaravelSdk.resolveExampleUrl(slot.slug))); + } + + private async generateRouteFile(slug: string, view: string): Promise { + const path = this.fileSystem.joinPaths('routes', 'web.php'); + const absolutePath = this.fileSystem.joinPaths(this.projectDirectory.get(), path); + const current = await this.fileSystem.exists(absolutePath) + ? await this.fileSystem.readTextFile(absolutePath) + : ''; + + const {modified, result} = await this.routeCodemod.apply(current, { + slot: slug, + url: PlugLaravelSdk.resolveExampleUrl(slug), + view: view, + }); + + if (modified) { + return { + path: path, + code: result, + language: CodeLanguage.PHP, + }; + } + + return null; + } + + private resolveViewName(examplesDirectory: string, slug: string): string { + const rootPath = this.projectDirectory.get(); + const namespace = this.fileSystem.normalizeSeparators( + this.fileSystem.getRelativePath( + this.fileSystem.joinPaths(rootPath, PlugLaravelSdk.VIEWS_DIRECTORY), + this.fileSystem.joinPaths(rootPath, examplesDirectory), + ), + ); + + return [...namespace.split('/'), formatSlug(slug)] + .filter(segment => segment !== '' && segment !== '.') + .join('.'); + } + + private static resolveExampleUrl(slug: string): string { + return `/croct/${formatSlug(slug)}`; + } +} diff --git a/src/application/project/sdk/plugNextSdk.ts b/src/application/project/sdk/plugNextSdk.ts index 2d042190..e911141d 100644 --- a/src/application/project/sdk/plugNextSdk.ts +++ b/src/application/project/sdk/plugNextSdk.ts @@ -1,9 +1,6 @@ -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; import {SdkError} from '@/application/project/sdk/sdk'; -import type { - Configuration as JavaScriptSdkConfiguration, - InstallationPlan, -} from '@/application/project/sdk/javasScriptSdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; import type {ApplicationApi, GeneratedApiKey} from '@/application/api/application'; import type {WorkspaceApi} from '@/application/api/workspace'; @@ -26,6 +23,8 @@ import { import {ApiError} from '@/application/api/error'; import type {Slot} from '@/application/model/slot'; import {ErrorReason, HelpfulError} from '@/application/error'; +import type {Example} from '@/application/project/example/example'; +import {UrlExample} from '@/application/project/example/example'; import type {ImportResolver} from '@/application/project/import/importResolver'; import {ApiKeyPermission} from '@/application/model/application'; import {PlugReactExampleGenerator} from '@/application/project/code/generation/slot/plugReactExampleGenerator'; @@ -96,6 +95,15 @@ export class PlugNextSdk extends JavaScriptSdk { this.applicationApi = configuration.applicationApi; } + protected async createExample(slot: Slot, installation: Installation): Promise { + if (await this.isFallbackMode()) { + // Next.js < 13: the example is a React component to import, not a route. + return super.createExample(slot, installation); + } + + return new UrlExample(slot.name, `/${slot.slug}`); + } + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { const [router, isTypescript, fallbackMode] = await Promise.all([ this.detectRouter(), diff --git a/src/application/project/sdk/plugNuxtSdk.ts b/src/application/project/sdk/plugNuxtSdk.ts index 3ad74c5f..d21e189e 100644 --- a/src/application/project/sdk/plugNuxtSdk.ts +++ b/src/application/project/sdk/plugNuxtSdk.ts @@ -1,9 +1,6 @@ -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; import {SdkError} from '@/application/project/sdk/sdk'; -import type { - Configuration as JavaScriptSdkConfiguration, - InstallationPlan, -} from '@/application/project/sdk/javasScriptSdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; import type {ApplicationApi, GeneratedApiKey} from '@/application/api/application'; import type {WorkspaceApi} from '@/application/api/workspace'; @@ -16,6 +13,8 @@ import {PlugNuxtExampleGenerator} from '@/application/project/code/generation/sl import {ApiError} from '@/application/api/error'; import type {Slot} from '@/application/model/slot'; import {ErrorReason, HelpfulError} from '@/application/error'; +import type {Example} from '@/application/project/example/example'; +import {UrlExample} from '@/application/project/example/example'; import {ApiKeyPermission} from '@/application/model/application'; import type {CommandExecutor} from '@/application/system/process/executor'; @@ -67,6 +66,11 @@ export class PlugNuxtSdk extends JavaScriptSdk { this.commandExecutor = configuration.commandExecutor; } + protected createExample(slot: Slot): Promise { + // Nuxt auto-routes `pages//index.vue` to `/`. + return Promise.resolve(new UrlExample(slot.name, `/${slot.slug}`)); + } + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { const isTypeScript = await this.isTypeScriptProject(); diff --git a/src/application/project/sdk/plugPhpSdk.ts b/src/application/project/sdk/plugPhpSdk.ts new file mode 100644 index 00000000..88c08e1c --- /dev/null +++ b/src/application/project/sdk/plugPhpSdk.ts @@ -0,0 +1,64 @@ +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; +import {PhpSdk} from '@/application/project/sdk/phpSdk'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {PlugPhpExampleGenerator} from '@/application/project/code/generation/slot/plugPhpExampleGenerator'; +import type {Slot} from '@/application/model/slot'; +import {formatSlug} from '@/application/project/code/generation/utils'; +import {UrlExample} from '@/application/project/example/example'; +import type {Example} from '@/application/project/example/example'; + +/** + * SDK for framework-agnostic PHP projects. + */ +export class PlugPhpSdk extends PhpSdk { + protected getInstallationPlan(installation: Installation): Promise { + return Promise.resolve({ + dependencies: [ + 'croct/plug-php', + 'psr/http-client-implementation', + 'psr/http-factory-implementation', + 'psr/http-message-implementation', + ], + tasks: [], + configuration: installation.configuration, + }); + } + + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { + const paths = await this.getPaths(installation.configuration); + + const generator = new PlugPhpExampleGenerator({ + contentVariable: 'content', + filePath: this.fileSystem.joinPaths(paths.examples, '%slug%.php'), + autoloadPath: this.resolveAutoloadPath(paths.examples), + }); + + const example = generator.generate({ + id: slot.slug, + version: slot.version.major, + definition: slot.resolvedDefinition, + }); + + return example.files; + } + + protected async createExample(slot: Slot, installation: Installation): Promise { + const {examples} = await this.getPaths(installation.configuration); + + return new UrlExample(slot.name, PlugPhpSdk.resolveExampleUrl(examples, slot.slug)); + } + + private resolveAutoloadPath(examplesDirectory: string): string { + const projectDirectory = this.projectDirectory.get(); + const absoluteExamples = this.fileSystem.joinPaths(projectDirectory, examplesDirectory); + const relativeRoot = this.fileSystem.getRelativePath(absoluteExamples, projectDirectory); + + return this.fileSystem.normalizeSeparators( + this.fileSystem.joinPaths(relativeRoot, 'vendor', 'autoload.php'), + ); + } + + private static resolveExampleUrl(examplesDirectory: string, slug: string): string { + return `/${examplesDirectory}/${formatSlug(slug)}.php`; + } +} diff --git a/src/application/project/sdk/plugReactSdk.ts b/src/application/project/sdk/plugReactSdk.ts index c4177d56..10b24f89 100644 --- a/src/application/project/sdk/plugReactSdk.ts +++ b/src/application/project/sdk/plugReactSdk.ts @@ -1,9 +1,6 @@ -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; import {SdkError} from '@/application/project/sdk/sdk'; -import type { - InstallationPlan, - Configuration as JavaScriptSdkConfiguration, -} from '@/application/project/sdk/javasScriptSdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; import type {Codemod} from '@/application/project/code/transformation/codemod'; import type {Task, TaskNotifier} from '@/application/cli/io/output'; diff --git a/src/application/project/sdk/plugSymfonySdk.ts b/src/application/project/sdk/plugSymfonySdk.ts new file mode 100644 index 00000000..02ff9993 --- /dev/null +++ b/src/application/project/sdk/plugSymfonySdk.ts @@ -0,0 +1,196 @@ +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; +import type {Configuration as PhpSdkConfiguration} from '@/application/project/sdk/phpSdk'; +import {PhpEnvVar, PhpSdk} from '@/application/project/sdk/phpSdk'; +import type {YamlMappingOptions} from '@/application/project/code/transformation/yml/yamlMappingCodemod'; +import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {TwigExampleGenerator} from '@/application/project/code/generation/slot/twigExampleGenerator'; +import type {Codemod} from '@/application/project/code/transformation/codemod'; +import {formatSlug} from '@/application/project/code/generation/utils'; +import {formatName} from '@/application/project/utils/formatName'; +import {HelpfulError} from '@/application/error'; +import type {Slot} from '@/application/model/slot'; +import {UrlExample} from '@/application/project/example/example'; +import type {Example} from '@/application/project/example/example'; + +export type Configuration = PhpSdkConfiguration & { + /** + * Registers the Croct bundle in `config/bundles.php`. + */ + bundleCodemod: Codemod, + + /** + * Configures the Croct bundle in `config/packages/croct.yaml`. + */ + configCodemod: Codemod, +}; + +export class PlugSymfonySdk extends PhpSdk { + private static readonly TEMPLATES_DIRECTORY = 'templates'; + + private readonly bundleCodemod: Codemod; + + private readonly configCodemod: Codemod; + + public constructor(configuration: Configuration) { + super(configuration); + + this.bundleCodemod = configuration.bundleCodemod; + this.configCodemod = configuration.configCodemod; + } + + protected getInstallationPlan(installation: Installation): Promise { + return Promise.resolve({ + dependencies: ['croct/plug-symfony', 'symfony/twig-bundle'], + tasks: [ + { + title: 'Register bundle', + task: async notifier => { + notifier.update('Registering bundle'); + + try { + notifier.confirm( + await this.registerBundle() + ? 'Bundle registered' + : 'Bundle already registered', + ); + } catch (error) { + notifier.alert('Failed to register bundle', HelpfulError.formatMessage(error)); + } + }, + }, + { + title: 'Configure bundle', + task: async notifier => { + notifier.update('Configuring bundle'); + + try { + notifier.confirm( + await this.configureBundle() + ? 'Bundle configured' + : 'Bundle already configured', + ); + } catch (error) { + notifier.alert('Failed to configure bundle', HelpfulError.formatMessage(error)); + } + }, + }, + ], + configuration: installation.configuration, + }); + } + + public async getPaths(configuration: ProjectConfiguration): Promise { + const paths = await super.getPaths(configuration); + + return { + ...paths, + examples: configuration.paths?.examples ?? `${PlugSymfonySdk.TEMPLATES_DIRECTORY}/croct`, + }; + } + + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { + const paths = await this.getPaths(installation.configuration); + + const generator = new TwigExampleGenerator({ + contentVariable: 'content', + filePath: this.fileSystem.joinPaths(paths.examples, '%slug%.html.twig'), + }); + + const example = generator.generate({ + id: slot.slug, + version: slot.version.major, + definition: slot.resolvedDefinition, + }); + + return [ + ...example.files, + this.generateController(slot.slug, paths), + ]; + } + + protected createExample(slot: Slot): Promise { + return Promise.resolve(new UrlExample(slot.name, PlugSymfonySdk.resolveExampleUrl(slot.slug))); + } + + private generateController(slug: string, paths: ProjectPaths): ExampleFile { + const name = formatName(slug).replace(/^./, character => character.toUpperCase()); + const route = `croct_${formatSlug(slug).replace(/-/g, '_')}`; + const template = this.resolveTemplateReference(paths.examples, slug); + + const code = [ + 'render('${template}', [`, + ` 'content' => $croct->fetchContent('${slug}')->getContent(),`, + ' ]);', + ' }', + '}', + '', + ].join('\n'); + + return { + path: this.fileSystem.joinPaths(paths.source, 'Controller', `Croct${name}Controller.php`), + language: CodeLanguage.PHP, + code: code, + }; + } + + private resolveTemplateReference(examplesDirectory: string, slug: string): string { + const rootPath = this.projectDirectory.get(); + const namespace = this.fileSystem.normalizeSeparators( + this.fileSystem.getRelativePath( + this.fileSystem.joinPaths(rootPath, PlugSymfonySdk.TEMPLATES_DIRECTORY), + this.fileSystem.joinPaths(rootPath, examplesDirectory), + ), + ); + + return [...namespace.split('/'), `${formatSlug(slug)}.html.twig`] + .filter(segment => segment !== '' && segment !== '.') + .join('/'); + } + + private async registerBundle(): Promise { + const path = this.fileSystem.joinPaths(this.projectDirectory.get(), 'config', 'bundles.php'); + + return (await this.bundleCodemod.apply(path)).modified; + } + + private async configureBundle(): Promise { + const path = this.fileSystem.joinPaths( + this.projectDirectory.get(), + 'config', + 'packages', + 'croct.yaml', + ); + + const result = await this.configCodemod.apply(path, { + key: 'croct', + entries: { + app_id: `'%env(${PhpEnvVar.APP_ID})%'`, + api_key: `'%env(${PhpEnvVar.API_KEY})%'`, + }, + }); + + return result.modified; + } + + private static resolveExampleUrl(slug: string): string { + return `/croct/${formatSlug(slug)}`; + } +} diff --git a/src/application/project/sdk/plugVueSdk.ts b/src/application/project/sdk/plugVueSdk.ts index 64adeb82..759f7cc8 100644 --- a/src/application/project/sdk/plugVueSdk.ts +++ b/src/application/project/sdk/plugVueSdk.ts @@ -1,9 +1,6 @@ -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; import {SdkError} from '@/application/project/sdk/sdk'; -import type { - InstallationPlan, - Configuration as JavaScriptSdkConfiguration, -} from '@/application/project/sdk/javasScriptSdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; import type {Codemod} from '@/application/project/code/transformation/codemod'; import type {Task, TaskNotifier} from '@/application/cli/io/output'; diff --git a/src/application/project/sdk/sdk.ts b/src/application/project/sdk/sdk.ts index a5adc8fe..80696d20 100644 --- a/src/application/project/sdk/sdk.ts +++ b/src/application/project/sdk/sdk.ts @@ -1,5 +1,5 @@ import type {Input} from '@/application/cli/io/input'; -import type {Output} from '@/application/cli/io/output'; +import type {Output, Task} from '@/application/cli/io/output'; import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; import type {Slot} from '@/application/model/slot'; import type {Help} from '@/application/error'; @@ -16,6 +16,12 @@ export type UpdateOptions = { clean?: boolean, }; +export type InstallationPlan = { + tasks: Task[], + dependencies: string[], + configuration: ProjectConfiguration, +}; + export interface Sdk { /** * Sets up the SDK in the project. @@ -54,6 +60,18 @@ export interface Sdk { * @throws SdkError If an error occurs. */ generateSlotExample(slot: Slot, installation: Installation): Promise; + + /** + * Tell the user how to view the generated examples, optionally setting them up. + * + * Called once after all examples are generated. Implementations report where each example + * lives and how to open it (a route, a file, a component to import), and may detect the dev + * server to offer opening it directly. + * + * @param slots The slots whose examples were generated. + * @param installation The installation details. + */ + presentExamples?(slots: Slot[], installation: Installation): Promise; } export class SdkError extends HelpfulError { diff --git a/src/application/project/sdk/wrapperStoryblokPlugin.ts b/src/application/project/sdk/wrapperStoryblokPlugin.ts index bfc9c5e4..42a69cd9 100644 --- a/src/application/project/sdk/wrapperStoryblokPlugin.ts +++ b/src/application/project/sdk/wrapperStoryblokPlugin.ts @@ -1,13 +1,9 @@ import {extname} from 'path'; -import type { - InstallationPlan, - JavaScriptSdkPlugin, - JavaScriptPluginContext, -} from '@/application/project/sdk/javasScriptSdk'; +import type {JavaScriptSdkPlugin, JavaScriptPluginContext} from '@/application/project/sdk/javasScriptSdk'; import type {Task} from '@/application/cli/io/output'; import {HelpfulError} from '@/application/error'; import type {Codemod} from '@/application/project/code/transformation/codemod'; -import type {Installation} from '@/application/project/sdk/sdk'; +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; import type {ScanFilter} from '@/application/fs/fileSystem'; export type Configuration = { diff --git a/src/application/project/server/processServer.ts b/src/application/project/server/processServer.ts index c4a3b41c..4d5788e8 100644 --- a/src/application/project/server/processServer.ts +++ b/src/application/project/server/processServer.ts @@ -71,6 +71,11 @@ export class ProcessServer implements Server { this.execution.onExit(() => { this.execution = undefined; processObserver.off('exit', onExit); + + // If the process dies before the port opens (e.g. a boot error), stop polling + // immediately so startup fails fast with the captured output instead of waiting + // out the full startup timeout. + abortController.abort(); }); if (!this.execution.running) { @@ -97,12 +102,12 @@ export class ProcessServer implements Server { } })(); + void loggingLoop.catch(() => {}); + const url = await this.waitStart(abortController); abortController.abort(); - await loggingLoop; - if (url === null) { logger?.log({ level: LogLevel.ERROR, @@ -123,6 +128,10 @@ export class ProcessServer implements Server { this.execution = undefined; } + public async wait(): Promise { + await this.execution?.wait(); + } + private async waitStart(controller: AbortController): Promise { const {startupCheckDelay, startupTimeout} = this.configuration; diff --git a/src/application/project/server/server.ts b/src/application/project/server/server.ts index 9414fdf9..3cd0a791 100644 --- a/src/application/project/server/server.ts +++ b/src/application/project/server/server.ts @@ -32,4 +32,12 @@ export interface Server { start(options?: StartServerOptions): Promise; stop(options?: StartServerOptions): Promise; + + /** + * Resolves when the server process started by this instance exits. + * + * Resolves immediately when no process is running. Used to keep the CLI in the foreground while + * a server it started serves requests, returning once it is stopped (e.g. with Ctrl+C). + */ + wait(): Promise; } diff --git a/src/infrastructure/application/api/graphql/workspace.ts b/src/infrastructure/application/api/graphql/workspace.ts index 24b51c0f..e96d8dd3 100644 --- a/src/infrastructure/application/api/graphql/workspace.ts +++ b/src/infrastructure/application/api/graphql/workspace.ts @@ -34,7 +34,7 @@ import { Feature, Platform as GraphqlPlatform, ApplicationEnvironment as GraphqlApplicationEnvironment, - ApplicationTrafficStatus as GraphqlApplicationTrafficStatus, + TrafficStatus as GraphqlApplicationTrafficStatus, TargetSdk as GraphqlTargetSdk, ExperimentStatus as GraphqlExperimentStatus, ExperienceStatus as GraphqlExperienceStatus, @@ -121,15 +121,16 @@ function createNormalizationMap(map: Record< }; } -// The GraphQL schema does not yet have first-class Vue/Nuxt platforms, so they are -// reported to the server as JavaScript. JAVASCRIPT is intentionally listed last so that -// the inverse (api -> model) map resolves the shared `Javascript` value back to JAVASCRIPT. const platformMap = createNormalizationMap({ - [Platform.VUE]: GraphqlPlatform.Javascript, - [Platform.NUXT]: GraphqlPlatform.Javascript, + [Platform.JAVASCRIPT]: GraphqlPlatform.Javascript, [Platform.REACT]: GraphqlPlatform.React, [Platform.NEXTJS]: GraphqlPlatform.Next, - [Platform.JAVASCRIPT]: GraphqlPlatform.Javascript, + [Platform.VUE]: GraphqlPlatform.Vue, + [Platform.NUXT]: GraphqlPlatform.Nuxt, + [Platform.PHP]: GraphqlPlatform.Php, + [Platform.LARAVEL]: GraphqlPlatform.Laravel, + [Platform.SYMFONY]: GraphqlPlatform.Symfony, + [Platform.DRUPAL]: GraphqlPlatform.Drupal, }); const environmentMap = createNormalizationMap({ @@ -145,6 +146,7 @@ const trafficStatusMap = createNormalizationMap({ [TargetSdk.JAVASCRIPT]: GraphqlTargetSdk.PlugJs, + [TargetSdk.PHP]: GraphqlTargetSdk.PlugPhp, }); const experienceStatusMap = createNormalizationMap({ diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 24329ff7..82196d95 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -24,6 +24,12 @@ import {PlugReactSdk} from '@/application/project/sdk/plugReactSdk'; import {PlugNextSdk} from '@/application/project/sdk/plugNextSdk'; import {PlugVueSdk} from '@/application/project/sdk/plugVueSdk'; import {PlugNuxtSdk} from '@/application/project/sdk/plugNuxtSdk'; +import {PlugPhpSdk} from '@/application/project/sdk/plugPhpSdk'; +import {PlugLaravelSdk} from '@/application/project/sdk/plugLaravelSdk'; +import {PlugSymfonySdk} from '@/application/project/sdk/plugSymfonySdk'; +import {PlugDrupalSdk} from '@/application/project/sdk/plugDrupalSdk'; +import type {Configuration as PhpSdkConfiguration} from '@/application/project/sdk/phpSdk'; +import {WorkspaceContentLoader} from '@/application/project/sdk/content/workspaceContentLoader'; import type {InitInput} from '@/application/cli/command/init'; import {InitCommand} from '@/application/cli/command/init'; import type {LoginInput} from '@/application/cli/command/login'; @@ -59,6 +65,11 @@ import {NextJsProxyCodemod} from '@/application/project/code/transformation/java import type {CodeFormatter} from '@/application/project/code/formatting/formatter'; import {FormatCodemod} from '@/application/project/code/transformation/formatCodemod'; import {FileCodemod} from '@/application/project/code/transformation/fileCodemod'; +import {SymfonyBundleCodemod} from '@/application/project/code/transformation/php/symfonyBundleCodemod'; +import {YamlMappingCodemod} from '@/application/project/code/transformation/yml/yamlMappingCodemod'; +import {NeonListCodemod} from '@/application/project/code/transformation/neon/neonListCodemod'; +import {DrupalLocalSettingsCodemod} from '@/application/project/code/transformation/php/drupalLocalSettingsCodemod'; +import {LaravelRouteCodemod} from '@/application/project/code/transformation/php/laravelRouteCodemod'; import { NextJsLayoutComponentCodemod, } from '@/application/project/code/transformation/javascript/nextJsLayoutComponentCodemod'; @@ -66,6 +77,7 @@ import { NextJsAppComponentCodemod, } from '@/application/project/code/transformation/javascript/nextJsAppComponentCodemod'; import {JavaScriptFormatter} from '@/infrastructure/application/project/javaScriptFormatter'; +import {PhpFormatter} from '@/infrastructure/application/project/phpFormatter'; import type {AddSlotInput} from '@/application/cli/command/slot/add'; import {AddSlotCommand} from '@/application/cli/command/slot/add'; import {SlotForm} from '@/application/cli/form/workspace/slotForm'; @@ -188,6 +200,8 @@ import {FailOptionsValidator} from '@/infrastructure/application/validation/acti import {SpecificResourceProvider} from '@/application/provider/resource/specificResourceProvider'; import {ConstantProvider} from '@/application/provider/constantProvider'; import type {Server} from '@/application/project/server/server'; +import type {Command as ProcessCommand} from '@/application/system/process/command'; +import {ExampleLauncher} from '@/application/project/example/exampleLauncher'; import {ProjectServerProvider} from '@/application/project/server/provider/projectServerProvider'; import {NextCommandParser} from '@/application/project/server/provider/parser/nextCommandParser'; import {ViteCommandParser} from '@/application/project/server/provider/parser/viteCommandParser'; @@ -221,6 +235,12 @@ import type { Configuration as NodePackageManagerConfiguration, } from '@/application/project/packageManager/nodePackageManager'; import {NodePackageManager} from '@/application/project/packageManager/nodePackageManager'; +import {ComposerPackageManager} from '@/application/project/packageManager/composerPackageManager'; +import {ComposerAgent} from '@/application/project/packageManager/agent/composerAgent'; +import { + PartialComposerManifestValidator, +} from '@/infrastructure/application/validation/partialComposerManifestValidator'; +import {PartialComposerLockValidator} from '@/infrastructure/application/validation/partialComposerLockValidator'; import {NpmAgent} from '@/application/project/packageManager/agent/npmAgent'; import {YarnAgent} from '@/application/project/packageManager/agent/yarnAgent'; import {BunAgent} from '@/application/project/packageManager/agent/bunAgent'; @@ -1751,6 +1771,10 @@ export class Cli { const formatter = this.getJavaScriptFormatter(); const fileSystem = this.getFileSystem(); const importResolver = this.getNodeImportResolver(); + const contentLoader = new WorkspaceContentLoader({ + workspaceApi: this.getWorkspaceApi(), + fileSystem: fileSystem, + }); const config: JavaScriptSdkConfiguration = { projectDirectory: this.workingDirectory, @@ -1759,6 +1783,25 @@ export class Cli { formatter: formatter, workspaceApi: this.getWorkspaceApi(), tsConfigLoader: this.getTsConfigLoader(), + contentLoader: contentLoader, + exampleLauncher: new ExampleLauncher(this.getServerProvider()), + }; + + const phpConfig: PhpSdkConfiguration = { + projectDirectory: this.workingDirectory, + packageManager: this.getComposerPackageManager(), + fileSystem: fileSystem, + formatter: this.getPhpFormatter(), + commandExecutor: this.getAsynchronousCommandExecutor(), + contentLoader: contentLoader, + workspaceApi: this.getWorkspaceApi(), + userApi: this.getUserApi(), + applicationApi: this.getApplicationApi(), + exampleLauncher: new ExampleLauncher(this.getServerProvider()), + phpstanIncludeCodemod: new FileCodemod({ + fileSystem: fileSystem, + codemod: new NeonListCodemod(), + }), }; const unknown = Symbol('unknown'); @@ -1993,6 +2036,38 @@ export class Cli { }, }); }, + [Platform.PHP]: (): Sdk => new PlugPhpSdk(phpConfig), + [Platform.LARAVEL]: (): Sdk => new PlugLaravelSdk({ + ...phpConfig, + // Raw content codemod: the SDK runs it over routes/web.php and returns the + // result as an example file, so the base writes and formats it. + routeCodemod: new LaravelRouteCodemod(), + }), + [Platform.SYMFONY]: (): Sdk => new PlugSymfonySdk({ + ...phpConfig, + bundleCodemod: new FormatCodemod( + phpConfig.formatter, + new FileCodemod({ + fileSystem: fileSystem, + codemod: new SymfonyBundleCodemod({bundle: 'Croct\\Plug\\Symfony\\CroctBundle'}), + }), + ), + // YAML has no formatter, so it is not wrapped in FormatCodemod. + configCodemod: new FileCodemod({ + fileSystem: fileSystem, + codemod: new YamlMappingCodemod(), + }), + }), + [Platform.DRUPAL]: (): Sdk => new PlugDrupalSdk({ + ...phpConfig, + localSettingsFileCodemod: new FormatCodemod( + phpConfig.formatter, + new FileCodemod({ + fileSystem: fileSystem, + codemod: new DrupalLocalSettingsCodemod({file: PlugDrupalSdk.LOCAL_SETTINGS_FILE}), + }), + ), + }), [unknown]: () => null, }, }); @@ -2012,6 +2087,10 @@ export class Cli { [Platform.NEXTJS]: () => this.getJavaScriptFormatter(), [Platform.VUE]: () => this.getJavaScriptFormatter(), [Platform.NUXT]: () => this.getJavaScriptFormatter(), + [Platform.LARAVEL]: () => this.getPhpFormatter(), + [Platform.SYMFONY]: () => this.getPhpFormatter(), + [Platform.DRUPAL]: () => this.getPhpFormatter(), + [Platform.PHP]: () => this.getPhpFormatter(), [unknown]: (): never => { throw new ProviderError('No code formatter detected.', { reason: ErrorReason.NOT_SUPPORTED, @@ -2168,6 +2247,25 @@ export class Cli { }); } + private getComposerPackageManager(): PackageManager { + return this.share(this.getComposerPackageManager, () => { + const fileSystem = this.getFileSystem(); + + return new ComposerPackageManager({ + projectDirectory: this.workingDirectory, + fileSystem: fileSystem, + packageValidator: new PartialComposerManifestValidator(), + lockValidator: new PartialComposerLockValidator(), + agent: new ComposerAgent({ + projectDirectory: this.workingDirectory, + fileSystem: fileSystem, + commandExecutor: this.getAsynchronousCommandExecutor(), + executableLocator: this.getExecutableLocator(), + }), + }); + }); + } + private getNodePackageManagerProvider(): Provider { return this.share(this.getNodePackageManagerProvider, () => { const managers = this.getNodePackageManagers(); @@ -2285,6 +2383,35 @@ export class Cli { [Platform.NEXTJS]: () => this.getNodeServerProvider().get(), [Platform.VUE]: () => this.getNodeServerProvider().get(), [Platform.NUXT]: () => this.getNodeServerProvider().get(), + [Platform.LARAVEL]: () => this.createDevServer( + {name: 'php', arguments: ['artisan', 'serve']}, + 8000, + ), + [Platform.SYMFONY]: async () => { + // `symfony serve` needs the Symfony CLI; fall back to PHP's built-in + // server (routing through `public/index.php`) when it is not installed. + if (await this.getExecutableLocator().locate('symfony') !== null) { + return this.createDevServer({name: 'symfony', arguments: ['serve']}, 8000); + } + + return this.createDevServer( + { + name: 'php', + arguments: ['-S', '127.0.0.1:8000', '-t', 'public', 'public/index.php'], + }, + 8000, + 8000, + ); + }, + [Platform.DRUPAL]: async () => this.createDevServer( + await this.getComposerPackageManager().getPackageCommand('drush', ['runserver']), + 8888, + ), + [Platform.PHP]: () => this.createDevServer( + {name: 'php', arguments: ['-S', '127.0.0.1:8000']}, + 8000, + 8000, + ), [unknown]: () => null, }, }); @@ -2307,6 +2434,16 @@ export class Cli { ); } + private createDevServer(command: ProcessCommand, defaultPort: number, port?: number): Promise { + return this.getServerFactory().create({ + protocol: 'http', + host: '127.0.0.1', + defaultPort: defaultPort, + ...(port !== undefined ? {port: port} : {}), + command: command, + }); + } + private getServerFactory(): ServerFactory { return this.share( this.getServerFactory, @@ -2351,6 +2488,36 @@ export class Cli { ); } + private getPhpFormatter(): CodeFormatter { + return this.share( + this.getPhpFormatter, + () => new PhpFormatter({ + commandExecutor: this.getSynchronousCommandExecutor(), + workingDirectory: this.workingDirectory, + packageManager: this.getComposerPackageManager(), + fileSystem: this.getFileSystem(), + timeout: 10_000, + tools: [ + { + package: 'laravel/pint', + binary: 'pint', + args: files => [...files], + }, + { + package: 'friendsofphp/php-cs-fixer', + binary: 'php-cs-fixer', + args: files => ['fix', ...files], + }, + { + package: 'squizlabs/php_codesniffer', + binary: 'phpcbf', + args: files => [...files], + }, + ], + }), + ); + } + private getNodeImportResolver(): ImportResolver { return this.share( this.getNodeImportResolver, @@ -2423,6 +2590,14 @@ export class Cli { agent: new NoopAgent(), }); + const composerPackageManager = new ComposerPackageManager({ + projectDirectory: this.workingDirectory, + packageValidator: new PartialComposerManifestValidator(), + lockValidator: new PartialComposerLockValidator(), + fileSystem: this.getFileSystem(), + agent: new NoopAgent(), + }); + return new MemoizedProvider( new ConditionalProvider({ candidates: [ @@ -2454,12 +2629,44 @@ export class Cli { dependencies: ['vue'], }), }, + // Framework-specific dependencies are matched before the generic + // package.json/composer.json fallbacks, so a Laravel app that ships a JS + // build toolchain (e.g. Vite) still resolves to Laravel rather than + // JavaScript. Drupal is matched before Laravel/Symfony because it builds + // on Symfony but declares its own core packages. + { + value: Platform.DRUPAL, + condition: new HasDependency({ + packageManager: composerPackageManager, + dependencies: ['drupal/core', 'drupal/core-recommended'], + }), + }, + { + value: Platform.LARAVEL, + condition: new HasDependency({ + packageManager: composerPackageManager, + dependencies: ['laravel/framework'], + }), + }, + { + value: Platform.SYMFONY, + condition: new HasDependency({ + packageManager: composerPackageManager, + dependencies: ['symfony/framework-bundle'], + }), + }, { value: Platform.JAVASCRIPT, condition: new IsProject({ packageManager: nodePackageManager, }), }, + { + value: Platform.PHP, + condition: new IsProject({ + packageManager: composerPackageManager, + }), + }, ], }), this.workingDirectory, diff --git a/src/infrastructure/application/project/phpFormatter.ts b/src/infrastructure/application/project/phpFormatter.ts new file mode 100644 index 00000000..df381b71 --- /dev/null +++ b/src/infrastructure/application/project/phpFormatter.ts @@ -0,0 +1,75 @@ +import type {CodeFormatter} from '@/application/project/code/formatting/formatter'; +import type {FileSystem} from '@/application/fs/fileSystem'; +import type {PackageManager} from '@/application/project/packageManager/packageManager'; +import type {WorkingDirectory} from '@/application/fs/workingDirectory/workingDirectory'; +import type {SynchronousCommandExecutor} from '@/application/system/process/executor'; +import type {Command} from '@/application/system/process/command'; + +type FormatterTool = { + package: string, + binary: string, + args(files: string[]): string[], +}; + +export type Configuration = { + workingDirectory: WorkingDirectory, + fileSystem: FileSystem, + packageManager: PackageManager, + commandExecutor: SynchronousCommandExecutor, + timeout?: number, + tools: FormatterTool[], +}; + +/** + * Formats PHP code with whichever formatter the project already uses. + * + * Detects the first configured tool present as a direct dependency and runs its + * Composer-linked binary so the project's own ruleset applies. Formatting is + * best-effort: when no supported tool is installed, or the run fails, it is + * silently skipped so it never blocks the surrounding command. + */ +export class PhpFormatter implements CodeFormatter { + private readonly configuration: Configuration; + + public constructor(configuration: Configuration) { + this.configuration = configuration; + } + + public async format(files: string[]): Promise { + if (files.length === 0) { + return; + } + + const command = await this.getCommand(files); + + if (command === null) { + return; + } + + const {commandExecutor, workingDirectory, timeout} = this.configuration; + + try { + commandExecutor.runSync(command, { + workingDirectory: workingDirectory.get(), + timeout: timeout, + }); + } catch { + // suppress + } + } + + private async getCommand(files: string[]): Promise { + const {tools, packageManager, fileSystem, workingDirectory} = this.configuration; + + for (const tool of tools) { + if (await packageManager.hasDirectDependency(tool.package)) { + return { + name: fileSystem.joinPaths(workingDirectory.get(), 'vendor', 'bin', tool.binary), + arguments: tool.args(files), + }; + } + } + + return null; + } +} diff --git a/src/infrastructure/application/validation/partialComposerLockValidator.ts b/src/infrastructure/application/validation/partialComposerLockValidator.ts new file mode 100644 index 00000000..a76402be --- /dev/null +++ b/src/infrastructure/application/validation/partialComposerLockValidator.ts @@ -0,0 +1,19 @@ +import type {ZodType} from 'zod'; +import {z} from 'zod'; +import {ZodValidator} from '@/infrastructure/application/validation/zodValidator'; +import type {ComposerLock} from '@/application/project/packageManager/composerPackageManager'; + +const lockPackageSchema = z.object({ + provide: z.record(z.string()).optional(), +}); + +const lockSchema: ZodType = z.object({ + packages: z.array(lockPackageSchema).optional(), + 'packages-dev': z.array(lockPackageSchema).optional(), +}); + +export class PartialComposerLockValidator extends ZodValidator { + public constructor() { + super(lockSchema); + } +} diff --git a/src/infrastructure/application/validation/partialComposerManifestValidator.ts b/src/infrastructure/application/validation/partialComposerManifestValidator.ts new file mode 100644 index 00000000..6e5032fd --- /dev/null +++ b/src/infrastructure/application/validation/partialComposerManifestValidator.ts @@ -0,0 +1,26 @@ +import type {ZodType} from 'zod'; +import {z} from 'zod'; +import {ZodValidator} from '@/infrastructure/application/validation/zodValidator'; +import type {PartialComposerManifest} from '@/application/project/packageManager/composerPackageManager'; + +const pathValue = z.union([z.string(), z.array(z.string())]); + +const manifestSchema: ZodType = z.object({ + name: z.string().optional(), + version: z.string().optional(), + type: z.string().optional(), + require: z.record(z.string()).optional(), + 'require-dev': z.record(z.string()).optional(), + scripts: z.record(z.unknown()).optional(), + autoload: z.object({ + 'psr-4': z.record(pathValue).optional(), + }).optional(), + bin: z.union([z.string(), z.array(z.string())]).optional(), + extra: z.record(z.unknown()).optional(), +}); + +export class PartialComposerManifestValidator extends ZodValidator { + public constructor() { + super(manifestSchema); + } +} diff --git a/test/application/project/code/transformation/fixtures/drupal-local-settings/absent-no-newline.php b/test/application/project/code/transformation/fixtures/drupal-local-settings/absent-no-newline.php new file mode 100644 index 00000000..ecd583e3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/drupal-local-settings/absent-no-newline.php @@ -0,0 +1,2 @@ + $croct->fetchContent('home-banner')->getContent(), + ]); +}); diff --git a/test/application/project/code/transformation/fixtures/laravel-route/commented-routes.php b/test/application/project/code/transformation/fixtures/laravel-route/commented-routes.php new file mode 100644 index 00000000..1e5a504d --- /dev/null +++ b/test/application/project/code/transformation/fixtures/laravel-route/commented-routes.php @@ -0,0 +1,9 @@ + 'commented with slashes'); +# Route::get('/croct/home-banner', fn () => 'commented with hash'); +/* Route::get('/croct/home-banner', fn () => 'commented in a block'); */ + +Route::get('/', fn () => view('welcome')); diff --git a/test/application/project/code/transformation/fixtures/laravel-route/double-quoted.php b/test/application/project/code/transformation/fixtures/laravel-route/double-quoted.php new file mode 100644 index 00000000..2610d49f --- /dev/null +++ b/test/application/project/code/transformation/fixtures/laravel-route/double-quoted.php @@ -0,0 +1,3 @@ + view("croct.home-banner")); diff --git a/test/application/project/code/transformation/fixtures/laravel-route/escaped-string.php b/test/application/project/code/transformation/fixtures/laravel-route/escaped-string.php new file mode 100644 index 00000000..2a7a3552 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/laravel-route/escaped-string.php @@ -0,0 +1,5 @@ + 'it\'s a test']); +}); diff --git a/test/application/project/code/transformation/fixtures/laravel-route/existing-routes.php b/test/application/project/code/transformation/fixtures/laravel-route/existing-routes.php new file mode 100644 index 00000000..86a06c53 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/laravel-route/existing-routes.php @@ -0,0 +1,7 @@ + view('welcome')); \ No newline at end of file diff --git a/test/application/project/code/transformation/fixtures/laravel-route/url-as-label.php b/test/application/project/code/transformation/fixtures/laravel-route/url-as-label.php new file mode 100644 index 00000000..fdc96551 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/laravel-route/url-as-label.php @@ -0,0 +1,7 @@ + view('dashboard', ['label' => $label])); diff --git a/test/application/project/code/transformation/fixtures/neon-list/already-included.neon b/test/application/project/code/transformation/fixtures/neon-list/already-included.neon new file mode 100644 index 00000000..7b7cfbc5 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/already-included.neon @@ -0,0 +1,5 @@ +includes: + - vendor/croct/plug-php/extension.neon + +parameters: + level: 8 diff --git a/test/application/project/code/transformation/fixtures/neon-list/commented-include.neon b/test/application/project/code/transformation/fixtures/neon-list/commented-include.neon new file mode 100644 index 00000000..b3cfbbc6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/commented-include.neon @@ -0,0 +1,3 @@ +includes: + # - vendor/croct/plug-php/extension.neon + - phpstan-baseline.neon diff --git a/test/application/project/code/transformation/fixtures/neon-list/double-quoted.neon b/test/application/project/code/transformation/fixtures/neon-list/double-quoted.neon new file mode 100644 index 00000000..157afdfa --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/double-quoted.neon @@ -0,0 +1,5 @@ +includes: + - "other.neon" + +parameters: + level: 8 diff --git a/test/application/project/code/transformation/fixtures/neon-list/empty-includes.neon b/test/application/project/code/transformation/fixtures/neon-list/empty-includes.neon new file mode 100644 index 00000000..9a92248a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/empty-includes.neon @@ -0,0 +1,3 @@ +includes: +parameters: + level: 8 diff --git a/test/application/project/code/transformation/fixtures/neon-list/empty.neon b/test/application/project/code/transformation/fixtures/neon-list/empty.neon new file mode 100644 index 00000000..e69de29b diff --git a/test/application/project/code/transformation/fixtures/neon-list/no-includes.neon b/test/application/project/code/transformation/fixtures/neon-list/no-includes.neon new file mode 100644 index 00000000..c308dcf5 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/no-includes.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src diff --git a/test/application/project/code/transformation/fixtures/neon-list/quoted-include.neon b/test/application/project/code/transformation/fixtures/neon-list/quoted-include.neon new file mode 100644 index 00000000..60a5d733 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/quoted-include.neon @@ -0,0 +1,2 @@ +includes: + - 'vendor/croct/plug-php/extension.neon' diff --git a/test/application/project/code/transformation/fixtures/neon-list/trailing-includes.neon b/test/application/project/code/transformation/fixtures/neon-list/trailing-includes.neon new file mode 100644 index 00000000..5d44ff46 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/trailing-includes.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + +includes: diff --git a/test/application/project/code/transformation/fixtures/neon-list/with-includes.neon b/test/application/project/code/transformation/fixtures/neon-list/with-includes.neon new file mode 100644 index 00000000..e5adb2e2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/neon-list/with-includes.neon @@ -0,0 +1,5 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 8 diff --git a/test/application/project/code/transformation/fixtures/symfony-bundles/already-registered.php b/test/application/project/code/transformation/fixtures/symfony-bundles/already-registered.php new file mode 100644 index 00000000..d4e3cd8e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/symfony-bundles/already-registered.php @@ -0,0 +1,6 @@ + ['all' => true], + Croct\Plug\Symfony\CroctBundle::class => ['all' => true], +]; diff --git a/test/application/project/code/transformation/fixtures/symfony-bundles/empty-array.php b/test/application/project/code/transformation/fixtures/symfony-bundles/empty-array.php new file mode 100644 index 00000000..0b67a5fe --- /dev/null +++ b/test/application/project/code/transformation/fixtures/symfony-bundles/empty-array.php @@ -0,0 +1,3 @@ + ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], +]; diff --git a/test/application/project/code/transformation/fixtures/yaml-mapping/already-configured.yaml b/test/application/project/code/transformation/fixtures/yaml-mapping/already-configured.yaml new file mode 100644 index 00000000..81f5d9ae --- /dev/null +++ b/test/application/project/code/transformation/fixtures/yaml-mapping/already-configured.yaml @@ -0,0 +1,3 @@ +croct: + app_id: '%env(CROCT_APP_ID)%' + api_key: '%env(CROCT_API_KEY)%' diff --git a/test/application/project/code/transformation/fixtures/yaml-mapping/commented-croct.yaml b/test/application/project/code/transformation/fixtures/yaml-mapping/commented-croct.yaml new file mode 100644 index 00000000..e109c089 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/yaml-mapping/commented-croct.yaml @@ -0,0 +1,2 @@ +# croct: +# app_id: '%env(CROCT_APP_ID)%' diff --git a/test/application/project/code/transformation/fixtures/yaml-mapping/empty.yaml b/test/application/project/code/transformation/fixtures/yaml-mapping/empty.yaml new file mode 100644 index 00000000..e69de29b diff --git a/test/application/project/code/transformation/fixtures/yaml-mapping/no-trailing-newline.yaml b/test/application/project/code/transformation/fixtures/yaml-mapping/no-trailing-newline.yaml new file mode 100644 index 00000000..a4a81daf --- /dev/null +++ b/test/application/project/code/transformation/fixtures/yaml-mapping/no-trailing-newline.yaml @@ -0,0 +1,2 @@ +parameters: + locale: en \ No newline at end of file diff --git a/test/application/project/code/transformation/fixtures/yaml-mapping/other-config.yaml b/test/application/project/code/transformation/fixtures/yaml-mapping/other-config.yaml new file mode 100644 index 00000000..83e15d75 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/yaml-mapping/other-config.yaml @@ -0,0 +1,2 @@ +framework: + secret: '%env(APP_SECRET)%' diff --git a/test/application/project/code/transformation/neon/__snapshots__/neonListCodemod.test.ts.snap b/test/application/project/code/transformation/neon/__snapshots__/neonListCodemod.test.ts.snap new file mode 100644 index 00000000..f18bbc29 --- /dev/null +++ b/test/application/project/code/transformation/neon/__snapshots__/neonListCodemod.test.ts.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NeonListCodemod transforms already-included.neon: already-included.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon + +parameters: + level: 8 +" +`; + +exports[`NeonListCodemod transforms commented-include.neon: commented-include.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon + # - vendor/croct/plug-php/extension.neon + - phpstan-baseline.neon +" +`; + +exports[`NeonListCodemod transforms double-quoted.neon: double-quoted.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon + - "other.neon" + +parameters: + level: 8 +" +`; + +exports[`NeonListCodemod transforms empty.neon: empty.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon +" +`; + +exports[`NeonListCodemod transforms empty-includes.neon: empty-includes.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon +parameters: + level: 8 +" +`; + +exports[`NeonListCodemod transforms no-includes.neon: no-includes.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon + +parameters: + level: 8 + paths: + - src +" +`; + +exports[`NeonListCodemod transforms quoted-include.neon: quoted-include.neon 1`] = ` +"includes: + - 'vendor/croct/plug-php/extension.neon' +" +`; + +exports[`NeonListCodemod transforms trailing-includes.neon: trailing-includes.neon 1`] = ` +"parameters: + level: 8 + +includes: + - vendor/croct/plug-php/extension.neon +" +`; + +exports[`NeonListCodemod transforms with-includes.neon: with-includes.neon 1`] = ` +"includes: + - vendor/croct/plug-php/extension.neon + - phpstan-baseline.neon + +parameters: + level: 8 +" +`; diff --git a/test/application/project/code/transformation/neon/neonListCodemod.test.ts b/test/application/project/code/transformation/neon/neonListCodemod.test.ts new file mode 100644 index 00000000..13292361 --- /dev/null +++ b/test/application/project/code/transformation/neon/neonListCodemod.test.ts @@ -0,0 +1,38 @@ +import {resolve} from 'path'; +import {NeonListCodemod} from '@/application/project/code/transformation/neon/neonListCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('NeonListCodemod', () => { + const codemod = new NeonListCodemod(); + + const options = {key: 'includes', value: 'vendor/croct/plug-php/extension.neon'}; + + const scenarios = loadFixtures(resolve(__dirname, '../fixtures/neon-list'), {}, {}); + + it.each(scenarios)('transforms $name', async ({name, fixture}) => { + const {modified, result} = await codemod.apply(fixture, options); + + expect(result).toMatchSnapshot(name); + + // `modified` is true exactly when the value was added. + expect(modified).toBe(result !== fixture); + + // Adding is idempotent: the value is listed in the result, so a second pass + // detects it and changes nothing. + const reapplied = await codemod.apply(result, options); + + expect(reapplied.modified).toBe(false); + expect(reapplied.result).toBe(result); + }); + + it('rejects an inline list', () => { + expect(() => codemod.apply('includes: [foo.neon]\nparameters:\n\tlevel: 8\n', options)).toThrow(); + }); + + it('tolerates an unterminated quote', async () => { + // The unterminated string consumes the rest of the line rather than crashing. + const {modified} = await codemod.apply("parameters:\n\tname: 'oops\n", options); + + expect(modified).toBe(true); + }); +}); diff --git a/test/application/project/code/transformation/php/__snapshots__/drupalLocalSettingsCodemod.test.ts.snap b/test/application/project/code/transformation/php/__snapshots__/drupalLocalSettingsCodemod.test.ts.snap new file mode 100644 index 00000000..e20e712b --- /dev/null +++ b/test/application/project/code/transformation/php/__snapshots__/drupalLocalSettingsCodemod.test.ts.snap @@ -0,0 +1,174 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DrupalLocalSettingsCodemod transforms absent.php: absent.php 1`] = ` +" $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; + +exports[`LaravelRouteCodemod transforms commented-routes.php: commented-routes.php 1`] = ` +" 'commented with slashes'); +# Route::get('/croct/home-banner', fn () => 'commented with hash'); +/* Route::get('/croct/home-banner', fn () => 'commented in a block'); */ + +Route::get('/', fn () => view('welcome')); + +Route::get('/croct/home-banner', static function (\\Croct\\Plug\\Plug $croct) { + return view('croct.home-banner', [ + 'content' => $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; + +exports[`LaravelRouteCodemod transforms double-quoted.php: double-quoted.php 1`] = ` +" view("croct.home-banner")); +" +`; + +exports[`LaravelRouteCodemod transforms escaped-string.php: escaped-string.php 1`] = ` +" 'it\\'s a test']); +}); + +Route::get('/croct/home-banner', static function (\\Croct\\Plug\\Plug $croct) { + return view('croct.home-banner', [ + 'content' => $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; + +exports[`LaravelRouteCodemod transforms existing-routes.php: existing-routes.php 1`] = ` +" $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; + +exports[`LaravelRouteCodemod transforms no-routes.php: no-routes.php 1`] = ` +" $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; + +exports[`LaravelRouteCodemod transforms no-trailing-newline.php: no-trailing-newline.php 1`] = ` +" view('welcome')); + +Route::get('/croct/home-banner', static function (\\Croct\\Plug\\Plug $croct) { + return view('croct.home-banner', [ + 'content' => $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; + +exports[`LaravelRouteCodemod transforms url-as-label.php: url-as-label.php 1`] = ` +" view('dashboard', ['label' => $label])); + +Route::get('/croct/home-banner', static function (\\Croct\\Plug\\Plug $croct) { + return view('croct.home-banner', [ + 'content' => $croct->fetchContent('home-banner')->getContent(), + ]); +}); +" +`; diff --git a/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap b/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap new file mode 100644 index 00000000..afbf7d21 --- /dev/null +++ b/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SymfonyBundleCodemod transforms already-registered.php: already-registered.php 1`] = ` +" ['all' => true], + Croct\\Plug\\Symfony\\CroctBundle::class => ['all' => true], +]; +" +`; + +exports[`SymfonyBundleCodemod transforms empty-array.php: empty-array.php 1`] = ` +" ['all' => true], +]; +" +`; + +exports[`SymfonyBundleCodemod transforms no-closing-array.php: no-closing-array.php 1`] = ` +" ['all' => true], + Symfony\\Bundle\\TwigBundle\\TwigBundle::class => ['all' => true], + Croct\\Plug\\Symfony\\CroctBundle::class => ['all' => true], +]; +" +`; diff --git a/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts b/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts new file mode 100644 index 00000000..36bbb5e0 --- /dev/null +++ b/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts @@ -0,0 +1,32 @@ +import {resolve} from 'path'; +import {DrupalLocalSettingsCodemod} from '@/application/project/code/transformation/php/drupalLocalSettingsCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('DrupalLocalSettingsCodemod', () => { + const codemod = new DrupalLocalSettingsCodemod({file: 'settings.local.php'}); + + const scenarios = loadFixtures(resolve(__dirname, '../fixtures/drupal-local-settings'), {}, {}); + + it.each(scenarios)('transforms $name', async ({name, fixture}) => { + const {modified, result} = await codemod.apply(fixture); + + expect(result).toMatchSnapshot(name); + + // `modified` is true exactly when the content changed. + expect(modified).toBe(result !== fixture); + + // Enabling is idempotent: the include is active in the result, so a second + // pass detects it and changes nothing. + const reapplied = await codemod.apply(result); + + expect(reapplied.modified).toBe(false); + expect(reapplied.result).toBe(result); + }); + + it('never creates settings.php from empty content', async () => { + const {modified, result} = await codemod.apply(''); + + expect(modified).toBe(false); + expect(result).toBe(''); + }); +}); diff --git a/test/application/project/code/transformation/php/laravelRouteCodemod.test.ts b/test/application/project/code/transformation/php/laravelRouteCodemod.test.ts new file mode 100644 index 00000000..f1c6b575 --- /dev/null +++ b/test/application/project/code/transformation/php/laravelRouteCodemod.test.ts @@ -0,0 +1,45 @@ +import {resolve} from 'path'; +import {LaravelRouteCodemod} from '@/application/project/code/transformation/php/laravelRouteCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('LaravelRouteCodemod', () => { + const codemod = new LaravelRouteCodemod(); + + const options = { + slot: 'home-banner', + url: '/croct/home-banner', + view: 'croct.home-banner', + }; + + const scenarios = loadFixtures(resolve(__dirname, '../fixtures/laravel-route'), {}, {}); + + it.each(scenarios)('transforms $name', async ({name, fixture}) => { + const {modified, result} = await codemod.apply(fixture, options); + + expect(result).toMatchSnapshot(name); + + // `modified` is true exactly when the route was appended. + expect(modified).toBe(result !== fixture); + + // Registering is idempotent: the route is present in the result, so a second + // pass detects it and changes nothing. + const reapplied = await codemod.apply(result, options); + + expect(reapplied.modified).toBe(false); + expect(reapplied.result).toBe(result); + }); + + it('is a no-op when no route options are given', async () => { + const {modified, result} = await codemod.apply(' { + const {modified, result} = await codemod.apply('', options); + + expect(modified).toBe(false); + expect(result).toBe(''); + }); +}); diff --git a/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts b/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts new file mode 100644 index 00000000..8350bc14 --- /dev/null +++ b/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts @@ -0,0 +1,25 @@ +import {resolve} from 'path'; +import {SymfonyBundleCodemod} from '@/application/project/code/transformation/php/symfonyBundleCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('SymfonyBundleCodemod', () => { + const codemod = new SymfonyBundleCodemod({bundle: 'Croct\\Plug\\Symfony\\CroctBundle'}); + + const scenarios = loadFixtures(resolve(__dirname, '../fixtures/symfony-bundles'), {}, {}); + + it.each(scenarios)('transforms $name', async ({name, fixture}) => { + const {modified, result} = await codemod.apply(fixture); + + expect(result).toMatchSnapshot(name); + + // `modified` is true exactly when the bundle was registered. + expect(modified).toBe(result !== fixture); + + // Registering is idempotent: the bundle class is present in the result, or + // the array was missing and the file was left unchanged. + const reapplied = await codemod.apply(result); + + expect(reapplied.modified).toBe(false); + expect(reapplied.result).toBe(result); + }); +}); diff --git a/test/application/project/code/transformation/yml/__snapshots__/yamlMappingCodemod.test.ts.snap b/test/application/project/code/transformation/yml/__snapshots__/yamlMappingCodemod.test.ts.snap new file mode 100644 index 00000000..4b6124e9 --- /dev/null +++ b/test/application/project/code/transformation/yml/__snapshots__/yamlMappingCodemod.test.ts.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`YamlMappingCodemod transforms already-configured.yaml: already-configured.yaml 1`] = ` +"croct: + app_id: '%env(CROCT_APP_ID)%' + api_key: '%env(CROCT_API_KEY)%' +" +`; + +exports[`YamlMappingCodemod transforms commented-croct.yaml: commented-croct.yaml 1`] = ` +"# croct: +# app_id: '%env(CROCT_APP_ID)%' + +croct: + app_id: '%env(CROCT_APP_ID)%' + api_key: '%env(CROCT_API_KEY)%' +" +`; + +exports[`YamlMappingCodemod transforms empty.yaml: empty.yaml 1`] = ` +"croct: + app_id: '%env(CROCT_APP_ID)%' + api_key: '%env(CROCT_API_KEY)%' +" +`; + +exports[`YamlMappingCodemod transforms no-trailing-newline.yaml: no-trailing-newline.yaml 1`] = ` +"parameters: + locale: en + +croct: + app_id: '%env(CROCT_APP_ID)%' + api_key: '%env(CROCT_API_KEY)%' +" +`; + +exports[`YamlMappingCodemod transforms other-config.yaml: other-config.yaml 1`] = ` +"framework: + secret: '%env(APP_SECRET)%' + +croct: + app_id: '%env(CROCT_APP_ID)%' + api_key: '%env(CROCT_API_KEY)%' +" +`; diff --git a/test/application/project/code/transformation/yml/yamlMappingCodemod.test.ts b/test/application/project/code/transformation/yml/yamlMappingCodemod.test.ts new file mode 100644 index 00000000..4ccfb167 --- /dev/null +++ b/test/application/project/code/transformation/yml/yamlMappingCodemod.test.ts @@ -0,0 +1,33 @@ +import {resolve} from 'path'; +import {YamlMappingCodemod} from '@/application/project/code/transformation/yml/yamlMappingCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('YamlMappingCodemod', () => { + const codemod = new YamlMappingCodemod(); + + const options = { + key: 'croct', + entries: { + app_id: "'%env(CROCT_APP_ID)%'", + api_key: "'%env(CROCT_API_KEY)%'", + }, + }; + + const scenarios = loadFixtures(resolve(__dirname, '../fixtures/yaml-mapping'), {}, {}); + + it.each(scenarios)('transforms $name', async ({name, fixture}) => { + const {modified, result} = await codemod.apply(fixture, options); + + expect(result).toMatchSnapshot(name); + + // `modified` is true exactly when the mapping was added. + expect(modified).toBe(result !== fixture); + + // Adding is idempotent: the top-level key is present in the result, so a + // second pass detects it and changes nothing. + const reapplied = await codemod.apply(result, options); + + expect(reapplied.modified).toBe(false); + expect(reapplied.result).toBe(result); + }); +}); From 8cfe9d42216b8d37e4aeeac73784279eef4d923c Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 16 Jun 2026 19:11:00 -0400 Subject: [PATCH 02/12] Fix nuxt support --- src/application/project/example/example.ts | 21 --------------- src/application/project/sdk/plugJsSdk.ts | 14 ++++++---- src/application/project/sdk/plugNextSdk.ts | 26 ++++++++++--------- .../provider/parser/nuxtCommandParser.ts | 22 ++++++++++++++++ src/infrastructure/application/cli/cli.ts | 2 ++ 5 files changed, 47 insertions(+), 38 deletions(-) create mode 100644 src/application/project/server/provider/parser/nuxtCommandParser.ts diff --git a/src/application/project/example/example.ts b/src/application/project/example/example.ts index d550ffe6..ba2df445 100644 --- a/src/application/project/example/example.ts +++ b/src/application/project/example/example.ts @@ -44,27 +44,6 @@ export class UrlExample extends Example { } } -/** - * An example opened directly as a file (e.g. a generated `index.html`). - */ -export class FileExample extends Example { - private readonly path: string; - - public constructor(name: string, path: string) { - super(name); - - this.path = path; - } - - public async present({input, output}: ExampleContext): Promise { - output.inform(`View the '${this.name}' example by opening \`${this.path}\` in your browser.`); - - if (input !== undefined && await input.confirm({message: 'Open it now?', default: true})) { - await output.open(`file://${this.path}`); - } - } -} - /** * An example the developer wires up by following an instruction (e.g. importing a component). */ diff --git a/src/application/project/sdk/plugJsSdk.ts b/src/application/project/sdk/plugJsSdk.ts index 61ddb536..6aba635e 100644 --- a/src/application/project/sdk/plugJsSdk.ts +++ b/src/application/project/sdk/plugJsSdk.ts @@ -12,7 +12,7 @@ import type {Slot} from '@/application/model/slot'; import {sortAttributes} from '@/application/project/code/generation/utils'; import {ErrorReason} from '@/application/error'; import type {Example} from '@/application/project/example/example'; -import {FileExample} from '@/application/project/example/example'; +import {UrlExample} from '@/application/project/example/example'; export type Configuration = JavaScriptSdkConfiguration & { bundlers: string[], @@ -30,10 +30,10 @@ export class PlugJsSdk extends JavaScriptSdk { protected async createExample(slot: Slot, installation: Installation): Promise { const {examples} = await this.getPaths(installation.configuration); - return new FileExample( - slot.name, - this.fileSystem.joinPaths(this.projectDirectory.get(), examples, slot.slug, 'index.html'), - ); + // The example is an HTML page that loads `slot.js` as a module importing `@croct/plug`, + // which only resolves when served by the dev server (a `file://` open is blocked as a + // unique origin), so present it as a served URL like the other SDKs. + return new UrlExample(slot.name, PlugJsSdk.resolveExampleUrl(examples, slot.slug)); } protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { @@ -143,4 +143,8 @@ export class PlugJsSdk extends JavaScriptSdk { configuration: installation.configuration, }); } + + private static resolveExampleUrl(examplesDirectory: string, slug: string): string { + return `/${examplesDirectory}/${slug}/index.html`; + } } diff --git a/src/application/project/sdk/plugNextSdk.ts b/src/application/project/sdk/plugNextSdk.ts index e911141d..a661c645 100644 --- a/src/application/project/sdk/plugNextSdk.ts +++ b/src/application/project/sdk/plugNextSdk.ts @@ -105,21 +105,26 @@ export class PlugNextSdk extends JavaScriptSdk { } protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { - const [router, isTypescript, fallbackMode] = await Promise.all([ + const [router, isTypeScript, fallbackMode] = await Promise.all([ this.detectRouter(), this.isTypeScriptProject(), this.isFallbackMode(), ]); - const isTypeScript = await this.isTypeScriptProject(); - const paths = await this.getPaths(installation.configuration); - const slotPath = this.fileSystem.joinPaths(paths.components, `%slug%${isTypeScript ? '.tsx' : '.jsx'}`); - const pagePath = this.fileSystem.joinPaths(paths.examples, '%slug%'); + const extension = isTypeScript ? '.tsx' : '.jsx'; + const slotPath = this.fileSystem.joinPaths(paths.components, `%slug%${extension}`); + + // App Router routes live at `//page`. Page Router and the React fallback + // use `//index`. + const pageFileName = fallbackMode || router === 'page' ? 'index' : 'page'; + const pageFilePath = this.fileSystem.joinPaths(paths.examples, '%slug%', `${pageFileName}${extension}`); - const slotImportPath = await this.importResolver.getImportPath(slotPath, pagePath); + // Resolve the component import relative to the page file itself. Resolving it against the + // route directory drops a level, leaving the `../` prefix short by one. + const slotImportPath = await this.importResolver.getImportPath(slotPath, pageFilePath); - const language = isTypescript ? CodeLanguage.TYPESCRIPT_XML : CodeLanguage.JAVASCRIPT_XML; + const language = isTypeScript ? CodeLanguage.TYPESCRIPT_XML : CodeLanguage.JAVASCRIPT_XML; const generator = fallbackMode ? new PlugReactExampleGenerator({ @@ -129,7 +134,7 @@ export class PlugNextSdk extends JavaScriptSdk { slotImportPath: slotImportPath, slotFilePath: slotPath, slotComponentName: '%name%', - pageFilePath: this.fileSystem.joinPaths(pagePath, `index${isTypeScript ? '.tsx' : '.jsx'}`), + pageFilePath: pageFilePath, pageComponentName: 'Page', }) : new PlugNextExampleGenerator({ @@ -139,10 +144,7 @@ export class PlugNextSdk extends JavaScriptSdk { slotImportPath: slotImportPath, slotFilePath: slotPath, slotComponentName: '%name%', - pageFilePath: this.fileSystem.joinPaths( - pagePath, - `${router === 'page' ? 'index' : 'page'}${isTypescript ? '.tsx' : '.jsx'}`, - ), + pageFilePath: pageFilePath, pageComponentName: 'Page', }); diff --git a/src/application/project/server/provider/parser/nuxtCommandParser.ts b/src/application/project/server/provider/parser/nuxtCommandParser.ts new file mode 100644 index 00000000..bb2d738e --- /dev/null +++ b/src/application/project/server/provider/parser/nuxtCommandParser.ts @@ -0,0 +1,22 @@ +import type {ServerCommandParser} from '@/application/project/server/provider/projectServerProvider'; +import type {ServerInfo} from '@/application/project/server/factory/serverFactory'; + +export class NuxtCommandParser implements ServerCommandParser { + public parse(command: string): ServerInfo | null { + if (!command.includes('nuxt dev') && !command.includes('nuxi dev')) { + return null; + } + + const portMatch = command.match(/(?:-p|--port)\s*(\d+)/); + const hostMatch = command.match(/--host\s*(\S+)/); + const port = portMatch !== null ? Number.parseInt(portMatch[1], 10) : null; + const host = hostMatch !== null ? hostMatch[1] : 'localhost'; + + return { + protocol: 'http', + host: host, + ...(port !== null ? {port: port} : {}), + defaultPort: 3000, + }; + } +} diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 82196d95..7ca8cf78 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -204,6 +204,7 @@ import type {Command as ProcessCommand} from '@/application/system/process/comma import {ExampleLauncher} from '@/application/project/example/exampleLauncher'; import {ProjectServerProvider} from '@/application/project/server/provider/projectServerProvider'; import {NextCommandParser} from '@/application/project/server/provider/parser/nextCommandParser'; +import {NuxtCommandParser} from '@/application/project/server/provider/parser/nuxtCommandParser'; import {ViteCommandParser} from '@/application/project/server/provider/parser/viteCommandParser'; import {ParcelCommandParser} from '@/application/project/server/provider/parser/parcelCommandParser'; import {ReactScriptCommandParser} from '@/application/project/server/provider/parser/reactScriptCommandParser'; @@ -2426,6 +2427,7 @@ export class Cli { factory: this.getServerFactory(), parsers: [ new NextCommandParser(), + new NuxtCommandParser(), new ViteCommandParser(), new ParcelCommandParser(), new ReactScriptCommandParser(), From 9e6ede29cc1ef5b5970f3a01e8167f765f3b1f15 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 17 Jun 2026 07:57:36 -0400 Subject: [PATCH 03/12] Apply reviews --- .../slot/plugPhpExampleGenerator.ts | 2 +- .../php/symfonyBundleCodemod.ts | 5 ++++- src/application/project/sdk/plugLaravelSdk.ts | 14 +++++++------- src/application/project/sdk/plugPhpSdk.ts | 8 +++++--- src/application/project/sdk/plugSymfonySdk.ts | 14 +++++++------- .../project/server/processServer.ts | 19 +++++++++++++++++-- .../symfonyBundleCodemod.test.ts.snap | 3 ++- 7 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts b/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts index eb27aac1..c4e96d1e 100644 --- a/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts +++ b/src/application/project/code/generation/slot/plugPhpExampleGenerator.ts @@ -24,7 +24,7 @@ export class PlugPhpExampleGenerator extends PhpExampleGenerator { protected writeScript(writer: CodeWriter, definition: SlotDefinition): void { const slotId = PlugPhpExampleGenerator.escapeString(definition.id); - const rootPath = this.autoloadPath.replace(/\/?vendor\/autoload\.php$/, '') || '.'; + const rootPath = this.autoloadPath.replace(/[\\/]?vendor[\\/]autoload\.php$/, '') || '.'; writer.write(' { return Promise.resolve({modified: false, result: input}); } - const entry = ` ${this.bundle}::class => ['all' => true],\n`; + // Break onto its own line when inserting right after the opening `[` (e.g. `return [];`), + // so the result stays readable even when no PHP formatter is available to fix it up. + const leadingNewline = closing > 0 && input[closing - 1] !== '\n' ? '\n' : ''; + const entry = `${leadingNewline} ${this.bundle}::class => ['all' => true],\n`; return Promise.resolve({ modified: true, diff --git a/src/application/project/sdk/plugLaravelSdk.ts b/src/application/project/sdk/plugLaravelSdk.ts index 56369a3b..3f62c32b 100644 --- a/src/application/project/sdk/plugLaravelSdk.ts +++ b/src/application/project/sdk/plugLaravelSdk.ts @@ -98,19 +98,19 @@ export class PlugLaravelSdk extends PhpSdk { private resolveViewName(examplesDirectory: string, slug: string): string { const rootPath = this.projectDirectory.get(); - const namespace = this.fileSystem.normalizeSeparators( - this.fileSystem.getRelativePath( - this.fileSystem.joinPaths(rootPath, PlugLaravelSdk.VIEWS_DIRECTORY), - this.fileSystem.joinPaths(rootPath, examplesDirectory), - ), + const namespace = this.fileSystem.getRelativePath( + this.fileSystem.joinPaths(rootPath, PlugLaravelSdk.VIEWS_DIRECTORY), + this.fileSystem.joinPaths(rootPath, examplesDirectory), ); - return [...namespace.split('/'), formatSlug(slug)] + // Split on either separator: the relative path uses the OS separator (backslashes on + // Windows), while the Blade view name is always built with dots. + return [...namespace.split(/[\\/]/), formatSlug(slug)] .filter(segment => segment !== '' && segment !== '.') .join('.'); } private static resolveExampleUrl(slug: string): string { - return `/croct/${formatSlug(slug)}`; + return `/${formatSlug(slug)}`; } } diff --git a/src/application/project/sdk/plugPhpSdk.ts b/src/application/project/sdk/plugPhpSdk.ts index 88c08e1c..b710177c 100644 --- a/src/application/project/sdk/plugPhpSdk.ts +++ b/src/application/project/sdk/plugPhpSdk.ts @@ -53,9 +53,11 @@ export class PlugPhpSdk extends PhpSdk { const absoluteExamples = this.fileSystem.joinPaths(projectDirectory, examplesDirectory); const relativeRoot = this.fileSystem.getRelativePath(absoluteExamples, projectDirectory); - return this.fileSystem.normalizeSeparators( - this.fileSystem.joinPaths(relativeRoot, 'vendor', 'autoload.php'), - ); + // Use forward slashes: the path is embedded in generated PHP, which accepts `/` on every + // platform, and the generator strips the `vendor/autoload.php` suffix with a `/` matcher. + return this.fileSystem + .joinPaths(relativeRoot, 'vendor', 'autoload.php') + .replace(/\\/g, '/'); } private static resolveExampleUrl(examplesDirectory: string, slug: string): string { diff --git a/src/application/project/sdk/plugSymfonySdk.ts b/src/application/project/sdk/plugSymfonySdk.ts index 02ff9993..f6ee5545 100644 --- a/src/application/project/sdk/plugSymfonySdk.ts +++ b/src/application/project/sdk/plugSymfonySdk.ts @@ -153,14 +153,14 @@ export class PlugSymfonySdk extends PhpSdk { private resolveTemplateReference(examplesDirectory: string, slug: string): string { const rootPath = this.projectDirectory.get(); - const namespace = this.fileSystem.normalizeSeparators( - this.fileSystem.getRelativePath( - this.fileSystem.joinPaths(rootPath, PlugSymfonySdk.TEMPLATES_DIRECTORY), - this.fileSystem.joinPaths(rootPath, examplesDirectory), - ), + const namespace = this.fileSystem.getRelativePath( + this.fileSystem.joinPaths(rootPath, PlugSymfonySdk.TEMPLATES_DIRECTORY), + this.fileSystem.joinPaths(rootPath, examplesDirectory), ); - return [...namespace.split('/'), `${formatSlug(slug)}.html.twig`] + // Split on either separator: the relative path uses the OS separator (backslashes on + // Windows), while the Twig reference is always built with forward slashes. + return [...namespace.split(/[\\/]/), `${formatSlug(slug)}.html.twig`] .filter(segment => segment !== '' && segment !== '.') .join('/'); } @@ -191,6 +191,6 @@ export class PlugSymfonySdk extends PhpSdk { } private static resolveExampleUrl(slug: string): string { - return `/croct/${formatSlug(slug)}`; + return `/${formatSlug(slug)}`; } } diff --git a/src/application/project/server/processServer.ts b/src/application/project/server/processServer.ts index 4d5788e8..18c4507b 100644 --- a/src/application/project/server/processServer.ts +++ b/src/application/project/server/processServer.ts @@ -86,11 +86,15 @@ export class ProcessServer implements Server { const {output} = this.execution; const buffer = new ScreenBuffer(); + let capturing = true; + // Drain the output for the whole lifetime of the process so it never backs up in memory, + // but only record it while the server is starting: the snapshot explains a startup + // failure, and once the server is up the per-request logs are not needed. const loggingLoop = (async (): Promise => { for await (const line of output) { - if (abortController.signal.aborted) { - return; + if (!capturing) { + continue; } buffer.write(line); @@ -109,6 +113,15 @@ export class ProcessServer implements Server { abortController.abort(); if (url === null) { + // A null URL with no live process means the server exited during startup (e.g. a boot + // error). Let the loop finish draining the now-closed output so the snapshot includes + // the final lines that explain the failure. + if (this.execution === undefined) { + await loggingLoop.catch(() => {}); + } + + capturing = false; + logger?.log({ level: LogLevel.ERROR, message: 'Unable to reach the server after it was started.', @@ -119,6 +132,8 @@ export class ProcessServer implements Server { throw new ServerError(`Server is unreachable${finalOutput === '' ? '.' : `:\n\n${finalOutput}`}`); } + capturing = false; + return url; } diff --git a/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap b/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap index afbf7d21..51877dee 100644 --- a/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap +++ b/test/application/project/code/transformation/php/__snapshots__/symfonyBundleCodemod.test.ts.snap @@ -13,7 +13,8 @@ return [ exports[`SymfonyBundleCodemod transforms empty-array.php: empty-array.php 1`] = ` " ['all' => true], +return [ + Croct\\Plug\\Symfony\\CroctBundle::class => ['all' => true], ]; " `; From 1fa821e7e45ab88c7054e31e2de05f52f59226a0 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 17 Jun 2026 11:33:17 -0400 Subject: [PATCH 04/12] Wip --- .gitignore | 1 + .../javascript/jsxWrapperCodemod.ts | 114 ++++++- .../javascript/viteConfigPluginCodemod.ts | 218 +++++++++++++ .../jsx-wrapper/containerAlreadyWrapped.tsx | 12 + .../containerIdentifierElement.tsx | 5 + .../jsx-wrapper/containerMemberExpression.tsx | 9 + .../jsx-wrapper/containerNamespaced.tsx | 7 + .../jsx-wrapper/containerNotFound.tsx | 7 + .../jsx-wrapper/containerRemixTernary.tsx | 21 ++ .../jsx-wrapper/containerSelfClosing.tsx | 5 + .../targetComponentMemberExpression.tsx | 9 + .../vite-config-plugin/aliasedDefineConfig.ts | 6 + .../vite-config-plugin/alreadyRegistered.ts | 7 + .../vite-config-plugin/asConstConfig.ts | 7 + .../vite-config-plugin/bareObjectExport.ts | 5 + .../bareObjectIndirectVariable.ts | 7 + .../vite-config-plugin/emptyDefineCall.ts | 3 + .../vite-config-plugin/emptyPlugins.ts | 5 + .../vite-config-plugin/functionConfig.ts | 6 + .../functionDefaultExport.ts | 3 + .../vite-config-plugin/importPresentNoCall.ts | 7 + .../indirectConfigVariable.ts | 8 + .../indirectConfigWithExtraDeclarator.ts | 9 + .../indirectNonDefineCall.ts | 9 + .../vite-config-plugin/memberCalleeExport.ts | 6 + .../vite-config-plugin/noPluginsKey.ts | 7 + .../vite-config-plugin/nonArrayPlugins.ts | 8 + .../vite-config-plugin/nonDefineConfigCall.ts | 9 + .../vite-config-plugin/positionStart.ts | 6 + .../vite-config-plugin/remixPlugins.ts | 16 + .../vite-config-plugin/satisfiesConfig.ts | 7 + .../vite-config-plugin/sparsePluginsArray.ts | 7 + .../spreadConfigProperties.ts | 9 + .../fixtures/vite-config-plugin/standard.ts | 8 + .../stringLiteralPluginsKey.ts | 6 + .../uninitializedBinding.ts | 5 + .../jsxWrapperCodemod.test.ts.snap | 132 ++++++++ .../viteConfigPluginCodemod.test.ts.snap | 287 ++++++++++++++++++ .../javascript/jsxWrapperCodemod.test.ts | 41 +++ .../viteConfigPluginCodemod.test.ts | 38 +++ 40 files changed, 1075 insertions(+), 7 deletions(-) create mode 100644 src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts create mode 100644 test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts create mode 100644 test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts diff --git a/.gitignore b/.gitignore index d77cc311..90cdae56 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ build/ coverage/ src/infrastructure/graphql +e2e/ diff --git a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts index 1641084a..647c6d10 100644 --- a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts +++ b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts @@ -18,16 +18,45 @@ type TargetChildren = { index: number, }; +/** + * Wraps the `{name}` expression child (e.g. `children`). + */ +export type VariableTarget = { + variable: string, + component?: never, + container?: never, +}; + +/** + * Wraps the matched `` element itself. `Name` may be dotted (e.g. `Foo.Bar`) or namespaced. + */ +export type ComponentTarget = { + component: string, + variable?: never, + container?: never, +}; + +/** + * Wraps its children, nesting the former children inside the wrapper. + */ +export type ContainerTarget = { + container: string, + variable?: never, + component?: never, +}; + +/** + * Selects what the wrapper wraps. + */ +export type WrapperTarget = VariableTarget | ComponentTarget | ContainerTarget; + export type WrapperConfiguration = { wrapper: { component: string, module: string, props?: Record, }, - targets?: { - variable?: string, - component?: string, - }, + targets?: WrapperTarget, fallbackToNamedExports?: boolean, fallbackCodemod?: Codemod, }; @@ -280,6 +309,12 @@ export class JsxWrapperCodemod implem }; } + const container = this.configuration.targets?.container; + + if (container !== undefined) { + return this.wrapElementChildren(node, container, component, options); + } + const target = this.findTargetChildren(ast, node); if (target !== null) { @@ -366,6 +401,73 @@ export class JsxWrapperCodemod implem return createJsxAttributes({...this.configuration.wrapper.props, ...options?.props}); } + /** + * Wraps the children of the named element with the wrapper component, in place. + * + * The element stays where it is and the wrapper becomes its single child, nesting the + * former children inside. Returns NOT_APPLIED when the element is absent or has no content + * to wrap, so the caller can fall back to other exports. + */ + private wrapElementChildren(node: ExpressionKind, name: string, component: string, options?: O): WrapperInsertion { + const element = this.findElement(node, name); + + if (element === null) { + return {result: Transformation.NOT_APPLIED, node: node}; + } + + const hasContent = element.children.some(child => !t.isJSXText(child) || child.value.trim() !== ''); + + if (!hasContent) { + return {result: Transformation.NOT_APPLIED, node: node}; + } + + element.children = [ + t.jsxText('\n'), + t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(component), this.getProviderProps(options)), + t.jsxClosingElement(t.jsxIdentifier(component)), + [t.jsxText('\n'), ...element.children, t.jsxText('\n')], + ), + t.jsxText('\n'), + ]; + + return {result: Transformation.APPLIED, node: node}; + } + + /** + * Finds the first JSX element whose opening name matches the given (dotted) name. + */ + private findElement(node: t.Node, name: string): t.JSXElement | null { + let element: t.JSXElement | null = null; + + traverseFast(node, current => { + if ( + element === null + && t.isJSXElement(current) + && JsxWrapperCodemod.getJsxName(current.openingElement.name) === name + ) { + element = current; + } + }); + + return element; + } + + /** + * Returns the dotted name of a JSX element name (identifier, member expression, or namespace). + */ + private static getJsxName(name: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName): string { + if (t.isJSXMemberExpression(name)) { + return `${JsxWrapperCodemod.getJsxName(name.object)}.${JsxWrapperCodemod.getJsxName(name.property)}`; + } + + if (t.isJSXNamespacedName(name)) { + return `${name.namespace.name}:${name.name.name}`; + } + + return name.name; + } + /** * Determines if the element contains the specified component. * @@ -488,9 +590,7 @@ export class JsxWrapperCodemod implem if ( configuration.targets?.component !== undefined - && t.isJSXOpeningElement(openingElement) - && t.isJSXIdentifier(openingElement.name) - && openingElement.name.name === configuration.targets.component + && JsxWrapperCodemod.getJsxName(openingElement.name) === configuration.targets.component ) { if (nestedPath.parent !== null) { const parent = nestedPath.parent as t.JSXElement; diff --git a/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts b/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts new file mode 100644 index 00000000..6da57284 --- /dev/null +++ b/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts @@ -0,0 +1,218 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; + +export type ViteConfigPluginConfiguration = { + /** + * The plugin import to add and register. + */ + plugin: { + moduleName: string, + importName: string, + localName?: string, + }, + + /** + * Where to insert the plugin in the `plugins` array. Defaults to the end. + */ + position?: 'start' | 'end', +}; + +/** + * Registers a Vite plugin in the `plugins` array of the Vite configuration. + * + * Supports the `defineConfig` call form and bare object exports, including the + * indirect variable forms of each, with type-assertion wrappers unwrapped. The + * plugin import is added and a `()` call is inserted into the array. If + * the plugin is already registered, the codemod returns unmodified. + */ +export class ViteConfigPluginCodemod implements Codemod { + private readonly configuration: ViteConfigPluginConfiguration; + + public constructor(configuration: ViteConfigPluginConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const config = ViteConfigPluginCodemod.findConfig(input); + + if (config === null) { + return Promise.resolve({modified: false, result: input}); + } + + const plugins = ViteConfigPluginCodemod.resolvePluginsArray(config); + + if (plugins === null) { + return Promise.resolve({modified: false, result: input}); + } + + const {plugin, position = 'end'} = this.configuration; + const importedName = getImportLocalName(input, { + moduleName: plugin.moduleName, + importName: plugin.importName, + }); + + if (importedName !== null && ViteConfigPluginCodemod.hasPluginCall(plugins, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: plugin.moduleName, + importName: plugin.importName, + localName: plugin.localName, + }); + + const call = t.callExpression(t.identifier(localName), []); + + if (position === 'start') { + plugins.elements.unshift(call); + } else { + plugins.elements.push(call); + } + + return Promise.resolve({modified: true, result: input}); + } + + private static hasPluginCall(array: t.ArrayExpression, localName: string): boolean { + return array.elements.some( + element => element !== null + && t.isCallExpression(element) + && t.isIdentifier(element.callee) + && element.callee.name === localName, + ); + } + + /** + * Returns the `plugins` array of the config, creating an empty one if it is + * missing. Returns null when a `plugins` property exists but is not an inline + * array (e.g. a spread or a variable), which cannot be edited safely. + */ + private static resolvePluginsArray(config: t.ObjectExpression): t.ArrayExpression | null { + for (const property of config.properties) { + if (!t.isObjectProperty(property) || property.computed) { + continue; + } + + const {key} = property; + const isPlugins = (t.isIdentifier(key) && key.name === 'plugins') + || (t.isStringLiteral(key) && key.value === 'plugins'); + + if (isPlugins) { + return t.isArrayExpression(property.value) ? property.value : null; + } + } + + const plugins = t.arrayExpression([]); + + config.properties.push(t.objectProperty(t.identifier('plugins'), plugins)); + + return plugins; + } + + private static findConfig(ast: t.File): t.ObjectExpression | null { + const defineName = getImportLocalName(ast, { + moduleName: 'vite', + importName: 'defineConfig', + }) ?? 'defineConfig'; + + let config: t.ObjectExpression | null = null; + + traverse(ast, { + ExportDefaultDeclaration: path => { + const declaration = ViteConfigPluginCodemod.unwrapTypeWrapper(path.node.declaration); + + if (t.isObjectExpression(declaration)) { + config = declaration; + + return path.stop(); + } + + if (t.isCallExpression(declaration)) { + config = ViteConfigPluginCodemod.unwrapDefineCall(declaration, defineName); + + return path.stop(); + } + + if (t.isIdentifier(declaration)) { + config = ViteConfigPluginCodemod.resolveBinding(ast, declaration.name, defineName); + + return path.stop(); + } + + return path.skip(); + }, + }); + + return config; + } + + private static unwrapDefineCall(call: t.CallExpression, defineName: string): t.ObjectExpression | null { + if (!t.isIdentifier(call.callee) || call.callee.name !== defineName) { + return null; + } + + const argument = call.arguments[0]; + + if (argument === undefined) { + return null; + } + + const unwrapped = ViteConfigPluginCodemod.unwrapTypeWrapper(argument); + + return t.isObjectExpression(unwrapped) ? unwrapped : null; + } + + private static unwrapTypeWrapper(node: t.Node): t.Node { + let current = node; + + while ( + t.isTSAsExpression(current) + || t.isTSSatisfiesExpression(current) + || t.isTSTypeAssertion(current) + || t.isTSNonNullExpression(current) + ) { + current = current.expression; + } + + return current; + } + + private static resolveBinding(ast: t.File, name: string, defineName: string): t.ObjectExpression | null { + let resolved: t.ObjectExpression | null = null; + + traverse(ast, { + VariableDeclarator: path => { + const {node} = path; + + if (!t.isIdentifier(node.id) || node.id.name !== name) { + return; + } + + if (node.init === null || node.init === undefined) { + return; + } + + const init = ViteConfigPluginCodemod.unwrapTypeWrapper(node.init); + + if (t.isObjectExpression(init)) { + resolved = init; + + return path.stop(); + } + + if (t.isCallExpression(init)) { + resolved = ViteConfigPluginCodemod.unwrapDefineCall(init, defineName); + + if (resolved !== null) { + return path.stop(); + } + } + }, + }); + + return resolved; + } +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx new file mode 100644 index 00000000..4d74f2ae --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx @@ -0,0 +1,12 @@ +import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx new file mode 100644 index 00000000..5dca326b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx @@ -0,0 +1,5 @@ +export default function App({data}) { + return + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx new file mode 100644 index 00000000..775acb9e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx @@ -0,0 +1,9 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx new file mode 100644 index 00000000..a1c66bfb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx @@ -0,0 +1,7 @@ +export default function App() { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx new file mode 100644 index 00000000..a63adbd3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx @@ -0,0 +1,7 @@ +export default function App({data}) { + return
    + + + +
    ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx new file mode 100644 index 00000000..a7756031 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx @@ -0,0 +1,21 @@ +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return + + {data ? ( + + {children} + + ) : ( + children + )} + + ; +} + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx new file mode 100644 index 00000000..64a4d196 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx @@ -0,0 +1,5 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx new file mode 100644 index 00000000..7b77ce65 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx @@ -0,0 +1,9 @@ +import {Theme} from 'ui'; + +export default function App({Component, pageProps}) { + return
    + + + +
    ; +} diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts new file mode 100644 index 00000000..91037a6e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts @@ -0,0 +1,6 @@ +import {defineConfig as define} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default define({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts new file mode 100644 index 00000000..350e59a6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts new file mode 100644 index 00000000..29cb5ee0 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}) as UserConfig; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts new file mode 100644 index 00000000..7e270761 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts @@ -0,0 +1,5 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default { + plugins: [hydrogen()], +}; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts new file mode 100644 index 00000000..41124fc2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts @@ -0,0 +1,7 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = { + plugins: [hydrogen()], +}; + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts new file mode 100644 index 00000000..ed8b1878 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts @@ -0,0 +1,3 @@ +import {defineConfig} from 'vite'; + +export default defineConfig(); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts new file mode 100644 index 00000000..228faab6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts @@ -0,0 +1,5 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + plugins: [], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts new file mode 100644 index 00000000..176cb1eb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig(() => ({ + plugins: [hydrogen()], +})); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts new file mode 100644 index 00000000..ad8de35c --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts @@ -0,0 +1,3 @@ +export default function config() { + return {plugins: []}; +} diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts new file mode 100644 index 00000000..b843f069 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts new file mode 100644 index 00000000..84d2c40a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = defineConfig({ + plugins: [hydrogen()], +}); + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts new file mode 100644 index 00000000..23a01583 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts @@ -0,0 +1,9 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const version = '2024'; +const config = defineConfig({ + plugins: [hydrogen()], +}); + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts new file mode 100644 index 00000000..9d39fc1b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts @@ -0,0 +1,9 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +function build() { + return {plugins: [hydrogen()]}; +} + +const config = build(); + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts new file mode 100644 index 00000000..57c25a06 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts @@ -0,0 +1,6 @@ +import * as vite from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default vite.defineConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts new file mode 100644 index 00000000..949a630a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + }, +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts new file mode 100644 index 00000000..dece227e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const basePlugins = [hydrogen()]; + +export default defineConfig({ + plugins: basePlugins, +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts new file mode 100644 index 00000000..206fa255 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts @@ -0,0 +1,9 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +function wrapConfig(config) { + return config; +} + +export default wrapConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts new file mode 100644 index 00000000..6175c888 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts new file mode 100644 index 00000000..70ded5b6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts @@ -0,0 +1,16 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {vitePlugin as remix} from '@remix-run/dev'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + hydrogen(), + oxygen(), + remix({ + presets: [hydrogen.v3preset()], + }), + tsconfigPaths(), + ], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts new file mode 100644 index 00000000..cac15a9a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}) satisfies UserConfig; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts new file mode 100644 index 00000000..c686a8fb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), , croct()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts new file mode 100644 index 00000000..6568eb48 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts @@ -0,0 +1,9 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const base = {server: {port: 3000}}; + +export default defineConfig({ + ...base, + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts new file mode 100644 index 00000000..60276dac --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {reactRouter} from '@react-router/dev/vite'; + +export default defineConfig({ + plugins: [hydrogen(), oxygen(), reactRouter()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts new file mode 100644 index 00000000..d89bd99d --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + 'plugins': [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts new file mode 100644 index 00000000..a69c3c75 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts @@ -0,0 +1,5 @@ +import {defineConfig} from 'vite'; + +let config; + +export default config; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap index 2a1c787c..0e4e26ea 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap @@ -13,6 +13,122 @@ export default function App({Component, pageProps}: AppProps): ReactElement { " `; +exports[`JsxWrapperCodemod should correctly transform containerAlreadyWrapped.tsx: containerAlreadyWrapped.tsx 1`] = ` +"import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerIdentifierElement.tsx: containerIdentifierElement.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App({data}) { + return ( + + + + + +); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerMemberExpression.tsx: containerMemberExpression.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return ( + + + + + + + +); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerNamespaced.tsx: containerNamespaced.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App() { + return ( + + + + + + + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerNotFound.tsx: containerNotFound.tsx 1`] = ` +"export default function App({data}) { + return
    + + + +
    ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerRemixTernary.tsx: containerRemixTernary.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return ( + + {data ? ( + + + + {children} + + + + ) : ( + children + )} + + ); +} + +export default function App() { + return ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerSelfClosing.tsx: containerSelfClosing.tsx 1`] = ` +"import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ; +} +" +`; + exports[`JsxWrapperCodemod should correctly transform defaultExport.tsx: defaultExport.tsx 1`] = ` "import { CroctProvider } from "@croct/plug-react"; import type {AppProps} from "next/app"; @@ -380,3 +496,19 @@ export default function App({Component, pageProps}) { } " `; + +exports[`JsxWrapperCodemod should correctly transform targetComponentMemberExpression.tsx: targetComponentMemberExpression.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Theme} from 'ui'; + +export default function App({Component, pageProps}) { + return (
    + + + + + +
    ); +} +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap new file mode 100644 index 00000000..db528572 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap @@ -0,0 +1,287 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ViteConfigPluginCodemod should correctly transform aliasedDefineConfig.ts: aliasedDefineConfig.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig as define} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default define({ + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform alreadyRegistered.ts: alreadyRegistered.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform asConstConfig.ts: asConstConfig.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}) as UserConfig; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform bareObjectExport.ts: bareObjectExport.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default { + plugins: [hydrogen(), croct()], +}; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform bareObjectIndirectVariable.ts: bareObjectIndirectVariable.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = { + plugins: [hydrogen(), croct()], +}; + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform emptyDefineCall.ts: emptyDefineCall.ts 1`] = ` +"import {defineConfig} from 'vite'; + +export default defineConfig(); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform emptyPlugins.ts: emptyPlugins.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; + +export default defineConfig({ + plugins: [croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform functionConfig.ts: functionConfig.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig(() => ({ + plugins: [hydrogen()], +})); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform functionDefaultExport.ts: functionDefaultExport.ts 1`] = ` +"export default function config() { + return {plugins: []}; +} +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform importPresentNoCall.ts: importPresentNoCall.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform indirectConfigVariable.ts: indirectConfigVariable.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = defineConfig({ + plugins: [hydrogen(), croct()], +}); + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform indirectConfigWithExtraDeclarator.ts: indirectConfigWithExtraDeclarator.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +const version = '2024'; + +const config = defineConfig({ + plugins: [hydrogen(), croct()], +}); + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform indirectNonDefineCall.ts: indirectNonDefineCall.ts 1`] = ` +"import {hydrogen} from '@shopify/hydrogen/vite'; + +function build() { + return {plugins: [hydrogen()]}; +} + +const config = build(); + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform memberCalleeExport.ts: memberCalleeExport.ts 1`] = ` +"import * as vite from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default vite.defineConfig({ + plugins: [hydrogen()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform noPluginsKey.ts: noPluginsKey.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + }, + + plugins: [croct()] +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform nonArrayPlugins.ts: nonArrayPlugins.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const basePlugins = [hydrogen()]; + +export default defineConfig({ + plugins: basePlugins, +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform nonDefineConfigCall.ts: nonDefineConfigCall.ts 1`] = ` +"import {hydrogen} from '@shopify/hydrogen/vite'; + +function wrapConfig(config) { + return config; +} + +export default wrapConfig({ + plugins: [hydrogen()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform positionStart.ts: positionStart.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [croct(), hydrogen()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform remixPlugins.ts: remixPlugins.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {vitePlugin as remix} from '@remix-run/dev'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [hydrogen(), oxygen(), remix({ + presets: [hydrogen.v3preset()], + }), tsconfigPaths(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform satisfiesConfig.ts: satisfiesConfig.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}) satisfies UserConfig; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform sparsePluginsArray.ts: sparsePluginsArray.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), , croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform spreadConfigProperties.ts: spreadConfigProperties.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +const base = {server: {port: 3000}}; + +export default defineConfig({ + ...base, + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform standard.ts: standard.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {reactRouter} from '@react-router/dev/vite'; + +export default defineConfig({ + plugins: [hydrogen(), oxygen(), reactRouter(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform stringLiteralPluginsKey.ts: stringLiteralPluginsKey.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + 'plugins': [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform uninitializedBinding.ts: uninitializedBinding.ts 1`] = ` +"import {defineConfig} from 'vite'; + +let config; + +export default config; +" +`; diff --git a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts index 6a5c75d1..6272cf02 100644 --- a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts @@ -25,6 +25,47 @@ describe('JsxWrapperCodemod', () => { component: 'Component', }, }, + 'targetComponentMemberExpression.tsx': { + targets: { + component: 'Theme.Provider', + }, + }, + 'containerMemberExpression.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerRemixTernary.tsx': { + targets: { + container: 'Analytics.Provider', + }, + fallbackToNamedExports: true, + }, + 'containerAlreadyWrapped.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerNotFound.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerSelfClosing.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerNamespaced.tsx': { + targets: { + container: 'svg:g', + }, + }, + 'containerIdentifierElement.tsx': { + targets: { + container: 'Providers', + }, + }, 'targetChildren.tsx': { targets: { variable: 'children', diff --git a/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts new file mode 100644 index 00000000..b511bdca --- /dev/null +++ b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts @@ -0,0 +1,38 @@ +import {resolve} from 'path'; +import type { + ViteConfigPluginConfiguration, +} from '@/application/project/code/transformation/javascript/viteConfigPluginCodemod'; +import {ViteConfigPluginCodemod} from '@/application/project/code/transformation/javascript/viteConfigPluginCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('ViteConfigPluginCodemod', () => { + const defaultOptions: ViteConfigPluginConfiguration = { + plugin: { + moduleName: '@croct/plug-hydrogen/vite', + importName: 'croct', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/vite-config-plugin'), + defaultOptions, + { + 'positionStart.ts': { + ...defaultOptions, + position: 'start', + }, + }, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new ViteConfigPluginCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); +}); From bec7a2195a8274f34d05852827e470893dde00aa Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 17 Jun 2026 17:43:05 -0400 Subject: [PATCH 05/12] Wip --- src/application/model/platform.ts | 4 + .../slot/hydrogenExampleGenerator.ts | 110 +++++ .../javascript/hydrogenContextCodemod.ts | 187 ++++++++ .../javascript/hydrogenCookiesCodemod.ts | 201 ++++++++ .../javascript/hydrogenCspCodemod.ts | 127 +++++ .../javascript/hydrogenMiddlewareCodemod.ts | 155 ++++++ .../javascript/jsxWrapperCodemod.ts | 32 +- .../javascript/utils/spreadAsArray.ts | 21 + .../javascript/viteConfigPluginCodemod.ts | 32 +- .../project/sdk/plugHydrogenSdk.ts | 311 ++++++++++++ .../provider/parser/hydrogenCommandParser.ts | 22 + .../application/api/graphql/workspace.ts | 3 + src/infrastructure/application/cli/cli.ts | 118 +++++ .../hydrogenExampleGenerator.test.ts.snap | 447 ++++++++++++++++++ .../slot/hydrogenExampleGenerator.test.ts | 58 +++ .../hydrogen-context/aliasedFactory.ts | 10 + .../fixtures/hydrogen-context/alreadyWired.ts | 11 + .../hydrogen-context/awaitedContext.ts | 7 + .../hydrogen-context/croctAlreadyOnObject.ts | 10 + .../hydrogen-context/croctStringLiteralKey.ts | 10 + .../hydrogen-context/destructuredContextId.ts | 7 + .../hydrogen-context/destructuredParam.ts | 7 + .../fixtures/hydrogen-context/nestedReturn.ts | 13 + .../fixtures/hydrogen-context/noFactory.ts | 8 + .../fixtures/hydrogen-context/noFunction.ts | 5 + .../fixtures/hydrogen-context/noParams.ts | 7 + .../fixtures/hydrogen-context/noReturn.ts | 11 + .../hydrogen-context/returnIdentifier.ts | 11 + .../fixtures/hydrogen-context/returnObject.ts | 10 + .../hydrogen-cookies/alreadyPresent.ts | 13 + .../hydrogen-cookies/arrowExpressionBody.ts | 4 + .../hydrogen-cookies/destructuredHeaders.ts | 8 + .../hydrogen-cookies/differentVars.ts | 9 + .../fixtures/hydrogen-cookies/directSet.ts | 7 + .../fixtures/hydrogen-cookies/ifWrapped.ts | 15 + .../fixtures/hydrogen-cookies/noFunction.ts | 3 + .../fixtures/hydrogen-cookies/noSession.ts | 7 + .../hydrogen-cookies/nonHeadersObject.ts | 7 + .../hydrogen-cookies/notSetCookieHeader.ts | 7 + .../fixtures/hydrogen-csp/aliasedImport.ts | 5 + .../fixtures/hydrogen-csp/alreadyPresent.ts | 5 + .../fixtures/hydrogen-csp/emptyCall.ts | 3 + .../fixtures/hydrogen-csp/emptyConnectSrc.ts | 5 + .../hydrogen-csp/existingConnectSrc.ts | 5 + .../fixtures/hydrogen-csp/memberCalleeCall.ts | 3 + .../fixtures/hydrogen-csp/noCall.ts | 3 + .../hydrogen-csp/nonArrayConnectSrc.ts | 5 + .../fixtures/hydrogen-csp/nonObjectArg.ts | 3 + .../fixtures/hydrogen-csp/spreadOption.ts | 6 + .../fixtures/hydrogen-csp/standard.ts | 12 + .../fixtures/hydrogen-csp/stringLiteralKey.ts | 5 + .../fixtures/hydrogen-middleware/absent.ts | 3 + .../hydrogen-middleware/alreadyRegistered.ts | 3 + .../hydrogen-middleware/decoyConst.ts | 5 + .../hydrogen-middleware/emptyArray.ts | 1 + .../hydrogen-middleware/existingArray.ts | 3 + .../hydrogen-middleware/identifierInit.ts | 3 + .../importPresentNoCall.ts | 4 + .../multipleDeclarators.ts | 3 + .../hydrogen-middleware/nonArrayInit.ts | 3 + .../hydrogen-middleware/typedArray.ts | 4 + .../hydrogen-middleware/uninitializedLet.ts | 1 + .../jsx-wrapper/containerAliasedImport.tsx | 9 + .../hydrogenContextCodemod.test.ts.snap | 200 ++++++++ .../hydrogenCookiesCodemod.test.ts.snap | 127 +++++ .../hydrogenCspCodemod.test.ts.snap | 114 +++++ .../hydrogenMiddlewareCodemod.test.ts.snap | 93 ++++ .../jsxWrapperCodemod.test.ts.snap | 18 + .../viteConfigPluginCodemod.test.ts.snap | 6 +- .../javascript/hydrogenContextCodemod.test.ts | 33 ++ .../javascript/hydrogenCookiesCodemod.test.ts | 33 ++ .../javascript/hydrogenCspCodemod.test.ts | 28 ++ .../hydrogenMiddlewareCodemod.test.ts | 35 ++ .../javascript/jsxWrapperCodemod.test.ts | 5 + 74 files changed, 2811 insertions(+), 18 deletions(-) create mode 100644 src/application/project/code/generation/slot/hydrogenExampleGenerator.ts create mode 100644 src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts create mode 100644 src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts create mode 100644 src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts create mode 100644 src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts create mode 100644 src/application/project/code/transformation/javascript/utils/spreadAsArray.ts create mode 100644 src/application/project/sdk/plugHydrogenSdk.ts create mode 100644 src/application/project/server/provider/parser/hydrogenCommandParser.ts create mode 100644 test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap create mode 100644 test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/aliasedFactory.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/alreadyWired.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/awaitedContext.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/croctAlreadyOnObject.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/croctStringLiteralKey.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/destructuredContextId.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/destructuredParam.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/nestedReturn.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx create mode 100644 test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap create mode 100644 test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts create mode 100644 test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts create mode 100644 test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts create mode 100644 test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts diff --git a/src/application/model/platform.ts b/src/application/model/platform.ts index 4ae72c89..5c8d879c 100644 --- a/src/application/model/platform.ts +++ b/src/application/model/platform.ts @@ -1,6 +1,7 @@ export enum Platform { NEXTJS = 'nextjs', NUXT = 'nuxt', + HYDROGEN = 'hydrogen', REACT = 'react', VUE = 'vue', JAVASCRIPT = 'javascript', @@ -19,6 +20,9 @@ export namespace Platform { case Platform.NUXT: return 'Nuxt'; + case Platform.HYDROGEN: + return 'Hydrogen'; + case Platform.REACT: return 'React'; diff --git a/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts b/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts new file mode 100644 index 00000000..584d674f --- /dev/null +++ b/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts @@ -0,0 +1,110 @@ +import type {SlotDefinition, SlotExampleGenerator} from './slotExampleGenerator'; +import type {CodeExample} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {CodeWriter} from '@/application/project/code/generation/codeWritter'; +import {formatSlug} from '@/application/project/code/generation/utils'; + +/** + * The Hydrogen era, which selects the routing imports. + * + * - `react-router`: React Router 7 (`react-router`, route `+types`). + * - `remix`: Remix v2 (`@remix-run/react`, `@shopify/remix-oxygen`). + */ +export type HydrogenEra = 'react-router' | 'remix'; + +export type Configuration = { + typescript: boolean, + era: HydrogenEra, + routeFilePath: string, + routeComponentName: string, + indentationSize?: number, +}; + +/** + * Generates a Hydrogen route that renders a slot. + * + * The route fetches the slot content server-side in a `loader` via + * `fetchContent('', {context})` and renders it with ``, so the content + * is server-rendered and revalidated on the client. The routing imports follow the era. + */ +export class HydrogenExampleGenerator implements SlotExampleGenerator { + private readonly configuration: Configuration; + + public constructor(configuration: Configuration) { + this.configuration = configuration; + } + + public generate(definition: SlotDefinition): CodeExample { + const slug = formatSlug(definition.id); + const writer = new CodeWriter(this.configuration.indentationSize); + + this.writeRoute(writer, slug); + + return { + files: [ + { + path: HydrogenExampleGenerator.replaceVariables(this.configuration.routeFilePath, definition.id), + language: this.configuration.typescript + ? CodeLanguage.TYPESCRIPT_XML + : CodeLanguage.JAVASCRIPT_XML, + code: writer.toString(), + }, + ], + }; + } + + private writeRoute(writer: CodeWriter, slug: string): void { + const {typescript, era} = this.configuration; + const isReactRouter = era === 'react-router'; + const name = HydrogenExampleGenerator.replaceVariables(this.configuration.routeComponentName, slug); + + writer.write("import {Slot} from '@croct/plug-hydrogen';"); + writer.write("import {fetchContent} from '@croct/plug-hydrogen/server';"); + writer.write(`import {useLoaderData} from '${isReactRouter ? 'react-router' : '@remix-run/react'}';`); + + if (typescript) { + writer.write( + isReactRouter + ? `import type {Route} from './+types/${slug}';` + : "import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';", + ); + } + + writer.newLine(); + + const argsType = typescript + ? `: ${isReactRouter ? 'Route.LoaderArgs' : 'LoaderFunctionArgs'}` + : ''; + + writer.write(`export async function loader({context}${argsType}) {`) + .indent() + .write(`const {content} = await fetchContent('${slug}', {context});`) + .newLine() + .write('return {content};') + .outdent() + .write('}'); + + writer.newLine(); + + writer.write(`export default function ${name}() {`) + .indent() + .write(`const data = useLoaderData${typescript ? '' : ''}();`) + .newLine() + .write('return (') + .indent() + .write(``) + .indent() + .write('{({content}) =>
    {JSON.stringify(content, null, 2)}
    }') + .outdent() + .write('
    ') + .outdent() + .write(');') + .outdent() + .write('}'); + } + + private static replaceVariables(value: string, id: string): string { + return value.replace(/%name%/g, CodeWriter.formatName(id, true)) + .replace(/%slug%/g, formatSlug(id)); + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts new file mode 100644 index 00000000..caa563a5 --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts @@ -0,0 +1,187 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import {traverseFast} from '@babel/types'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; + +export type HydrogenContextConfiguration = { + /** + * The context factory import to call, e.g. `createCroctContext`. + */ + factory: { + moduleName: string, + importName: string, + localName?: string, + }, +}; + +type Anchor = { + request: string, + context: string, + returnStatement: t.ReturnStatement, + returnArgument: t.Expression, + functionNode: t.Function, +}; + +/** + * Exposes the Croct visitor context on the Hydrogen (Remix) load context. + * + * Finds the function that builds `const = createHydrogenContext(...)` and adds a + * `croct: await (, )` property to its returned object, where `` + * is the function's first parameter. Adds the import. Returns unmodified when the anchor is + * absent or `croct` is already wired. + */ +export class HydrogenContextCodemod implements Codemod { + private static readonly FACTORY_NAME = 'createHydrogenContext'; + + private static readonly FACTORY_MODULE = '@shopify/hydrogen'; + + private static readonly PROPERTY = 'croct'; + + private readonly configuration: HydrogenContextConfiguration; + + public constructor(configuration: HydrogenContextConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const anchor = HydrogenContextCodemod.findAnchor(input); + + if (anchor === null) { + return Promise.resolve({modified: false, result: input}); + } + + const {factory} = this.configuration; + const importedName = getImportLocalName(input, { + moduleName: factory.moduleName, + importName: factory.importName, + }); + + if (importedName !== null && HydrogenContextCodemod.callsFactory(anchor.functionNode, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {returnArgument} = anchor; + + if (t.isObjectExpression(returnArgument) && HydrogenContextCodemod.hasProperty(returnArgument)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: factory.moduleName, + importName: factory.importName, + localName: factory.localName, + }); + + const property = t.objectProperty( + t.identifier(HydrogenContextCodemod.PROPERTY), + t.awaitExpression( + t.callExpression(t.identifier(localName), [t.identifier(anchor.request), t.identifier(anchor.context)]), + ), + ); + + if (t.isObjectExpression(returnArgument)) { + returnArgument.properties.push(property); + } else { + anchor.returnStatement.argument = t.objectExpression([t.spreadElement(returnArgument), property]); + } + + return Promise.resolve({modified: true, result: input}); + } + + private static findAnchor(ast: t.File): Anchor | null { + const factoryName = getImportLocalName(ast, { + moduleName: HydrogenContextCodemod.FACTORY_MODULE, + importName: HydrogenContextCodemod.FACTORY_NAME, + }) ?? HydrogenContextCodemod.FACTORY_NAME; + + let anchor: Anchor | null = null; + + traverse(ast, { + VariableDeclarator: path => { + const {init} = path.node; + const call = init !== null && t.isAwaitExpression(init) ? init.argument : init; + + if ( + call === null + || !t.isCallExpression(call) + || !t.isIdentifier(call.callee) + || call.callee.name !== factoryName + || !t.isIdentifier(path.node.id) + ) { + return; + } + + const fn = path.getFunctionParent(); + + if (fn === null) { + return; + } + + const [param] = fn.node.params; + + if (param === undefined || !t.isIdentifier(param)) { + return; + } + + const request = param.name; + const context = path.node.id.name; + + fn.traverse({ + ReturnStatement: returnPath => { + const {argument} = returnPath.node; + + if (anchor !== null || returnPath.getFunctionParent()?.node !== fn.node || argument === null) { + return; + } + + anchor = { + request: request, + context: context, + returnStatement: returnPath.node, + returnArgument: argument as t.Expression, + functionNode: fn.node, + }; + + returnPath.stop(); + }, + }); + + if (anchor !== null) { + path.stop(); + } + }, + }); + + return anchor; + } + + private static callsFactory(node: t.Node, localName: string): boolean { + let called = false; + + traverseFast(node, current => { + if (called || !t.isCallExpression(current) || !t.isIdentifier(current.callee)) { + return; + } + + called = current.callee.name === localName; + }); + + return called; + } + + private static hasProperty(object: t.ObjectExpression): boolean { + return object.properties.some(property => { + if (!t.isObjectProperty(property) || property.computed) { + return false; + } + + const {key} = property; + + return (t.isIdentifier(key) && key.name === HydrogenContextCodemod.PROPERTY) + || (t.isStringLiteral(key) && key.value === HydrogenContextCodemod.PROPERTY); + }); + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts new file mode 100644 index 00000000..a5f57264 --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts @@ -0,0 +1,201 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import {traverseFast} from '@babel/types'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; + +export type HydrogenCookiesConfiguration = { + /** + * The cookie-writer import to call, e.g. `writeCroctCookies`. + */ + writer: { + moduleName: string, + importName: string, + localName?: string, + }, +}; + +type Anchor = { + response: t.Expression, + context: t.Expression, + statements: t.Statement[], + call: t.CallExpression, +}; + +/** + * Writes the Croct visitor cookies after Hydrogen commits its session. + * + * Finds the `.headers.set('Set-Cookie', … .session.commit())` statement in the + * server fetch handler and inserts `(, )` right after it (so it runs + * after the session `Set-Cookie`, which is replaced rather than appended). Adds the import. + * Returns unmodified when the anchor is absent or the writer is already called. + */ +export class HydrogenCookiesCodemod implements Codemod { + private readonly configuration: HydrogenCookiesConfiguration; + + public constructor(configuration: HydrogenCookiesConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const anchor = HydrogenCookiesCodemod.findAnchor(input); + + if (anchor === null) { + return Promise.resolve({modified: false, result: input}); + } + + const {writer} = this.configuration; + const importedName = getImportLocalName(input, { + moduleName: writer.moduleName, + importName: writer.importName, + }); + + if (importedName !== null && HydrogenCookiesCodemod.hasCall(anchor.statements, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: writer.moduleName, + importName: writer.importName, + localName: writer.localName, + }); + + const index = anchor.statements.findIndex(statement => HydrogenCookiesCodemod.contains(statement, anchor.call)); + + anchor.statements.splice( + index + 1, + 0, + t.expressionStatement( + t.callExpression(t.identifier(localName), [ + t.cloneNode(anchor.response), + t.cloneNode(anchor.context), + ]), + ), + ); + + return Promise.resolve({modified: true, result: input}); + } + + private static findAnchor(ast: t.File): Anchor | null { + let anchor: Anchor | null = null; + + traverse(ast, { + CallExpression: path => { + const response = HydrogenCookiesCodemod.matchSetCookie(path.node); + + if (response === null) { + return; + } + + const fn = path.getFunctionParent(); + + if (fn === null || !t.isBlockStatement(fn.node.body)) { + return; + } + + const context = HydrogenCookiesCodemod.findSessionContext(path.node); + + if (context === null) { + return; + } + + anchor = { + response: response, + context: context, + statements: fn.node.body.body, + call: path.node, + }; + + path.stop(); + }, + }); + + return anchor; + } + + /** + * Returns the response expression of a `.headers.set('Set-Cookie', …)` call. + */ + private static matchSetCookie(node: t.CallExpression): t.Expression | null { + const {callee} = node; + + if ( + !t.isMemberExpression(callee) + || callee.computed + || !t.isIdentifier(callee.property) + || callee.property.name !== 'set' + ) { + return null; + } + + const headers = callee.object; + + if ( + !t.isMemberExpression(headers) + || headers.computed + || !t.isIdentifier(headers.property) + || headers.property.name !== 'headers' + ) { + return null; + } + + const [first] = node.arguments; + + if (first === undefined || !t.isStringLiteral(first) || first.value !== 'Set-Cookie') { + return null; + } + + return headers.object; + } + + /** + * Returns the object of the first `.session` member access within the node. + */ + private static findSessionContext(node: t.Node): t.Expression | null { + let context: t.Expression | null = null; + + traverseFast(node, current => { + if ( + context === null + && t.isMemberExpression(current) + && !current.computed + && t.isIdentifier(current.property) + && current.property.name === 'session' + ) { + context = current.object; + } + }); + + return context; + } + + private static hasCall(statements: t.Statement[], localName: string): boolean { + return statements.some(statement => { + let called = false; + + traverseFast(statement, node => { + if (called || !t.isCallExpression(node) || !t.isIdentifier(node.callee)) { + return; + } + + called = node.callee.name === localName; + }); + + return called; + }); + } + + private static contains(statement: t.Statement, target: t.Node): boolean { + let found = false; + + traverseFast(statement, node => { + if (node === target) { + found = true; + } + }); + + return found; + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts new file mode 100644 index 00000000..dd5d071b --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts @@ -0,0 +1,127 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; + +export type HydrogenCspConfiguration = { + /** + * The origin the browser SDK must be allowed to reach, e.g. `https://api.croct.io`. + */ + origin: string, +}; + +/** + * Allows the Croct origin in Hydrogen's Content Security Policy. + * + * Adds the origin to the `connectSrc` array of the options object passed to + * `createContentSecurityPolicy(...)`, creating the directive when missing and normalizing a + * non-array value into one. The function import is resolved so aliased imports are matched. Returns + * unmodified when the call or its options object is absent or when the origin is already present. + */ +export class HydrogenCspCodemod implements Codemod { + private static readonly FUNCTION_NAME = 'createContentSecurityPolicy'; + + private static readonly FUNCTION_MODULE = '@shopify/hydrogen'; + + private static readonly DIRECTIVE = 'connectSrc'; + + private readonly configuration: HydrogenCspConfiguration; + + public constructor(configuration: HydrogenCspConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const options = HydrogenCspCodemod.findOptionsObject(input); + + if (options === null) { + return Promise.resolve({modified: false, result: input}); + } + + const {origin} = this.configuration; + const directive = HydrogenCspCodemod.findProperty(options, HydrogenCspCodemod.DIRECTIVE); + + if (directive === null) { + options.properties.push( + t.objectProperty( + t.identifier(HydrogenCspCodemod.DIRECTIVE), + t.arrayExpression([t.stringLiteral(origin)]), + ), + ); + + return Promise.resolve({modified: true, result: input}); + } + + if (!t.isArrayExpression(directive.value)) { + // Normalize a non-array directive (a variable, a call, etc.) into an array with the + // origin, preserving the existing value. The cast is forced by `ObjectProperty.value`'s + // `Expression | PatternLike` type; an object-literal value is always an expression. + directive.value = t.arrayExpression([ + spreadAsArray(directive.value as t.Expression), + t.stringLiteral(origin), + ]); + + return Promise.resolve({modified: true, result: input}); + } + + const array = directive.value; + + if (HydrogenCspCodemod.hasValue(array, origin)) { + return Promise.resolve({modified: false, result: input}); + } + + array.elements.push(t.stringLiteral(origin)); + + return Promise.resolve({modified: true, result: input}); + } + + private static findOptionsObject(ast: t.File): t.ObjectExpression | null { + const functionName = getImportLocalName(ast, { + moduleName: HydrogenCspCodemod.FUNCTION_MODULE, + importName: HydrogenCspCodemod.FUNCTION_NAME, + }) ?? HydrogenCspCodemod.FUNCTION_NAME; + + let options: t.ObjectExpression | null = null; + + traverse(ast, { + CallExpression: path => { + if (!t.isIdentifier(path.node.callee) || path.node.callee.name !== functionName) { + return; + } + + const [argument] = path.node.arguments; + + if (argument !== undefined && t.isObjectExpression(argument)) { + options = argument; + + path.stop(); + } + }, + }); + + return options; + } + + private static hasValue(array: t.ArrayExpression, value: string): boolean { + return array.elements.some( + element => element !== null && t.isStringLiteral(element) && element.value === value, + ); + } + + private static findProperty(object: t.ObjectExpression, name: string): t.ObjectProperty | null { + for (const property of object.properties) { + if (!t.isObjectProperty(property) || property.computed) { + continue; + } + + const {key} = property; + + if ((t.isIdentifier(key) && key.name === name) || (t.isStringLiteral(key) && key.value === name)) { + return property; + } + } + + return null; + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts new file mode 100644 index 00000000..6d915d4c --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts @@ -0,0 +1,155 @@ +import * as t from '@babel/types'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; + +export type HydrogenMiddlewareConfiguration = { + /** + * The middleware factory import to register, e.g. `createCroctMiddleware`. + */ + middleware: { + moduleName: string, + importName: string, + localName?: string, + }, +}; + +type Match = { + declarator: t.VariableDeclarator, + index: number, +}; + +/** + * Registers the Croct middleware in the Hydrogen (React Router 7) root route. + * + * Ensures the `export const middleware` array contains a `()` call and adds the import: + * - creates `export const middleware = [()]` when absent; + * - appends to an existing array; + * - normalizes a non-array value (e.g. `buildMiddleware()`) to + * `[...(Array.isArray(existing) ? existing : [existing]), ()]`, preserving it. + * + * Returns unmodified when the middleware is already registered. + */ +export class HydrogenMiddlewareCodemod implements Codemod { + private static readonly EXPORT_NAME = 'middleware'; + + private static readonly EXISTING_NAME = 'existingMiddleware'; + + private readonly configuration: HydrogenMiddlewareConfiguration; + + public constructor(configuration: HydrogenMiddlewareConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const {middleware} = this.configuration; + const match = HydrogenMiddlewareCodemod.findDeclarator(input); + const array = match === null + ? HydrogenMiddlewareCodemod.createExport(input) + : HydrogenMiddlewareCodemod.resolveArray(input, match); + + const importedName = getImportLocalName(input, { + moduleName: middleware.moduleName, + importName: middleware.importName, + }); + + if (importedName !== null && HydrogenMiddlewareCodemod.hasCall(array, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: middleware.moduleName, + importName: middleware.importName, + localName: middleware.localName, + }); + + array.elements.push(t.callExpression(t.identifier(localName), [])); + + return Promise.resolve({modified: true, result: input}); + } + + /** + * Creates `export const middleware = []` at the end of the program and returns the array. + */ + private static createExport(input: t.File): t.ArrayExpression { + const array = t.arrayExpression([]); + const {body} = input.program; + + body.push( + t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(HydrogenMiddlewareCodemod.EXPORT_NAME), array), + ]), + ), + ); + + return array; + } + + /** + * Returns the array to append to, normalizing a non-array export value into one. + */ + private static resolveArray(input: t.File, match: Match): t.ArrayExpression { + const {declarator, index} = match; + const {init} = declarator; + + if (t.isArrayExpression(init)) { + return init; + } + + if (init === null) { + declarator.init = t.arrayExpression([]); + + return declarator.init; + } + + // Bind the existing value to a constant and normalize it to an array, preserving it whether + // it is a single middleware or already an array. + const {EXISTING_NAME} = HydrogenMiddlewareCodemod; + const {body} = input.program; + + body.splice( + index, + 0, + t.variableDeclaration('const', [t.variableDeclarator(t.identifier(EXISTING_NAME), init)]), + ); + + const array = t.arrayExpression([spreadAsArray(t.identifier(EXISTING_NAME))]); + + declarator.init = array; + + return array; + } + + private static hasCall(array: t.ArrayExpression, localName: string): boolean { + return array.elements.some( + element => element !== null + && t.isCallExpression(element) + && t.isIdentifier(element.callee) + && element.callee.name === localName, + ); + } + + private static findDeclarator(ast: t.File): Match | null { + const {body} = ast.program; + + for (let index = 0; index < body.length; index++) { + const statement = body[index]; + const declaration = t.isExportNamedDeclaration(statement) ? statement.declaration : statement; + + if (!t.isVariableDeclaration(declaration)) { + continue; + } + + for (const declarator of declaration.declarations) { + if (t.isIdentifier(declarator.id) && declarator.id.name === HydrogenMiddlewareCodemod.EXPORT_NAME) { + return {declarator: declarator, index: index}; + } + } + } + + return null; + } +} diff --git a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts index 647c6d10..0280cf0d 100644 --- a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts +++ b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts @@ -312,7 +312,12 @@ export class JsxWrapperCodemod implem const container = this.configuration.targets?.container; if (container !== undefined) { - return this.wrapElementChildren(node, container, component, options); + return this.wrapElementChildren( + node, + JsxWrapperCodemod.resolveElementName(ast, container), + component, + options, + ); } const target = this.findTargetChildren(ast, node); @@ -468,6 +473,23 @@ export class JsxWrapperCodemod implem return name.name; } + /** + * Resolves the import alias of a target element name's root identifier, so an aliased import + * (e.g. `import {Analytics as Shopify}`) still matches `` for the configured + * `Analytics.Provider`. The root is matched against any module, leaving it untouched when it is + * not an imported binding. + */ + private static resolveElementName(ast: t.File, name: string): string { + const segments = name.split('.'); + const local = getImportLocalName(ast, {moduleName: /.*/, importName: segments[0]}); + + if (local !== null) { + segments[0] = local; + } + + return segments.join('.'); + } + /** * Determines if the element contains the specified component. * @@ -558,6 +580,10 @@ export class JsxWrapperCodemod implem return null; } + const componentName = configuration.targets?.component === undefined + ? undefined + : JsxWrapperCodemod.resolveElementName(ast, configuration.targets.component); + traverse(ast, { enter: function enter(path) { const {node} = path; @@ -589,8 +615,8 @@ export class JsxWrapperCodemod implem const {openingElement} = nestedPath.node; if ( - configuration.targets?.component !== undefined - && JsxWrapperCodemod.getJsxName(openingElement.name) === configuration.targets.component + componentName !== undefined + && JsxWrapperCodemod.getJsxName(openingElement.name) === componentName ) { if (nestedPath.parent !== null) { const parent = nestedPath.parent as t.JSXElement; diff --git a/src/application/project/code/transformation/javascript/utils/spreadAsArray.ts b/src/application/project/code/transformation/javascript/utils/spreadAsArray.ts new file mode 100644 index 00000000..857c9d90 --- /dev/null +++ b/src/application/project/code/transformation/javascript/utils/spreadAsArray.ts @@ -0,0 +1,21 @@ +import * as t from '@babel/types'; + +/** + * Builds a spread that flattens a value as an array: `...(Array.isArray(value) ? value : [value])`. + * + * Lets a codemod append to a list whose current value may not be an array literal (a variable, a + * call, etc.) while preserving it, whether it is a single item or already an array. Pass an + * identifier (e.g. a hoisted constant) when the value has side effects to avoid evaluating it twice. + */ +export function spreadAsArray(value: t.Expression): t.SpreadElement { + return t.spreadElement( + t.conditionalExpression( + t.callExpression( + t.memberExpression(t.identifier('Array'), t.identifier('isArray')), + [t.cloneNode(value)], + ), + t.cloneNode(value), + t.arrayExpression([t.cloneNode(value)]), + ), + ); +} diff --git a/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts b/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts index 6da57284..61fdc4c6 100644 --- a/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts +++ b/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts @@ -3,6 +3,7 @@ import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; export type ViteConfigPluginConfiguration = { /** @@ -25,8 +26,9 @@ export type ViteConfigPluginConfiguration = { * * Supports the `defineConfig` call form and bare object exports, including the * indirect variable forms of each, with type-assertion wrappers unwrapped. The - * plugin import is added and a `()` call is inserted into the array. If - * the plugin is already registered, the codemod returns unmodified. + * plugin import is added and a `()` call is inserted into the array, + * creating it when missing and normalizing a non-array value into one. If the + * plugin is already registered, the codemod returns unmodified. */ export class ViteConfigPluginCodemod implements Codemod { private readonly configuration: ViteConfigPluginConfiguration; @@ -43,11 +45,6 @@ export class ViteConfigPluginCodemod implements Codemod } const plugins = ViteConfigPluginCodemod.resolvePluginsArray(config); - - if (plugins === null) { - return Promise.resolve({modified: false, result: input}); - } - const {plugin, position = 'end'} = this.configuration; const importedName = getImportLocalName(input, { moduleName: plugin.moduleName, @@ -86,11 +83,11 @@ export class ViteConfigPluginCodemod implements Codemod } /** - * Returns the `plugins` array of the config, creating an empty one if it is - * missing. Returns null when a `plugins` property exists but is not an inline - * array (e.g. a spread or a variable), which cannot be edited safely. + * Returns the `plugins` array of the config, creating an empty one if it is missing. When + * `plugins` is a non-array value (a variable, a call, etc.), it is normalized into an array, + * preserving the existing value, so the plugin can still be appended. */ - private static resolvePluginsArray(config: t.ObjectExpression): t.ArrayExpression | null { + private static resolvePluginsArray(config: t.ObjectExpression): t.ArrayExpression { for (const property of config.properties) { if (!t.isObjectProperty(property) || property.computed) { continue; @@ -101,7 +98,18 @@ export class ViteConfigPluginCodemod implements Codemod || (t.isStringLiteral(key) && key.value === 'plugins'); if (isPlugins) { - return t.isArrayExpression(property.value) ? property.value : null; + if (t.isArrayExpression(property.value)) { + return property.value; + } + + // Normalize a non-array plugins value, preserving it. The cast is forced by + // `ObjectProperty.value`'s `Expression | PatternLike` type; an object-literal + // value is always an expression. + const normalized = t.arrayExpression([spreadAsArray(property.value as t.Expression)]); + + property.value = normalized; + + return normalized; } } diff --git a/src/application/project/sdk/plugHydrogenSdk.ts b/src/application/project/sdk/plugHydrogenSdk.ts new file mode 100644 index 00000000..dec14669 --- /dev/null +++ b/src/application/project/sdk/plugHydrogenSdk.ts @@ -0,0 +1,311 @@ +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; +import {SdkError} from '@/application/project/sdk/sdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; +import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; +import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; +import type {Example} from '@/application/project/example/example'; +import {UrlExample} from '@/application/project/example/example'; +import type {Codemod, CodemodOptions} from '@/application/project/code/transformation/codemod'; +import type {WrapperOptions} from '@/application/project/code/transformation/javascript/jsxWrapperCodemod'; +import type {Task} from '@/application/cli/io/output'; +import {EnvFile} from '@/application/project/code/envFile'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {HydrogenExampleGenerator} from '@/application/project/code/generation/slot/hydrogenExampleGenerator'; +import type {Slot} from '@/application/model/slot'; +import {ErrorReason, HelpfulError} from '@/application/error'; +import type {UserApi} from '@/application/api/user'; +import type {ApplicationApi, GeneratedApiKey} from '@/application/api/application'; +import {ApiKeyPermission} from '@/application/model/application'; +import {ApiError} from '@/application/api/error'; + +/** + * The Hydrogen era. The boundary is `@shopify/hydrogen@2025.5.0`, where the skeleton migrated from + * Remix to React Router 7. + */ +type Era = 'react-router' | 'remix'; + +type CodemodConfiguration = { + /** + * Registers the `croct()` Vite plugin in `vite.config.ts`. + */ + vite: Codemod, + + /** + * Wraps the app with `` inside `` in `app/root.tsx`. + */ + provider: Codemod, + + /** + * Registers the Croct middleware in `app/root.tsx` (React Router 7 only). + */ + middleware: Codemod, + + /** + * Exposes the Croct context on the load context in `app/lib/context.ts` (Remix only). + */ + context: Codemod, + + /** + * Writes the Croct cookies after the session commit in `server.ts`. + */ + cookies: Codemod, + + /** + * Allows the Croct origin in the CSP in `app/entry.server.tsx`. + */ + csp: Codemod, +}; + +export type Configuration = JavaScriptSdkConfiguration & { + codemod: CodemodConfiguration, + userApi: UserApi, + applicationApi: ApplicationApi, +}; + +enum HydrogenEnvVar { + API_KEY = 'CROCT_API_KEY', + APP_ID = 'PUBLIC_CROCT_APP_ID', +} + +type HydrogenProjectInfo = { + era: Era, + viteConfig: string | null, + server: string | null, + root: string | null, + context: string | null, + entryServer: string | null, + envFile: EnvFile, +}; + +type HydrogenInstallation = Installation & { + project: HydrogenProjectInfo, +}; + +export class PlugHydrogenSdk extends JavaScriptSdk { + private readonly codemod: CodemodConfiguration; + + private readonly userApi: UserApi; + + private readonly applicationApi: ApplicationApi; + + public constructor(configuration: Configuration) { + super(configuration); + + this.codemod = configuration.codemod; + this.userApi = configuration.userApi; + this.applicationApi = configuration.applicationApi; + } + + public getPaths(configuration: ProjectConfiguration): Promise { + return Promise.resolve({ + ...configuration.paths, + source: configuration.paths?.source ?? 'app', + utilities: configuration.paths?.utilities ?? 'app/lib', + components: configuration.paths?.components ?? 'app/components', + examples: configuration.paths?.examples ?? 'app/routes', + }); + } + + protected createExample(slot: Slot): Promise { + // Hydrogen file-based routing serves `app/routes/.tsx` at `/`. + return Promise.resolve(new UrlExample(slot.name, `/${slot.slug}`)); + } + + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { + const [isTypeScript, era] = await Promise.all([ + this.isTypeScriptProject(), + this.detectEra(), + ]); + + const paths = await this.getPaths(installation.configuration); + + const generator = new HydrogenExampleGenerator({ + typescript: isTypeScript, + era: era, + routeFilePath: this.fileSystem.joinPaths(paths.examples, `%slug%${isTypeScript ? '.tsx' : '.jsx'}`), + routeComponentName: '%name%Route', + }); + + const example = generator.generate({ + id: slot.slug, + version: slot.version.major, + definition: slot.resolvedDefinition, + }); + + return example.files; + } + + protected async getInstallationPlan(installation: Installation): Promise { + const {configuration} = installation; + const project = await this.getProjectInfo(); + + return { + dependencies: ['@croct/plug-hydrogen'], + tasks: this.getInstallationTasks({...installation, project: project}), + configuration: configuration, + }; + } + + private async getProjectInfo(): Promise { + const projectDirectory = this.projectDirectory.get(); + + const [era, viteConfig, server, root, context, entryServer] = await Promise.all([ + this.detectEra(), + this.locateFile('vite.config.ts', 'vite.config.js', 'vite.config.mts'), + this.locateFile('server.ts', 'server.js'), + this.locateFile('app/root.tsx', 'app/root.jsx'), + this.locateFile('app/lib/context.ts', 'app/lib/context.js'), + this.locateFile('app/entry.server.tsx', 'app/entry.server.jsx'), + ]); + + return { + era: era, + viteConfig: viteConfig, + server: server, + root: root, + context: context, + entryServer: entryServer, + envFile: new EnvFile(this.fileSystem, this.fileSystem.joinPaths(projectDirectory, '.env')), + }; + } + + /** + * Detects the era from the `@shopify/hydrogen` version, falling back to the routing dependency. + */ + private async detectEra(): Promise { + const [byVersion, hasReactRouter] = await Promise.all([ + this.packageManager.hasDirectDependency('@shopify/hydrogen', '>=2025.5.0'), + this.packageManager.hasDirectDependency('react-router'), + ]); + + return byVersion || hasReactRouter ? 'react-router' : 'remix'; + } + + private getInstallationTasks(installation: HydrogenInstallation): Task[] { + const {project} = installation; + + const tasks: Task[] = [ + { + title: 'Set up environment variables', + task: async notifier => { + notifier.update('Setting up environment variables'); + + try { + await this.updateEnvVariables(installation); + + notifier.confirm('Environment variables updated'); + } catch (error) { + notifier.alert('Failed to update environment variables', HelpfulError.formatMessage(error)); + } + }, + }, + this.getCodemodTask('Register Vite plugin', 'vite', project.viteConfig), + project.era === 'react-router' + ? this.getCodemodTask('Register middleware', 'middleware', project.root) + : this.getCodemodTask('Expose Croct context', 'context', project.context), + this.getCodemodTask('Write Croct cookies', 'cookies', project.server), + this.getProviderTask(project.root), + this.getCodemodTask('Configure content security policy', 'csp', project.entryServer), + ]; + + return tasks; + } + + private getCodemodTask(title: string, codemod: keyof CodemodConfiguration, file: string | null): Task { + return { + title: title, + task: async notifier => { + notifier.update(title); + + if (file === null) { + notifier.warn(`${title}: file not found`); + + return; + } + + try { + await this.applyCodemod(this.codemod[codemod], file); + + notifier.confirm(title); + } catch (error) { + notifier.alert(`Failed: ${title}`, HelpfulError.formatMessage(error)); + } + }, + }; + } + + private getProviderTask(file: string | null): Task { + return { + title: 'Configure provider', + task: async notifier => { + notifier.update('Configuring provider'); + + if (file === null) { + notifier.warn('Configure provider: app/root not found'); + + return; + } + + try { + await this.applyCodemod(this.codemod.provider, file); + + notifier.confirm('Provider configured'); + } catch (error) { + notifier.alert('Failed to configure provider', HelpfulError.formatMessage(error)); + } + }, + }; + } + + private async applyCodemod(codemod: Codemod, file: string): Promise { + await codemod.apply(this.fileSystem.joinPaths(this.projectDirectory.get(), file)); + } + + private async updateEnvVariables(installation: HydrogenInstallation): Promise { + const {project: {envFile}, configuration} = installation; + + const application = await this.workspaceApi.getApplication({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + applicationSlug: configuration.applications.development, + }); + + if (application === null) { + throw new SdkError( + `Development application \`${configuration.applications.development}\` not found.`, + {reason: ErrorReason.NOT_FOUND}, + ); + } + + if (!await envFile.hasVariable(HydrogenEnvVar.API_KEY) && installation.skipApiKeySetup !== true) { + const user = await this.userApi.getUser(); + + let apiKey: GeneratedApiKey; + + try { + apiKey = await this.applicationApi.createApiKey({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + applicationSlug: application.slug, + name: `${user.username} CLI`, + permissions: [ApiKeyPermission.ISSUE_TOKEN], + }); + } catch (error) { + if (error instanceof HelpfulError) { + throw new SdkError( + error instanceof ApiError && error.isAccessDenied() + ? 'Your user does not have permission to create an API key' + : error.message, + error.help, + ); + } + + throw error; + } + + await envFile.setVariables({[HydrogenEnvVar.API_KEY]: apiKey.secret}); + } + + await envFile.setVariables({[HydrogenEnvVar.APP_ID]: application.publicId}); + } +} diff --git a/src/application/project/server/provider/parser/hydrogenCommandParser.ts b/src/application/project/server/provider/parser/hydrogenCommandParser.ts new file mode 100644 index 00000000..4e48dcfd --- /dev/null +++ b/src/application/project/server/provider/parser/hydrogenCommandParser.ts @@ -0,0 +1,22 @@ +import type {ServerCommandParser} from '@/application/project/server/provider/projectServerProvider'; +import type {ServerInfo} from '@/application/project/server/factory/serverFactory'; + +export class HydrogenCommandParser implements ServerCommandParser { + public parse(command: string): ServerInfo | null { + if (!command.includes('hydrogen dev') && !command.includes('h2 dev')) { + return null; + } + + const portMatch = command.match(/--port\s*(\d+)/); + const hostMatch = command.match(/--host\s*(\S+)/); + const port = portMatch !== null ? Number.parseInt(portMatch[1], 10) : null; + const host = hostMatch !== null ? hostMatch[1] : 'localhost'; + + return { + protocol: 'http', + host: host, + ...(port !== null ? {port: port} : {}), + defaultPort: 3000, + }; + } +} diff --git a/src/infrastructure/application/api/graphql/workspace.ts b/src/infrastructure/application/api/graphql/workspace.ts index e96d8dd3..d3c843ed 100644 --- a/src/infrastructure/application/api/graphql/workspace.ts +++ b/src/infrastructure/application/api/graphql/workspace.ts @@ -123,6 +123,9 @@ function createNormalizationMap(map: Record< const platformMap = createNormalizationMap({ [Platform.JAVASCRIPT]: GraphqlPlatform.Javascript, + // The API has no Hydrogen platform; report it as React (it is a React framework). Listed + // before React so the reverse map keeps `React → react`. + [Platform.HYDROGEN]: GraphqlPlatform.React, [Platform.REACT]: GraphqlPlatform.React, [Platform.NEXTJS]: GraphqlPlatform.Next, [Platform.VUE]: GraphqlPlatform.Vue, diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 7ca8cf78..64c2e430 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -24,6 +24,7 @@ import {PlugReactSdk} from '@/application/project/sdk/plugReactSdk'; import {PlugNextSdk} from '@/application/project/sdk/plugNextSdk'; import {PlugVueSdk} from '@/application/project/sdk/plugVueSdk'; import {PlugNuxtSdk} from '@/application/project/sdk/plugNuxtSdk'; +import {PlugHydrogenSdk} from '@/application/project/sdk/plugHydrogenSdk'; import {PlugPhpSdk} from '@/application/project/sdk/plugPhpSdk'; import {PlugLaravelSdk} from '@/application/project/sdk/plugLaravelSdk'; import {PlugSymfonySdk} from '@/application/project/sdk/plugSymfonySdk'; @@ -205,6 +206,7 @@ import {ExampleLauncher} from '@/application/project/example/exampleLauncher'; import {ProjectServerProvider} from '@/application/project/server/provider/projectServerProvider'; import {NextCommandParser} from '@/application/project/server/provider/parser/nextCommandParser'; import {NuxtCommandParser} from '@/application/project/server/provider/parser/nuxtCommandParser'; +import {HydrogenCommandParser} from '@/application/project/server/provider/parser/hydrogenCommandParser'; import {ViteCommandParser} from '@/application/project/server/provider/parser/viteCommandParser'; import {ParcelCommandParser} from '@/application/project/server/provider/parser/parcelCommandParser'; import {ReactScriptCommandParser} from '@/application/project/server/provider/parser/reactScriptCommandParser'; @@ -377,6 +379,13 @@ import {NuxtStoryblokPlugin} from '@/application/project/sdk/nuxtStoryblokPlugin import {VuePluginCodemod} from '@/application/project/code/transformation/javascript/vuePluginCodemod'; import {VueStoryblokCodemod} from '@/application/project/code/transformation/javascript/vueStoryblokCodemod'; import {NuxtConfigModuleCodemod} from '@/application/project/code/transformation/javascript/nuxtConfigModuleCodemod'; +import {ViteConfigPluginCodemod} from '@/application/project/code/transformation/javascript/viteConfigPluginCodemod'; +import { + HydrogenMiddlewareCodemod, +} from '@/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod'; +import {HydrogenContextCodemod} from '@/application/project/code/transformation/javascript/hydrogenContextCodemod'; +import {HydrogenCookiesCodemod} from '@/application/project/code/transformation/javascript/hydrogenCookiesCodemod'; +import {HydrogenCspCodemod} from '@/application/project/code/transformation/javascript/hydrogenCspCodemod'; import { NuxtStoryblokPluginCodemod, @@ -1908,6 +1917,104 @@ export class Cli { ), }, }), + [Platform.HYDROGEN]: (): Sdk => new PlugHydrogenSdk({ + ...config, + userApi: this.getUserApi(), + applicationApi: this.getApplicationApi(), + codemod: { + vite: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new ViteConfigPluginCodemod({ + plugin: { + moduleName: '@croct/plug-hydrogen/vite', + importName: 'croct', + }, + }), + }), + }), + ), + provider: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new JsxWrapperCodemod({ + wrapper: { + component: 'CroctProvider', + module: '@croct/plug-hydrogen', + }, + targets: { + container: 'Analytics.Provider', + }, + fallbackToNamedExports: true, + }), + }), + }), + ), + middleware: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new HydrogenMiddlewareCodemod({ + middleware: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctMiddleware', + }, + }), + }), + }), + ), + context: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenContextCodemod({ + factory: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctContext', + }, + }), + }), + }), + ), + cookies: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod({ + writer: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'writeCroctCookies', + }, + }), + }), + }), + ), + csp: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new HydrogenCspCodemod({ + origin: 'https://api.croct.io', + }), + }), + }), + ), + }, + }), [Platform.NEXTJS]: (): Sdk => { const providerProps: Record = { appId: { @@ -2088,6 +2195,7 @@ export class Cli { [Platform.NEXTJS]: () => this.getJavaScriptFormatter(), [Platform.VUE]: () => this.getJavaScriptFormatter(), [Platform.NUXT]: () => this.getJavaScriptFormatter(), + [Platform.HYDROGEN]: () => this.getJavaScriptFormatter(), [Platform.LARAVEL]: () => this.getPhpFormatter(), [Platform.SYMFONY]: () => this.getPhpFormatter(), [Platform.DRUPAL]: () => this.getPhpFormatter(), @@ -2384,6 +2492,7 @@ export class Cli { [Platform.NEXTJS]: () => this.getNodeServerProvider().get(), [Platform.VUE]: () => this.getNodeServerProvider().get(), [Platform.NUXT]: () => this.getNodeServerProvider().get(), + [Platform.HYDROGEN]: () => this.getNodeServerProvider().get(), [Platform.LARAVEL]: () => this.createDevServer( {name: 'php', arguments: ['artisan', 'serve']}, 8000, @@ -2428,6 +2537,7 @@ export class Cli { parsers: [ new NextCommandParser(), new NuxtCommandParser(), + new HydrogenCommandParser(), new ViteCommandParser(), new ParcelCommandParser(), new ReactScriptCommandParser(), @@ -2617,6 +2727,14 @@ export class Cli { dependencies: ['nuxt'], }), }, + // Hydrogen ships React, so it must be matched before the generic React rule. + { + value: Platform.HYDROGEN, + condition: new HasDependency({ + packageManager: nodePackageManager, + dependencies: ['@shopify/hydrogen'], + }), + }, { value: Platform.REACT, condition: new HasDependency({ diff --git a/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap new file mode 100644 index 00000000..cca68aa4 --- /dev/null +++ b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap @@ -0,0 +1,447 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenExampleGenerator should generate a route for booleanWithLabel: booleanWithLabel 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/feature-toggle'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('feature-toggle', {context}); + + return {content}; +} + +export default function FeatureToggleRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/feature-toggle.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for labeledAttributes: labeledAttributes 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/stylized'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('stylized', {context}); + + return {content}; +} + +export default function StylizedRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/stylized.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for listOfScalars: listOfScalars 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/tag-cloud'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('tag-cloud', {context}); + + return {content}; +} + +export default function TagCloudRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/tag-cloud.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for listOfStructures: listOfStructures 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/product-grid'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('product-grid', {context}); + + return {content}; +} + +export default function ProductGridRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/product-grid.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for nestedStructure: nestedStructure 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/author-card'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('author-card', {context}); + + return {content}; +} + +export default function AuthorCardRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/author-card.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for optionalAttributes: optionalAttributes 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/promo-card'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('promo-card', {context}); + + return {content}; +} + +export default function PromoCardRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/promo-card.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for privateAttributes: privateAttributes 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/tracked-banner'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('tracked-banner', {context}); + + return {content}; +} + +export default function TrackedBannerRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/tracked-banner.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for simpleStructure: simpleStructure 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/home-hero'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('home-hero', {context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for unionInStructure: unionInStructure 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/block'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('block', {context}); + + return {content}; +} + +export default function BlockRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/block.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for unionRoot: unionRoot 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/cta-block'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('cta-block', {context}); + + return {content}; +} + +export default function CtaBlockRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/cta-block.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the react-router-js route: react-router-js 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; + +export async function loader({context}) { + const {content} = await fetchContent('home-hero', {context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "jsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the react-router-ts route: react-router-ts 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/home-hero'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('home-hero', {context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the remix-js route: remix-js 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from '@remix-run/react'; + +export async function loader({context}) { + const {content} = await fetchContent('home-hero', {context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "jsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the remix-ts route: remix-ts 1`] = ` +{ + "files": [ + { + "code": "import {Slot} from '@croct/plug-hydrogen'; +import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from '@remix-run/react'; +import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; + +export async function loader({context}: LoaderFunctionArgs) { + const {content} = await fetchContent('home-hero', {context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const data = useLoaderData(); + + return ( + + {({content}) =>
    {JSON.stringify(content, null, 2)}
    } +
    + ); +} +", + "language": "tsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; diff --git a/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts new file mode 100644 index 00000000..39c64275 --- /dev/null +++ b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts @@ -0,0 +1,58 @@ +import {readFileSync, readdirSync} from 'fs'; +import {basename, resolve} from 'path'; +import type {SlotDefinition} from '@/application/project/code/generation/slot/slotExampleGenerator'; +import type {Configuration} from '@/application/project/code/generation/slot/hydrogenExampleGenerator'; +import {HydrogenExampleGenerator} from '@/application/project/code/generation/slot/hydrogenExampleGenerator'; + +describe('HydrogenExampleGenerator', () => { + const fixturesPath = resolve(__dirname, 'fixtures'); + + function loadFixture(file: string): SlotDefinition { + return JSON.parse(readFileSync(resolve(fixturesPath, file), 'utf-8')); + } + + const fixtures = readdirSync(fixturesPath).filter(file => file.endsWith('.json')) + .map( + file => ({ + name: basename(file, '.json'), + definition: loadFixture(file), + }), + ); + + const baseOptions: Configuration = { + typescript: true, + era: 'react-router', + routeFilePath: 'app/routes/%slug%.tsx', + routeComponentName: '%name%Route', + }; + + const variants: Array<{label: string, options: Partial}> = [ + {label: 'react-router-ts', options: {era: 'react-router', typescript: true}}, + {label: 'react-router-js', options: {era: 'react-router', typescript: false}}, + {label: 'remix-ts', options: {era: 'remix', typescript: true}}, + {label: 'remix-js', options: {era: 'remix', typescript: false}}, + ]; + + it.each(variants)('should generate the $label route', ({label, options}) => { + // The content shape does not affect the route, so a single fixture exercises every variant. + const example = new HydrogenExampleGenerator({...baseOptions, ...options}) + .generate(loadFixture('simpleStructure.json')); + + expect(example).toMatchSnapshot(label); + }); + + it.each(fixtures)('should generate a route for $name', ({name, definition}) => { + const example = new HydrogenExampleGenerator(baseOptions).generate(definition); + + expect(example).toMatchSnapshot(name); + }); + + it('fetches the slot server-side and seeds the client Slot', () => { + const [route] = new HydrogenExampleGenerator(baseOptions) + .generate(loadFixture('simpleStructure.json')) + .files; + + expect(route.code).toContain("const {content} = await fetchContent('home-hero', {context});"); + expect(route.code).toContain(''); + }); +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/aliasedFactory.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/aliasedFactory.ts new file mode 100644 index 00000000..7e423fa4 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/aliasedFactory.ts @@ -0,0 +1,10 @@ +import {createHydrogenContext as createContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + }; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/alreadyWired.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/alreadyWired.ts new file mode 100644 index 00000000..2756a3e1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/alreadyWired.ts @@ -0,0 +1,11 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; +import {createCroctContext} from '@croct/plug-hydrogen/server'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext), + }; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/awaitedContext.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/awaitedContext.ts new file mode 100644 index 00000000..52dec0b7 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/awaitedContext.ts @@ -0,0 +1,7 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = await createHydrogenContext({env, request}); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/croctAlreadyOnObject.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/croctAlreadyOnObject.ts new file mode 100644 index 00000000..865deba5 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/croctAlreadyOnObject.ts @@ -0,0 +1,10 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: customCroct, + }; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/croctStringLiteralKey.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/croctStringLiteralKey.ts new file mode 100644 index 00000000..79bacd4d --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/croctStringLiteralKey.ts @@ -0,0 +1,10 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + 'croct': customCroct, + }; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/destructuredContextId.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/destructuredContextId.ts new file mode 100644 index 00000000..8e153111 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/destructuredContextId.ts @@ -0,0 +1,7 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const {storefront} = createHydrogenContext({env, request}); + + return {storefront}; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/destructuredParam.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/destructuredParam.ts new file mode 100644 index 00000000..62f4ad03 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/destructuredParam.ts @@ -0,0 +1,7 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext({request, env}) { + const hydrogenContext = createHydrogenContext({env, request}); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/nestedReturn.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/nestedReturn.ts new file mode 100644 index 00000000..aa85fa13 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/nestedReturn.ts @@ -0,0 +1,13 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + hydrogenContext.cart = createCartHandler({ + getCartId: () => { + return request.headers.get('Cookie'); + }, + }); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts new file mode 100644 index 00000000..56247f07 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts @@ -0,0 +1,8 @@ +export async function createAppLoadContext(request, env, executionContext) { + let pending; + const count = 1; + const built = factory.create(); + const handler = createRequestHandler(request); + + return handler; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts new file mode 100644 index 00000000..e0aaeec1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts @@ -0,0 +1,5 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +const hydrogenContext = createHydrogenContext({env, request}); + +export {hydrogenContext}; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts new file mode 100644 index 00000000..d1311185 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts @@ -0,0 +1,7 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext() { + const hydrogenContext = createHydrogenContext({}); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts new file mode 100644 index 00000000..050a12bf --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts @@ -0,0 +1,11 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + if (!request) { + return; + } + + doSomething(hydrogenContext); +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts new file mode 100644 index 00000000..6f34c1b1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts @@ -0,0 +1,11 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({ + env, + request, + cache: await caches.open('hydrogen'), + }); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts new file mode 100644 index 00000000..64e8f13a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts @@ -0,0 +1,10 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + }; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts new file mode 100644 index 00000000..8bace5f2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts @@ -0,0 +1,13 @@ +import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + writeCroctCookies(response, hydrogenContext); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts new file mode 100644 index 00000000..406b4e09 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts @@ -0,0 +1,4 @@ +export const handle = (request, hydrogenContext) => response.headers.set( + 'Set-Cookie', + hydrogenContext.session.commit(), +); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts new file mode 100644 index 00000000..79d65f98 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts @@ -0,0 +1,8 @@ +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + const {headers} = response; + + headers.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts new file mode 100644 index 00000000..15676ed1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts @@ -0,0 +1,9 @@ +export async function handleFetch(request, appLoadContext) { + const res = await handleRequest(request); + + if (appLoadContext.session.isPending) { + res.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + return res; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts new file mode 100644 index 00000000..5e9d0517 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request, appLoadContext) { + const response = await handleRequest(request); + + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts new file mode 100644 index 00000000..0eb32eb9 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts @@ -0,0 +1,15 @@ +import {createRequestHandler} from '@shopify/hydrogen'; + +export default { + async fetch(request, env, executionContext) { + const hydrogenContext = await createHydrogenRouterContext(request, env, executionContext); + const handleRequest = createRequestHandler({build: serverBuild, getContext: () => hydrogenContext}); + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + return response; + }, +}; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts new file mode 100644 index 00000000..6a1b322f --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts @@ -0,0 +1,3 @@ +const response = new Response(); + +response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts new file mode 100644 index 00000000..0b0d9fbd --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request) { + const response = await handleRequest(request); + + response.headers.set('Set-Cookie', buildCookie()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts new file mode 100644 index 00000000..7ca51226 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.cookies.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts new file mode 100644 index 00000000..a96888af --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.headers.set('Content-Type', 'text/html'); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts new file mode 100644 index 00000000..42c9f8c2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy as createCsp} from '@shopify/hydrogen'; + +const csp = createCsp({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts new file mode 100644 index 00000000..f88b9447 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://api.croct.io'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts new file mode 100644 index 00000000..c2f00b90 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts @@ -0,0 +1,3 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts new file mode 100644 index 00000000..ebc0c830 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: [], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts new file mode 100644 index 00000000..49bd7c00 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts new file mode 100644 index 00000000..428dd563 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts @@ -0,0 +1,3 @@ +const csp = security.createContentSecurityPolicy({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts new file mode 100644 index 00000000..382a7b76 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts @@ -0,0 +1,3 @@ +const csp = buildPolicy({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts new file mode 100644 index 00000000..4e5b2a65 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: defaultSources, +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts new file mode 100644 index 00000000..52c31170 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts @@ -0,0 +1,3 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(policyOptions); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts new file mode 100644 index 00000000..84861956 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts @@ -0,0 +1,6 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + ...base, + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts new file mode 100644 index 00000000..a4fa3c0a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts @@ -0,0 +1,12 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +export default function handleRequest(request, context) { + const {header} = createContentSecurityPolicy({ + shop: { + checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, + storeDomain: context.env.PUBLIC_STORE_DOMAIN, + }, + }); + + return header; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts new file mode 100644 index 00000000..6697154a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + 'connectSrc': ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts new file mode 100644 index 00000000..e636fdef --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts @@ -0,0 +1,3 @@ +export default function App() { + return null; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts new file mode 100644 index 00000000..a2ff36cc --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts @@ -0,0 +1,3 @@ +import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; + +export const middleware = [createCroctMiddleware()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts new file mode 100644 index 00000000..5b5685c3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts @@ -0,0 +1,5 @@ +import {logger} from './middleware/logger'; + +const VERSION = '1'; + +export const middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts new file mode 100644 index 00000000..29b860af --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts @@ -0,0 +1 @@ +export const middleware = []; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts new file mode 100644 index 00000000..3a909f91 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts @@ -0,0 +1,3 @@ +import {logger} from './middleware/logger'; + +export const middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts new file mode 100644 index 00000000..dce3a402 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts @@ -0,0 +1,3 @@ +import {baseMiddleware} from './middleware/base'; + +export const middleware = baseMiddleware; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts new file mode 100644 index 00000000..1e882fe5 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts @@ -0,0 +1,4 @@ +import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; +import {logger} from './middleware/logger'; + +export const middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts new file mode 100644 index 00000000..67e8ce96 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts @@ -0,0 +1,3 @@ +import {logger} from './middleware/logger'; + +export const version = '1', middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts new file mode 100644 index 00000000..2acf2ae8 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts @@ -0,0 +1,3 @@ +import {buildMiddleware} from './middleware'; + +export const middleware = buildMiddleware(); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts new file mode 100644 index 00000000..cd8e3e43 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts @@ -0,0 +1,4 @@ +import type {MiddlewareFunction} from 'react-router'; +import {logger} from './middleware/logger'; + +export const middleware: MiddlewareFunction[] = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts new file mode 100644 index 00000000..d9f16d77 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts @@ -0,0 +1 @@ +export let middleware; diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx new file mode 100644 index 00000000..357ee84e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx @@ -0,0 +1,9 @@ +import {Analytics as Shopify} from '@shopify/hydrogen'; + +export default function App({data}) { + return + + + + ; +} diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap new file mode 100644 index 00000000..2d781387 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap @@ -0,0 +1,200 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenContextCodemod should correctly transform aliasedFactory.ts: aliasedFactory.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext as createContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform alreadyWired.ts: alreadyWired.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; +import {createCroctContext} from '@croct/plug-hydrogen/server'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext), + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform awaitedContext.ts: awaitedContext.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = await createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform croctAlreadyOnObject.ts: croctAlreadyOnObject.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: customCroct, + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform croctStringLiteralKey.ts: croctStringLiteralKey.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + 'croct': customCroct, + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform destructuredContextId.ts: destructuredContextId.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const {storefront} = createHydrogenContext({env, request}); + + return {storefront}; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform destructuredParam.ts: destructuredParam.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext({request, env}) { + const hydrogenContext = createHydrogenContext({env, request}); + + return hydrogenContext; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform nestedReturn.ts: nestedReturn.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + hydrogenContext.cart = createCartHandler({ + getCartId: () => { + return request.headers.get('Cookie'); + }, + }); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform noFactory.ts: noFactory.ts 1`] = ` +"export async function createAppLoadContext(request, env, executionContext) { + let pending; + const count = 1; + const built = factory.create(); + const handler = createRequestHandler(request); + + return handler; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform noFunction.ts: noFunction.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +const hydrogenContext = createHydrogenContext({env, request}); + +export {hydrogenContext}; +" +`; + +exports[`HydrogenContextCodemod should correctly transform noParams.ts: noParams.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext() { + const hydrogenContext = createHydrogenContext({}); + + return hydrogenContext; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform noReturn.ts: noReturn.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + if (!request) { + return; + } + + doSomething(hydrogenContext); +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform returnIdentifier.ts: returnIdentifier.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({ + env, + request, + cache: await caches.open('hydrogen'), + }); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform returnObject.ts: returnObject.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap new file mode 100644 index 00000000..37a4b2a0 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenCookiesCodemod should correctly transform alreadyPresent.ts: alreadyPresent.ts 1`] = ` +"import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + writeCroctCookies(response, hydrogenContext); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform arrowExpressionBody.ts: arrowExpressionBody.ts 1`] = ` +"export const handle = (request, hydrogenContext) => response.headers.set( + 'Set-Cookie', + hydrogenContext.session.commit(), +); +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform destructuredHeaders.ts: destructuredHeaders.ts 1`] = ` +"export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + const {headers} = response; + + headers.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform differentVars.ts: differentVars.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; + +export async function handleFetch(request, appLoadContext) { + const res = await handleRequest(request); + + if (appLoadContext.session.isPending) { + res.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + writeCroctCookies(res, appLoadContext); + return res; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform directSet.ts: directSet.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; + +export async function handleFetch(request, appLoadContext) { + const response = await handleRequest(request); + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + writeCroctCookies(response, appLoadContext); + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform ifWrapped.ts: ifWrapped.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; +import {createRequestHandler} from '@shopify/hydrogen'; + +export default { + async fetch(request, env, executionContext) { + const hydrogenContext = await createHydrogenRouterContext(request, env, executionContext); + const handleRequest = createRequestHandler({build: serverBuild, getContext: () => hydrogenContext}); + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + writeCroctCookies(response, hydrogenContext); + return response; + }, +}; +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform noFunction.ts: noFunction.ts 1`] = ` +"const response = new Response(); + +response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform noSession.ts: noSession.ts 1`] = ` +"export async function handleFetch(request) { + const response = await handleRequest(request); + + response.headers.set('Set-Cookie', buildCookie()); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform nonHeadersObject.ts: nonHeadersObject.ts 1`] = ` +"export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.cookies.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform notSetCookieHeader.ts: notSetCookieHeader.ts 1`] = ` +"export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.headers.set('Content-Type', 'text/html'); + + return response; +} +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap new file mode 100644 index 00000000..2b5c6572 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenCspCodemod should correctly transform aliasedImport.ts: aliasedImport.ts 1`] = ` +"import {createContentSecurityPolicy as createCsp} from '@shopify/hydrogen'; + +const csp = createCsp({ + connectSrc: ['https://example.com', "https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform alreadyPresent.ts: alreadyPresent.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://api.croct.io'], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform emptyCall.ts: emptyCall.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(); +" +`; + +exports[`HydrogenCspCodemod should correctly transform emptyConnectSrc.ts: emptyConnectSrc.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ["https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform existingConnectSrc.ts: existingConnectSrc.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://example.com', "https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform memberCalleeCall.ts: memberCalleeCall.ts 1`] = ` +"const csp = security.createContentSecurityPolicy({ + connectSrc: ['https://example.com'], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform noCall.ts: noCall.ts 1`] = ` +"const csp = buildPolicy({ + connectSrc: ['https://example.com'], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform nonArrayConnectSrc.ts: nonArrayConnectSrc.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: [ + ...(Array.isArray(defaultSources) ? defaultSources : [defaultSources]), + "https://api.croct.io" + ], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform nonObjectArg.ts: nonObjectArg.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(policyOptions); +" +`; + +exports[`HydrogenCspCodemod should correctly transform spreadOption.ts: spreadOption.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + ...base, + connectSrc: ['https://example.com', "https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform standard.ts: standard.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +export default function handleRequest(request, context) { + const {header} = createContentSecurityPolicy({ + shop: { + checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, + storeDomain: context.env.PUBLIC_STORE_DOMAIN, + }, + + connectSrc: ["https://api.croct.io"] + }); + + return header; +} +" +`; + +exports[`HydrogenCspCodemod should correctly transform stringLiteralKey.ts: stringLiteralKey.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + 'connectSrc': ['https://example.com', "https://api.croct.io"], +}); +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap new file mode 100644 index 00000000..38bc11f4 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenMiddlewareCodemod should correctly transform absent.ts: absent.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; + +export default function App() { + return null; +} + +export const middleware = [createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform alreadyRegistered.ts: alreadyRegistered.ts 1`] = ` +"import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; + +export const middleware = [createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform decoyConst.ts: decoyConst.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {logger} from './middleware/logger'; +const VERSION = '1'; +export const middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform emptyArray.ts: emptyArray.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +export const middleware = [createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform existingArray.ts: existingArray.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {logger} from './middleware/logger'; +export const middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform identifierInit.ts: identifierInit.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {baseMiddleware} from './middleware/base'; +const existingMiddleware = baseMiddleware; + +export const middleware = [ + ...(Array.isArray(existingMiddleware) ? existingMiddleware : [existingMiddleware]), + createCroctMiddleware() +]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform importPresentNoCall.ts: importPresentNoCall.ts 1`] = ` +"import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; +import {logger} from './middleware/logger'; + +export const middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform multipleDeclarators.ts: multipleDeclarators.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {logger} from './middleware/logger'; +export const version = '1', middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform nonArrayInit.ts: nonArrayInit.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {buildMiddleware} from './middleware'; +const existingMiddleware = buildMiddleware(); + +export const middleware = [ + ...(Array.isArray(existingMiddleware) ? existingMiddleware : [existingMiddleware]), + createCroctMiddleware() +]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform typedArray.ts: typedArray.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import type {MiddlewareFunction} from 'react-router'; +import {logger} from './middleware/logger'; +export const middleware: MiddlewareFunction[] = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform uninitializedLet.ts: uninitializedLet.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +export let middleware = [createCroctMiddleware()]; +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap index 0e4e26ea..ace640e8 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap @@ -13,6 +13,24 @@ export default function App({Component, pageProps}: AppProps): ReactElement { " `; +exports[`JsxWrapperCodemod should correctly transform containerAliasedImport.tsx: containerAliasedImport.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics as Shopify} from '@shopify/hydrogen'; + +export default function App({data}) { + return ( + + + + + + + +); +} +" +`; + exports[`JsxWrapperCodemod should correctly transform containerAlreadyWrapped.tsx: containerAlreadyWrapped.tsx 1`] = ` "import {Analytics} from '@shopify/hydrogen'; import {CroctProvider} from '@croct/plug-react'; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap index db528572..62c2e03d 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap @@ -166,13 +166,13 @@ export default defineConfig({ `; exports[`ViteConfigPluginCodemod should correctly transform nonArrayPlugins.ts: nonArrayPlugins.ts 1`] = ` -"import {defineConfig} from 'vite'; +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; import {hydrogen} from '@shopify/hydrogen/vite'; - const basePlugins = [hydrogen()]; export default defineConfig({ - plugins: basePlugins, + plugins: [...(Array.isArray(basePlugins) ? basePlugins : [basePlugins]), croct()], }); " `; diff --git a/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts new file mode 100644 index 00000000..ac0a2608 --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts @@ -0,0 +1,33 @@ +import {resolve} from 'path'; +import type { + HydrogenContextConfiguration, +} from '@/application/project/code/transformation/javascript/hydrogenContextCodemod'; +import {HydrogenContextCodemod} from '@/application/project/code/transformation/javascript/hydrogenContextCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenContextCodemod', () => { + const defaultOptions: HydrogenContextConfiguration = { + factory: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctContext', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-context'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenContextCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); +}); diff --git a/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts new file mode 100644 index 00000000..82c3606a --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts @@ -0,0 +1,33 @@ +import {resolve} from 'path'; +import type { + HydrogenCookiesConfiguration, +} from '@/application/project/code/transformation/javascript/hydrogenCookiesCodemod'; +import {HydrogenCookiesCodemod} from '@/application/project/code/transformation/javascript/hydrogenCookiesCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenCookiesCodemod', () => { + const defaultOptions: HydrogenCookiesConfiguration = { + writer: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'writeCroctCookies', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-cookies'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); +}); diff --git a/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts new file mode 100644 index 00000000..ea2539fb --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts @@ -0,0 +1,28 @@ +import {resolve} from 'path'; +import type {HydrogenCspConfiguration} from '@/application/project/code/transformation/javascript/hydrogenCspCodemod'; +import {HydrogenCspCodemod} from '@/application/project/code/transformation/javascript/hydrogenCspCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenCspCodemod', () => { + const defaultOptions: HydrogenCspConfiguration = { + origin: 'https://api.croct.io', + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-csp'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCspCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); +}); diff --git a/test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts new file mode 100644 index 00000000..da0cdb62 --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts @@ -0,0 +1,35 @@ +import {resolve} from 'path'; +import type { + HydrogenMiddlewareConfiguration, +} from '@/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod'; +import { + HydrogenMiddlewareCodemod, +} from '@/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenMiddlewareCodemod', () => { + const defaultOptions: HydrogenMiddlewareConfiguration = { + middleware: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctMiddleware', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-middleware'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenMiddlewareCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); +}); diff --git a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts index 6272cf02..80470cea 100644 --- a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts @@ -35,6 +35,11 @@ describe('JsxWrapperCodemod', () => { container: 'Analytics.Provider', }, }, + 'containerAliasedImport.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, 'containerRemixTernary.tsx': { targets: { container: 'Analytics.Provider', From 6329d6b582e47a20d28070bf0a49f93fcd245728 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 18 Jun 2026 10:43:13 -0400 Subject: [PATCH 06/12] Wip --- .../slot/hydrogenExampleGenerator.ts | 67 +++-- .../generation/slot/reactExampleGenerator.ts | 16 +- .../javascript/utils/getImportSource.ts | 51 ++++ .../project/import/importResolver.ts | 13 + .../project/import/lazyImportResolver.ts | 4 + .../project/import/nodeImportResolver.ts | 107 +++++++- .../project/sdk/plugHydrogenSdk.ts | 186 +++++++++---- src/infrastructure/application/cli/cli.ts | 1 + .../hydrogenExampleGenerator.test.ts.snap | 247 +++++++++++------- .../slot/hydrogenExampleGenerator.test.ts | 21 +- .../javascript/utils/getImportSource.test.ts | 77 ++++++ 11 files changed, 589 insertions(+), 201 deletions(-) create mode 100644 src/application/project/code/transformation/javascript/utils/getImportSource.ts create mode 100644 test/application/project/code/transformation/javascript/utils/getImportSource.test.ts diff --git a/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts b/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts index 584d674f..6a751515 100644 --- a/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts +++ b/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts @@ -1,4 +1,5 @@ import type {SlotDefinition, SlotExampleGenerator} from './slotExampleGenerator'; +import {ReactExampleGenerator} from './reactExampleGenerator'; import type {CodeExample} from '@/application/project/code/generation/example'; import {CodeLanguage} from '@/application/project/code/generation/example'; import {CodeWriter} from '@/application/project/code/generation/codeWritter'; @@ -10,11 +11,11 @@ import {formatSlug} from '@/application/project/code/generation/utils'; * - `react-router`: React Router 7 (`react-router`, route `+types`). * - `remix`: Remix v2 (`@remix-run/react`, `@shopify/remix-oxygen`). */ -export type HydrogenEra = 'react-router' | 'remix'; +export type HydrogenFramework = 'react-router' | 'remix'; export type Configuration = { typescript: boolean, - era: HydrogenEra, + framework: HydrogenFramework, routeFilePath: string, routeComponentName: string, indentationSize?: number, @@ -22,10 +23,6 @@ export type Configuration = { /** * Generates a Hydrogen route that renders a slot. - * - * The route fetches the slot content server-side in a `loader` via - * `fetchContent('', {context})` and renders it with ``, so the content - * is server-rendered and revalidated on the client. The routing imports follow the era. */ export class HydrogenExampleGenerator implements SlotExampleGenerator { private readonly configuration: Configuration; @@ -35,30 +32,19 @@ export class HydrogenExampleGenerator implements SlotExampleGenerator { } public generate(definition: SlotDefinition): CodeExample { - const slug = formatSlug(definition.id); - const writer = new CodeWriter(this.configuration.indentationSize); + const { + typescript, + framework, + routeFilePath, + routeComponentName, + indentationSize, + } = this.configuration; - this.writeRoute(writer, slug); - - return { - files: [ - { - path: HydrogenExampleGenerator.replaceVariables(this.configuration.routeFilePath, definition.id), - language: this.configuration.typescript - ? CodeLanguage.TYPESCRIPT_XML - : CodeLanguage.JAVASCRIPT_XML, - code: writer.toString(), - }, - ], - }; - } - - private writeRoute(writer: CodeWriter, slug: string): void { - const {typescript, era} = this.configuration; - const isReactRouter = era === 'react-router'; - const name = HydrogenExampleGenerator.replaceVariables(this.configuration.routeComponentName, slug); + const slug = formatSlug(definition.id); + const isReactRouter = framework === 'react-router'; + const name = HydrogenExampleGenerator.replaceVariables(routeComponentName, definition.id); + const writer = new CodeWriter(indentationSize); - writer.write("import {Slot} from '@croct/plug-hydrogen';"); writer.write("import {fetchContent} from '@croct/plug-hydrogen/server';"); writer.write(`import {useLoaderData} from '${isReactRouter ? 'react-router' : '@remix-run/react'}';`); @@ -78,7 +64,7 @@ export class HydrogenExampleGenerator implements SlotExampleGenerator { writer.write(`export async function loader({context}${argsType}) {`) .indent() - .write(`const {content} = await fetchContent('${slug}', {context});`) + .write(`const {content} = await fetchContent('${slug}', {scope: context});`) .newLine() .write('return {content};') .outdent() @@ -88,19 +74,28 @@ export class HydrogenExampleGenerator implements SlotExampleGenerator { writer.write(`export default function ${name}() {`) .indent() - .write(`const data = useLoaderData${typescript ? '' : ''}();`) + .write(`const {content} = useLoaderData${typescript ? '' : ''}();`) .newLine() .write('return (') - .indent() - .write(``) - .indent() - .write('{({content}) =>
    {JSON.stringify(content, null, 2)}
    }') - .outdent() - .write('
    ') + .indent(); + + ReactExampleGenerator.renderComponentSnippet(writer, definition.definition, 'content'); + + writer .outdent() .write(');') .outdent() .write('}'); + + return { + files: [ + { + path: HydrogenExampleGenerator.replaceVariables(routeFilePath, definition.id), + language: typescript ? CodeLanguage.TYPESCRIPT_XML : CodeLanguage.JAVASCRIPT_XML, + code: writer.toString(), + }, + ], + }; } private static replaceVariables(value: string, id: string): string { diff --git a/src/application/project/code/generation/slot/reactExampleGenerator.ts b/src/application/project/code/generation/slot/reactExampleGenerator.ts index 09a64197..96887470 100644 --- a/src/application/project/code/generation/slot/reactExampleGenerator.ts +++ b/src/application/project/code/generation/slot/reactExampleGenerator.ts @@ -161,7 +161,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { writer.write('return (') .indent(); - this.writeRenderingSnippet(writer, definition.definition, this.options.contentVariable); + ReactExampleGenerator.renderComponentSnippet(writer, definition.definition, this.options.contentVariable); writer .outdent() @@ -197,7 +197,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { protected abstract writeSlotHeader(writer: CodeWriter, definition: SlotDefinition): void; - private writeRenderingSnippet(writer: CodeWriter, definition: ContentDefinition, path: string): void { + public static renderComponentSnippet(writer: CodeWriter, definition: ContentDefinition, path: string): void { switch (definition.type) { case 'number': case 'text': @@ -238,7 +238,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { writer.appendIndentation(); } - this.writeRenderingSnippet(writer, definition.items, variable); + ReactExampleGenerator.renderComponentSnippet(writer, definition.items, variable); if (inline) { writer.newLine(); @@ -270,7 +270,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { writer.indent(); } - this.writeAttributeSnippet(writer, {name: name, ...attribute}, path); + ReactExampleGenerator.writeAttributeSnippet(writer, {name: name, ...attribute}, path); if (attribute.optional === true) { writer.outdent(); @@ -297,7 +297,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { .write(`{${path}._type === '${id}' && (`) .indent(); - this.writeRenderingSnippet(writer, variant, path); + ReactExampleGenerator.renderComponentSnippet(writer, variant, path); writer .outdent() @@ -315,7 +315,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { } } - private writeAttributeSnippet(writer: CodeWriter, attribute: Attribute, path: string): void { + private static writeAttributeSnippet(writer: CodeWriter, attribute: Attribute, path: string): void { const definition = attribute.type; const label = ReactExampleGenerator.escapeEntities(attribute.label ?? formatLabel(attribute.name)); @@ -327,7 +327,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { .write('
  • ', false) .append(`${label}: `); - this.writeRenderingSnippet(writer, definition, `${path}.${attribute.name}`); + ReactExampleGenerator.renderComponentSnippet(writer, definition, `${path}.${attribute.name}`); writer.append('
  • ') .newLine(); @@ -341,7 +341,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { .indent() .write(`${label}`); - this.writeRenderingSnippet(writer, definition, `${path}.${attribute.name}`); + ReactExampleGenerator.renderComponentSnippet(writer, definition, `${path}.${attribute.name}`); writer .outdent() diff --git a/src/application/project/code/transformation/javascript/utils/getImportSource.ts b/src/application/project/code/transformation/javascript/utils/getImportSource.ts new file mode 100644 index 00000000..f5c96f2f --- /dev/null +++ b/src/application/project/code/transformation/javascript/utils/getImportSource.ts @@ -0,0 +1,51 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import {parse} from '@/application/project/code/transformation/javascript/utils/parse'; + +/** + * Returns the module specifier from which the given value is imported, or null when it is not + * imported anywhere. + * + * The inverse of `getImportLocalName`: it answers "where does this come from?". Default imports are + * matched against `default`, namespace imports against `*`, and named imports against their + * imported (not local) name. + */ +export function getImportSource(source: string | t.File, importName: string | RegExp): string | null { + const ast = typeof source === 'string' + ? parse(source, ['jsx', 'typescript']) + : source; + + let moduleName: string | null = null; + + traverse(ast, { + ImportDeclaration: path => { + for (const specifier of path.node.specifiers) { + if (matches(getImportedName(specifier), importName)) { + moduleName = path.node.source.value; + + return path.stop(); + } + } + + return path.skip(); + }, + }); + + return moduleName; +} + +function getImportedName(specifier: t.ImportDeclaration['specifiers'][number]): string { + if (t.isImportDefaultSpecifier(specifier)) { + return 'default'; + } + + if (t.isImportNamespaceSpecifier(specifier)) { + return '*'; + } + + return t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value; +} + +function matches(value: string, matcher: string | RegExp): boolean { + return typeof matcher === 'string' ? value === matcher : matcher.test(value); +} diff --git a/src/application/project/import/importResolver.ts b/src/application/project/import/importResolver.ts index dc0d8f85..4f86876f 100644 --- a/src/application/project/import/importResolver.ts +++ b/src/application/project/import/importResolver.ts @@ -10,5 +10,18 @@ export class ImportResolverError extends HelpfulError { } export interface ImportResolver { + /** + * Returns the import specifier to use for `filePath` from `importPath` (file → specifier). + */ getImportPath(filePath: string, importPath?: string): Promise; + + /** + * Resolves an import specifier written in `sourcePath` to the project-relative file path it + * points to (specifier → file), the inverse of {@link getImportPath}. + * + * Honors the project's tsconfig `paths`/`baseUrl` aliases and relative specifiers, then probes + * extensions (`.ts`, `.tsx`, `.js`, `.jsx`, and `index.*`) the way the TypeScript/Node resolver + * does. Returns null for bare packages or when no matching file exists. + */ + resolveImport(importPath: string, sourcePath: string): Promise; } diff --git a/src/application/project/import/lazyImportResolver.ts b/src/application/project/import/lazyImportResolver.ts index 98965e2b..b6ff5606 100644 --- a/src/application/project/import/lazyImportResolver.ts +++ b/src/application/project/import/lazyImportResolver.ts @@ -23,4 +23,8 @@ export class LazyImportResolver implements ImportResolver { public async getImportPath(filePath: string, importPath?: string): Promise { return (await this.resolver).getImportPath(filePath, importPath); } + + public async resolveImport(importPath: string, sourcePath: string): Promise { + return (await this.resolver).resolveImport(importPath, sourcePath); + } } diff --git a/src/application/project/import/nodeImportResolver.ts b/src/application/project/import/nodeImportResolver.ts index af97d31f..1f446ddf 100644 --- a/src/application/project/import/nodeImportResolver.ts +++ b/src/application/project/import/nodeImportResolver.ts @@ -1,7 +1,7 @@ import type {ImportResolver} from '@/application/project/import/importResolver'; import type {WorkingDirectory} from '@/application/fs/workingDirectory/workingDirectory'; import type {FileSystem} from '@/application/fs/fileSystem'; -import type {TsConfigLoader} from '@/application/project/import/tsConfigLoader'; +import type {NodeImportConfig, TsConfigLoader} from '@/application/project/import/tsConfigLoader'; export type Configuration = { projectDirectory: WorkingDirectory, @@ -10,6 +10,8 @@ export type Configuration = { }; export class NodeImportResolver implements ImportResolver { + private static readonly EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs']; + private readonly projectDirectory: WorkingDirectory; private readonly fileSystem: FileSystem; @@ -92,4 +94,107 @@ export class NodeImportResolver implements ImportResolver { : resolvedRelativePath, ); } + + public async resolveImport(importPath: string, sourcePath: string): Promise { + const projectDirectory = this.projectDirectory.get(); + const absoluteSourcePath = this.fileSystem.isAbsolutePath(sourcePath) + ? sourcePath + : this.fileSystem.joinPaths(projectDirectory, sourcePath); + + let candidates: string[]; + + if (importPath.startsWith('.')) { + // Relative specifier resolved against the importing file's directory. + candidates = [this.fileSystem.joinPaths(this.fileSystem.getDirectoryName(absoluteSourcePath), importPath)]; + } else { + const config = await this.tsConfigLoader.load(projectDirectory, {sourcePaths: [absoluteSourcePath]}); + + // A bare specifier (a package, not a project alias) is not resolvable to a project file. + candidates = config !== null ? this.resolveAliases(config, importPath) : []; + } + + for (const candidate of candidates) { + const file = await this.findFile(candidate); + + if (file !== null) { + return this.fileSystem.getRelativePath(projectDirectory, file); + } + } + + return null; + } + + /** + * Maps an import specifier to the candidate base paths of every matching tsconfig `paths` alias, + * most specific (longest literal prefix) first. + */ + private resolveAliases(config: NodeImportConfig, importPath: string): string[] { + const specifier = this.fileSystem.normalizeSeparators(importPath); + const matches: Array<{prefixLength: number, bases: string[]}> = []; + + for (const [pattern, targets] of Object.entries(config.paths)) { + const wildcard = pattern.indexOf('*'); + let substitution: string | null = null; + + if (wildcard === -1) { + substitution = specifier === this.fileSystem.normalizeSeparators(pattern) ? '' : null; + } else { + const prefix = this.fileSystem.normalizeSeparators(pattern.slice(0, wildcard)); + const suffix = pattern.slice(wildcard + 1); + + if ( + specifier.startsWith(prefix) && specifier.endsWith(suffix) + && specifier.length >= prefix.length + suffix.length + ) { + substitution = specifier.slice(prefix.length, specifier.length - suffix.length); + } + } + + if (substitution === null) { + continue; + } + + matches.push({ + prefixLength: wildcard === -1 ? pattern.length : wildcard, + bases: targets.map( + target => this.fileSystem.joinPaths( + config.baseUrl, + target.includes('*') ? target.replace('*', substitution ?? '') : target, + ), + ), + }); + } + + return matches + .sort((first, second) => second.prefixLength - first.prefixLength) + .flatMap(match => match.bases); + } + + /** + * Probes a base path for the actual module file, mirroring TypeScript/Node extension and + * directory-index resolution. + */ + private async findFile(base: string): Promise { + if (/\.[mc]?[jt]sx?$/.test(base) && await this.fileSystem.exists(base)) { + return base; + } + + for (const extension of NodeImportResolver.EXTENSIONS) { + const file = `${base}.${extension}`; + + if (await this.fileSystem.exists(file)) { + return file; + } + } + + for (const extension of NodeImportResolver.EXTENSIONS) { + const file = this.fileSystem.joinPaths(base, `index.${extension}`); + + if (await this.fileSystem.exists(file)) { + return file; + } + } + + return null; + } } diff --git a/src/application/project/sdk/plugHydrogenSdk.ts b/src/application/project/sdk/plugHydrogenSdk.ts index dec14669..fb36338c 100644 --- a/src/application/project/sdk/plugHydrogenSdk.ts +++ b/src/application/project/sdk/plugHydrogenSdk.ts @@ -17,12 +17,16 @@ import type {UserApi} from '@/application/api/user'; import type {ApplicationApi, GeneratedApiKey} from '@/application/api/application'; import {ApiKeyPermission} from '@/application/model/application'; import {ApiError} from '@/application/api/error'; +import {getImportSource} from '@/application/project/code/transformation/javascript/utils/getImportSource'; +import type {ImportResolver} from '@/application/project/import/importResolver'; /** - * The Hydrogen era. The boundary is `@shopify/hydrogen@2025.5.0`, where the skeleton migrated from + * The Hydrogen's underlying framework, which determines the codemod transformations. + * + * The boundary is `@shopify/hydrogen@2025.5.0`, where the skeleton migrated from * Remix to React Router 7. */ -type Era = 'react-router' | 'remix'; +type Framework = 'react-router' | 'remix'; type CodemodConfiguration = { /** @@ -60,6 +64,7 @@ export type Configuration = JavaScriptSdkConfiguration & { codemod: CodemodConfiguration, userApi: UserApi, applicationApi: ApplicationApi, + importResolver: ImportResolver, }; enum HydrogenEnvVar { @@ -68,7 +73,7 @@ enum HydrogenEnvVar { } type HydrogenProjectInfo = { - era: Era, + framework: Framework, viteConfig: string | null, server: string | null, root: string | null, @@ -81,6 +86,28 @@ type HydrogenInstallation = Installation & { project: HydrogenProjectInfo, }; +type CodemodTaskOptions = { + /** + * The task title, shown while it runs. + */ + title: string, + + /** + * The success message confirmed once the codemod is applied. + */ + confirmation: string, + + /** + * The codemod to apply. + */ + codemod: keyof CodemodConfiguration, + + /** + * The target file, or null when it could not be located. + */ + file: string | null, +}; + export class PlugHydrogenSdk extends JavaScriptSdk { private readonly codemod: CodemodConfiguration; @@ -88,12 +115,15 @@ export class PlugHydrogenSdk extends JavaScriptSdk { private readonly applicationApi: ApplicationApi; + private readonly importResolver: ImportResolver; + public constructor(configuration: Configuration) { super(configuration); this.codemod = configuration.codemod; this.userApi = configuration.userApi; this.applicationApi = configuration.applicationApi; + this.importResolver = configuration.importResolver; } public getPaths(configuration: ProjectConfiguration): Promise { @@ -112,16 +142,16 @@ export class PlugHydrogenSdk extends JavaScriptSdk { } protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { - const [isTypeScript, era] = await Promise.all([ + const [isTypeScript, framework] = await Promise.all([ this.isTypeScriptProject(), - this.detectEra(), + this.detectFramework(), ]); const paths = await this.getPaths(installation.configuration); const generator = new HydrogenExampleGenerator({ typescript: isTypeScript, - era: era, + framework: framework, routeFilePath: this.fileSystem.joinPaths(paths.examples, `%slug%${isTypeScript ? '.tsx' : '.jsx'}`), routeComponentName: '%name%Route', }); @@ -149,17 +179,27 @@ export class PlugHydrogenSdk extends JavaScriptSdk { private async getProjectInfo(): Promise { const projectDirectory = this.projectDirectory.get(); - const [era, viteConfig, server, root, context, entryServer] = await Promise.all([ - this.detectEra(), - this.locateFile('vite.config.ts', 'vite.config.js', 'vite.config.mts'), + const [framework, viteConfig, server, root, entryServer] = await Promise.all([ + this.detectFramework(), + this.locateFile( + 'vite.config.ts', + 'vite.config.mts', + 'vite.config.cts', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cjs', + ), this.locateFile('server.ts', 'server.js'), this.locateFile('app/root.tsx', 'app/root.jsx'), - this.locateFile('app/lib/context.ts', 'app/lib/context.js'), this.locateFile('app/entry.server.tsx', 'app/entry.server.jsx'), ]); + // The load-context module is app code, not a fixed framework path, so follow its import + // from the server entry before assuming the skeleton's conventional location. + const context = await this.locateContext(server); + return { - era: era, + framework: framework, viteConfig: viteConfig, server: server, root: root, @@ -172,19 +212,60 @@ export class PlugHydrogenSdk extends JavaScriptSdk { /** * Detects the era from the `@shopify/hydrogen` version, falling back to the routing dependency. */ - private async detectEra(): Promise { - const [byVersion, hasReactRouter] = await Promise.all([ + private async detectFramework(): Promise { + const [hydrogenVersionUsesReactRouter, hasReactRouterDependency] = await Promise.all([ this.packageManager.hasDirectDependency('@shopify/hydrogen', '>=2025.5.0'), this.packageManager.hasDirectDependency('react-router'), ]); - return byVersion || hasReactRouter ? 'react-router' : 'remix'; + return hydrogenVersionUsesReactRouter || hasReactRouterDependency ? 'react-router' : 'remix'; + } + + /** + * Locates the load-context module. + * + * The skeleton puts it at `app/lib/context.*`, but it is ordinary app code referenced from the + * server entry (e.g. `import {createAppLoadContext} from '~/lib/context'`), so it is resolved by + * following that import first, falling back to the conventional path. + */ + private async locateContext(server: string | null): Promise { + const imported = server !== null ? await this.followContextImport(server) : null; + + if (imported !== null) { + return imported; + } + + return this.locateFile( + 'app/lib/context.ts', + 'app/lib/context.tsx', + 'app/lib/context.js', + 'app/lib/context.jsx', + ); + } + + /** + * Resolves the load-context module by following its import in the server entry. + * + * The factory is imported from a local module (e.g. `import {createAppLoadContext} from + * '~/lib/context'`); its name varies by era (`createAppLoadContext`, `createHydrogenRouterContext`). + * The specifier is resolved through the project's tsconfig aliases. + */ + private async followContextImport(server: string): Promise { + const source = await this.readFile(server); + + if (source === null) { + return null; + } + + const specifier = getImportSource(source, /^create[A-Za-z]*Context$/); + + return specifier !== null ? this.importResolver.resolveImport(specifier, server) : null; } private getInstallationTasks(installation: HydrogenInstallation): Task[] { const {project} = installation; - const tasks: Task[] = [ + return [ { title: 'Set up environment variables', task: async notifier => { @@ -199,19 +280,47 @@ export class PlugHydrogenSdk extends JavaScriptSdk { } }, }, - this.getCodemodTask('Register Vite plugin', 'vite', project.viteConfig), - project.era === 'react-router' - ? this.getCodemodTask('Register middleware', 'middleware', project.root) - : this.getCodemodTask('Expose Croct context', 'context', project.context), - this.getCodemodTask('Write Croct cookies', 'cookies', project.server), - this.getProviderTask(project.root), - this.getCodemodTask('Configure content security policy', 'csp', project.entryServer), + this.getCodemodTask({ + title: 'Register Vite plugin', + confirmation: 'Vite plugin registered', + codemod: 'vite', + file: project.viteConfig, + }), + project.framework === 'react-router' + ? this.getCodemodTask({ + title: 'Register middleware', + confirmation: 'Middleware registered', + codemod: 'middleware', + file: project.root, + }) + : this.getCodemodTask({ + title: 'Expose Croct context', + confirmation: 'Croct context exposed', + codemod: 'context', + file: project.context, + }), + this.getCodemodTask({ + title: 'Write Croct cookies', + confirmation: 'Croct cookies written', + codemod: 'cookies', + file: project.server, + }), + this.getCodemodTask({ + title: 'Configure provider', + confirmation: 'Provider configured', + codemod: 'provider', + file: project.root, + }), + this.getCodemodTask({ + title: 'Configure content security policy', + confirmation: 'Content security policy configured', + codemod: 'csp', + file: project.entryServer, + }), ]; - - return tasks; } - private getCodemodTask(title: string, codemod: keyof CodemodConfiguration, file: string | null): Task { + private getCodemodTask({title, confirmation, codemod, file}: CodemodTaskOptions): Task { return { title: title, task: async notifier => { @@ -226,32 +335,11 @@ export class PlugHydrogenSdk extends JavaScriptSdk { try { await this.applyCodemod(this.codemod[codemod], file); - notifier.confirm(title); + notifier.confirm(confirmation); } catch (error) { - notifier.alert(`Failed: ${title}`, HelpfulError.formatMessage(error)); - } - }, - }; - } + const action = `${title.charAt(0).toLowerCase()}${title.slice(1)}`; - private getProviderTask(file: string | null): Task { - return { - title: 'Configure provider', - task: async notifier => { - notifier.update('Configuring provider'); - - if (file === null) { - notifier.warn('Configure provider: app/root not found'); - - return; - } - - try { - await this.applyCodemod(this.codemod.provider, file); - - notifier.confirm('Provider configured'); - } catch (error) { - notifier.alert('Failed to configure provider', HelpfulError.formatMessage(error)); + notifier.alert(`Failed to ${action}`, HelpfulError.formatMessage(error)); } }, }; diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 64c2e430..421b23d4 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -1921,6 +1921,7 @@ export class Cli { ...config, userApi: this.getUserApi(), applicationApi: this.getApplicationApi(), + importResolver: importResolver, codemod: { vite: new FormatCodemod( formatter, diff --git a/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap index cca68aa4..de0725ef 100644 --- a/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap +++ b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap @@ -4,24 +4,24 @@ exports[`HydrogenExampleGenerator should generate a route for booleanWithLabel: { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/feature-toggle'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('feature-toggle', {context}); + const {content} = await fetchContent('feature-toggle', {scope: context}); return {content}; } export default function FeatureToggleRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Enabled: {content.enabled ? 'Active' : 'Inactive'}
    • +
    • Visible: {content.visible ? 'Yes' : 'No'}
    • +
    ); } ", @@ -36,24 +36,23 @@ exports[`HydrogenExampleGenerator should generate a route for labeledAttributes: { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/stylized'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('stylized', {context}); + const {content} = await fetchContent('stylized', {scope: context}); return {content}; } export default function StylizedRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Tom & Jerry <special>: {content.title}
    • +
    ); } ", @@ -68,24 +67,32 @@ exports[`HydrogenExampleGenerator should generate a route for listOfScalars: lis { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/tag-cloud'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('tag-cloud', {context}); + const {content} = await fetchContent('tag-cloud', {scope: context}); return {content}; } export default function TagCloudRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • + Tags +
        + {content.tags.map((item, index) => ( +
      1. + {item} +
      2. + ))} +
      +
    • +
    ); } ", @@ -100,24 +107,35 @@ exports[`HydrogenExampleGenerator should generate a route for listOfStructures: { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/product-grid'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('product-grid', {context}); + const {content} = await fetchContent('product-grid', {scope: context}); return {content}; } export default function ProductGridRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • + Products +
        + {content.products.map((product, index) => ( +
      1. +
          +
        • Name: {product.name}
        • +
        • Price: {product.price}
        • +
        +
      2. + ))} +
      +
    • +
    ); } ", @@ -132,24 +150,31 @@ exports[`HydrogenExampleGenerator should generate a route for nestedStructure: n { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/author-card'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('author-card', {context}); + const {content} = await fetchContent('author-card', {scope: context}); return {content}; } export default function AuthorCardRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • + Author +
        +
      • Name: {content.author.name}
      • + {content.author.bio && ( +
      • Bio: {content.author.bio}
      • + )} +
      +
    • +
    ); } ", @@ -164,24 +189,26 @@ exports[`HydrogenExampleGenerator should generate a route for optionalAttributes { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/promo-card'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('promo-card', {context}); + const {content} = await fetchContent('promo-card', {scope: context}); return {content}; } export default function PromoCardRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Title: {content.title}
    • + {content.subtitle && ( +
    • Subtitle: {content.subtitle}
    • + )} +
    ); } ", @@ -196,24 +223,23 @@ exports[`HydrogenExampleGenerator should generate a route for privateAttributes: { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/tracked-banner'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('tracked-banner', {context}); + const {content} = await fetchContent('tracked-banner', {scope: context}); return {content}; } export default function TrackedBannerRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Headline: {content.headline}
    • +
    ); } ", @@ -228,24 +254,25 @@ exports[`HydrogenExampleGenerator should generate a route for simpleStructure: s { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/home-hero'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('home-hero', {context}); + const {content} = await fetchContent('home-hero', {scope: context}); return {content}; } export default function HomeHeroRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    ); } ", @@ -260,24 +287,35 @@ exports[`HydrogenExampleGenerator should generate a route for unionInStructure: { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/block'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('block', {context}); + const {content} = await fetchContent('block', {scope: context}); return {content}; } export default function BlockRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • + Media + {content.media._type === 'image' && ( +
        +
      • Url: {content.media.url}
      • +
      + )} + {content.media._type === 'video' && ( +
        +
      • Source: {content.media.source}
      • +
      + )} +
    • +
    ); } ", @@ -292,24 +330,33 @@ exports[`HydrogenExampleGenerator should generate a route for unionRoot: unionRo { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/cta-block'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('cta-block', {context}); + const {content} = await fetchContent('cta-block', {scope: context}); return {content}; } export default function CtaBlockRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    + <> + {content._type === 'button' && ( +
      +
    • Label: {content.label}
    • +
    + )} + {content._type === 'link' && ( +
      +
    • Url: {content.url}
    • +
    • Text: {content.text}
    • +
    + )} + ); } ", @@ -324,23 +371,24 @@ exports[`HydrogenExampleGenerator should generate the react-router-js route: rea { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; export async function loader({context}) { - const {content} = await fetchContent('home-hero', {context}); + const {content} = await fetchContent('home-hero', {scope: context}); return {content}; } export default function HomeHeroRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    ); } ", @@ -355,24 +403,25 @@ exports[`HydrogenExampleGenerator should generate the react-router-ts route: rea { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from 'react-router'; import type {Route} from './+types/home-hero'; export async function loader({context}: Route.LoaderArgs) { - const {content} = await fetchContent('home-hero', {context}); + const {content} = await fetchContent('home-hero', {scope: context}); return {content}; } export default function HomeHeroRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    ); } ", @@ -387,23 +436,24 @@ exports[`HydrogenExampleGenerator should generate the remix-js route: remix-js 1 { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from '@remix-run/react'; export async function loader({context}) { - const {content} = await fetchContent('home-hero', {context}); + const {content} = await fetchContent('home-hero', {scope: context}); return {content}; } export default function HomeHeroRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    ); } ", @@ -418,24 +468,25 @@ exports[`HydrogenExampleGenerator should generate the remix-ts route: remix-ts 1 { "files": [ { - "code": "import {Slot} from '@croct/plug-hydrogen'; -import {fetchContent} from '@croct/plug-hydrogen/server'; + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; import {useLoaderData} from '@remix-run/react'; import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; export async function loader({context}: LoaderFunctionArgs) { - const {content} = await fetchContent('home-hero', {context}); + const {content} = await fetchContent('home-hero', {scope: context}); return {content}; } export default function HomeHeroRoute() { - const data = useLoaderData(); + const {content} = useLoaderData(); return ( - - {({content}) =>
    {JSON.stringify(content, null, 2)}
    } -
    +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    ); } ", diff --git a/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts index 39c64275..1aeb4516 100644 --- a/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts +++ b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts @@ -21,20 +21,21 @@ describe('HydrogenExampleGenerator', () => { const baseOptions: Configuration = { typescript: true, - era: 'react-router', + framework: 'react-router', routeFilePath: 'app/routes/%slug%.tsx', routeComponentName: '%name%Route', }; const variants: Array<{label: string, options: Partial}> = [ - {label: 'react-router-ts', options: {era: 'react-router', typescript: true}}, - {label: 'react-router-js', options: {era: 'react-router', typescript: false}}, - {label: 'remix-ts', options: {era: 'remix', typescript: true}}, - {label: 'remix-js', options: {era: 'remix', typescript: false}}, + {label: 'react-router-ts', options: {framework: 'react-router', typescript: true}}, + {label: 'react-router-js', options: {framework: 'react-router', typescript: false}}, + {label: 'remix-ts', options: {framework: 'remix', typescript: true}}, + {label: 'remix-js', options: {framework: 'remix', typescript: false}}, ]; it.each(variants)('should generate the $label route', ({label, options}) => { - // The content shape does not affect the route, so a single fixture exercises every variant. + // A single fixture exercises every era/language variant; the content-shape variations are + // covered by the per-fixture cases below. const example = new HydrogenExampleGenerator({...baseOptions, ...options}) .generate(loadFixture('simpleStructure.json')); @@ -47,12 +48,14 @@ describe('HydrogenExampleGenerator', () => { expect(example).toMatchSnapshot(name); }); - it('fetches the slot server-side and seeds the client Slot', () => { + it('fetches the slot server-side and renders the content directly', () => { const [route] = new HydrogenExampleGenerator(baseOptions) .generate(loadFixture('simpleStructure.json')) .files; - expect(route.code).toContain("const {content} = await fetchContent('home-hero', {context});"); - expect(route.code).toContain(''); + expect(route.code).toContain("const {content} = await fetchContent('home-hero', {scope: context});"); + expect(route.code).toContain('const {content} = useLoaderData();'); + expect(route.code).not.toContain(' { + type Scenario = { + description: string, + code: string, + importName: string | RegExp, + expected: string | null, + }; + + it.each([ + { + description: 'return null when there is no import', + code: '', + importName: 'sdk', + expected: null, + }, + { + description: 'return null when no import matches the name', + code: "import {sdk} from 'croct';", + importName: 'something', + expected: null, + }, + { + description: 'return the source of a named import', + code: "import {createAppLoadContext} from '~/lib/context';", + importName: 'createAppLoadContext', + expected: '~/lib/context', + }, + { + description: 'match the imported name, not the local alias', + code: "import {createAppLoadContext as build} from '~/lib/context';", + importName: 'createAppLoadContext', + expected: '~/lib/context', + }, + { + description: 'match a literal named import', + code: "import {'createContext' as build} from '~/lib/context';", + importName: 'createContext', + expected: '~/lib/context', + }, + { + description: 'match a default import against `default`', + code: "import sdk from 'croct';", + importName: 'default', + expected: 'croct', + }, + { + description: 'match a namespace import against `*`', + code: "import * as sdk from 'croct';", + importName: '*', + expected: 'croct', + }, + { + description: 'match the imported name by regex', + code: "import {createHydrogenRouterContext} from '~/lib/context';", + importName: /^create[A-Za-z]*Context$/, + expected: '~/lib/context', + }, + { + description: 'return the first matching import source', + code: "import type {AppLoadContext} from '@shopify/remix-oxygen';\n" + + "import {createAppLoadContext} from '~/lib/context';", + importName: /^create[A-Za-z]*Context$/, + expected: '~/lib/context', + }, + ])('should $description', ({code, importName, expected}) => { + expect(getImportSource(code, importName)).toBe(expected); + }); + + it('should accept an already-parsed AST', () => { + const ast = parse("import {sdk} from 'croct';", ['typescript']); + + expect(getImportSource(ast, 'sdk')).toBe('croct'); + }); +}); From c9f721e4d0f27ee1a4f9b80c96b19aa1802d002e Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 18 Jun 2026 13:10:14 -0400 Subject: [PATCH 07/12] Wip --- .../javascript/hydrogenCookiesCodemod.ts | 74 +++++++++++++------ .../javascript/jsxWrapperCodemod.ts | 31 +++++++- .../project/sdk/plugHydrogenSdk.ts | 4 +- .../alreadyPresentOutsideBlock.ts | 20 +++++ .../hydrogen-cookies/tryCatchWrapped.ts | 30 ++++++++ .../jsx-wrapper/containerEarlyReturn.tsx | 17 +++++ .../hydrogenCookiesCodemod.test.ts.snap | 62 ++++++++++++++++ .../jsxWrapperCodemod.test.ts.snap | 24 ++++++ .../javascript/hydrogenCookiesCodemod.test.ts | 36 +++++++++ .../javascript/jsxWrapperCodemod.test.ts | 5 ++ 10 files changed, 278 insertions(+), 25 deletions(-) create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts create mode 100644 test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx diff --git a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts index a5f57264..5212df00 100644 --- a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts @@ -1,5 +1,6 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; +import type {NodePath} from '@babel/core'; import {traverseFast} from '@babel/types'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; @@ -20,16 +21,12 @@ type Anchor = { response: t.Expression, context: t.Expression, statements: t.Statement[], - call: t.CallExpression, + after: t.Statement, + scope: t.Statement[], }; /** * Writes the Croct visitor cookies after Hydrogen commits its session. - * - * Finds the `.headers.set('Set-Cookie', … .session.commit())` statement in the - * server fetch handler and inserts `(, )` right after it (so it runs - * after the session `Set-Cookie`, which is replaced rather than appended). Adds the import. - * Returns unmodified when the anchor is absent or the writer is already called. */ export class HydrogenCookiesCodemod implements Codemod { private readonly configuration: HydrogenCookiesConfiguration; @@ -51,7 +48,7 @@ export class HydrogenCookiesCodemod implements Codemod { importName: writer.importName, }); - if (importedName !== null && HydrogenCookiesCodemod.hasCall(anchor.statements, importedName)) { + if (importedName !== null && HydrogenCookiesCodemod.hasCall(anchor.scope, importedName)) { return Promise.resolve({modified: false, result: input}); } @@ -62,7 +59,7 @@ export class HydrogenCookiesCodemod implements Codemod { localName: writer.localName, }); - const index = anchor.statements.findIndex(statement => HydrogenCookiesCodemod.contains(statement, anchor.call)); + const index = anchor.statements.indexOf(anchor.after); anchor.statements.splice( index + 1, @@ -101,11 +98,24 @@ export class HydrogenCookiesCodemod implements Codemod { return; } + const insertion = HydrogenCookiesCodemod.findInsertionPoint(path); + + if (insertion === null) { + return; + } + + const block = insertion.parentPath; + + if (block === null || !block.isBlockStatement()) { + return; + } + anchor = { response: response, context: context, - statements: fn.node.body.body, - call: path.node, + statements: block.node.body, + after: insertion.node, + scope: fn.node.body.body, }; path.stop(); @@ -115,6 +125,38 @@ export class HydrogenCookiesCodemod implements Codemod { return anchor; } + private static findInsertionPoint(callPath: NodePath): NodePath | null { + let statement = callPath.getStatementParent(); + + while (statement !== null) { + const parent = statement.parentPath; + + if (parent === null) { + break; + } + + if (parent.isIfStatement()) { + // Braceless guard: `if (session.isPending) `. + statement = parent; + + continue; + } + + const grandParent = parent.parentPath; + + if (parent.isBlockStatement() && grandParent !== null && grandParent.isIfStatement()) { + // Braced guard: `if (session.isPending) { }`. + statement = grandParent; + + continue; + } + + break; + } + + return statement; + } + /** * Returns the response expression of a `.headers.set('Set-Cookie', …)` call. */ @@ -186,16 +228,4 @@ export class HydrogenCookiesCodemod implements Codemod { return called; }); } - - private static contains(statement: t.Statement, target: t.Node): boolean { - let found = false; - - traverseFast(statement, node => { - if (node === target) { - found = true; - } - }); - - return found; - } } diff --git a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts index 0280cf0d..d48f239f 100644 --- a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts +++ b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts @@ -270,7 +270,16 @@ export class JsxWrapperCodemod implem * @return the result of the transformation. */ private wrapBlockStatement(node: t.BlockStatement, component: string, ast: t.File, options?: O): Transformation { - const returnStatement = JsxWrapperCodemod.findReturnStatement(node); + const container = this.configuration.targets?.container; + + // When wrapping the children of a container element, target the return that actually renders + // it: a component may guard with early returns (e.g. `if (!data) return ;`) before + // the branch that mounts the container. + const returnStatement = container !== undefined + ? this.findReturnWithElement(node, JsxWrapperCodemod.resolveElementName(ast, container)) + ?? JsxWrapperCodemod.findReturnStatement(node) + : JsxWrapperCodemod.findReturnStatement(node); + const argument = returnStatement?.argument ?? null; if (returnStatement !== null && argument !== null) { @@ -654,4 +663,24 @@ export class JsxWrapperCodemod implem return returnStatement; } + + /** + * Finds the first return statement whose argument renders an element with the given (dotted) name. + */ + private findReturnWithElement(body: t.BlockStatement, name: string): t.ReturnStatement | null { + let match: t.ReturnStatement | null = null; + + traverseFast(body, node => { + if ( + match === null + && t.isReturnStatement(node) + && node.argument != null + && this.findElement(node.argument, name) !== null + ) { + match = node; + } + }); + + return match; + } } diff --git a/src/application/project/sdk/plugHydrogenSdk.ts b/src/application/project/sdk/plugHydrogenSdk.ts index fb36338c..9394fb1c 100644 --- a/src/application/project/sdk/plugHydrogenSdk.ts +++ b/src/application/project/sdk/plugHydrogenSdk.ts @@ -300,8 +300,8 @@ export class PlugHydrogenSdk extends JavaScriptSdk { file: project.context, }), this.getCodemodTask({ - title: 'Write Croct cookies', - confirmation: 'Croct cookies written', + title: 'Configure Croct cookies', + confirmation: 'Croct cookies configured', codemod: 'cookies', file: project.server, }), diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts new file mode 100644 index 00000000..a69fa922 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts @@ -0,0 +1,20 @@ +import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, appLoadContext) { + let response; + + try { + response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + } catch (error) { + response = new Response('error', {status: 500}); + } + + // Already called, but in the function body — not in the `try` block that holds the Set-Cookie. + writeCroctCookies(response, appLoadContext); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts new file mode 100644 index 00000000..7b1eeb3b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts @@ -0,0 +1,30 @@ +import {storefrontRedirect, createRequestHandler} from '@shopify/hydrogen'; +import {createAppLoadContext} from '~/lib/context'; + +export default { + async fetch(request, env, executionContext) { + try { + const appLoadContext = await createAppLoadContext(request, env, executionContext); + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + getLoadContext: () => appLoadContext, + }); + + const response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + if (response.status === 404) { + return storefrontRedirect({request, response, storefront: appLoadContext.storefront}); + } + + return response; + } catch (error) { + console.error(error); + return new Response('An unexpected error occurred', {status: 500}); + } + }, +}; diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx new file mode 100644 index 00000000..41c2ea9e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx @@ -0,0 +1,17 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + const data = useRouteLoaderData('root'); + + if (!data) { + return ; + } + + return ( + + + + + + ); +} diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap index 37a4b2a0..addb2ed9 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap @@ -17,6 +17,30 @@ export async function handleFetch(request, hydrogenContext) { " `; +exports[`HydrogenCookiesCodemod should correctly transform alreadyPresentOutsideBlock.ts: alreadyPresentOutsideBlock.ts 1`] = ` +"import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, appLoadContext) { + let response; + + try { + response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + } catch (error) { + response = new Response('error', {status: 500}); + } + + // Already called, but in the function body — not in the \`try\` block that holds the Set-Cookie. + writeCroctCookies(response, appLoadContext); + + return response; +} +" +`; + exports[`HydrogenCookiesCodemod should correctly transform arrowExpressionBody.ts: arrowExpressionBody.ts 1`] = ` "export const handle = (request, hydrogenContext) => response.headers.set( 'Set-Cookie', @@ -125,3 +149,41 @@ exports[`HydrogenCookiesCodemod should correctly transform notSetCookieHeader.ts } " `; + +exports[`HydrogenCookiesCodemod should correctly transform tryCatchWrapped.ts: tryCatchWrapped.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; +import {storefrontRedirect, createRequestHandler} from '@shopify/hydrogen'; +import {createAppLoadContext} from '~/lib/context'; + +export default { + async fetch(request, env, executionContext) { + try { + const appLoadContext = await createAppLoadContext(request, env, executionContext); + + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + getLoadContext: () => appLoadContext, + }); + + const response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + writeCroctCookies(response, appLoadContext); + + if (response.status === 404) { + return storefrontRedirect({request, response, storefront: appLoadContext.storefront}); + } + + return response; + } catch (error) { + console.error(error); + return new Response('An unexpected error occurred', {status: 500}); + } + }, +}; +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap index ace640e8..87600081 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap @@ -47,6 +47,30 @@ export default function App({data}) { " `; +exports[`JsxWrapperCodemod should correctly transform containerEarlyReturn.tsx: containerEarlyReturn.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + const data = useRouteLoaderData('root'); + + if (!data) { + return ; + } + + return ( + + + + + + + +); +} +" +`; + exports[`JsxWrapperCodemod should correctly transform containerIdentifierElement.tsx: containerIdentifierElement.tsx 1`] = ` "import { CroctProvider } from "@croct/plug-react"; diff --git a/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts index 82c3606a..f1fe3cc2 100644 --- a/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts @@ -30,4 +30,40 @@ describe('HydrogenCookiesCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('inserts the writer before the return when the handler is wrapped in try/catch', async () => { + const {fixture} = scenarios.find(scenario => scenario.name === 'tryCatchWrapped.ts')!; + + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod(defaultOptions), + }); + + const {result} = await transformer.apply(fixture); + + const writerIndex = result.indexOf('writeCroctCookies(response, appLoadContext)'); + const returnIndex = result.indexOf('return response;'); + + // The writer must run on the response before it is returned, not as unreachable + // code after the try/catch block. + expect(writerIndex).toBeGreaterThanOrEqual(0); + expect(returnIndex).toBeGreaterThanOrEqual(0); + expect(writerIndex).toBeLessThan(returnIndex); + }); + + it('does not add a second writer when one is already called elsewhere in the handler', async () => { + const {fixture} = scenarios.find(scenario => scenario.name === 'alreadyPresentOutsideBlock.ts')!; + + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod(defaultOptions), + }); + + const {result} = await transformer.apply(fixture); + + // The writer is already called in the handler (outside the `try` block that holds the + // Set-Cookie), so the codemod must be a no-op — idempotence is scoped to the whole handler, + // not just the insertion block. + expect((result.match(/writeCroctCookies\(/g) ?? []).length).toBe(1); + }); }); diff --git a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts index 80470cea..072f2a49 100644 --- a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts @@ -46,6 +46,11 @@ describe('JsxWrapperCodemod', () => { }, fallbackToNamedExports: true, }, + 'containerEarlyReturn.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, 'containerAlreadyWrapped.tsx': { targets: { container: 'Analytics.Provider', From 0ad042caabc37065bc141d0c1dfcca57646c65fb Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 18 Jun 2026 14:40:11 -0400 Subject: [PATCH 08/12] Improve error reporting --- .../javascript/hydrogenContextCodemod.ts | 11 +++ .../javascript/hydrogenCookiesCodemod.ts | 81 +++++++++---------- .../javascript/hydrogenCspCodemod.ts | 31 +++++-- .../javascript/jsxWrapperCodemod.ts | 21 +++-- .../javascript/nextJsProxyCodemod.ts | 1 + .../javascript/nuxtConfigModuleCodemod.ts | 17 +++- .../javascript/storyblokInitCodemod.ts | 16 ++-- .../javascript/viteConfigPluginCodemod.ts | 6 ++ .../javascript/vuePluginCodemod.ts | 6 ++ .../javascript/vueStoryblokCodemod.ts | 14 ++++ .../php/drupalLocalSettingsCodemod.ts | 11 ++- .../php/symfonyBundleCodemod.ts | 11 ++- src/application/project/sdk/plugDrupalSdk.ts | 19 ++++- .../project/sdk/plugHydrogenSdk.ts | 24 ++++-- src/application/project/sdk/plugNuxtSdk.ts | 7 +- src/application/project/sdk/plugReactSdk.ts | 10 +-- src/application/project/sdk/plugSymfonySdk.ts | 8 +- src/application/project/sdk/plugVueSdk.ts | 8 +- src/infrastructure/application/cli/cli.ts | 18 ++++- .../hydrogenCspCodemod.test.ts.snap | 4 +- .../jsxWrapperCodemod.test.ts.snap | 20 +++++ .../nextJsProxyCodemod.test.ts.snap | 9 +++ .../nuxtConfigModuleCodemod.test.ts.snap | 2 +- .../javascript/hydrogenContextCodemod.test.ts | 11 +++ .../javascript/hydrogenCookiesCodemod.test.ts | 11 +++ .../javascript/hydrogenCspCodemod.test.ts | 11 +++ .../javascript/jsxWrapperCodemod.test.ts | 39 +++++++++ .../nuxtConfigModuleCodemod.test.ts | 10 +++ .../javascript/storyblokInitCodemod.test.ts | 18 +++++ .../viteConfigPluginCodemod.test.ts | 11 +++ .../javascript/vuePluginCodemod.test.ts | 11 +++ .../javascript/vueStoryblokCodemod.test.ts | 62 ++++++++++++++ .../php/drupalLocalSettingsCodemod.test.ts | 10 +++ .../php/symfonyBundleCodemod.test.ts | 10 +++ 34 files changed, 469 insertions(+), 90 deletions(-) diff --git a/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts index caa563a5..45df4dd8 100644 --- a/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts @@ -2,6 +2,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import {traverseFast} from '@babel/types'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; @@ -14,6 +15,12 @@ export type HydrogenContextConfiguration = { importName: string, localName?: string, }, + + /** + * When true, throw if the load-context factory's return could not be found (instead of a + * silent no-op), so the SDK can report the failure. + */ + required?: boolean, }; type Anchor = { @@ -49,6 +56,10 @@ export class HydrogenContextCodemod implements Codemod { const anchor = HydrogenContextCodemod.findAnchor(input); if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Hydrogen load context found to expose the Croct context on.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts index 5212df00..a078368a 100644 --- a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts @@ -1,8 +1,8 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; -import type {NodePath} from '@babel/core'; import {traverseFast} from '@babel/types'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; @@ -15,13 +15,19 @@ export type HydrogenCookiesConfiguration = { importName: string, localName?: string, }, + + /** + * When true, throw if there is no session `Set-Cookie` to anchor to (instead of a silent + * no-op), so the SDK can report the failure. + */ + required?: boolean, }; type Anchor = { response: t.Expression, context: t.Expression, statements: t.Statement[], - after: t.Statement, + call: t.CallExpression, scope: t.Statement[], }; @@ -39,6 +45,10 @@ export class HydrogenCookiesCodemod implements Codemod { const anchor = HydrogenCookiesCodemod.findAnchor(input); if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No session Set-Cookie statement found to write the Croct cookies after.'); + } + return Promise.resolve({modified: false, result: input}); } @@ -59,7 +69,9 @@ export class HydrogenCookiesCodemod implements Codemod { localName: writer.localName, }); - const index = anchor.statements.indexOf(anchor.after); + const index = anchor.statements.findIndex( + statement => HydrogenCookiesCodemod.contains(statement, anchor.call), + ); anchor.statements.splice( index + 1, @@ -98,23 +110,23 @@ export class HydrogenCookiesCodemod implements Codemod { return; } - const insertion = HydrogenCookiesCodemod.findInsertionPoint(path); - - if (insertion === null) { - return; - } - - const block = insertion.parentPath; - - if (block === null || !block.isBlockStatement()) { - return; - } + // Insert in the block that holds the session cookie — the wrapping `try`, if any, + // so the writer runs before `return response`, not after the try/catch. A guarding + // `if (session.isPending) { … }` is handled by `findIndex`/`contains` below, which + // resolves to the whole guard statement rather than the nested set call. + const tryStatement = fn.node + .body + .body + .find( + (statement): statement is t.TryStatement => t.isTryStatement(statement) + && HydrogenCookiesCodemod.contains(statement.block, path.node), + ); anchor = { response: response, context: context, - statements: block.node.body, - after: insertion.node, + statements: tryStatement !== undefined ? tryStatement.block.body : fn.node.body.body, + call: path.node, scope: fn.node.body.body, }; @@ -125,36 +137,19 @@ export class HydrogenCookiesCodemod implements Codemod { return anchor; } - private static findInsertionPoint(callPath: NodePath): NodePath | null { - let statement = callPath.getStatementParent(); - - while (statement !== null) { - const parent = statement.parentPath; - - if (parent === null) { - break; - } - - if (parent.isIfStatement()) { - // Braceless guard: `if (session.isPending) `. - statement = parent; - - continue; - } - - const grandParent = parent.parentPath; - - if (parent.isBlockStatement() && grandParent !== null && grandParent.isIfStatement()) { - // Braced guard: `if (session.isPending) { }`. - statement = grandParent; + /** + * Whether the node contains the target node anywhere within it. + */ + private static contains(node: t.Node, target: t.Node): boolean { + let found = false; - continue; + traverseFast(node, current => { + if (current === target) { + found = true; } + }); - break; - } - - return statement; + return found; } /** diff --git a/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts index dd5d071b..bd05d785 100644 --- a/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts @@ -1,6 +1,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; @@ -9,15 +10,16 @@ export type HydrogenCspConfiguration = { * The origin the browser SDK must be allowed to reach, e.g. `https://api.croct.io`. */ origin: string, + + /** + * When true, throw if no content security policy configuration could be found (instead of a + * silent no-op), so the SDK can report the failure. + */ + required?: boolean, }; /** * Allows the Croct origin in Hydrogen's Content Security Policy. - * - * Adds the origin to the `connectSrc` array of the options object passed to - * `createContentSecurityPolicy(...)`, creating the directive when missing and normalizing a - * non-array value into one. The function import is resolved so aliased imports are matched. Returns - * unmodified when the call or its options object is absent or when the origin is already present. */ export class HydrogenCspCodemod implements Codemod { private static readonly FUNCTION_NAME = 'createContentSecurityPolicy'; @@ -33,9 +35,13 @@ export class HydrogenCspCodemod implements Codemod { } public apply(input: t.File): Promise> { - const options = HydrogenCspCodemod.findOptionsObject(input); + const options = HydrogenCspCodemod.resolveOptionsObject(input); if (options === null) { + if (this.configuration.required === true) { + throw new CodemodError('No content security policy configuration found to allow the Croct origin.'); + } + return Promise.resolve({modified: false, result: input}); } @@ -76,7 +82,7 @@ export class HydrogenCspCodemod implements Codemod { return Promise.resolve({modified: true, result: input}); } - private static findOptionsObject(ast: t.File): t.ObjectExpression | null { + private static resolveOptionsObject(ast: t.File): t.ObjectExpression | null { const functionName = getImportLocalName(ast, { moduleName: HydrogenCspCodemod.FUNCTION_MODULE, importName: HydrogenCspCodemod.FUNCTION_NAME, @@ -92,9 +98,18 @@ export class HydrogenCspCodemod implements Codemod { const [argument] = path.node.arguments; - if (argument !== undefined && t.isObjectExpression(argument)) { + if (argument === undefined) { + // The policy is created without options; add an empty object to extend. + options = t.objectExpression([]); + + path.node + .arguments + .push(options); + } else if (t.isObjectExpression(argument)) { options = argument; + } + if (options !== null) { path.stop(); } }, diff --git a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts index d48f239f..091748c1 100644 --- a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts +++ b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts @@ -3,6 +3,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import {traverseFast} from '@babel/types'; import type {ResultCode, Codemod, CodemodOptions} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import type {AttributeType} from '@/application/project/code/transformation/javascript/utils/createJsxProps'; @@ -59,6 +60,12 @@ export type WrapperConfiguration = { targets?: WrapperTarget, fallbackToNamedExports?: boolean, fallbackCodemod?: Codemod, + + /** + * When true, throw if no component could be wrapped (instead of a silent no-op), so the SDK + * can report the failure. Already-wrapped inputs stay a clean no-op. + */ + required?: boolean, }; export type WrapperOptions = CodemodOptions & { @@ -156,7 +163,7 @@ export class JsxWrapperCodemod implem } // export {Component as SomeComponent}; - for (const specifier of namedExport.specifiers ?? []) { + for (const specifier of namedExport.specifiers) { if (t.isExportSpecifier(specifier)) { const declaration = this.findComponentDeclaration(input, specifier.local.name); @@ -201,6 +208,10 @@ export class JsxWrapperCodemod implem return fallbackCodemod.apply(input, options); } + if (result === Transformation.NOT_APPLIED && this.configuration.required === true) { + throw new CodemodError(`No component found to wrap with <${this.configuration.wrapper.component}>.`); + } + return Promise.resolve({ modified: result === Transformation.APPLIED, result: input, @@ -334,7 +345,7 @@ export class JsxWrapperCodemod implem if (target !== null) { const {parent, index} = target; - const children = [...parent.children ?? []]; + const children = [...parent.children]; const child = children.splice(index, 1)[0]; target.parent.children = children.length === 0 @@ -385,18 +396,18 @@ export class JsxWrapperCodemod implem * @param options The wrapper options. * @return The wrapped JSX element. */ - private wrapElement(node: JsxKind, name: string | undefined, options?: O): t.JSXElement { + private wrapElement(node: JsxKind, name: string, options?: O): t.JSXElement { if (node.extra?.parenthesized === true) { node.extra.parenthesized = false; } return t.jsxElement( t.jsxOpeningElement( - t.jsxIdentifier(name ?? this.configuration.wrapper.component), + t.jsxIdentifier(name), this.getProviderProps(options), ), t.jsxClosingElement( - t.jsxIdentifier(name ?? this.configuration.wrapper.component), + t.jsxIdentifier(name), ), [ t.jsxText('\n'), diff --git a/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts b/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts index 2c7c9d2c..679c1b78 100644 --- a/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts +++ b/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts @@ -28,6 +28,7 @@ export type ProxyConfiguration = { proxyFactoryName: string, proxyName: string, }, + required?: boolean, }; /** diff --git a/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts b/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts index 7053153d..636046db 100644 --- a/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts +++ b/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts @@ -1,10 +1,13 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; export type NuxtConfigModuleConfiguration = { moduleName: string, + required?: boolean, }; /** @@ -26,6 +29,10 @@ export class NuxtConfigModuleCodemod implements Codemod const config = NuxtConfigModuleCodemod.findConfig(input); if (config === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Nuxt configuration found to register the Croct module.'); + } + return Promise.resolve({modified: false, result: input}); } @@ -43,7 +50,15 @@ export class NuxtConfigModuleCodemod implements Codemod } if (!t.isArrayExpression(modulesProperty.value)) { - return Promise.resolve({modified: false, result: input}); + // Normalize a non-array `modules` value (a variable, a call, etc.) into an array with + // the module, preserving the existing value. The cast is forced by `ObjectProperty.value`'s + // `Expression | PatternLike` type; an object-literal value is always an expression. + modulesProperty.value = t.arrayExpression([ + spreadAsArray(modulesProperty.value as t.Expression), + t.stringLiteral(this.configuration.moduleName), + ]); + + return Promise.resolve({modified: true, result: input}); } if (this.hasModule(modulesProperty.value)) { diff --git a/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts b/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts index 4de5fe87..8d3ca4d9 100644 --- a/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts +++ b/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts @@ -2,12 +2,14 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; export type StoryblokInitCodemodOptions = CodemodOptions & { name: string, module: string, + required?: boolean, }; export class StoryblokInitCodemod implements Codemod { @@ -25,18 +27,20 @@ export class StoryblokInitCodemod implements Codemod const config = ViteConfigPluginCodemod.findConfig(input); if (config === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Vite configuration found to register the plugin.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/javascript/vuePluginCodemod.ts b/src/application/project/code/transformation/javascript/vuePluginCodemod.ts index 438657c4..63556fa0 100644 --- a/src/application/project/code/transformation/javascript/vuePluginCodemod.ts +++ b/src/application/project/code/transformation/javascript/vuePluginCodemod.ts @@ -1,6 +1,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import type {AttributeType} from '@/application/project/code/transformation/javascript/utils/createObjectProps'; @@ -34,6 +35,7 @@ export type VuePluginConfiguration = { factory: string, }, args?: Record, + required?: boolean, }; export type VuePluginOptions = CodemodOptions & { @@ -58,6 +60,10 @@ export class VuePluginCodemod implements Codemod { const anchor = VuePluginCodemod.findMountAnchor(input); if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Vue app initialization found to register the Croct plugin.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts b/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts index ca0b5df0..e969eeaf 100644 --- a/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts +++ b/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts @@ -1,6 +1,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; import {VuePluginCodemod} from '@/application/project/code/transformation/javascript/vuePluginCodemod'; @@ -14,6 +15,7 @@ export type VueStoryblokConfiguration = { module: string, identifier: string, }, + required?: boolean, }; /** @@ -35,6 +37,10 @@ export class VueStoryblokCodemod implements Codemod { const anchor = VuePluginCodemod.findMountAnchor(input); if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Vue app initialization found to wire the Storyblok integration.'); + } + return Promise.resolve({modified: false, result: input}); } @@ -55,12 +61,20 @@ export class VueStoryblokCodemod implements Codemod { }); if (storyblokLocal === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Storyblok Vue plugin import found.'); + } + return Promise.resolve({modified: false, result: input}); } const useCall = VuePluginCodemod.findUseCall(anchor, storyblokLocal); if (useCall === null) { + if (this.configuration.required === true) { + throw new CodemodError('No app.use(StoryblokVue) call found to wrap.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts b/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts index 720041b5..d70cafef 100644 --- a/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts +++ b/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts @@ -1,10 +1,12 @@ import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; export type Configuration = { /** * The local settings filename that `settings.php` should include. */ file: string, + required?: boolean, }; type Scan = { @@ -29,12 +31,19 @@ export class DrupalLocalSettingsCodemod implements Codemod { private readonly file: string; - public constructor({file}: Configuration) { + private readonly required: boolean; + + public constructor({file, required = false}: Configuration) { this.file = file; + this.required = required; } public apply(input: string): Promise> { if (input.trim() === '') { + if (this.required) { + throw new CodemodError('settings.php is empty; cannot add the settings.local.php include.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/php/symfonyBundleCodemod.ts b/src/application/project/code/transformation/php/symfonyBundleCodemod.ts index fda514c5..3d1c16e0 100644 --- a/src/application/project/code/transformation/php/symfonyBundleCodemod.ts +++ b/src/application/project/code/transformation/php/symfonyBundleCodemod.ts @@ -1,10 +1,12 @@ import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; export type Configuration = { /** * The fully-qualified bundle class to register (without a leading backslash). */ bundle: string, + required?: boolean, }; /** @@ -13,8 +15,11 @@ export type Configuration = { export class SymfonyBundleCodemod implements Codemod { private readonly bundle: string; - public constructor({bundle}: Configuration) { + private readonly required: boolean; + + public constructor({bundle, required = false}: Configuration) { this.bundle = bundle; + this.required = required; } public apply(input: string): Promise> { @@ -27,6 +32,10 @@ export class SymfonyBundleCodemod implements Codemod { const closing = input.lastIndexOf('];'); if (closing === -1) { + if (this.required) { + throw new CodemodError('No bundle array found in config/bundles.php to register the Croct bundle.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/sdk/plugDrupalSdk.ts b/src/application/project/sdk/plugDrupalSdk.ts index 3c5824a3..965751ed 100644 --- a/src/application/project/sdk/plugDrupalSdk.ts +++ b/src/application/project/sdk/plugDrupalSdk.ts @@ -19,6 +19,12 @@ export type Configuration = PhpSdkConfiguration & { localSettingsFileCodemod: Codemod, }; +/** + * The outcome of including `settings.local.php`: added, already present, file not found, or the + * codemod could not modify it. + */ +type LocalSettingsResult = 'included' | 'unchanged' | 'missing' | 'failed'; + export class PlugDrupalSdk extends PhpSdk { private static readonly MODULE_NAME = 'croct_example'; @@ -80,6 +86,9 @@ export class PlugDrupalSdk extends PhpSdk { case 'unchanged': return notifier.confirm('`settings.php` already includes `settings.local.php`'); + case 'failed': + return notifier.alert('Could not include the local settings', instruction); + default: return notifier.warn('Could not include the local settings', instruction); } @@ -465,7 +474,7 @@ export class PlugDrupalSdk extends PhpSdk { ); } - private async includeLocalSettings(): Promise<'included' | 'unchanged' | 'missing'> { + private async includeLocalSettings(): Promise { const path = await this.resolveSettingsFile(); if (path === null) { @@ -475,9 +484,13 @@ export class PlugDrupalSdk extends PhpSdk { // The injected codemod reads/writes settings.php and style-fixes it by // decoration; its `modified` flag tells whether the include was added. Drupal leaves // settings.php read-only after install, so apply it under a temporary unlock. - const {modified} = await this.runWritablePaths([path], () => this.localSettingsFileCodemod.apply(path)); + try { + const {modified} = await this.runWritablePaths([path], () => this.localSettingsFileCodemod.apply(path)); - return modified ? 'included' : 'unchanged'; + return modified ? 'included' : 'unchanged'; + } catch { + return 'failed'; + } } private async resolveSettingsFile(): Promise { diff --git a/src/application/project/sdk/plugHydrogenSdk.ts b/src/application/project/sdk/plugHydrogenSdk.ts index 9394fb1c..23135f6a 100644 --- a/src/application/project/sdk/plugHydrogenSdk.ts +++ b/src/application/project/sdk/plugHydrogenSdk.ts @@ -106,6 +106,11 @@ type CodemodTaskOptions = { * The target file, or null when it could not be located. */ file: string | null, + + /** + * The manual step shown when the codemod cannot wire the file, so the user can do it by hand. + */ + instructions: string, }; export class PlugHydrogenSdk extends JavaScriptSdk { @@ -285,6 +290,7 @@ export class PlugHydrogenSdk extends JavaScriptSdk { confirmation: 'Vite plugin registered', codemod: 'vite', file: project.viteConfig, + instructions: 'Add the `croct()` plugin to the `plugins` array in your Vite config.', }), project.framework === 'react-router' ? this.getCodemodTask({ @@ -292,42 +298,50 @@ export class PlugHydrogenSdk extends JavaScriptSdk { confirmation: 'Middleware registered', codemod: 'middleware', file: project.root, + instructions: 'Add `createCroctMiddleware()` to the `middleware` array exported from app/root.', }) : this.getCodemodTask({ title: 'Expose Croct context', confirmation: 'Croct context exposed', codemod: 'context', file: project.context, + instructions: 'Add `croct: await createCroctContext(request, context)` to the load context.', }), this.getCodemodTask({ title: 'Configure Croct cookies', confirmation: 'Croct cookies configured', codemod: 'cookies', file: project.server, + instructions: 'Call `writeCroctCookies(response, context)` after the session commits in server.ts.', }), this.getCodemodTask({ title: 'Configure provider', confirmation: 'Provider configured', codemod: 'provider', file: project.root, + instructions: 'Wrap your app with `` in app/root.', }), this.getCodemodTask({ title: 'Configure content security policy', confirmation: 'Content security policy configured', codemod: 'csp', file: project.entryServer, + instructions: 'Add `https://api.croct.io` to `connectSrc` in your content security policy.', }), ]; } - private getCodemodTask({title, confirmation, codemod, file}: CodemodTaskOptions): Task { + private getCodemodTask(options: CodemodTaskOptions): Task { + const {title, confirmation, codemod, file, instructions} = options; + const action = `${title.charAt(0).toLowerCase()}${title.slice(1)}`; + return { title: title, task: async notifier => { notifier.update(title); if (file === null) { - notifier.warn(`${title}: file not found`); + notifier.alert(`Failed to ${action}`, instructions); return; } @@ -336,10 +350,8 @@ export class PlugHydrogenSdk extends JavaScriptSdk { await this.applyCodemod(this.codemod[codemod], file); notifier.confirm(confirmation); - } catch (error) { - const action = `${title.charAt(0).toLowerCase()}${title.slice(1)}`; - - notifier.alert(`Failed to ${action}`, HelpfulError.formatMessage(error)); + } catch { + notifier.alert(`Failed to ${action}`, instructions); } }, }; diff --git a/src/application/project/sdk/plugNuxtSdk.ts b/src/application/project/sdk/plugNuxtSdk.ts index d21e189e..4e109e6d 100644 --- a/src/application/project/sdk/plugNuxtSdk.ts +++ b/src/application/project/sdk/plugNuxtSdk.ts @@ -154,8 +154,11 @@ export class PlugNuxtSdk extends JavaScriptSdk { await this.applyConfigCodemod(installation.project.config.file); notifier.confirm('Module registered'); - } catch (error) { - notifier.alert('Failed to register module', HelpfulError.formatMessage(error)); + } catch { + notifier.alert( + 'Failed to register module', + 'Add \'@croct/plug-nuxt\' to the modules array in your nuxt.config.', + ); } }, }, diff --git a/src/application/project/sdk/plugReactSdk.ts b/src/application/project/sdk/plugReactSdk.ts index 10b24f89..eab0cd86 100644 --- a/src/application/project/sdk/plugReactSdk.ts +++ b/src/application/project/sdk/plugReactSdk.ts @@ -216,14 +216,12 @@ export class PlugReactSdk extends JavaScriptSdk { notifier.update('Configuring provider'); const providerFile = installation.project.provider.file; + const instructions = 'Wrap your app\'s root component with from @croct/plug-react.'; try { if (providerFile === null) { - // @todo add help link to documentation - notifier.alert('No root component found'); + notifier.alert('No root component found', instructions); } else { - notifier.update('Configuring provider'); - await this.installProvider(providerFile, { props: { appId: PlugReactSdk.getAppIdProperty(await getPublicIds(), projectEnv?.property), @@ -232,8 +230,8 @@ export class PlugReactSdk extends JavaScriptSdk { notifier.confirm('Provider configured'); } - } catch (error) { - notifier.alert('Failed to install provider', HelpfulError.formatMessage(error)); + } catch { + notifier.alert('Failed to install provider', instructions); } }, }); diff --git a/src/application/project/sdk/plugSymfonySdk.ts b/src/application/project/sdk/plugSymfonySdk.ts index f6ee5545..8b4d25ff 100644 --- a/src/application/project/sdk/plugSymfonySdk.ts +++ b/src/application/project/sdk/plugSymfonySdk.ts @@ -55,8 +55,12 @@ export class PlugSymfonySdk extends PhpSdk { ? 'Bundle registered' : 'Bundle already registered', ); - } catch (error) { - notifier.alert('Failed to register bundle', HelpfulError.formatMessage(error)); + } catch { + notifier.alert( + 'Failed to register bundle', + 'Add Croct\\Plug\\Symfony\\CroctBundle::class => [\'all\' => true] ' + + 'to the array in config/bundles.php.', + ); } }, }, diff --git a/src/application/project/sdk/plugVueSdk.ts b/src/application/project/sdk/plugVueSdk.ts index 759f7cc8..e5df4e5f 100644 --- a/src/application/project/sdk/plugVueSdk.ts +++ b/src/application/project/sdk/plugVueSdk.ts @@ -223,8 +223,12 @@ export class PlugVueSdk extends JavaScriptSdk { notifier.confirm('Plugin registered'); } - } catch (error) { - notifier.alert('Failed to register plugin', HelpfulError.formatMessage(error)); + } catch { + notifier.alert( + 'Failed to register plugin', + 'Register the Croct plugin in your Vue entry: ' + + 'app.use(createCroct({appId: \'\'})) before app.mount().', + ); } }, }); diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 421b23d4..d4725c46 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -1837,6 +1837,7 @@ export class Cli { languages: ['typescript', 'jsx'], codemod: new JsxWrapperCodemod({ fallbackToNamedExports: true, + required: true, wrapper: { module: '@croct/plug-react', component: 'CroctProvider', @@ -1880,6 +1881,7 @@ export class Cli { module: '@croct/plug-vue', factory: 'createCroct', }, + required: true, }), }), }), @@ -1911,6 +1913,7 @@ export class Cli { languages: ['typescript'], codemod: new NuxtConfigModuleCodemod({ moduleName: '@croct/plug-nuxt', + required: true, }), }), }), @@ -1934,6 +1937,7 @@ export class Cli { moduleName: '@croct/plug-hydrogen/vite', importName: 'croct', }, + required: true, }), }), }), @@ -1953,6 +1957,7 @@ export class Cli { container: 'Analytics.Provider', }, fallbackToNamedExports: true, + required: true, }), }), }), @@ -1983,6 +1988,7 @@ export class Cli { moduleName: '@croct/plug-hydrogen/server', importName: 'createCroctContext', }, + required: true, }), }), }), @@ -1998,6 +2004,7 @@ export class Cli { moduleName: '@croct/plug-hydrogen/server', importName: 'writeCroctCookies', }, + required: true, }), }), }), @@ -2010,6 +2017,7 @@ export class Cli { languages: ['typescript', 'jsx'], codemod: new HydrogenCspCodemod({ origin: 'https://api.croct.io', + required: true, }), }), }), @@ -2158,7 +2166,10 @@ export class Cli { phpConfig.formatter, new FileCodemod({ fileSystem: fileSystem, - codemod: new SymfonyBundleCodemod({bundle: 'Croct\\Plug\\Symfony\\CroctBundle'}), + codemod: new SymfonyBundleCodemod({ + bundle: 'Croct\\Plug\\Symfony\\CroctBundle', + required: true, + }), }), ), // YAML has no formatter, so it is not wrapped in FormatCodemod. @@ -2173,7 +2184,10 @@ export class Cli { phpConfig.formatter, new FileCodemod({ fileSystem: fileSystem, - codemod: new DrupalLocalSettingsCodemod({file: PlugDrupalSdk.LOCAL_SETTINGS_FILE}), + codemod: new DrupalLocalSettingsCodemod({ + file: PlugDrupalSdk.LOCAL_SETTINGS_FILE, + required: true, + }), }), ), }), diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap index 2b5c6572..8f9b38f4 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap @@ -21,7 +21,9 @@ const csp = createContentSecurityPolicy({ exports[`HydrogenCspCodemod should correctly transform emptyCall.ts: emptyCall.ts 1`] = ` "import {createContentSecurityPolicy} from '@shopify/hydrogen'; -const csp = createContentSecurityPolicy(); +const csp = createContentSecurityPolicy({ + connectSrc: ["https://api.croct.io"] +}); " `; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap index 87600081..fe376c0e 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap @@ -279,6 +279,13 @@ export default Reference; " `; +exports[`JsxWrapperCodemod should correctly transform defaultExportUninitializedReference.tsx: defaultExportUninitializedReference.tsx 1`] = ` +"let Component; + +export default Component; +" +`; + exports[`JsxWrapperCodemod should correctly transform defaultExportUnrelated.tsx: defaultExportUnrelated.tsx 1`] = ` "export default 1; " @@ -554,3 +561,16 @@ export default function App({Component, pageProps}) { } " `; + +exports[`JsxWrapperCodemod should correctly transform targetSingleChild.tsx: targetSingleChild.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App({Component, pageProps}) { + return (
    + + + +
    ); +} +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap index ccb35262..0aaa9108 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap @@ -458,6 +458,15 @@ export const config = { " `; +exports[`NextJsProxyCodemod should correctly transform existingProxyReexportWithConfig.ts: existingProxyReexportWithConfig.ts 1`] = ` +"export { proxy } from "@croct/plug-next/proxy"; + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +} +" +`; + exports[`NextJsProxyCodemod should correctly transform matcherAlias.ts: matcherAlias.ts 1`] = ` "import { withCroct } from "@croct/plug-next/proxy"; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap index abf2d589..b03d5854 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap @@ -132,7 +132,7 @@ exports[`NuxtConfigModuleCodemod should correctly transform nonArrayModules.ts: "const modules = ['@nuxtjs/tailwindcss']; export default defineNuxtConfig({ - modules: modules, + modules: [...(Array.isArray(modules) ? modules : [modules]), "@croct/plug-nuxt"], }); " `; diff --git a/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts index ac0a2608..0dd58a17 100644 --- a/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts @@ -30,4 +30,15 @@ describe('HydrogenContextCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no Hydrogen load context', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenContextCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export function noop() {\n return 1;\n}\n')) + .rejects + .toThrow('No Hydrogen load context found'); + }); }); diff --git a/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts index f1fe3cc2..36efdb4b 100644 --- a/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts @@ -66,4 +66,15 @@ describe('HydrogenCookiesCodemod', () => { // not just the insertion block. expect((result.match(/writeCroctCookies\(/g) ?? []).length).toBe(1); }); + + it('throws when required and there is no session Set-Cookie statement', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export function handleFetch() {\n return new Response();\n}\n')) + .rejects + .toThrow('No session Set-Cookie statement found'); + }); }); diff --git a/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts index ea2539fb..3911e77b 100644 --- a/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts @@ -25,4 +25,15 @@ describe('HydrogenCspCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no content security policy', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCspCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export const value = 1;\n')) + .rejects + .toThrow('No content security policy configuration found'); + }); }); diff --git a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts index 072f2a49..f3b28e0b 100644 --- a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts @@ -25,6 +25,11 @@ describe('JsxWrapperCodemod', () => { component: 'Component', }, }, + 'targetSingleChild.tsx': { + targets: { + component: 'Component', + }, + }, 'targetComponentMemberExpression.tsx': { targets: { component: 'Theme.Provider', @@ -268,4 +273,38 @@ describe('JsxWrapperCodemod', () => { expect(result).toEqual(input); }); + + it('should throw when required and no component can be wrapped', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new JsxWrapperCodemod({ + ...defaultOptions, + targets: {container: 'Analytics.Provider'}, + required: true, + }), + }); + + await expect(transformer.apply('export default function App() {\n return
    ;\n}\n')) + .rejects + .toThrow('No component found to wrap with .'); + }); + + it('should not throw when required but the wrapper is already present', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new JsxWrapperCodemod({...defaultOptions, required: true}), + }); + + const input = [ + "import {CroctProvider} from '@croct/plug-react';", + 'export default function App() {', + ' return ;', + '}', + '', + ].join('\n'); + + const {result} = await transformer.apply(input); + + expect(result).toEqual(input); + }); }); diff --git a/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts b/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts index 3a1f63e2..80e785a1 100644 --- a/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts @@ -27,4 +27,14 @@ describe('NuxtConfigModuleCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no Nuxt configuration', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new NuxtConfigModuleCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export default makeConfig();\n')).rejects + .toThrow('No Nuxt configuration found to register the Croct module.'); + }); }); diff --git a/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts b/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts index a4b21c10..566bc03a 100644 --- a/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts @@ -138,6 +138,24 @@ describe('StoryblokInitCodemod', () => { expect(result).toBe(input); }); + it('should throw when required and no storyblok import is found', async () => { + const transformer = createTransformer(); + + const input = [ + "import { someOtherFunction } from '@storyblok/js';", + '', + 'someOtherFunction({ accessToken: "token" });', + ].join('\n'); + + await expect( + transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + required: true, + }), + ).rejects.toThrow('No Storyblok import found to wire the Croct integration.'); + }); + it('should return unmodified when options are not provided', async () => { const transformer = createTransformer(); diff --git a/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts index b511bdca..a7fdef99 100644 --- a/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts @@ -35,4 +35,15 @@ describe('ViteConfigPluginCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no Vite config', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new ViteConfigPluginCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export const value = 1;\n')) + .rejects + .toThrow('No Vite configuration found'); + }); }); diff --git a/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts b/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts index f2285439..13074adb 100644 --- a/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts @@ -210,4 +210,15 @@ describe('VuePluginCodemod', () => { expect(useCall).toEqual(expected); }); + + it('throws when required and there is no Vue app initialization', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VuePluginCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply("import { something } from 'somewhere';\n\nsomething({ foo: 'bar' });\n")) + .rejects + .toThrow('No Vue app initialization found to register the Croct plugin.'); + }); }); diff --git a/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts b/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts index 2e97491d..7e966874 100644 --- a/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts @@ -32,4 +32,66 @@ describe('VueStoryblokCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no Vue app initialization', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VueStoryblokCodemod({...defaultOptions, required: true}), + }); + + const source = [ + "import { StoryblokVue } from '@storyblok/vue';", + '', + 'const app = { use: () => app, mount: () => {} };', + '', + "app.use(StoryblokVue, { accessToken: 'YOUR_ACCESS_TOKEN' });", + "app.mount('#app');", + '', + ].join('\n'); + + await expect(transformer.apply(source)).rejects + .toThrow('No Vue app initialization found to wire the Storyblok integration.'); + }); + + it('throws when required and there is no Storyblok Vue plugin import', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VueStoryblokCodemod({...defaultOptions, required: true}), + }); + + const source = [ + "import { createApp } from 'vue';", + "import { apiPlugin } from '@storyblok/vue';", + "import App from './App.vue';", + '', + 'const app = createApp(App);', + "app.mount('#app');", + '', + ].join('\n'); + + await expect(transformer.apply(source)).rejects + .toThrow('No Storyblok Vue plugin import found.'); + }); + + it('throws when required and there is no app.use(StoryblokVue) call', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VueStoryblokCodemod({...defaultOptions, required: true}), + }); + + const source = [ + "import { createApp } from 'vue';", + "import { StoryblokVue } from '@storyblok/vue';", + "import App from './App.vue';", + '', + 'console.log(StoryblokVue);', + '', + 'const app = createApp(App);', + "app.mount('#app');", + '', + ].join('\n'); + + await expect(transformer.apply(source)).rejects + .toThrow('No app.use(StoryblokVue) call found to wrap.'); + }); }); diff --git a/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts b/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts index 36bbb5e0..228efb99 100644 --- a/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts +++ b/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts @@ -29,4 +29,14 @@ describe('DrupalLocalSettingsCodemod', () => { expect(modified).toBe(false); expect(result).toBe(''); }); + + it('throws when required and the content is empty', async () => { + const requiredCodemod = new DrupalLocalSettingsCodemod({ + file: 'settings.local.php', + required: true, + }); + + await expect(async () => requiredCodemod.apply(' \n\t')).rejects + .toThrow('settings.php is empty; cannot add the settings.local.php include.'); + }); }); diff --git a/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts b/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts index 8350bc14..5f88ce6d 100644 --- a/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts +++ b/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts @@ -22,4 +22,14 @@ describe('SymfonyBundleCodemod', () => { expect(reapplied.modified).toBe(false); expect(reapplied.result).toBe(result); }); + + it('throws when required and the bundle array is missing', async () => { + const requiredCodemod = new SymfonyBundleCodemod({ + bundle: 'Croct\\Plug\\Symfony\\CroctBundle', + required: true, + }); + + await expect(async () => requiredCodemod.apply(' Date: Thu, 18 Jun 2026 14:57:54 -0400 Subject: [PATCH 09/12] Improve error reporting --- .../jsx-wrapper/defaultExportUninitializedReference.tsx | 3 +++ .../fixtures/jsx-wrapper/targetSingleChild.tsx | 3 +++ .../fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts | 5 +++++ 3 files changed, 11 insertions(+) create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx create mode 100644 test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx new file mode 100644 index 00000000..1f5529e2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx @@ -0,0 +1,3 @@ +let Component; + +export default Component; diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx new file mode 100644 index 00000000..36ecab86 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx @@ -0,0 +1,3 @@ +export default function App({Component, pageProps}) { + return
    ; +} diff --git a/test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts b/test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts new file mode 100644 index 00000000..72745167 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts @@ -0,0 +1,5 @@ +export { proxy } from "@croct/plug-next/proxy"; + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +} From a44a6d5df36c186e92cfddb30f4d4713dca9fb42 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 18 Jun 2026 14:58:51 -0400 Subject: [PATCH 10/12] Improve error reporting --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 90cdae56..d77cc311 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ node_modules/ build/ coverage/ src/infrastructure/graphql -e2e/ From 805c0b88c6bc84e7602546880ef68fb6b65d127b Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 18 Jun 2026 15:56:41 -0400 Subject: [PATCH 11/12] Apply review --- schema.graphql | 1 + .../javascript/hydrogenContextCodemod.ts | 5 --- .../javascript/hydrogenCookiesCodemod.ts | 5 --- .../javascript/hydrogenCspCodemod.ts | 5 --- .../javascript/hydrogenMiddlewareCodemod.ts | 8 ----- .../javascript/jsxWrapperCodemod.ts | 8 ----- .../javascript/nextJsProxyCodemod.ts | 1 - .../project/import/importResolver.ts | 7 +--- .../packageManager/composerPackageManager.ts | 25 ++++++++++++-- .../application/api/graphql/workspace.ts | 4 +-- .../partialComposerLockValidator.ts | 2 ++ .../hydrogenExampleGenerator.test.ts.snap | 4 +-- .../slot/hydrogenExampleGenerator.test.ts | 34 ++++++++++++++++--- .../javascript/hydrogenContextCodemod.test.ts | 3 +- .../javascript/hydrogenCspCodemod.test.ts | 3 +- .../viteConfigPluginCodemod.test.ts | 3 +- 16 files changed, 62 insertions(+), 56 deletions(-) diff --git a/schema.graphql b/schema.graphql index 86bf209d..17a72274 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2629,6 +2629,7 @@ input PlanQuotaIntentInput { enum Platform { ASTRO DRUPAL + HYDROGEN JAVASCRIPT LARAVEL NEXT diff --git a/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts index 45df4dd8..bd4566d8 100644 --- a/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts @@ -15,11 +15,6 @@ export type HydrogenContextConfiguration = { importName: string, localName?: string, }, - - /** - * When true, throw if the load-context factory's return could not be found (instead of a - * silent no-op), so the SDK can report the failure. - */ required?: boolean, }; diff --git a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts index a078368a..c5ff5ba0 100644 --- a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts @@ -15,11 +15,6 @@ export type HydrogenCookiesConfiguration = { importName: string, localName?: string, }, - - /** - * When true, throw if there is no session `Set-Cookie` to anchor to (instead of a silent - * no-op), so the SDK can report the failure. - */ required?: boolean, }; diff --git a/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts index bd05d785..3401643e 100644 --- a/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts @@ -10,11 +10,6 @@ export type HydrogenCspConfiguration = { * The origin the browser SDK must be allowed to reach, e.g. `https://api.croct.io`. */ origin: string, - - /** - * When true, throw if no content security policy configuration could be found (instead of a - * silent no-op), so the SDK can report the failure. - */ required?: boolean, }; diff --git a/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts index 6d915d4c..18549881 100644 --- a/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts +++ b/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts @@ -22,14 +22,6 @@ type Match = { /** * Registers the Croct middleware in the Hydrogen (React Router 7) root route. - * - * Ensures the `export const middleware` array contains a `()` call and adds the import: - * - creates `export const middleware = [()]` when absent; - * - appends to an existing array; - * - normalizes a non-array value (e.g. `buildMiddleware()`) to - * `[...(Array.isArray(existing) ? existing : [existing]), ()]`, preserving it. - * - * Returns unmodified when the middleware is already registered. */ export class HydrogenMiddlewareCodemod implements Codemod { private static readonly EXPORT_NAME = 'middleware'; diff --git a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts index 091748c1..00022d5f 100644 --- a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts +++ b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts @@ -60,11 +60,6 @@ export type WrapperConfiguration = { targets?: WrapperTarget, fallbackToNamedExports?: boolean, fallbackCodemod?: Codemod, - - /** - * When true, throw if no component could be wrapped (instead of a silent no-op), so the SDK - * can report the failure. Already-wrapped inputs stay a clean no-op. - */ required?: boolean, }; @@ -283,9 +278,6 @@ export class JsxWrapperCodemod implem private wrapBlockStatement(node: t.BlockStatement, component: string, ast: t.File, options?: O): Transformation { const container = this.configuration.targets?.container; - // When wrapping the children of a container element, target the return that actually renders - // it: a component may guard with early returns (e.g. `if (!data) return ;`) before - // the branch that mounts the container. const returnStatement = container !== undefined ? this.findReturnWithElement(node, JsxWrapperCodemod.resolveElementName(ast, container)) ?? JsxWrapperCodemod.findReturnStatement(node) diff --git a/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts b/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts index 679c1b78..2c7c9d2c 100644 --- a/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts +++ b/src/application/project/code/transformation/javascript/nextJsProxyCodemod.ts @@ -28,7 +28,6 @@ export type ProxyConfiguration = { proxyFactoryName: string, proxyName: string, }, - required?: boolean, }; /** diff --git a/src/application/project/import/importResolver.ts b/src/application/project/import/importResolver.ts index 4f86876f..fddef7a1 100644 --- a/src/application/project/import/importResolver.ts +++ b/src/application/project/import/importResolver.ts @@ -16,12 +16,7 @@ export interface ImportResolver { getImportPath(filePath: string, importPath?: string): Promise; /** - * Resolves an import specifier written in `sourcePath` to the project-relative file path it - * points to (specifier → file), the inverse of {@link getImportPath}. - * - * Honors the project's tsconfig `paths`/`baseUrl` aliases and relative specifiers, then probes - * extensions (`.ts`, `.tsx`, `.js`, `.jsx`, and `index.*`) the way the TypeScript/Node resolver - * does. Returns null for bare packages or when no matching file exists. + * Resolves an import specifier from `sourcePath` to the file it points to, or null (specifier → file). */ resolveImport(importPath: string, sourcePath: string): Promise; } diff --git a/src/application/project/packageManager/composerPackageManager.ts b/src/application/project/packageManager/composerPackageManager.ts index 27dcf378..a4228f88 100644 --- a/src/application/project/packageManager/composerPackageManager.ts +++ b/src/application/project/packageManager/composerPackageManager.ts @@ -38,9 +38,15 @@ export type PartialComposerManifest = { extra?: Record, }; +type ComposerLockPackage = { + name?: string, + version?: string, + provide?: Record, +}; + export type ComposerLock = { - packages?: Array<{provide?: Record}>, - 'packages-dev'?: Array<{provide?: Record}>, + packages?: ComposerLockPackage[], + 'packages-dev'?: ComposerLockPackage[], }; /** @@ -210,12 +216,25 @@ export class ComposerPackageManager implements PackageManager { return { name: manifest.name ?? name, - version: manifest.version ?? null, + // The authoritative installed version lives in `composer.lock`: a package's own + // `vendor//composer.json` usually omits `version` (and a self-declared one can + // be stale), so read the lock first. Fall back to the manifest only when the lock + // has no entry (e.g. no lock file present) to avoid a needless null version. + version: (await this.getLockedVersion(name)) ?? manifest.version ?? null, directory: this.fileSystem.getDirectoryName(manifestPath), metadata: manifest, }; } + private async getLockedVersion(name: string): Promise { + const lock = await this.readLock(); + + const entry = [...(lock.packages ?? []), ...(lock['packages-dev'] ?? [])] + .find(item => item.name === name); + + return entry?.version ?? null; + } + public async getScripts(): Promise> { const manifest = await this.readManifest(this.getProjectManifestPath()); diff --git a/src/infrastructure/application/api/graphql/workspace.ts b/src/infrastructure/application/api/graphql/workspace.ts index d3c843ed..d0d49139 100644 --- a/src/infrastructure/application/api/graphql/workspace.ts +++ b/src/infrastructure/application/api/graphql/workspace.ts @@ -123,9 +123,7 @@ function createNormalizationMap(map: Record< const platformMap = createNormalizationMap({ [Platform.JAVASCRIPT]: GraphqlPlatform.Javascript, - // The API has no Hydrogen platform; report it as React (it is a React framework). Listed - // before React so the reverse map keeps `React → react`. - [Platform.HYDROGEN]: GraphqlPlatform.React, + [Platform.HYDROGEN]: GraphqlPlatform.Hydrogen, [Platform.REACT]: GraphqlPlatform.React, [Platform.NEXTJS]: GraphqlPlatform.Next, [Platform.VUE]: GraphqlPlatform.Vue, diff --git a/src/infrastructure/application/validation/partialComposerLockValidator.ts b/src/infrastructure/application/validation/partialComposerLockValidator.ts index a76402be..85a0fbe6 100644 --- a/src/infrastructure/application/validation/partialComposerLockValidator.ts +++ b/src/infrastructure/application/validation/partialComposerLockValidator.ts @@ -4,6 +4,8 @@ import {ZodValidator} from '@/infrastructure/application/validation/zodValidator import type {ComposerLock} from '@/application/project/packageManager/composerPackageManager'; const lockPackageSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), provide: z.record(z.string()).optional(), }); diff --git a/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap index de0725ef..c9de4f13 100644 --- a/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap +++ b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap @@ -393,7 +393,7 @@ export default function HomeHeroRoute() { } ", "language": "jsx", - "path": "app/routes/home-hero.tsx", + "path": "app/routes/home-hero.jsx", }, ], } @@ -458,7 +458,7 @@ export default function HomeHeroRoute() { } ", "language": "jsx", - "path": "app/routes/home-hero.tsx", + "path": "app/routes/home-hero.jsx", }, ], } diff --git a/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts index 1aeb4516..997c2eb4 100644 --- a/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts +++ b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts @@ -27,10 +27,36 @@ describe('HydrogenExampleGenerator', () => { }; const variants: Array<{label: string, options: Partial}> = [ - {label: 'react-router-ts', options: {framework: 'react-router', typescript: true}}, - {label: 'react-router-js', options: {framework: 'react-router', typescript: false}}, - {label: 'remix-ts', options: {framework: 'remix', typescript: true}}, - {label: 'remix-js', options: {framework: 'remix', typescript: false}}, + { + label: 'react-router-ts', + options: { + framework: 'react-router', + typescript: true, + }, + }, + { + label: 'react-router-js', + options: { + framework: 'react-router', + typescript: false, + routeFilePath: 'app/routes/%slug%.jsx', + }, + }, + { + label: 'remix-ts', + options: { + framework: 'remix', + typescript: true, + }, + }, + { + label: 'remix-js', + options: { + framework: 'remix', + typescript: false, + routeFilePath: 'app/routes/%slug%.jsx', + }, + }, ]; it.each(variants)('should generate the $label route', ({label, options}) => { diff --git a/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts index 0dd58a17..db5c16e4 100644 --- a/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts @@ -37,8 +37,7 @@ describe('HydrogenContextCodemod', () => { codemod: new HydrogenContextCodemod({...defaultOptions, required: true}), }); - await expect(transformer.apply('export function noop() {\n return 1;\n}\n')) - .rejects + await expect(transformer.apply('export function noop() {\n return 1;\n}\n')).rejects .toThrow('No Hydrogen load context found'); }); }); diff --git a/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts index 3911e77b..baa0b60d 100644 --- a/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts @@ -32,8 +32,7 @@ describe('HydrogenCspCodemod', () => { codemod: new HydrogenCspCodemod({...defaultOptions, required: true}), }); - await expect(transformer.apply('export const value = 1;\n')) - .rejects + await expect(transformer.apply('export const value = 1;\n')).rejects .toThrow('No content security policy configuration found'); }); }); diff --git a/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts index a7fdef99..5a9f1454 100644 --- a/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts @@ -42,8 +42,7 @@ describe('ViteConfigPluginCodemod', () => { codemod: new ViteConfigPluginCodemod({...defaultOptions, required: true}), }); - await expect(transformer.apply('export const value = 1;\n')) - .rejects + await expect(transformer.apply('export const value = 1;\n')).rejects .toThrow('No Vite configuration found'); }); }); From 9be71e66be647ea2439ca3b81380e4c18a6bfbd2 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 18 Jun 2026 15:59:32 -0400 Subject: [PATCH 12/12] Add missing files --- .../jsx-wrapper/childrenOfAlreadyWrapped.tsx | 12 +++++++++++ .../childrenOfIdentifierElement.tsx | 5 +++++ .../childrenOfMemberExpression.tsx | 9 ++++++++ .../jsx-wrapper/childrenOfNamespaced.tsx | 7 +++++++ .../jsx-wrapper/childrenOfNotFound.tsx | 7 +++++++ .../jsx-wrapper/childrenOfRemixTernary.tsx | 21 +++++++++++++++++++ .../jsx-wrapper/childrenOfSelfClosing.tsx | 5 +++++ 7 files changed, 66 insertions(+) create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx create mode 100644 test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx new file mode 100644 index 00000000..4d74f2ae --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx @@ -0,0 +1,12 @@ +import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx new file mode 100644 index 00000000..5dca326b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx @@ -0,0 +1,5 @@ +export default function App({data}) { + return + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx new file mode 100644 index 00000000..775acb9e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx @@ -0,0 +1,9 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx new file mode 100644 index 00000000..a1c66bfb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx @@ -0,0 +1,7 @@ +export default function App() { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx new file mode 100644 index 00000000..a63adbd3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx @@ -0,0 +1,7 @@ +export default function App({data}) { + return
    + + + +
    ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx new file mode 100644 index 00000000..a7756031 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx @@ -0,0 +1,21 @@ +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return + + {data ? ( + + {children} + + ) : ( + children + )} + + ; +} + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx new file mode 100644 index 00000000..64a4d196 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx @@ -0,0 +1,5 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ; +}