From a4abaee4c02957b3aad4e6253c7719b0c1894deb Mon Sep 17 00:00:00 2001 From: orshih6 <34618353+orshih6@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:58:11 +0000 Subject: [PATCH 1/5] fix(publicsite): send a post or page to its one address --- .../src/content/docs/reference/content-api.md | 3 +- .../docs/self-hosting/configuration.md | 9 +- internal/content/resolver.go | 27 ++++++ internal/content/resolver_test.go | 48 ++++++++++ internal/publicsite/canonical_address_test.go | 90 +++++++++++++++++++ internal/publicsite/publicsite.go | 29 ++++-- .../features/features/content-serving.feature | 10 +++ 7 files changed, 207 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/reference/content-api.md b/docs/src/content/docs/reference/content-api.md index 9972180d..ab2d9893 100644 --- a/docs/src/content/docs/reference/content-api.md +++ b/docs/src/content/docs/reference/content-api.md @@ -436,7 +436,8 @@ curl "https://example.com/api/content/v1/resolve?path=/categories/news" An item filed under the term through two different fields is listed once. Term pages paginate behind `/page/{n}` like archives, and a -page suffix on an ordinary single item stays `404`. +page suffix on an ordinary single item stays `404` here, while the +built-in renderer redirects it to the item's own address. Item answers carry an `ETag` computed over the whole served answer. Send it back as `If-None-Match` and an unchanged answer comes back diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 497d896c..f1d3ae71 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -643,5 +643,10 @@ listing address that ends in a slash gets a `301` redirect to the plain address, with the query kept. So `/page/1` goes to `/`, `/page/02` goes to `/page/2` and `/pages/` goes to `/pages`. Only a `GET` or `HEAD` is redirected, and a listing page stays where it -was asked when other content is stored at its plain address. While -a theme serves the site, the theme answers these addresses itself. +was asked when other content is stored at its plain address. + +Each post and page has one address too. A visit with a trailing +slash, with repeated slashes or with a page number gets the same +`301` to the plain address, so `/hello-world/`, `//hello-world` and +`/hello-world/page/1` all go to `/hello-world`. While a theme serves +the site, the theme answers these addresses itself. diff --git a/internal/content/resolver.go b/internal/content/resolver.go index a7f705ce..32ff0d54 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -73,6 +73,33 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) (Address, error) { return r.archiveAt(ctx, address) } +// PagedItem returns the post or page an address asks a page number of, or [ErrNotFound]. +func (r *Resolver) PagedItem(ctx context.Context, raw string) (Address, error) { + if !storableAddress(raw) { + return Address{}, ErrNotFound + } + address := tidy(raw) + word, _, err := listingAt(address) + if err != nil { + return Address{}, err + } + if word == address || word == "" { + return Address{}, ErrNotFound + } + item, err := r.content.PublishedByPath(ctx, word) + if err != nil { + return Address{}, err + } + held, err := r.itemAt(ctx, item, 1) + if err != nil { + return Address{}, err + } + if held.Kind != KindItem { + return Address{}, ErrNotFound + } + return held, nil +} + // itemAt returns what an item answers with, which is a term page when its type lists its pointers. func (r *Resolver) itemAt(ctx context.Context, item Content, page int) (Address, error) { listed, err := r.types.ByKey(ctx, item.Type) diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index 39375ff0..4d932099 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -287,3 +287,51 @@ func TestResolveAnswersAnAddressNoStoreCanHoldAsNotFound(t *testing.T) { t.Errorf("lookups = %d, want none for an address no store can hold", store.lookups) } } + +func TestPagedItemFindsThePostOrPageBehindAPageNumber(t *testing.T) { + t.Parallel() + + store := newFakeAddresses() + hello := store.hold("post", "hello-world") + team := store.hold("page", "pages/about/team") + resolver := newResolver(t, store) + + for raw, want := range map[string]content.Content{ + "/hello-world/page/1": hello, + "/hello-world/page/02/": hello, + "/pages/about/team/page/3": team, + "//pages/about/team/page/1": team, + } { + answer, err := resolver.PagedItem(t.Context(), raw) + if err != nil { + t.Errorf("PagedItem(%q) error = %v, want nil", raw, err) + continue + } + if answer.Kind != content.KindItem || answer.Item.ID != want.ID { + t.Errorf("PagedItem(%q) = %+v, want the item at %q", raw, answer, want.Path) + } + } +} + +func TestPagedItemAnswersNotFoundWithoutAnItemBehindAPageNumber(t *testing.T) { + t.Parallel() + + store := newFakeAddresses() + store.hold("post", "hello-world") + resolver := newResolver(t, store) + + for name, raw := range map[string]string{ + "no page number": "/hello-world", + "a page below the first": "/hello-world/page/0", + "a word for a number": "/hello-world/page/next", + "the front page": "/page/1", + "an archive": "/pages/page/1", + "nothing stored": "/nowhere/page/1", + "a NUL byte": "/hello\x00world/page/1", + } { + _, err := resolver.PagedItem(t.Context(), raw) + if !errors.Is(err, content.ErrNotFound) { + t.Errorf("%s error = %v, want %v", name, err, content.ErrNotFound) + } + } +} diff --git a/internal/publicsite/canonical_address_test.go b/internal/publicsite/canonical_address_test.go index 9c051ca9..6478222e 100644 --- a/internal/publicsite/canonical_address_test.go +++ b/internal/publicsite/canonical_address_test.go @@ -167,3 +167,93 @@ func TestSiteSendsNothingButTheRedirect(t *testing.T) { t.Errorf("GET /page/1 body = %q, want none", recorder.Body.String()) } } + +func TestSiteSendsAPostOrPageToTheOneAddressItAnswersAt(t *testing.T) { + t.Parallel() + + hello := publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC()) + about := storedAt("page", "About", "pages/about") + handler, _ := siteWith(hello, about) + + for _, tc := range []struct { + requested string + want string + }{ + {"/hello-world/", "/hello-world"}, + {"//hello-world", "/hello-world"}, + {"/hello-world//", "/hello-world"}, + {"/hello-world/?utm_source=feed", "/hello-world?utm_source=feed"}, + {"/pages/about/", "/pages/about"}, + {"/pages//about", "/pages/about"}, + {"/hello-world/page/1", "/hello-world"}, + {"/hello-world/page/2", "/hello-world"}, + {"/hello-world/page/1/", "/hello-world"}, + {"/hello-world/page/01?utm_source=feed", "/hello-world?utm_source=feed"}, + {"/pages/about/page/1", "/pages/about"}, + } { + recorder := get(t, handler, tc.requested) + + if recorder.Code != http.StatusMovedPermanently { + t.Errorf("GET %s status = %d, want %d", tc.requested, recorder.Code, http.StatusMovedPermanently) + } + if got := recorder.Header().Get("Location"); got != tc.want { + t.Errorf("GET %s Location = %q, want %q", tc.requested, got, tc.want) + } + } +} + +func TestSiteKeepsTheAddressesAPostOrPageAlreadyAnswersAt(t *testing.T) { + t.Parallel() + + hello := publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC()) + about := storedAt("page", "About", "pages/about") + handler, _ := siteWith(hello, about) + + for _, address := range []string{"/", "/hello-world", "/pages/about"} { + recorder := get(t, handler, address) + + if recorder.Code != http.StatusOK { + t.Errorf("GET %s status = %d, want %d", address, recorder.Code, http.StatusOK) + } + } +} + +func TestSiteAnswersNotFoundForAPageNumberNothingHangsFrom(t *testing.T) { + t.Parallel() + + handler, _ := siteWith(publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC())) + + for _, address := range []string{"/hello-world/page/0", "/nowhere/page/1", "/hello-world/page/next"} { + recorder := get(t, handler, address) + + if recorder.Code != http.StatusNotFound { + t.Errorf("GET %s status = %d, want %d", address, recorder.Code, http.StatusNotFound) + } + } +} + +func TestSiteRedirectsOnlyAReadOfAPostOrPage(t *testing.T) { + t.Parallel() + + handler, _ := siteWith(publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC())) + + for _, tc := range []struct { + method string + requested string + want int + }{ + {http.MethodGet, "/hello-world/", http.StatusMovedPermanently}, + {http.MethodHead, "/hello-world/", http.StatusMovedPermanently}, + {http.MethodPost, "/hello-world/", http.StatusOK}, + {http.MethodHead, "/hello-world/page/1", http.StatusMovedPermanently}, + {http.MethodPost, "/hello-world/page/1", http.StatusNotFound}, + } { + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, httptest.NewRequest(tc.method, tc.requested, nil)) + + if recorder.Code != tc.want { + t.Errorf("%s %s status = %d, want %d", tc.method, tc.requested, recorder.Code, tc.want) + } + } +} diff --git a/internal/publicsite/publicsite.go b/internal/publicsite/publicsite.go index b037c582..19c7c514 100644 --- a/internal/publicsite/publicsite.go +++ b/internal/publicsite/publicsite.go @@ -144,6 +144,10 @@ func New(cfg Config) http.Handler { func (s *site) ServeHTTP(w http.ResponseWriter, r *http.Request) { held, err := s.addresses.Resolve(r.Context(), r.URL.Path) if errors.Is(err, content.ErrNotFound) { + if moved := s.pagedItemTarget(r); moved != "" { + serveRedirect(w, r, moved) + return + } s.serveNotFound(w, r) return } @@ -151,14 +155,14 @@ func (s *site) ServeHTTP(w http.ResponseWriter, r *http.Request) { serveError(w) return } - if held.Kind == content.KindItem { - s.serveItem(w, r, held.Item) - return - } if moved := s.redirectTarget(r, held); moved != "" { serveRedirect(w, r, moved) return } + if held.Kind == content.KindItem { + s.serveItem(w, r, held.Item) + return + } if held.Kind == content.KindArchive { s.serveList(w, r, held.Type, held.Page) return @@ -166,7 +170,8 @@ func (s *site) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.serveTerm(w, r, held) } -// redirectTarget returns the one address a read of a listing page moves to, empty when it stays where it was asked. +// redirectTarget returns the one address a read of a post, page or listing page moves to, empty when it stays where +// it was asked. func (s *site) redirectTarget(r *http.Request, held content.Address) string { if r.Method != http.MethodGet && r.Method != http.MethodHead { return "" @@ -182,7 +187,19 @@ func (s *site) redirectTarget(r *http.Request, held content.Address) string { return canonical } -// canonicalAddress returns the one address a listing page answers at. +// pagedItemTarget returns the plain address of the post or page a read asks a page number of, empty when there is none. +func (s *site) pagedItemTarget(r *http.Request) string { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return "" + } + held, err := s.addresses.PagedItem(r.Context(), r.URL.Path) + if err != nil { + return "" + } + return "/" + held.Item.Path +} + +// canonicalAddress returns the one address a post, page or listing page answers at. func canonicalAddress(held content.Address) string { listing := held.Item.Path if held.Kind == content.KindArchive { diff --git a/test/features/features/content-serving.feature b/test/features/features/content-serving.feature index 773e5fb6..e592b265 100644 --- a/test/features/features/content-serving.feature +++ b/test/features/features/content-serving.feature @@ -4,6 +4,7 @@ Feature: Serving content by address route word, and the front page always keeps its numbered pages. A listing page answers at one address, so page 1, a padded number and a trailing slash move for good to it, unless content already holds that address. + A post or page answers at one address too. Background: Given a running Gophenberg with the default content types @@ -40,6 +41,15 @@ Feature: Serving content by address Scenario: A listing that moves keeps its query Then "/page/1?ref=feed" moves for good to "/?ref=feed" + Scenario: A post with a trailing slash moves to the address without it + Then "/hello-world/" moves for good to "/hello-world" + + Scenario: A page asked with repeated slashes moves to its plain address + Then "/pages//about" moves for good to "/pages/about" + + Scenario: A page number on a post moves to the post + Then "/hello-world/page/1?ref=feed" moves for good to "/hello-world?ref=feed" + Scenario: Exact content beats pagination Given the published page "Page" And the published page "2" filed under "Page" From ba1600d2f90695e3bd01b27518b6d382a035e3f1 Mon Sep 17 00:00:00 2001 From: orshih6 <34618353+orshih6@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:12:31 +0000 Subject: [PATCH 2/5] fix(publicsite): report a failed read behind a page number as a server error --- internal/publicsite/canonical_address_test.go | 14 ++++++++++++++ internal/publicsite/publicsite.go | 18 +++++++++++++----- internal/publicsite/publicsite_test.go | 10 ++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/publicsite/canonical_address_test.go b/internal/publicsite/canonical_address_test.go index 6478222e..f1f4440f 100644 --- a/internal/publicsite/canonical_address_test.go +++ b/internal/publicsite/canonical_address_test.go @@ -232,6 +232,20 @@ func TestSiteAnswersNotFoundForAPageNumberNothingHangsFrom(t *testing.T) { } } +func TestSiteReportsAPostItCannotReadBehindAPageNumber(t *testing.T) { + t.Parallel() + + handler, reader := siteWith(publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC())) + reader.rereadPath = "hello-world" + reader.rereadErr = errors.New("database down") + + recorder := get(t, handler, "/hello-world/page/2") + + if recorder.Code != http.StatusInternalServerError { + t.Errorf("GET /hello-world/page/2 status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } +} + func TestSiteRedirectsOnlyAReadOfAPostOrPage(t *testing.T) { t.Parallel() diff --git a/internal/publicsite/publicsite.go b/internal/publicsite/publicsite.go index 19c7c514..0fa47c53 100644 --- a/internal/publicsite/publicsite.go +++ b/internal/publicsite/publicsite.go @@ -144,7 +144,12 @@ func New(cfg Config) http.Handler { func (s *site) ServeHTTP(w http.ResponseWriter, r *http.Request) { held, err := s.addresses.Resolve(r.Context(), r.URL.Path) if errors.Is(err, content.ErrNotFound) { - if moved := s.pagedItemTarget(r); moved != "" { + moved, err := s.pagedItemTarget(r) + if err != nil { + serveError(w) + return + } + if moved != "" { serveRedirect(w, r, moved) return } @@ -188,15 +193,18 @@ func (s *site) redirectTarget(r *http.Request, held content.Address) string { } // pagedItemTarget returns the plain address of the post or page a read asks a page number of, empty when there is none. -func (s *site) pagedItemTarget(r *http.Request) string { +func (s *site) pagedItemTarget(r *http.Request) (string, error) { if r.Method != http.MethodGet && r.Method != http.MethodHead { - return "" + return "", nil } held, err := s.addresses.PagedItem(r.Context(), r.URL.Path) + if errors.Is(err, content.ErrNotFound) { + return "", nil + } if err != nil { - return "" + return "", err } - return "/" + held.Item.Path + return "/" + held.Item.Path, nil } // canonicalAddress returns the one address a post, page or listing page answers at. diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index 6b54886f..387f8c37 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -27,6 +27,10 @@ type fakeReader struct { filter content.Filter listErr error readErr error + // rereadErr fails every read of rereadPath after its first. + rereadPath string + rereadErr error + rereads int } // List records the filter and returns the matching page without content. @@ -78,6 +82,12 @@ func (r *fakeReader) PublishedByPath(_ context.Context, path string) (content.Co if r.readErr != nil { return content.Content{}, r.readErr } + if path == r.rereadPath && r.rereadErr != nil { + r.rereads++ + if r.rereads > 1 { + return content.Content{}, r.rereadErr + } + } for _, p := range r.posts { if p.Path == path && p.Status == content.StatusPublished { return p, nil From e459228caee3c10bda1c0f070f8635f99d273aa0 Mon Sep 17 00:00:00 2001 From: orshih6 <34618353+orshih6@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:33:38 +0000 Subject: [PATCH 3/5] test(publicsite): flatten the fake reader's reread check --- internal/publicsite/publicsite_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index 387f8c37..de6378e8 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -82,11 +82,8 @@ func (r *fakeReader) PublishedByPath(_ context.Context, path string) (content.Co if r.readErr != nil { return content.Content{}, r.readErr } - if path == r.rereadPath && r.rereadErr != nil { - r.rereads++ - if r.rereads > 1 { - return content.Content{}, r.rereadErr - } + if err := r.reread(path); err != nil { + return content.Content{}, err } for _, p := range r.posts { if p.Path == path && p.Status == content.StatusPublished { @@ -96,6 +93,18 @@ func (r *fakeReader) PublishedByPath(_ context.Context, path string) (content.Co return content.Content{}, content.ErrNotFound } +// reread counts a read of rereadPath and returns rereadErr for every read after the first. +func (r *fakeReader) reread(path string) error { + if path != r.rereadPath || r.rereadErr == nil { + return nil + } + r.rereads++ + if r.rereads == 1 { + return nil + } + return r.rereadErr +} + // fakeTypes answers which type answers under a route word and which carries a key. type fakeTypes struct{ err error } From 610a18fb9b60b22071e80d1c0232722980c3b8d0 Mon Sep 17 00:00:00 2001 From: orshih6 <34618353+orshih6@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:54:55 +0000 Subject: [PATCH 4/5] test(publicsite): keep the reread failure out of the shared fake reader --- internal/publicsite/canonical_address_test.go | 32 +++++++++++++++++-- internal/publicsite/publicsite_test.go | 19 ----------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/internal/publicsite/canonical_address_test.go b/internal/publicsite/canonical_address_test.go index f1f4440f..57d6a225 100644 --- a/internal/publicsite/canonical_address_test.go +++ b/internal/publicsite/canonical_address_test.go @@ -3,6 +3,7 @@ package publicsite_test import ( + "context" "errors" "net/http" "net/http/httptest" @@ -13,8 +14,28 @@ import ( "github.com/google/uuid" "github.com/gopherium/gophenberg/internal/content" + "github.com/gopherium/gophenberg/internal/publicsite" ) +// failingReread serves like its reader but fails every read of path after its first. +type failingReread struct { + *fakeReader + path string + err error + reads int +} + +// PublishedByPath lets the first read of path through and returns err for every later one. +func (r *failingReread) PublishedByPath(ctx context.Context, path string) (content.Content, error) { + if path == r.path { + r.reads++ + if r.reads > 1 { + return content.Content{}, r.err + } + } + return r.fakeReader.PublishedByPath(ctx, path) +} + // storedAt returns a published item of the type stored at the path. func storedAt(kind, title, path string) content.Content { stored := publishedPost(title, path, blockMarkup, time.Now().UTC()) @@ -235,9 +256,14 @@ func TestSiteAnswersNotFoundForAPageNumberNothingHangsFrom(t *testing.T) { func TestSiteReportsAPostItCannotReadBehindAPageNumber(t *testing.T) { t.Parallel() - handler, reader := siteWith(publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC())) - reader.rereadPath = "hello-world" - reader.rereadErr = errors.New("database down") + reader := &failingReread{ + fakeReader: &fakeReader{posts: []content.Content{ + publishedPost("Hello World", "hello-world", blockMarkup, time.Now().UTC()), + }}, + path: "hello-world", + err: errors.New("database down"), + } + handler := publicsite.New(publicsite.Config{Content: reader, Types: fakeTypes{}}) recorder := get(t, handler, "/hello-world/page/2") diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index de6378e8..6b54886f 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -27,10 +27,6 @@ type fakeReader struct { filter content.Filter listErr error readErr error - // rereadErr fails every read of rereadPath after its first. - rereadPath string - rereadErr error - rereads int } // List records the filter and returns the matching page without content. @@ -82,9 +78,6 @@ func (r *fakeReader) PublishedByPath(_ context.Context, path string) (content.Co if r.readErr != nil { return content.Content{}, r.readErr } - if err := r.reread(path); err != nil { - return content.Content{}, err - } for _, p := range r.posts { if p.Path == path && p.Status == content.StatusPublished { return p, nil @@ -93,18 +86,6 @@ func (r *fakeReader) PublishedByPath(_ context.Context, path string) (content.Co return content.Content{}, content.ErrNotFound } -// reread counts a read of rereadPath and returns rereadErr for every read after the first. -func (r *fakeReader) reread(path string) error { - if path != r.rereadPath || r.rereadErr == nil { - return nil - } - r.rereads++ - if r.rereads == 1 { - return nil - } - return r.rereadErr -} - // fakeTypes answers which type answers under a route word and which carries a key. type fakeTypes struct{ err error } From 07e025f053c88d848ed274fd0e933c6cafc3327c Mon Sep 17 00:00:00 2001 From: orshih6 <34618353+orshih6@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:45:45 +0000 Subject: [PATCH 5/5] test(features): check the resolve API for a page suffix on a single item --- test/features/features/content-terms.feature | 4 ++-- test/features/steps_content_terms_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/features/features/content-terms.feature b/test/features/features/content-terms.feature index ba3aec71..5a033736 100644 --- a/test/features/features/content-terms.feature +++ b/test/features/features/content-terms.feature @@ -52,9 +52,9 @@ Feature: Serving term pages Then the answer is a term of "category" And it holds "Second filed" alone of 3 related items - Scenario: A page suffix on a single item answers nothing + Scenario: A page suffix on a single item resolves nothing Given the published post "Hello world" - Then "/hello-world/page/2" answers not found + When a visitor resolves "/hello-world/page/2" and finds nothing Scenario: A term page holds no validator of its own Given the published post "Hello world" filed under "News" diff --git a/test/features/steps_content_terms_test.go b/test/features/steps_content_terms_test.go index b365b507..d50e1088 100644 --- a/test/features/steps_content_terms_test.go +++ b/test/features/steps_content_terms_test.go @@ -292,6 +292,7 @@ func initializeContentTerms(sc *godog.ScenarioContext) { sc.Given(`^the type "([^"]*)" labeled "([^"]*)" and "([^"]*)" under "([^"]*)"$`, theTypeExists) sc.When(`^a visitor resolves "([^"]*)"$`, aVisitorResolves) sc.When(`^a visitor resolves "([^"]*)" one item at a time$`, aVisitorResolvesOneAtATime) + sc.When(`^a visitor resolves "([^"]*)" and finds nothing$`, aVisitorResolvesNothingAt) sc.When(`^the administrator trashes "([^"]*)"$`, theAdministratorTrashes) sc.When(`^the administrator deactivates the type "([^"]*)"$`, theTypeIsDeactivated) sc.Then(`^the answer is a term of "([^"]*)"$`, theAnswerIsATermOf) @@ -303,5 +304,4 @@ func initializeContentTerms(sc *godog.ScenarioContext) { sc.Then(`^one item is related$`, oneItemIsRelated) sc.Then(`^it holds "([^"]*)" alone of (\d+) related items$`, theTermPageHoldsAloneOf) sc.Then(`^the answer carries no entity tag$`, theAnswerCarriesNoEntityTag) - sc.Then(`^"([^"]*)" answers not found$`, theAddressAnswersNotFound) }