diff --git a/cmd/extension/extension_make.go b/cmd/extension/extension_make.go new file mode 100644 index 000000000..972131ed7 --- /dev/null +++ b/cmd/extension/extension_make.go @@ -0,0 +1,47 @@ +package extension + +import ( + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/extension/scaffolding" +) + +func newMakeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "make", + Short: "Add an example implementation to a plugin", + Long: `Add an example implementation of a single Shopware feature to the plugin in the +current directory. Generators only create missing files and extend the service +and route configuration, existing code is never changed.`, + } + + for _, generator := range extension.Generators() { + cmd.AddCommand(newGeneratorCmd(generator)) + } + + return cmd +} + +func newGeneratorCmd(generator scaffolding.Generator) *cobra.Command { + use := generator.Name + args := cobra.NoArgs + + if generator.Args != "" { + use += " " + generator.Args + args = cobra.MinimumNArgs(1) + } + + return &cobra.Command{ + Use: use, + Short: generator.Short, + Args: args, + RunE: func(cmd *cobra.Command, args []string) error { + return extension.Make(cmd.Context(), generator, args) + }, + } +} + +func init() { + extensionRootCmd.AddCommand(newMakeCmd()) +} diff --git a/internal/extension/create_test.go b/internal/extension/create_test.go index 746c134f3..10c434769 100644 --- a/internal/extension/create_test.go +++ b/internal/extension/create_test.go @@ -96,7 +96,6 @@ func TestCreateGeneratesAnExtension(t *testing.T) { assert.FileExists(t, filepath.Join(extensionDir, "src", technicalName+".php")) if extensionType == Plugin { - assert.FileExists(t, filepath.Join(extensionDir, "src", "Resources", "config", "config.xml")) assert.FileExists(t, filepath.Join(extensionDir, ".gitignore")) assert.FileExists(t, filepath.Join(extensionDir, "phpunit.xml")) assert.FileExists(t, filepath.Join(extensionDir, "tests", "TestBootstrap.php")) diff --git a/internal/extension/make.go b/internal/extension/make.go new file mode 100644 index 000000000..e47e5eec1 --- /dev/null +++ b/internal/extension/make.go @@ -0,0 +1,109 @@ +package extension + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/shopware/shopware-cli/internal/extension/scaffolding" + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/logging" +) + +// MinimumMakeShopwareVersion is the oldest Shopware release the generated code +// runs on. 6.7.13.0 replaced the custom field installer with a declarative +// Resources/config/custom-fields.xml, which the generators rely on. +const MinimumMakeShopwareVersion = "6.7.13.0" + +// Generators returns the generators that can be run inside an existing plugin. +func Generators() []scaffolding.Generator { + return scaffolding.Generators() +} + +// Make runs a generator inside the plugin in the current directory. +func Make(ctx context.Context, generator scaffolding.Generator, args []string) error { + logger := logging.FromContext(ctx) + + projectDir, err := shop.FindClosestShopwareProject(false) + if err != nil { + return err + } + + if err := ensureMakeSupported(projectDir); err != nil { + return err + } + + plugin, err := currentPlugin(ctx) + if err != nil { + return err + } + + result, err := generator.Run(plugin, args) + if err != nil { + return err + } + + for _, path := range result.Created { + logger.Infof("✓ created %s", path) + } + for _, path := range result.Updated { + logger.Infof("✓ updated %s", path) + } + for _, path := range result.Skipped { + logger.Infof("• skipped %s, it is already up to date", path) + } + + return nil +} + +// ensureMakeSupported fails when the project runs a Shopware release that does +// not understand the generated code. +func ensureMakeSupported(projectDir string) error { + supported, err := shop.IsShopwareVersion(projectDir, ">="+MinimumMakeShopwareVersion) + if err != nil { + return fmt.Errorf("cannot determine the Shopware version of %s: %w", projectDir, err) + } + + if !supported { + return fmt.Errorf("the generators require Shopware %s or newer, %s uses an older release", MinimumMakeShopwareVersion, projectDir) + } + + return nil +} + +// currentPlugin describes the plugin the generators write into. +func currentPlugin(ctx context.Context) (scaffolding.PluginInfo, error) { + dir, err := os.Getwd() + if err != nil { + return scaffolding.PluginInfo{}, err + } + + ext, err := GetExtensionByFolder(ctx, dir) + if err != nil { + return scaffolding.PluginInfo{}, fmt.Errorf("the current directory is not an extension: %w", err) + } + + plugin, ok := ext.(*PlatformPlugin) + if !ok { + return scaffolding.PluginInfo{}, fmt.Errorf("the generators only support plugins, %s is of type %s", dir, ext.GetType()) + } + + namespace, className, err := splitPluginClass(plugin.Composer.Extra.ShopwarePluginClass) + if err != nil { + return scaffolding.PluginInfo{}, err + } + + return scaffolding.PluginInfo{Dir: dir, Namespace: namespace, ClassName: className}, nil +} + +// splitPluginClass separates the namespace from the class name of the fully +// qualified plugin class, e.g. Swag\BasicExample\SwagBasicExample. +func splitPluginClass(pluginClass string) (namespace string, className string, err error) { + separator := strings.LastIndex(pluginClass, `\`) + if separator <= 0 || separator == len(pluginClass)-1 { + return "", "", fmt.Errorf("composer.json needs a namespaced extra.shopware-plugin-class, got %q", pluginClass) + } + + return pluginClass[:separator], pluginClass[separator+1:], nil +} diff --git a/internal/extension/scaffolding/generator.go b/internal/extension/scaffolding/generator.go new file mode 100644 index 000000000..e6dfe5624 --- /dev/null +++ b/internal/extension/scaffolding/generator.go @@ -0,0 +1,235 @@ +package scaffolding + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// PluginInfo describes the existing plugin a generator writes into. +type PluginInfo struct { + // Dir is the absolute path of the plugin. + Dir string + // Namespace is the PHP namespace of the plugin, e.g. Swag\BasicExample. + Namespace string + // ClassName is the plugin class without its namespace, e.g. SwagBasicExample. + ClassName string +} + +// Generator creates an example implementation of a single Shopware feature +// inside an existing plugin. Generators are additive: they create missing files +// and extend the service and route configuration, but never touch code that is +// already there. +type Generator struct { + // Name is the sub command name, e.g. "event-subscriber". + Name string + // Short describes the generator in the command list. + Short string + // Args documents the positional arguments, e.g. "ENTITY...". + // It is empty for generators that take no arguments. + Args string + + build func(plugin PluginInfo, args []string) (output, error) +} + +// Result lists the paths a generator run touched, relative to the plugin. +type Result struct { + // Created are files that did not exist before. + Created []string + // Updated are config files that gained a new block. + Updated []string + // Skipped are files that were already there and stayed untouched. + Skipped []string +} + +// Run executes the generator inside the plugin. +func (g Generator) Run(plugin PluginInfo, args []string) (Result, error) { + var result Result + + out, err := g.build(plugin, args) + if err != nil { + return result, err + } + + for _, f := range out.Files { + state, err := createFile(plugin.Dir, f) + if err != nil { + return result, fmt.Errorf("create %s: %w", f.Path, err) + } + + result.record(f.Path, state) + } + + for _, s := range out.Snippets { + state, err := applySnippet(plugin.Dir, s) + if err != nil { + return result, fmt.Errorf("update %s: %w", s.Path, err) + } + + result.record(s.Path, state) + } + + return result, nil +} + +// templateData holds every value the generator stubs can reference. The entity +// fields are only filled by the entity generator. +type templateData struct { + Namespace string + ClassName string + EntityName string + TableName string + Timestamp string +} + +// file is a single file a generator creates from an embedded stub. +type file struct { + // Path is relative to the plugin directory and always uses forward slashes. + Path string + Stub string + // Raw copies the stub verbatim instead of rendering it. Twig stubs need + // this because Twig and Go templates share the {{ }} delimiters. + Raw bool + Data templateData +} + +// snippet is a block of code added to a config file shared by all generators. +// A missing file is created from Intro and Outro, an existing one only gains +// the block. Content that is already present is never added twice. +type snippet struct { + // Path is relative to the plugin directory and always uses forward slashes. + Path string + Content string + Intro string + Outro string +} + +// output is everything a single generator contributes to a plugin. +type output struct { + Files []file + Snippets []snippet +} + +// fileState tells what happened to a file during a generator run. +type fileState int + +const ( + skipped fileState = iota + created + updated +) + +func (r *Result) record(path string, state fileState) { + switch state { + case created: + r.Created = append(r.Created, path) + case updated: + r.Updated = append(r.Updated, path) + case skipped: + r.Skipped = append(r.Skipped, path) + } +} + +// createFile renders a stub into the plugin, unless the file already exists. +func createFile(pluginDir string, f file) (fileState, error) { + dest := filepath.Join(pluginDir, filepath.FromSlash(f.Path)) + + _, err := os.Stat(dest) + if err == nil { + return skipped, nil + } + if !errors.Is(err, os.ErrNotExist) { + return skipped, fmt.Errorf("stat file: %w", err) + } + + content, err := renderStub(f) + if err != nil { + return skipped, err + } + + if err := writeFile(dest, content); err != nil { + return skipped, err + } + + return created, nil +} + +// applySnippet adds a block to a config file without losing its current content. +func applySnippet(pluginDir string, s snippet) (fileState, error) { + dest := filepath.Join(pluginDir, filepath.FromSlash(s.Path)) + + existing, err := os.ReadFile(dest) + if errors.Is(err, os.ErrNotExist) { + if err := writeFile(dest, s.Intro+s.Content+s.Outro); err != nil { + return skipped, err + } + + return created, nil + } + if err != nil { + return skipped, fmt.Errorf("read file: %w", err) + } + + content := string(existing) + if strings.Contains(content, s.Content) { + return skipped, nil + } + + // The block of a PHP config file belongs inside the returned closure, so it + // goes in front of the closing "};" rather than at the end of the file. + if marker := strings.TrimSpace(s.Outro); marker != "" { + if at := strings.LastIndex(content, marker); at >= 0 { + content = content[:at] + s.Content + content[at:] + + if err := writeFile(dest, content); err != nil { + return skipped, err + } + + return updated, nil + } + } + + if err := writeFile(dest, content+s.Content); err != nil { + return skipped, err + } + + return updated, nil +} + +func renderStub(f file) (string, error) { + stub, err := stubsFS.ReadFile(f.Stub) + if err != nil { + return "", fmt.Errorf("read stub: %w", err) + } + + if f.Raw { + return string(stub), nil + } + + tmpl, err := template.New(f.Path).Parse(string(stub)) + if err != nil { + return "", fmt.Errorf("parse stub: %w", err) + } + + var rendered strings.Builder + if err := tmpl.Execute(&rendered, f.Data); err != nil { + return "", fmt.Errorf("render stub: %w", err) + } + + return rendered.String(), nil +} + +func writeFile(dest, content string) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create subdirectories: %w", err) + } + + if err := os.WriteFile(dest, []byte(content), 0o644); err != nil { + return fmt.Errorf("write file: %w", err) + } + + return nil +} diff --git a/internal/extension/scaffolding/generators.go b/internal/extension/scaffolding/generators.go new file mode 100644 index 000000000..60bfbc765 --- /dev/null +++ b/internal/extension/scaffolding/generators.go @@ -0,0 +1,347 @@ +package scaffolding + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + servicesPath = "src/Resources/config/services.php" + routesPath = "src/Resources/config/routes.php" + adminSrcPath = "src/Resources/app/administration/src/" + storefrontPath = "src/Resources/app/storefront/src/" + viewsPath = "src/Resources/views/storefront/" +) + +// Shopware loads services and routes of a plugin from these two files. Both are +// a PHP closure, so a generator adds its block in front of the closing "};". +const ( + servicesIntro = `services(); +` + + routesIntro = `set(\%s\Command\ExampleCommand::class) + ->tag('console.command'); +` + + return output{ + Files: []file{ + {Path: "src/Command/ExampleCommand.php", Stub: "stubs/make/command.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, + }, nil +} + +func buildCustomFieldset(_ PluginInfo, _ []string) (output, error) { + return output{ + Files: []file{ + {Path: "src/Resources/config/custom-fields.xml", Stub: "stubs/make/custom_fields.xml", Raw: true}, + }, + }, nil +} + +func buildEventSubscriber(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\Subscriber\MySubscriber::class) + ->tag('kernel.event_subscriber'); +` + + return output{ + Files: []file{ + {Path: "src/Subscriber/MySubscriber.php", Stub: "stubs/make/event_subscriber.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, + }, nil +} + +func buildJavascriptPlugin(_ PluginInfo, _ []string) (output, error) { + const pluginRegistration = `// Import all necessary Storefront plugins +import ExamplePlugin from './example-plugin/example-plugin.plugin'; + +// Register your plugin via the existing PluginManager +const PluginManager = window.PluginManager; + +PluginManager.register('ExamplePlugin', ExamplePlugin, '[data-example-plugin]'); +` + + return output{ + Files: []file{ + {Path: storefrontPath + "example-plugin/example-plugin.plugin.js", Stub: "stubs/make/javascript_plugin.js", Raw: true}, + {Path: viewsPath + "page/content/index.html.twig", Stub: "stubs/make/javascript_plugin_template.html.twig", Raw: true}, + }, + Snippets: []snippet{{Path: storefrontPath + "main.js", Content: pluginRegistration}}, + }, nil +} + +func buildPluginConfig(_ PluginInfo, _ []string) (output, error) { + return output{ + Files: []file{ + {Path: "src/Resources/config/config.xml", Stub: "stubs/make/plugin_config.xml", Raw: true}, + }, + }, nil +} + +func buildScheduledTask(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%[1]s\ScheduledTask\ExampleTask::class) + ->tag('shopware.scheduled.task'); + $services->set(\%[1]s\ScheduledTask\ExampleTaskHandler::class) + ->args([ + service('scheduled_task.repository'), + service('logger'), + ]) + ->tag('messenger.message_handler'); +` + + return output{ + Files: []file{ + {Path: "src/ScheduledTask/ExampleTask.php", Stub: "stubs/make/scheduled_task.php.tmpl", Data: plugin.data()}, + {Path: "src/ScheduledTask/ExampleTaskHandler.php", Stub: "stubs/make/scheduled_task_handler.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, + }, nil +} + +func buildStoreAPIRoute(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\Core\Content\Example\SalesChannel\ExampleRoute::class) + ->public() + ->args([ + service('product.repository'), + ]); +` + + const route = ` + $routes->import('../../Core/**/*Route.php', 'attribute'); +` + + const salesChannelPath = "src/Core/Content/Example/SalesChannel/" + + return output{ + Files: []file{ + {Path: salesChannelPath + "AbstractExampleRoute.php", Stub: "stubs/make/store_api_abstract_route.php.tmpl", Data: plugin.data()}, + {Path: salesChannelPath + "ExampleRoute.php", Stub: "stubs/make/store_api_route.php.tmpl", Data: plugin.data()}, + {Path: salesChannelPath + "ExampleRouteResponse.php", Stub: "stubs/make/store_api_response.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{ + servicesSnippet(fmt.Sprintf(service, plugin.Namespace)), + routesSnippet(route), + }, + }, nil +} + +func buildStorefrontController(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\Storefront\Controller\ExampleController::class) + ->public() + ->call('setContainer', [service('service_container')]); +` + + const route = ` + $routes->import('../../Storefront/Controller/**/*Controller.php', 'attribute'); +` + + return output{ + Files: []file{ + {Path: "src/Storefront/Controller/ExampleController.php", Stub: "stubs/make/storefront_controller.php.tmpl", Data: plugin.data()}, + {Path: viewsPath + "page/example.html.twig", Stub: "stubs/make/storefront_template.html.twig", Raw: true}, + }, + Snippets: []snippet{ + servicesSnippet(fmt.Sprintf(service, plugin.Namespace)), + routesSnippet(route), + }, + }, nil +} + +func buildEntity(plugin PluginInfo, entities []string) (output, error) { + const service = ` + $services->set(\%s\Core\Content\%s\%sDefinition::class) + ->tag('shopware.entity.definition', ['entity' => '%s']); +` + + // All migrations of one run share a timestamp; the entity name keeps the + // class names unique. + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + + var out output + + for _, entity := range entities { + if !entityNameRegexp.MatchString(entity) { + return output{}, fmt.Errorf("invalid entity name %q: use PascalCase, e.g. ExampleEntity", entity) + } + + data := templateData{ + Namespace: plugin.Namespace, + ClassName: plugin.ClassName, + EntityName: entity, + TableName: tableName(entity), + Timestamp: timestamp, + } + + contentPath := "src/Core/Content/" + entity + "/" + + migration, err := migrationFile(plugin.Dir, data) + if err != nil { + return output{}, err + } + + out.Files = append(out.Files, + file{Path: contentPath + entity + "Entity.php", Stub: "stubs/make/entity.php.tmpl", Data: data}, + file{Path: contentPath + entity + "Definition.php", Stub: "stubs/make/entity_definition.php.tmpl", Data: data}, + file{Path: contentPath + entity + "Collection.php", Stub: "stubs/make/entity_collection.php.tmpl", Data: data}, + migration, + ) + + out.Snippets = append(out.Snippets, servicesSnippet( + fmt.Sprintf(service, plugin.Namespace, entity, entity, data.TableName), + )) + } + + return out, nil +} + +// migrationFile returns the migration that creates the entity table. The file +// name carries the creation timestamp, so an earlier migration for the same +// entity can only be found by a glob. When one exists, its path is reused and +// the migration is reported as skipped instead of being written a second time. +func migrationFile(pluginDir string, data templateData) (file, error) { + f := file{ + Path: fmt.Sprintf("src/Migration/Migration%sCreate%sTable.php", data.Timestamp, data.EntityName), + Stub: "stubs/make/entity_migration.php.tmpl", + Data: data, + } + + pattern := filepath.Join(pluginDir, "src", "Migration", "Migration*Create"+data.EntityName+"Table.php") + + existing, err := filepath.Glob(pattern) + if err != nil { + return file{}, fmt.Errorf("look for existing migrations: %w", err) + } + + if len(existing) > 0 { + f.Path = "src/Migration/" + filepath.Base(existing[0]) + } + + return f, nil +} + +func (p PluginInfo) data() templateData { + return templateData{Namespace: p.Namespace, ClassName: p.ClassName} +} + +func servicesSnippet(content string) snippet { + return snippet{Path: servicesPath, Content: content, Intro: servicesIntro, Outro: configOutro} +} + +func routesSnippet(content string) snippet { + return snippet{Path: routesPath, Content: content, Intro: routesIntro, Outro: configOutro} +} + +// tableName turns a PascalCase entity name into its snake_case table name. +func tableName(entityName string) string { + return strings.ToLower(strings.Join(splitPascalCase(entityName), "_")) +} diff --git a/internal/extension/scaffolding/generators_test.go b/internal/extension/scaffolding/generators_test.go new file mode 100644 index 000000000..59fad73d3 --- /dev/null +++ b/internal/extension/scaffolding/generators_test.go @@ -0,0 +1,121 @@ +package scaffolding + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every generator has to render its stubs and may only create files on the +// first run. +func TestGeneratorsCreateFilesOnceRunInAPlugin(t *testing.T) { + for _, generator := range Generators() { + t.Run(generator.Name, func(t *testing.T) { + plugin := newPlugin(t) + + var args []string + if generator.Args != "" { + args = []string{"ExampleEntity"} + } + + result, err := generator.Run(plugin, args) + require.NoError(t, err) + assert.NotEmpty(t, result.Created) + + for _, path := range result.Created { + assert.FileExists(t, filepath.Join(plugin.Dir, filepath.FromSlash(path))) + } + + second, err := generator.Run(plugin, args) + require.NoError(t, err) + assert.Empty(t, second.Created) + assert.Empty(t, second.Updated) + }) + } +} + +func TestAdminModuleGeneratorCreatesTheExampleComponent(t *testing.T) { + plugin := newPlugin(t) + + result, err := generatorByName(t, "admin-module").Run(plugin, nil) + require.NoError(t, err) + + listPath := adminSrcPath + "module/swag-example/page/swag-example-list/" + assert.Subset(t, result.Created, []string{ + adminSrcPath + "module/swag-example/index.js", + listPath + "index.js", + listPath + "swag-example-list.html.twig", + listPath + "swag-example-list.scss", + }) + + // The Twig stub is copied verbatim, its {{ }} are no Go template actions. + assert.Contains(t, readPluginFile(t, plugin, listPath+"swag-example-list.html.twig"), "{{ $t('swag-example.general.list.cardText') }}") + assert.Contains(t, readPluginFile(t, plugin, adminSrcPath+"module/swag-example/index.js"), "import './page/swag-example-list';") +} + +func TestScheduledTaskGeneratorRegistersTaskAndHandler(t *testing.T) { + plugin := newPlugin(t) + + result, err := generatorByName(t, "scheduled-task").Run(plugin, nil) + require.NoError(t, err) + + assert.Subset(t, result.Created, []string{ + "src/ScheduledTask/ExampleTask.php", + "src/ScheduledTask/ExampleTaskHandler.php", + }) + + handler := readPluginFile(t, plugin, "src/ScheduledTask/ExampleTaskHandler.php") + assert.Contains(t, handler, `namespace MyVendor\MyExtension\ScheduledTask;`) + assert.Contains(t, handler, "#[AsMessageHandler(handles: ExampleTask::class)]") + + services := readPluginFile(t, plugin, servicesPath) + assert.Contains(t, services, `$services->set(\MyVendor\MyExtension\ScheduledTask\ExampleTask::class)`) + assert.Contains(t, services, `$services->set(\MyVendor\MyExtension\ScheduledTask\ExampleTaskHandler::class)`) + assert.Contains(t, services, "->tag('messenger.message_handler');") +} + +func TestPluginConfigGeneratorCreatesTheConfigXML(t *testing.T) { + plugin := newPlugin(t) + + result, err := generatorByName(t, "plugin-config").Run(plugin, nil) + require.NoError(t, err) + + assert.Equal(t, []string{"src/Resources/config/config.xml"}, result.Created) + assert.Contains(t, readPluginFile(t, plugin, "src/Resources/config/config.xml"), "") +} + +func generatorByName(t *testing.T, name string) Generator { + t.Helper() + + for _, generator := range Generators() { + if generator.Name == name { + return generator + } + } + + t.Fatalf("unknown generator %q", name) + + return Generator{} +} + +func newPlugin(t *testing.T) PluginInfo { + t.Helper() + + return PluginInfo{ + Dir: t.TempDir(), + Namespace: `MyVendor\MyExtension`, + ClassName: "MyVendorMyExtension", + } +} + +func readPluginFile(t *testing.T, plugin PluginInfo, path string) string { + t.Helper() + + content, err := os.ReadFile(filepath.Join(plugin.Dir, filepath.FromSlash(path))) + require.NoError(t, err) + + return string(content) +} diff --git a/internal/extension/scaffolding/scaffolding.go b/internal/extension/scaffolding/scaffolding.go index 4a9b16591..eac5c8941 100644 --- a/internal/extension/scaffolding/scaffolding.go +++ b/internal/extension/scaffolding/scaffolding.go @@ -64,10 +64,6 @@ func pluginScaffoldingFiles(className string) []scaffoldingFile { Path: ".gitignore", StubPath: "stubs/gitignore.tmpl", }, - { - Path: "src/Resources/config/config.xml", - StubPath: "stubs/config.xml.tmpl", - }, { Path: filepath.Join("src", className+".php"), StubPath: "stubs/plugin_class.php.tmpl", diff --git a/internal/extension/scaffolding/scaffolding_test.go b/internal/extension/scaffolding/scaffolding_test.go index 63b438332..fd6c5385d 100644 --- a/internal/extension/scaffolding/scaffolding_test.go +++ b/internal/extension/scaffolding/scaffolding_test.go @@ -97,12 +97,10 @@ func TestCreatePluginFiles(t *testing.T) { require.NoError(t, CreatePluginFiles(extensionDir, "MyExtension", "MyVendor")) - assert.DirExists(t, filepath.Join(extensionDir, "src", "Resources", "config")) assert.DirExists(t, filepath.Join(extensionDir, "tests")) // all expected files for an installable extension are created assert.FileExists(t, filepath.Join(extensionDir, "composer.json")) - assert.FileExists(t, filepath.Join(extensionDir, "src", "Resources", "config", "config.xml")) assert.FileExists(t, filepath.Join(extensionDir, ".gitignore")) assert.FileExists(t, filepath.Join(extensionDir, "phpunit.xml")) assert.FileExists(t, filepath.Join(extensionDir, "src", technicalName+".php")) diff --git a/internal/extension/scaffolding/stubs/composer.json.tmpl b/internal/extension/scaffolding/stubs/composer.json.tmpl index 7786031f1..6c5967f31 100644 --- a/internal/extension/scaffolding/stubs/composer.json.tmpl +++ b/internal/extension/scaffolding/stubs/composer.json.tmpl @@ -2,16 +2,16 @@ "name": "{{ .ComposerName }}", "description": "{{ .ComposerName }}", "type": "shopware-platform-plugin", - "version": "1.0.0", - "license": "MIT", + "version": "0.1.0", + "license": "proprietary", "require": { "shopware/core": "~6.7.0" }, "extra": { "shopware-plugin-class": "{{ jsonEscape .Namespace }}\\{{ .ClassName }}", "label": { - "de-DE": "Skeleton plugin", - "en-GB": "Skeleton plugin" + "de-DE": "{{ .ClassName }}", + "en-GB": "{{ .ClassName }}" } }, "autoload": { diff --git a/internal/extension/scaffolding/stubs/make/admin_component_index.js b/internal/extension/scaffolding/stubs/make/admin_component_index.js new file mode 100644 index 000000000..8796f7d47 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_component_index.js @@ -0,0 +1,6 @@ +import template from './swag-example-list.html.twig'; +import './swag-example-list.scss'; + +Shopware.Component.register('swag-example-list', { + template, +}); diff --git a/internal/extension/scaffolding/stubs/make/admin_component_styling.scss b/internal/extension/scaffolding/stubs/make/admin_component_styling.scss new file mode 100644 index 000000000..9d58bca94 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_component_styling.scss @@ -0,0 +1,6 @@ +.swag-example-list { + &__text { + // docs on design tokens: https://meteor.shopware.com/documentation/design/tokens + color: var(--color-icon-attention-default); + } +} diff --git a/internal/extension/scaffolding/stubs/make/admin_component_template.html.twig b/internal/extension/scaffolding/stubs/make/admin_component_template.html.twig new file mode 100644 index 000000000..f7cdee9f3 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_component_template.html.twig @@ -0,0 +1,13 @@ +{% block swag_example_list %} + + + +{% endblock %} diff --git a/internal/extension/scaffolding/stubs/make/admin_module.js b/internal/extension/scaffolding/stubs/make/admin_module.js new file mode 100644 index 000000000..3007a6e36 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_module.js @@ -0,0 +1,26 @@ +import './page/swag-example-list'; + +Shopware.Module.register('swag-example', { + type: 'plugin', + name: 'Example', + title: 'swag-example.general.mainMenuItemGeneral', + description: 'sw-property.general.descriptionTextModule', + color: '#ff3d58', + icon: 'default-shopping-paper-bag-product', + + routes: { + list: { + component: 'swag-example-list', + path: 'list' + }, + }, + + navigation: [{ + parent: 'sw-catalogue', + label: 'swag-example.general.mainMenuItemGeneral', + color: '#ff3d58', + path: 'swag.example.list', + icon: 'default-shopping-paper-bag-product', + position: 100 + }] +}); diff --git a/internal/extension/scaffolding/stubs/make/admin_snippet.json b/internal/extension/scaffolding/stubs/make/admin_snippet.json new file mode 100644 index 000000000..e9781dd43 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_snippet.json @@ -0,0 +1,12 @@ +{ + "swag-example": { + "general": { + "mainMenuItemGeneral": "My custom module", + "descriptionTextModule": "Manage this custom module here", + "list": { + "cardTitle": "My Custom Admin Component", + "cardText": "Hello Admin!" + } + } + } +} diff --git a/internal/extension/scaffolding/stubs/make/command.php.tmpl b/internal/extension/scaffolding/stubs/make/command.php.tmpl new file mode 100644 index 000000000..79e0a865e --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/command.php.tmpl @@ -0,0 +1,30 @@ +setDescription('Does something very special.'); + } + + // Actual code executed in the command + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('It works!'); + + // Exit code 0 for success + return 0; + } +} diff --git a/internal/extension/scaffolding/stubs/make/custom_fields.xml b/internal/extension/scaffolding/stubs/make/custom_fields.xml new file mode 100644 index 000000000..7e4df1386 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/custom_fields.xml @@ -0,0 +1,19 @@ + + + + swag_example_set + + + + + + + + + + 1 + + + + diff --git a/internal/extension/scaffolding/stubs/make/entity.php.tmpl b/internal/extension/scaffolding/stubs/make/entity.php.tmpl new file mode 100644 index 000000000..94c1960d4 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity.php.tmpl @@ -0,0 +1,47 @@ +name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): void + { + $this->active = $active; + } +} diff --git a/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl b/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl new file mode 100644 index 000000000..14ead22cb --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl @@ -0,0 +1,16 @@ + + */ +class {{ .EntityName }}Collection extends EntityCollection +{ + protected function getExpectedClass(): string + { + return {{ .EntityName }}Entity::class; + } +} diff --git a/internal/extension/scaffolding/stubs/make/entity_definition.php.tmpl b/internal/extension/scaffolding/stubs/make/entity_definition.php.tmpl new file mode 100644 index 000000000..68819c0ca --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity_definition.php.tmpl @@ -0,0 +1,41 @@ +addFlags(new Required(), new PrimaryKey()), + (new StringField('name', 'name')), + (new StringField('description', 'description')), + (new BoolField('active', 'active')) + ]); + } +} diff --git a/internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl b/internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl new file mode 100644 index 000000000..36ed9762c --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl @@ -0,0 +1,38 @@ +executeStatement($sql); + } + + public function updateDestructive(Connection $connection): void + { + } +} diff --git a/internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl b/internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl new file mode 100644 index 000000000..1431ca935 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl @@ -0,0 +1,24 @@ + => + return [ + ProductEvents::PRODUCT_LOADED_EVENT => 'onProductsLoaded' + ]; + } + + public function onProductsLoaded(EntityLoadedEvent $event) + { + // Do something + // E.g. work with the loaded entities: $event->getEntities() + } +} diff --git a/internal/extension/scaffolding/stubs/make/javascript_plugin.js b/internal/extension/scaffolding/stubs/make/javascript_plugin.js new file mode 100644 index 000000000..c543ef3dc --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/javascript_plugin.js @@ -0,0 +1,13 @@ +import Plugin from 'src/plugin-system/plugin.class'; + +export default class ExamplePlugin extends Plugin { + init() { + window.addEventListener('scroll', this.onScroll.bind(this)); + } + + onScroll() { + if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) { + alert('Seems like there\'s nothing more to see here.'); + } + } +} diff --git a/internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig b/internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig new file mode 100644 index 000000000..38b9c59fd --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig @@ -0,0 +1,7 @@ +{% sw_extends '@Storefront/storefront/page/content/index.html.twig' %} + +{% block base_main_inner %} + {{ parent() }} + + +{% endblock %} diff --git a/internal/extension/scaffolding/stubs/config.xml.tmpl b/internal/extension/scaffolding/stubs/make/plugin_config.xml similarity index 73% rename from internal/extension/scaffolding/stubs/config.xml.tmpl rename to internal/extension/scaffolding/stubs/make/plugin_config.xml index b45fed585..7505c062e 100644 --- a/internal/extension/scaffolding/stubs/config.xml.tmpl +++ b/internal/extension/scaffolding/stubs/make/plugin_config.xml @@ -3,6 +3,11 @@ + + Minimal configuration diff --git a/internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl b/internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl new file mode 100644 index 000000000..aacb45954 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl @@ -0,0 +1,18 @@ +object->getEntities(); + + return $collection; + } +} diff --git a/internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl b/internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl new file mode 100644 index 000000000..d2794b183 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl @@ -0,0 +1,39 @@ + [StoreApiRouteScope::ID]])] +class ExampleRoute extends AbstractExampleRoute +{ + /** + * @param EntityRepository $productRepository + */ + public function __construct(private readonly EntityRepository $productRepository) + { + } + + public function getDecorated(): AbstractExampleRoute + { + throw new DecorationPatternException(self::class); + } + + #[Route( + path: '/store-api/example', + name: 'store-api.example.search', + defaults: ['_entity' => 'product'], + methods: ['GET', 'POST'] + )] + public function load(Criteria $criteria, SalesChannelContext $context): ExampleRouteResponse + { + return new ExampleRouteResponse($this->productRepository->search($criteria, $context->getContext())); + } +} diff --git a/internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl b/internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl new file mode 100644 index 000000000..9742ec2cf --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl @@ -0,0 +1,27 @@ + [StorefrontRouteScope::ID]])] +class ExampleController extends StorefrontController +{ + #[Route( + path: '/example', + name: 'frontend.example.example', + methods: ['GET'] + )] + public function showExample(Request $request, SalesChannelContext $context): Response + { + return $this->renderStorefront('@{{ .ClassName }}/storefront/page/example.html.twig', [ + 'example' => 'Hello world' + ]); + } +} diff --git a/internal/extension/scaffolding/stubs/make/storefront_template.html.twig b/internal/extension/scaffolding/stubs/make/storefront_template.html.twig new file mode 100644 index 000000000..c02d97bf2 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/storefront_template.html.twig @@ -0,0 +1,5 @@ +{% sw_extends '@Storefront/storefront/base.html.twig' %} + +{% block base_content %} +

{{ example }}

+{% endblock %}