diff --git a/README.md b/README.md index 298be31..d05fe5b 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The WinterCMS forms are adapted to the admin's YAML: | Command | Arguments and flags | Effect | |---------|---------------------|--------| | `sitemap:import` | `FILE`, `--theme`, `--append` | The PHP `ImportCommand`: reads a JSON array of `{url, changefreq, priority}` entries (`monthly` and `0.5` by default). Without `--append` the definition keeps its non-URL items first and the imported URLs replace its URL items; with it the URLs follow every existing item. The definition is always `default`; a `--theme` other than `default` is reported. | -| `sitemap:import-winter` | `--definitions FILE`, `--settings FILE`, `--theme`, `--map old=new` (repeatable), `--force`, `--dry-run` | Copies a WinterCMS export into the definition and the settings row. URL items are kept; static and CMS page items are dropped; `--map` renames types first (for example `--map blog-post=journal-post`); an item of an unknown type, or whose reference is not among its type's current references, is dropped. Every dropped or remapped item and every dropped `cmsPage` is printed. A definition that already has items is refused without `--force`; `--dry-run` writes nothing. Settings missing from the export take the defaults. `--theme` picks a definition when the export holds several. | +| `sitemap:import-winter` | `--definitions FILE`, `--settings FILE`, `--theme`, `--map old=new` (repeatable), `--page-url reference=/path` (repeatable), `--force`, `--dry-run` | Copies a WinterCMS export into the definition and the settings row. URL items are kept; static and CMS page items are dropped unless `--page-url` names the URL of their reference (for example `--page-url blog=/blog` copies the CMS page item `blog` as a url item `/blog`); `--map` renames types first (for example `--map blog-post=journal-post`); an item of an unknown type, or whose reference is not among its type's current references, is dropped. Every dropped or remapped item and every dropped `cmsPage` is printed. A definition that already has items is refused without `--force`; `--dry-run` writes nothing. Settings missing from the export take the defaults. `--theme` picks a definition when the export holds several. | Both commands open the database themselves when the application has not published one. diff --git a/console/import_winter.go b/console/import_winter.go index f84165e..b4439d8 100644 --- a/console/import_winter.go +++ b/console/import_winter.go @@ -33,13 +33,16 @@ var droppedTypes = map[string]bool{ // of scripts/winter-export-settings.sql (the JSON object of the // golem15_sitemap_settings system setting). Theme picks a definition when // the export holds several. Maps rename item types (old to new) before they -// are checked. Force replaces a definition that has items; DryRun reports -// without writing. +// are checked. PageURLs turns a static or CMS page item into a url item: +// its key is the item's reference (the PHP page file name, for example +// blog), its value the page's URL (/blog). Force replaces a definition that +// has items; DryRun reports without writing. type WinterOptions struct { DefinitionsFile string SettingsFile string Theme string Maps map[string]string + PageURLs map[string]string Force bool DryRun bool } @@ -58,7 +61,8 @@ type WinterReport struct { // ImportWinter is sitemap:import-winter: it copies a WinterCMS site's // sitemap definition and settings into the definition /sitemap.xml serves // (theme default) and the settings row. Item types are mapped (url kept, -// static and CMS page types dropped, --map old=new renames applied first), +// static and CMS page types dropped unless --page-url reference=/path names +// their URL, --map old=new renames applied first), // an item of a registry type that takes references is dropped when its // reference is not among the type's current references, an item of an // unknown type is dropped, and every dropped or remapped item is printed. A @@ -73,6 +77,7 @@ func ImportWinter(app *backpack.App) bonfire.Command { {Name: "settings", Description: "Settings export (the JSON object from scripts/winter-export-settings.sql)"}, {Name: "theme", Description: "The PHP theme whose definition to copy when the export holds several"}, {Name: "map", Description: "Rename an item type, old=new (repeatable)", Repeatable: true}, + {Name: "page-url", Description: "Copy a static or CMS page item as a url item, reference=/path (repeatable)", Repeatable: true}, {Name: "force", Description: "Replace a definition that already has items", Bare: true}, {Name: "dry-run", Description: "Report what would be copied without writing", Bare: true}, }, @@ -81,11 +86,16 @@ func ImportWinter(app *backpack.App) bonfire.Command { if err != nil { return err } + pageURLs, err := parsePageURLs(in.Flags("page-url")) + if err != nil { + return err + } opts := WinterOptions{ DefinitionsFile: flagValue(in, "definitions"), SettingsFile: flagValue(in, "settings"), Theme: flagValue(in, "theme"), Maps: maps, + PageURLs: pageURLs, Force: flagOn(in, "force"), DryRun: flagOn(in, "dry-run"), } @@ -118,6 +128,25 @@ func parseMaps(values []string) (map[string]string, error) { return maps, nil } +// parsePageURLs reads --page-url reference=/path values. The URL must be a +// root-relative path or an http(s) URL. +func parsePageURLs(values []string) (map[string]string, error) { + urls := map[string]string{} + for _, v := range values { + ref, url, ok := strings.Cut(v, "=") + ref, url = strings.TrimSpace(ref), strings.TrimSpace(url) + if !ok || ref == "" || url == "" { + return nil, fmt.Errorf("--page-url %q: use reference=/path", v) + } + lower := strings.ToLower(url) + if !strings.HasPrefix(url, "/") && !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") { + return nil, fmt.Errorf("--page-url %q: the URL must start with / or http(s)://", v) + } + urls[ref] = url + } + return urls, nil +} + // winterDefinition is one exported golem15_sitemap_definitions row. type winterDefinition struct { ID json.RawMessage `json:"id"` @@ -142,7 +171,7 @@ func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry, return report, err } out.Info(fmt.Sprintf("Copying definition %s (theme '%s') into the '%s' definition.", strings.TrimSpace(string(row.ID)), models.Text(row.Theme), models.DefinitionTheme)) - items, err = mapItems(ctx, reg, row.Data, opts.Maps, &report) + items, err = mapItems(ctx, reg, row.Data, opts.Maps, opts.PageURLs, &report) if err != nil { return report, err } @@ -277,7 +306,7 @@ func exportedJSON(raw json.RawMessage) json.RawMessage { } // mapItems decodes the exported data and maps its items. -func mapItems(ctx context.Context, reg *signpost.Registry, data json.RawMessage, maps map[string]string, report *WinterReport) ([]models.DefinitionItem, error) { +func mapItems(ctx context.Context, reg *signpost.Registry, data json.RawMessage, maps, pageURLs map[string]string, report *WinterReport) ([]models.DefinitionItem, error) { data = exportedJSON(data) if len(data) == 0 || bytes.Equal(data, []byte("null")) { return []models.DefinitionItem{}, nil @@ -299,6 +328,16 @@ func mapItems(ctx context.Context, reg *signpost.Registry, data json.RawMessage, if page, ok := fields["cmsPage"].(string); ok && page != "" { report.Lines = append(report.Lines, fmt.Sprintf("%s: cmsPage '%s' dropped (CMS pages are not supported)", label, page)) } + if url, ok := pageURLs[models.Text(item.Reference)]; ok && droppedTypes[item.Type] { + report.Lines = append(report.Lines, fmt.Sprintf("%s: copied as type 'url' with url '%s'", label, url)) + report.Remapped++ + item.Type = models.ItemTypeURL + item.URL = &url + item.Reference = nil + out = append(out, item) + report.Kept++ + continue + } if to, ok := maps[item.Type]; ok && to != item.Type { report.Lines = append(report.Lines, fmt.Sprintf("%s: type '%s' mapped to '%s'", label, item.Type, to)) report.Remapped++ diff --git a/sitemap_smoke_test.go b/sitemap_smoke_test.go index a1ae643..2952365 100644 --- a/sitemap_smoke_test.go +++ b/sitemap_smoke_test.go @@ -250,7 +250,8 @@ func TestSitemapImportSmoke(t *testing.T) { data := `[{"changefreq":"always","priority":"1.0","nesting":"0","type":"url","url":"/","code":null,"reference":null,"cmsPage":null,"urlPattern":null,"exists":false,"fillable":["changefreq"]},` + `{"changefreq":"monthly","priority":"0.5","nesting":"0","type":"static-page","url":null,"reference":"about","cmsPage":null,"urlPattern":null},` + `{"changefreq":"weekly","priority":"0.4","nesting":"1","type":"blog-posts","url":null,"reference":null,"cmsPage":"blog/post","urlPattern":"/news/:slug"},` + - `{"changefreq":"weekly","priority":"0.4","nesting":"0","type":"acme-post","url":null,"reference":"99","cmsPage":null,"urlPattern":null}]` + `{"changefreq":"weekly","priority":"0.4","nesting":"0","type":"acme-post","url":null,"reference":"99","cmsPage":null,"urlPattern":null},` + + `{"changefreq":"daily","priority":"0.7","nesting":"false","type":"cms-page","url":"","reference":"news","cmsPage":"","urlPattern":""}]` quotedRaw, _ := json.Marshal(data) quoted := string(quotedRaw) defs := writeFile(t, dir, "definitions.jsonl", `{"id": 3, "theme": "site-theme", "data": `+quoted+`, "created_at": "2026-01-01 10:00:00", "updated_at": "2026-01-02 10:00:00"}`+"\n") @@ -259,11 +260,12 @@ func TestSitemapImportSmoke(t *testing.T) { out, buf := testOutput() report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{ DefinitionsFile: defs, SettingsFile: settings, Maps: map[string]string{"blog-posts": "acme-list"}, + PageURLs: map[string]string{"news": "/news"}, }, out) if err != nil { t.Fatalf("import-winter: %v\n%s", err, buf.String()) } - if report.Kept != 2 || report.Dropped != 2 || report.Remapped != 1 || !report.Settings { + if report.Kept != 3 || report.Dropped != 2 || report.Remapped != 2 || !report.Settings { t.Fatalf("report = %+v\n%s", report, buf.String()) } for _, want := range []string{ @@ -271,6 +273,7 @@ func TestSitemapImportSmoke(t *testing.T) { "item 2 (type 'blog-posts'): type 'blog-posts' mapped to 'acme-list'", "item 2 (type 'blog-posts'): cmsPage 'blog/post' dropped", "item 3 (type 'acme-post', reference '99'): dropped (reference '99' is not among the current references of 'acme-post')", + "item 4 (type 'cms-page', reference 'news'): copied as type 'url' with url '/news'", "setting 'legacy_key' ignored", "theme 'site-theme'", } { @@ -282,7 +285,8 @@ func TestSitemapImportSmoke(t *testing.T) { if err := e.gdb.Where("theme = ?", models.DefinitionTheme).First(&def).Error; err != nil { t.Fatal(err) } - if items := def.Items(); len(items) != 2 || items[0].Type != "url" || items[1].Type != "acme-list" || models.Text(items[1].URLPattern) != "/news/:slug" { + if items := def.Items(); len(items) != 3 || items[0].Type != "url" || items[1].Type != "acme-list" || models.Text(items[1].URLPattern) != "/news/:slug" || + items[2].Type != "url" || models.Text(items[2].URL) != "/news" || items[2].Reference != nil { t.Fatalf("copied items = %+v", items) } var st models.Settings @@ -309,6 +313,7 @@ func TestSitemapImportSmoke(t *testing.T) { for _, want := range []string{ `href="https://www.example.test/sitemap.xsl"`, `https://www.example.test/`, + `https://www.example.test/news`, `https://www.example.test/news/a`, `https://www.example.test/en/news/a`, `hreflang="en" href="https://www.example.test/en/news/a"/>`,