Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,15 @@ If you need to understand or change the versioning machinery itself:
not. Local `make serve` keeps the version-selection landing and its navigation
links at `/docs/`. The landing template carries `data-docs-version-landing` so
repeat mirror runs can identify it independently of Open Graph metadata.
- `data/docs-legacy-urls.json` maps old English URLs to current docs or a numbered
archive. The same generator serves full HTML copies at those paths, with the
source page's canonical and robots policy. Current-doc mappings also produce
their major-version paths. There is no redirect, so queries and existing
fragments stay on the requested URL. Copies carry `data-docs-legacy-url` and
are tracked in the manifest. Missing targets, symlinks and collisions with
real pages fail before output is replaced.
`distro-hosted` uses the v0 archive; the old LINSTOR path uses the storage
overview because its exact historical page was not identified.
- `layouts/partials/docs-seo.html` supplies canonical and robots rules to the
document head and sitemap. Current numbered docs and their copies canonicalize
to `/docs/`. Older versions remain noindex and stay out of sitemaps.
Expand Down
2 changes: 1 addition & 1 deletion content/en/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ features:
The Web UI facilitates a quick dive into the platform and provides a visual demonstration of its capabilities.
---

{{< blocks/hero title="Cozystack: Free Cloud Platform based on Kubernetes" link_title="Get started" link_url="/docs/get-started/" >}}
{{< blocks/hero title="Cozystack: Free Cloud Platform based on Kubernetes" link_title="Get started" link_url="/docs/getting-started/" >}}
Use Cozystack to build your own cloud or provide cost-effective development environments.
{{< /blocks/hero >}}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ Deploy your apps with standard `kubectl` or `helm` — no vendor-specific toolin

## Learn more

