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"/>`,