From ca67177636cb9de2acc3573e6c2bb0b8c235ae3c Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 16 Jun 2026 18:49:14 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 0f55057788631125fe15cc2acdd0a9e9db0ca855 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 17 Jun 2026 12:00:08 -0400 Subject: [PATCH 4/4] Fix review issues --- .../packageManager/composerPackageManager.ts | 25 ++++++++++++++++--- .../partialComposerLockValidator.ts | 2 ++ 2 files changed, 24 insertions(+), 3 deletions(-) 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/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(), });