- [Managed Kubernetes documentation](https://cozystack.io/docs/v1.6/kubernetes/)
- [Deploy Applications guide](https://cozystack.io/docs/v1.6/getting-started/deploy-app/)
- [Create a Tenant](https://cozystack.io/docs/v1.6/getting-started/create-tenant/)
- [Managed Kubernetes documentation](https://cozystack.io/docs/kubernetes/)
- [Deploy Applications guide](https://cozystack.io/docs/getting-started/deploy-app/)
- [Create a Tenant](https://cozystack.io/docs/getting-started/create-tenant/)

## Join the community

Expand Down
8 changes: 8 additions & 0 deletions data/docs-legacy-urls.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"/docs/reference/applications/": "/docs/applications/",
"/docs/reference/applications/mysql/": "/docs/applications/mariadb/",
"/docs/install/talos/kexec/": "/docs/install/talos/boot-to-talos/",
"/roadmap/": "/docs/roadmap/",
"/docs/bundles/distro-hosted/": "/docs/v0/operations/configuration/bundles/",
"/docs/operations/storage/linstor/": "/docs/storage/"
}
9 changes: 5 additions & 4 deletions hack/i18n/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,13 +783,14 @@ def test_empty_worklist_clears_a_prior_run_report(self):
# Once the backlog drains, build_worklist() is empty and main() returns
# early. A report left by an earlier run must still be cleared, or
# run-daily.sh reposts stale findings stamped with today's date.
orig_wl, orig_orph, orig_argv = (
lib.build_worklist, lib.find_orphan_translations, sys.argv)
orig_wl, orig_orph, orig_tc, orig_argv = (
lib.build_worklist, lib.find_orphan_translations, lib.find_transcreated, sys.argv)
existed = os.path.exists(translate.REPORT_PATH)
backup = open(translate.REPORT_PATH).read() if existed else None
try:
lib.build_worklist = lambda *a, **k: []
lib.find_orphan_translations = lambda *a, **k: []
lib.find_transcreated = lambda *a, **k: []
sys.argv = ["translate.py"]
with open(translate.REPORT_PATH, "w", encoding="utf-8") as fh:
fh.write("### stale findings from a previous run\n- something\n")
Expand All @@ -798,8 +799,8 @@ def test_empty_worklist_clears_a_prior_run_report(self):
self.assertFalse(os.path.exists(translate.REPORT_PATH),
"stale report was not cleared on an empty worklist")
finally:
lib.build_worklist, lib.find_orphan_translations, sys.argv = (
orig_wl, orig_orph, orig_argv)
(lib.build_worklist, lib.find_orphan_translations,
lib.find_transcreated, sys.argv) = (orig_wl, orig_orph, orig_tc, orig_argv)
if backup is not None:
with open(translate.REPORT_PATH, "w", encoding="utf-8") as fh:
fh.write(backup)
Expand Down
78 changes: 73 additions & 5 deletions hack/mirror_latest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ def __init__(self, html):
super().__init__()
self.url = None
self.version_landing = False
self.redirect = False
self.legacy_url = None
self.feed(html)

def handle_starttag(self, tag, attrs):
Expand All @@ -24,6 +26,10 @@ def handle_starttag(self, tag, attrs):
self.url = attrs.get("content")
if "data-docs-version-landing" in attrs:
self.version_landing = True
if tag == "meta" and attrs.get("http-equiv", "").lower() == "refresh":
self.redirect = True
if "data-docs-legacy-url" in attrs:
self.legacy_url = attrs["data-docs-legacy-url"]


def rewrite_og_url(text, source_url, target_url):
Expand Down Expand Up @@ -70,6 +76,59 @@ def root_target(public, name, *, allow_major=False):
return target


def legacy_page(public, url):
if (not isinstance(url, str) or not re.fullmatch(r"/(?:[A-Za-z0-9_.-]+/)+", url)
or any(part in (".", "..") for part in url.split("/"))):
raise ValueError(f"invalid legacy URL: {url}")
path = public / url.lstrip("/") / "index.html"
if (path.resolve() != path or path.is_dir()
or any(p.is_file() for p in path.parents if p == public or public in p.parents)):
raise ValueError(f"invalid legacy page path: {path}")
return path


def legacy_copies(public, latest, copies, previous):
mappings = json.loads(Path("data/docs-legacy-urls.json").read_text())
if not isinstance(mappings, dict):
raise ValueError("data/docs-legacy-urls.json must be a URL-to-URL object")
result = {}
major = latest.split(".")[0]
for old, new in mappings.items():
legacy_page(public, old)
source = legacy_page(public, new)
parts = old.strip("/").split("/")
if old != "/roadmap/" and (len(parts) < 2 or parts[0] != "docs" or reserved(parts[1])):
raise ValueError(f"legacy destination must be an unversioned docs path: {old}")
if old == new or not new.startswith("/docs/"):
raise ValueError(f"invalid legacy target: {old} -> {new}")
if source in copies:
content = copies[source]
elif re.match(r"^/docs/v[0-9]+(?:\.[0-9]+)?/", new) and source.is_file():
content = source.read_bytes()
else:
raise ValueError(f"legacy target is not built: {old} -> {new}")
if PageURL(content.decode()).redirect:
raise ValueError(f"legacy target must be a content page: {new}")
urls = [old]
if source in copies and old.startswith("/docs/") and major != latest:
urls.append(f"/docs/{major}/" + old[len("/docs/"):])
for url in urls:
target = legacy_page(public, url)
if target in copies and not PageURL(copies[target].decode()).redirect:
raise ValueError(f"legacy URL collides with current content: {url}")
if target.is_file():
existing = target.read_bytes()
page = PageURL(existing.decode())
owned = previous.get(str(target.relative_to(public))) == sha256(existing).hexdigest()
if not (page.redirect or page.legacy_url == url or owned):
raise ValueError(f"legacy URL collides with existing content: {url}")
text, count = re.subn(r"<html\b", f'<html data-docs-legacy-url="{url}"', content.decode(), count=1)
if not count:
raise ValueError(f"legacy target has no HTML document: {new}")
result[target] = text.encode()
return result


def mirror(public):
config = Path("hugo.yaml").read_text()
match = re.search(r'^ latest_version_id:\s*"(v[0-9]+(?:\.[0-9]+)?)"\s*$', config, re.M)
Expand All @@ -86,7 +145,10 @@ def mirror(public):
if not isinstance(previous, dict):
raise ValueError(f"invalid root mirror manifest: {manifest}")
for name, digest in previous.items():
root_target(public, name, allow_major=True)
if name == "roadmap/index.html":
legacy_page(public, "/roadmap/")
else:
root_target(public, name, allow_major=True)
if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest):
raise ValueError(f"invalid root mirror digest: {name}")

Expand Down Expand Up @@ -124,14 +186,20 @@ def mirror(public):
root_target(public, target.relative_to(public), allow_major=bool(alias))
plans.append((file, source_url, target, alias))

for versions, content in landings:
versions.parent.mkdir(parents=True, exist_ok=True)
versions.write_bytes(content)
current = {}
copies = {}
for file, source_url, target, alias in plans:
content = file.read_bytes()
if file.suffix in (".html", ".xml"):
content = rewrite(content.decode(), source_url, alias).encode()
copies[target] = content
legacy = legacy_copies(public, latest, copies, previous)
copies.update(legacy)

for versions, content in landings:
versions.parent.mkdir(parents=True, exist_ok=True)
versions.write_bytes(content)
current = {}
for target, content in copies.items():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
current[str(target.relative_to(public))] = sha256(content).hexdigest()
Expand Down
3 changes: 3 additions & 0 deletions hack/test_docs_seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ def __init__(self, text):
self.languages = {}
self.og_urls = []
self.formats = {}
self.ids = set()
self.feed(text)

def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if attrs.get("id"):
self.ids.add(attrs["id"])
if tag == "link" and attrs.get("rel") == "canonical":
self.canonical.append(attrs.get("href"))
if tag == "meta" and attrs.get("name", "").lower() == "robots":
Expand Down
159 changes: 159 additions & 0 deletions hack/test_docs_seo_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def setUp(self):
self.write("content/en/docs/v1.6/removed.md", '---\ntitle: Removed\n---\nOld page')
self.write("content/en/private.md", '---\ntitle: Private\nparams:\n noindex: true\n---\nPrivate')
self.write("content/en/print.md", '---\ntitle: Print\noutputs: [HTML, PRINT]\n---\nPrint')
self.write("data/docs-legacy-urls.json", "{}")
self.configure()

def write(self, name, text):
Expand Down Expand Up @@ -370,6 +371,164 @@ def log_message(self, *args):
thread.join()
server.server_close()

def legacy_site(self):
self.write("data/docs-legacy-urls.json", (REPO / "data/docs-legacy-urls.json").read_text())
for version in ("v1.6", "v1.7"):
for path, name in (("applications/_index.md", "Applications"),
("applications/mariadb.md", "MariaDB"),
("install/talos/boot-to-talos.md", "Boot to Talos"),
("roadmap.md", "Roadmap"), ("storage/_index.md", "Storage")):
self.write(f"content/en/docs/{version}/{path}", f'---\ntitle: {name}\n---\n{name} {version}\n\n## Backups\n')
self.write("content/en/docs/v0/operations/configuration/bundles.md",
'---\ntitle: Archived bundles\n---\nArchived bundles\n\n## distro-hosted\n')
return {
"/docs/reference/applications/": "/docs/applications/",
"/docs/reference/applications/mysql/": "/docs/applications/mariadb/",
"/docs/install/talos/kexec/": "/docs/install/talos/boot-to-talos/",
"/roadmap/": "/docs/roadmap/",
"/docs/bundles/distro-hosted/": None,
"/docs/operations/storage/linstor/": "/docs/storage/",
"/docs/v1/reference/applications/": "/docs/applications/",
"/docs/v1/reference/applications/mysql/": "/docs/applications/mariadb/",
"/docs/v1/install/talos/kexec/": "/docs/install/talos/boot-to-talos/",
"/docs/v1/operations/storage/linstor/": "/docs/storage/",
}

def test_legacy_urls_serve_current_and_archived_content(self):
expected = self.legacy_site()
self.build()
for old, target in expected.items():
with self.subTest(url=old):
page = self.head(old.lstrip("/") + "index.html")
self.assertFalse(page.refresh)
if target:
self.assertEqual(page.canonical, ["https://example.org" + target])
self.assertEqual(page.og_urls, page.canonical)
self.assertEqual(page.robots, ["index, follow"])
self.assertIn("backups", page.ids)
else:
self.assertEqual(page.canonical, [])
self.assertEqual(page.robots, ["noindex, follow"])
self.assertIn("distro-hosted", page.ids)
self.configure("v1.7")
self.build()
for old, target in expected.items():
html = (self.public / old.lstrip("/") / "index.html").read_text()
self.assertIn("v1.7" if target else "Archived bundles", html)
archive = (self.public / "docs/v1.6/applications/mariadb/index.html").read_bytes()
self.mirror()
self.assertEqual((self.public / "docs/v1.6/applications/mariadb/index.html").read_bytes(), archive)

def test_legacy_urls_preserve_queries_and_fragments_without_javascript(self):
expected = self.legacy_site()
self.configure(base="https://example.org/project/")
self.build()
class QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(QuietHandler, directory=self.public))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
for old, target in expected.items():
fragment = "backups" if target else "distro-hosted"
url = f"http://127.0.0.1:{server.server_port}{old}?source=test#{fragment}"
with urlopen(url) as response:
page = Head(response.read().decode())
self.assertEqual(response.status, 200)
self.assertEqual(response.geturl(), url)
self.assertFalse(page.refresh)
self.assertIn(fragment, page.ids)
if target:
self.assertEqual(page.canonical, ["https://example.org/project" + target])
finally:
server.shutdown()
thread.join()
server.server_close()

def test_invalid_legacy_map_preserves_previous_output(self):
expected = self.legacy_site()
self.build()
before = {old: (self.public / old.lstrip("/") / "index.html").read_bytes() for old in expected}
source = self.public / "docs/v1.6/applications/index.html"
source.write_text(source.read_text().replace("Applications v1.6", "Changed applications"))
self.write("public/docs/blocked", "existing file")
for old, target in (("/docs/unknown/", "/docs/missing/"),
("/docs/v0/overwrite/", "/docs/storage/"),
("/docs/../outside/", "/docs/storage/"),
("/docs/unknown/", "https://other.example/"),
("/docs/blocked/guide/", "/docs/storage/"),
("/docs/applications/", "/docs/storage/")):
with self.subTest(old=old, target=target):
mapping = {"/docs/reference/applications/": "/docs/applications/", old: target}
self.write("data/docs-legacy-urls.json", json.dumps(mapping))
self.mirror(success=False)
for path, content in before.items():
self.assertEqual((self.public / path.lstrip("/") / "index.html").read_bytes(), content)
for mapping in ([], None, {"/docs/unknown/": None},
{"/docs/unknown/": "/docs/storage/?x=one"},
{"/docs/%2e%2e/outside/": "/docs/storage/"}):
self.write("data/docs-legacy-urls.json", json.dumps(mapping))
self.mirror(success=False)
for path, content in before.items():
self.assertEqual((self.public / path.lstrip("/") / "index.html").read_bytes(), content)

def test_legacy_map_refuses_symlinks(self):
self.legacy_site()
self.build()
archive = self.public / "docs/v0/guide/index.html"
original = archive.read_bytes()
old = self.public / "docs/reference/applications/mysql/index.html"
old.unlink()
old.symlink_to(archive)
self.mirror(success=False)
self.assertTrue(old.is_symlink())
self.assertEqual(archive.read_bytes(), original)

def test_legacy_map_refuses_archive_source_symlink_and_alias_target(self):
self.legacy_site()
self.write("content/en/docs/v1.6/guide.md", '---\ntitle: Guide\naliases: [/docs/v1.6/alias-guide/]\n---\nGuide')
self.build()
self.write("data/docs-legacy-urls.json", json.dumps({"/docs/old-guide/": "/docs/alias-guide/"}))
self.mirror(success=False)
self.write("data/docs-legacy-urls.json", (REPO / "data/docs-legacy-urls.json").read_text())
source = self.public / "docs/v0/operations/configuration/bundles/index.html"
source.unlink()
source.symlink_to(self.public / "docs/v0/guide/index.html")
self.mirror(success=False)
self.assertTrue(source.is_symlink())

def test_legacy_map_preserves_real_roadmap_page(self):
self.legacy_site()
self.write("content/en/roadmap.md", '---\ntitle: Standalone roadmap\n---\nStandalone roadmap')
result = subprocess.run([HUGO, "--minify"], cwd=self.root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
before = (self.public / "roadmap/index.html").read_bytes()
self.mirror(success=False)
self.assertEqual((self.public / "roadmap/index.html").read_bytes(), before)

def test_legacy_preview_has_no_indexable_copies(self):
expected = self.legacy_site()
self.build("preview")
for old in expected:
page = self.head(old.lstrip("/") + "index.html")
self.assertEqual(page.robots, ["noindex, nofollow"])
self.assertEqual(page.canonical, [])
self.assertEqual(self.locations(), [])

def test_legacy_major_aliases_are_cleaned_on_major_rollover(self):
self.legacy_site()
self.build()
source = self.root / "content/en/docs/v1.7"
for file in source.rglob("*.md"):
self.write("content/en/docs/v2.0/" + str(file.relative_to(source)),
file.read_text().replace("v1.7", "v2.0"))
self.configure("v2.0")
self.build()
self.assertFalse((self.public / "docs/v1/reference/applications/mysql/index.html").exists())
self.assertIn("v2.0", (self.public / "docs/v2/reference/applications/mysql/index.html").read_text())
self.assertTrue((self.public / "docs/v1.6/applications/mariadb/index.html").is_file())


if __name__ == "__main__":
unittest.main()
Loading
Loading