v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
This commit is contained in:
@@ -47,5 +47,23 @@ func (ai AppInfo) WithAddress(subdomain, domain string) AppInfo {
|
||||
out.Prerequisites = list(ai.Prerequisites)
|
||||
out.DefaultCreds = fill(ai.DefaultCreds)
|
||||
out.AddPeople = fill(ai.AddPeople)
|
||||
// v0.315.0: the guide is catalog copy too, and it is the copy most likely to carry an address —
|
||||
// its steps tell the household which page to open. Every string in it goes through `fill`:
|
||||
// titles, lead-ins, steps, questions and answers. `checked_version` is not copy and is left alone.
|
||||
out.Guide = ai.Guide
|
||||
if len(ai.Guide.Sections) > 0 {
|
||||
secs := make([]GuideSection, len(ai.Guide.Sections))
|
||||
for i, se := range ai.Guide.Sections {
|
||||
secs[i] = GuideSection{Title: fill(se.Title), Intro: fill(se.Intro), Steps: list(se.Steps)}
|
||||
}
|
||||
out.Guide.Sections = secs
|
||||
}
|
||||
if len(ai.Guide.FAQ) > 0 {
|
||||
faq := make([]GuideFAQ, len(ai.Guide.FAQ))
|
||||
for i, qa := range ai.Guide.FAQ {
|
||||
faq[i] = GuideFAQ{Q: fill(qa.Q), A: fill(qa.A)}
|
||||
}
|
||||
out.Guide.FAQ = faq
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// THE GUIDE SCHEMA (v0.315.0) — parsing, the render gate, the English overlay, and the address.
|
||||
//
|
||||
// The guide is the first catalog block whose list entries are matched BY INDEX (AppGuideOverlay says
|
||||
// why). These tests pin the three properties that makes safe: the base language is untouched, every
|
||||
// field falls back on its own, and an oversized translation is dropped rather than rendered.
|
||||
|
||||
// guideYAML is one template carrying a guide and an English overlay of the FAQ ONLY — the
|
||||
// half-translated state the catalog is allowed to ship (10-localisation.md §7).
|
||||
const guideYAML = `
|
||||
name: testapp
|
||||
display_name: TestApp
|
||||
description: "Egy teszt alkalmazas"
|
||||
app_info:
|
||||
tagline: "Teszt"
|
||||
guide:
|
||||
checked_version: "1.2.3"
|
||||
sections:
|
||||
- title: "Elso bekapcsolas"
|
||||
intro: "Nyisd meg a wiki.DOMAIN oldalt."
|
||||
steps: ["Kattints a Fiok menure", "Add meg a DOMAIN cimet"]
|
||||
- title: "Megosztas"
|
||||
steps: ["Valaszd ki a mappat"]
|
||||
faq:
|
||||
- q: "Hol vannak a fajlok?"
|
||||
a: "A megadott mappaban."
|
||||
- q: "Lehet tobb felhasznalo?"
|
||||
a: "Igen."
|
||||
- q: "Mi van ha elfelejtem a jelszot?"
|
||||
a: "Kerj ujat."
|
||||
i18n:
|
||||
en:
|
||||
app_info:
|
||||
guide:
|
||||
faq:
|
||||
- q: "Where are the files?"
|
||||
a: "In the folder you chose."
|
||||
- q: "Can several people use it?"
|
||||
a: "Yes."
|
||||
- q: "What if I forget the password?"
|
||||
a: "Ask for a new one."
|
||||
`
|
||||
|
||||
func mustGuideMeta(t *testing.T, src string) Metadata {
|
||||
t.Helper()
|
||||
var m Metadata
|
||||
if err := yaml.Unmarshal([]byte(src), &m); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestGuide_Parses — the block reaches the struct with its version, both sections and all three FAQ
|
||||
// entries. Without this the three tests below could pass on an empty guide.
|
||||
func TestGuide_Parses(t *testing.T) {
|
||||
g := mustGuideMeta(t, guideYAML).AppInfo.Guide
|
||||
if g.CheckedVersion != "1.2.3" {
|
||||
t.Errorf("checked_version = %q, want 1.2.3", g.CheckedVersion)
|
||||
}
|
||||
if len(g.Sections) != 2 || len(g.FAQ) != 3 {
|
||||
t.Fatalf("got %d sections and %d faq, want 2 and 3", len(g.Sections), len(g.FAQ))
|
||||
}
|
||||
if g.Sections[0].Title != "Elso bekapcsolas" || len(g.Sections[0].Steps) != 2 {
|
||||
t.Errorf("section 0 = %+v", g.Sections[0])
|
||||
}
|
||||
if g.Sections[1].Intro != "" {
|
||||
t.Errorf("section 1 intro should be absent, got %q", g.Sections[1].Intro)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_RenderedGate — `checked_version` is the gate, and it is ALSO required that there be
|
||||
// something to show. Both halves are asserted here because the template asks this one method.
|
||||
func TestGuide_RenderedGate(t *testing.T) {
|
||||
full := mustGuideMeta(t, guideYAML).AppInfo.Guide
|
||||
if !full.Rendered() {
|
||||
t.Error("a checked guide with sections and faq must render")
|
||||
}
|
||||
|
||||
noVersion := full
|
||||
noVersion.CheckedVersion = ""
|
||||
if noVersion.Rendered() {
|
||||
t.Error("a guide with NO checked_version must not render — nobody vouched for it")
|
||||
}
|
||||
blankVersion := full
|
||||
blankVersion.CheckedVersion = " "
|
||||
if blankVersion.Rendered() {
|
||||
t.Error("a whitespace-only checked_version is absent, not a version")
|
||||
}
|
||||
|
||||
empty := AppGuide{CheckedVersion: "1.2.3"}
|
||||
if empty.Rendered() {
|
||||
t.Error("a checked guide with no sections and no faq has nothing to render")
|
||||
}
|
||||
if (AppGuide{}).Rendered() {
|
||||
t.Error("an absent guide must not render")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_OverlayIsFieldByField — English shows the English FAQ and keeps the Hungarian sections,
|
||||
// because this template translated only the FAQ. That is the half-translated state §7 permits, and
|
||||
// the per-field fallback is what makes it legal.
|
||||
func TestGuide_OverlayIsFieldByField(t *testing.T) {
|
||||
m := mustGuideMeta(t, guideYAML)
|
||||
en := m.For("en").AppInfo.Guide
|
||||
|
||||
if en.FAQ[0].Q != "Where are the files?" || en.FAQ[2].A != "Ask for a new one." {
|
||||
t.Errorf("the English FAQ did not reach the page: %+v", en.FAQ)
|
||||
}
|
||||
if en.Sections[0].Title != "Elso bekapcsolas" {
|
||||
t.Errorf("an untranslated section must fall back to Hungarian, got %q", en.Sections[0].Title)
|
||||
}
|
||||
// The gate is not copy: a translation can neither set nor clear it.
|
||||
if en.CheckedVersion != "1.2.3" {
|
||||
t.Errorf("checked_version = %q after overlay, want it carried through", en.CheckedVersion)
|
||||
}
|
||||
|
||||
// Hungarian is untouched by the presence of the block.
|
||||
hu := m.For("hu").AppInfo.Guide
|
||||
if hu.FAQ[0].Q != "Hol vannak a fajlok?" {
|
||||
t.Errorf("the Hungarian FAQ changed: %q", hu.FAQ[0].Q)
|
||||
}
|
||||
// ...and the overlay did not write through into the receiver (shared metadata, R-591).
|
||||
if m.AppInfo.Guide.FAQ[0].Q != "Hol vannak a fajlok?" {
|
||||
t.Error("For() mutated the receiver's guide")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_OverlayPartialFields — one section translated, its intro left blank. A blank English
|
||||
// string is ABSENT (operator ruling 3), so the Hungarian intro stays and no empty box renders.
|
||||
func TestGuide_OverlayPartialFields(t *testing.T) {
|
||||
src := strings.Replace(guideYAML, ` guide:
|
||||
faq:`, ` guide:
|
||||
sections:
|
||||
- title: "First start"
|
||||
intro: ""
|
||||
steps: ["Open the Account menu", "Enter the DOMAIN address"]
|
||||
faq:`, 1)
|
||||
en := mustGuideMeta(t, src).For("en").AppInfo.Guide
|
||||
|
||||
if en.Sections[0].Title != "First start" {
|
||||
t.Errorf("section 0 title = %q, want the English one", en.Sections[0].Title)
|
||||
}
|
||||
if en.Sections[0].Intro != "Nyisd meg a wiki.DOMAIN oldalt." {
|
||||
t.Errorf("a BLANK English intro must fall back to Hungarian, got %q", en.Sections[0].Intro)
|
||||
}
|
||||
if en.Sections[0].Steps[0] != "Open the Account menu" {
|
||||
t.Errorf("section 0 steps = %v", en.Sections[0].Steps)
|
||||
}
|
||||
// Section 1 had no English entry at all: whole-Hungarian.
|
||||
if en.Sections[1].Title != "Megosztas" || en.Sections[1].Steps[0] != "Valaszd ki a mappat" {
|
||||
t.Errorf("section 1 should be wholly Hungarian: %+v", en.Sections[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_OverlayExtrasAreDroppedAndCounted — a translation LONGER than the Hungarian guide is
|
||||
// ignored past the Hungarian count, and the extra is countable so the page can log it once. A
|
||||
// silently dropped translation is the absence nobody notices (CLAUDE.md: an absent line is not
|
||||
// evidence).
|
||||
func TestGuide_OverlayExtrasAreDroppedAndCounted(t *testing.T) {
|
||||
src := strings.Replace(guideYAML, ` - q: "What if I forget the password?"
|
||||
a: "Ask for a new one."`, ` - q: "What if I forget the password?"
|
||||
a: "Ask for a new one."
|
||||
- q: "An extra question nobody wrote in Hungarian"
|
||||
a: "Dropped."`, 1)
|
||||
m := mustGuideMeta(t, src)
|
||||
|
||||
en := m.For("en").AppInfo.Guide
|
||||
if len(en.FAQ) != 3 {
|
||||
t.Errorf("faq length = %d, want 3 — the extra English entry must be dropped", len(en.FAQ))
|
||||
}
|
||||
for _, qa := range en.FAQ {
|
||||
if strings.Contains(qa.Q, "nobody wrote") {
|
||||
t.Error("an entry with no Hungarian original reached the page")
|
||||
}
|
||||
}
|
||||
|
||||
secs, faq := m.GuideOverlayExtras("en")
|
||||
if secs != 0 || faq != 1 {
|
||||
t.Errorf("GuideOverlayExtras(en) = %d, %d; want 0, 1", secs, faq)
|
||||
}
|
||||
// Nothing to warn about for the base language, nor for a correctly sized overlay.
|
||||
if s, f := m.GuideOverlayExtras("hu"); s != 0 || f != 0 {
|
||||
t.Errorf("GuideOverlayExtras(hu) = %d, %d; want 0, 0", s, f)
|
||||
}
|
||||
if s, f := mustGuideMeta(t, guideYAML).GuideOverlayExtras("en"); s != 0 || f != 0 {
|
||||
t.Errorf("a correctly sized overlay warns: %d, %d", s, f)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_WithAddressFillsEveryString — R-498 over the guide: the household's own address in the
|
||||
// titles, the lead-ins, the steps, the questions and the answers.
|
||||
func TestGuide_WithAddressFillsEveryString(t *testing.T) {
|
||||
ai := mustGuideMeta(t, guideYAML).AppInfo
|
||||
ai.Guide.Sections[1].Title = "Nyisd meg: DOMAIN"
|
||||
ai.Guide.FAQ[0].A = "A wiki.DOMAIN oldalon."
|
||||
|
||||
got := ai.WithAddress("jegyzet", "pelda.hu")
|
||||
|
||||
if want := "Nyisd meg a jegyzet.pelda.hu oldalt."; got.Guide.Sections[0].Intro != want {
|
||||
t.Errorf("intro = %q, want %q", got.Guide.Sections[0].Intro, want)
|
||||
}
|
||||
if want := "Add meg a pelda.hu cimet"; got.Guide.Sections[0].Steps[1] != want {
|
||||
t.Errorf("step = %q, want %q", got.Guide.Sections[0].Steps[1], want)
|
||||
}
|
||||
if want := "Nyisd meg: pelda.hu"; got.Guide.Sections[1].Title != want {
|
||||
t.Errorf("title = %q, want %q", got.Guide.Sections[1].Title, want)
|
||||
}
|
||||
if want := "A jegyzet.pelda.hu oldalon."; got.Guide.FAQ[0].A != want {
|
||||
t.Errorf("faq answer = %q, want %q", got.Guide.FAQ[0].A, want)
|
||||
}
|
||||
// The receiver is shared metadata and must be untouched.
|
||||
if ai.Guide.Sections[0].Steps[1] != "Add meg a DOMAIN cimet" {
|
||||
t.Error("WithAddress wrote through into the receiver's guide")
|
||||
}
|
||||
// No domain configured: a placeholder beats a wrong address.
|
||||
if un := ai.WithAddress("jegyzet", ""); !reflect.DeepEqual(un.Guide, ai.Guide) {
|
||||
t.Error("with no domain the guide must come back unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_OverlayCloneIsNotShared — deepCopyStack's promise (R-591) over the new overlay: a guide
|
||||
// overlay's pointers and slices are not shared with the copy.
|
||||
func TestGuide_OverlayCloneIsNotShared(t *testing.T) {
|
||||
ov := mustGuideMeta(t, guideYAML).I18n["en"]
|
||||
c := ov.Clone()
|
||||
|
||||
if c.AppInfo == nil || c.AppInfo.Guide == nil || len(c.AppInfo.Guide.FAQ) != 3 {
|
||||
t.Fatalf("the clone lost the guide overlay: %+v", c.AppInfo)
|
||||
}
|
||||
if c.AppInfo.Guide == ov.AppInfo.Guide {
|
||||
t.Error("the clone shares the guide overlay pointer")
|
||||
}
|
||||
if c.AppInfo.Guide.FAQ[0].Q == ov.AppInfo.Guide.FAQ[0].Q {
|
||||
t.Error("the clone shares a FAQ question pointer")
|
||||
}
|
||||
if *c.AppInfo.Guide.FAQ[0].Q != *ov.AppInfo.Guide.FAQ[0].Q {
|
||||
t.Error("the clone lost the FAQ question's value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuide_CatalogSnapshotCarriesNoGuideYet pins what TestMetaForHuIsIdentity depends on: the real
|
||||
// catalog does not carry a guide yet, so adding the field changed no live app's page. When the
|
||||
// companion task lands the media pilot's guides and the snapshot is refreshed, this test fails —
|
||||
// and the refresher then asserts the guides parse, rather than deleting this.
|
||||
func TestGuide_CatalogSnapshotCarriesNoGuideYet(t *testing.T) {
|
||||
for app, m := range loadCatalogFixtures(t) {
|
||||
if m.AppInfo.Guide.Rendered() || len(m.AppInfo.Guide.Sections) > 0 || len(m.AppInfo.Guide.FAQ) > 0 {
|
||||
t.Errorf("%s: the snapshot now carries a guide — refresh this test's purpose, do not delete it", app)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1236,7 @@ func deepCopyStack(s *Stack) Stack {
|
||||
cp.Meta.AppInfo.UseCases = cloneStrs(s.Meta.AppInfo.UseCases)
|
||||
cp.Meta.AppInfo.FirstSteps = cloneStrs(s.Meta.AppInfo.FirstSteps)
|
||||
cp.Meta.AppInfo.Prerequisites = cloneStrs(s.Meta.AppInfo.Prerequisites)
|
||||
cp.Meta.AppInfo.Guide = s.Meta.AppInfo.Guide.Clone()
|
||||
cp.Meta.FamilyGateExcept = cloneStrs(s.Meta.FamilyGateExcept)
|
||||
if s.Meta.DataPaths != nil {
|
||||
cp.Meta.DataPaths = make([]DataPath, len(s.Meta.DataPaths))
|
||||
|
||||
@@ -166,6 +166,73 @@ type AppInfo struct {
|
||||
DocsURL string `yaml:"docs_url" json:"docs_url"`
|
||||
// AddPeople (v0.281.0, decision 47): how the household adds a family member once sign-up is closed.
|
||||
AddPeople string `yaml:"add_people,omitempty" json:"add_people,omitempty"`
|
||||
// Guide (v0.315.0): the app's own user guide, shown on the app page's „Utmutato" tab. Optional —
|
||||
// an app with no guide block renders the tab's existing content and says a guide is still to come.
|
||||
Guide AppGuide `yaml:"guide,omitempty" json:"guide,omitempty"`
|
||||
}
|
||||
|
||||
// AppGuide is the catalog's user guide for one app: numbered sections and a FAQ, both plain text.
|
||||
//
|
||||
// CheckedVersion is NOT copy. It is the image tag the guide's author actually clicked through, and it
|
||||
// is the render gate: a guide whose author never named a version is not shown at all (an unchecked
|
||||
// guide that walks the household through a UI two releases old is worse than no guide). It is
|
||||
// deliberately absent from the overlay below — a translator does not get to vouch for a version.
|
||||
//
|
||||
// Sections and FAQ entries are PLAIN TEXT and reach the page through html/template's escaping. No
|
||||
// markdown, no raw HTML: catalog copy is data, and a step that contains `<script>` is shown as the
|
||||
// characters the author typed. Pinned by TestGuide_RenderRules.
|
||||
type AppGuide struct {
|
||||
CheckedVersion string `yaml:"checked_version,omitempty" json:"checked_version,omitempty"`
|
||||
Sections []GuideSection `yaml:"sections,omitempty" json:"sections,omitempty"`
|
||||
FAQ []GuideFAQ `yaml:"faq,omitempty" json:"faq,omitempty"`
|
||||
}
|
||||
|
||||
// GuideSection is one headed block of the guide: a title, an optional lead-in, and the steps.
|
||||
type GuideSection struct {
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Intro string `yaml:"intro,omitempty" json:"intro,omitempty"`
|
||||
Steps []string `yaml:"steps,omitempty" json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// GuideFAQ is one question and its answer.
|
||||
type GuideFAQ struct {
|
||||
Q string `yaml:"q" json:"q"`
|
||||
A string `yaml:"a" json:"a"`
|
||||
}
|
||||
|
||||
// Rendered reports whether this guide may be shown: its author named the version they checked it
|
||||
// against, AND there is something to show. Both halves matter — `checked_version` alone renders an
|
||||
// empty „Gyakori kerdesek" heading, and sections alone are a guide nobody vouched for.
|
||||
//
|
||||
// VALUE receiver: this type reaches html/template inside an interface{}, where a pointer-receiver
|
||||
// method is a render-time 500 (the trap is on file in this repo twice).
|
||||
func (g AppGuide) Rendered() bool {
|
||||
return strings.TrimSpace(g.CheckedVersion) != "" && (len(g.Sections) > 0 || len(g.FAQ) > 0)
|
||||
}
|
||||
|
||||
// Clone returns a copy sharing no slice with the receiver — the three in a guide are `Sections`,
|
||||
// `FAQ` and each section's `Steps`.
|
||||
//
|
||||
// It exists for deepCopyStack (R-591): a Stack copy is „a snapshot the caller may mutate", and
|
||||
// Metadata lives in the stack manager's cache shared by every request. TestDeepCopyStackMetaSharesNoReference
|
||||
// fills every pointer, slice and map reachable from Metadata and fails on any the copy still shares —
|
||||
// it caught these three the moment the guide was added, which is exactly what it is for.
|
||||
func (g AppGuide) Clone() AppGuide {
|
||||
out := g
|
||||
if g.Sections != nil {
|
||||
out.Sections = make([]GuideSection, len(g.Sections))
|
||||
copy(out.Sections, g.Sections)
|
||||
for i, se := range g.Sections {
|
||||
if se.Steps != nil {
|
||||
out.Sections[i].Steps = append([]string(nil), se.Steps...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.FAQ != nil {
|
||||
out.FAQ = make([]GuideFAQ, len(g.FAQ))
|
||||
copy(out.FAQ, g.FAQ)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers").
|
||||
|
||||
@@ -64,12 +64,44 @@ type MetadataOverlay struct {
|
||||
|
||||
// AppInfoOverlay carries the info page's prose. The three lists replace whole.
|
||||
type AppInfoOverlay struct {
|
||||
Tagline *string `yaml:"tagline,omitempty"`
|
||||
UseCases []string `yaml:"use_cases,omitempty"`
|
||||
FirstSteps []string `yaml:"first_steps,omitempty"`
|
||||
Prerequisites []string `yaml:"prerequisites,omitempty"`
|
||||
DefaultCreds *string `yaml:"default_creds,omitempty"`
|
||||
AddPeople *string `yaml:"add_people,omitempty"`
|
||||
Tagline *string `yaml:"tagline,omitempty"`
|
||||
UseCases []string `yaml:"use_cases,omitempty"`
|
||||
FirstSteps []string `yaml:"first_steps,omitempty"`
|
||||
Prerequisites []string `yaml:"prerequisites,omitempty"`
|
||||
DefaultCreds *string `yaml:"default_creds,omitempty"`
|
||||
AddPeople *string `yaml:"add_people,omitempty"`
|
||||
Guide *AppGuideOverlay `yaml:"guide,omitempty"`
|
||||
}
|
||||
|
||||
// AppGuideOverlay translates the user guide. `checked_version` is NOT here and that is the rule
|
||||
// stated in code: a version vouches for a click-through, and a translator did not do it.
|
||||
//
|
||||
// SECTIONS AND FAQ ENTRIES ARE MATCHED BY INDEX, and they are the only lists in this file that are.
|
||||
// The header above says every list is matched by its own key — a guide section has no key to match
|
||||
// on. Its title is the very thing being translated, so a `match_title` would have to be kept in step
|
||||
// with the Hungarian by hand, which is the drift the key rule exists to prevent. Index matching is
|
||||
// safe HERE because the catalog's copy gate requires a translated guide to carry the same number of
|
||||
// sections and FAQ entries in the same running order, and because the fallback is field by field:
|
||||
// a misaligned entry shows the Hungarian one, never a sentence nobody wrote. An English list LONGER
|
||||
// than the Hungarian one is ignored past the Hungarian count, with one WARN naming the slug
|
||||
// (GuideOverlayExtras).
|
||||
type AppGuideOverlay struct {
|
||||
Sections []GuideSectionOverlay `yaml:"sections,omitempty"`
|
||||
FAQ []GuideFAQOverlay `yaml:"faq,omitempty"`
|
||||
}
|
||||
|
||||
// GuideSectionOverlay translates one section, found by its POSITION. Steps replace whole, like
|
||||
// every other prose list in this file.
|
||||
type GuideSectionOverlay struct {
|
||||
Title *string `yaml:"title,omitempty"`
|
||||
Intro *string `yaml:"intro,omitempty"`
|
||||
Steps []string `yaml:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// GuideFAQOverlay translates one FAQ entry, found by its POSITION.
|
||||
type GuideFAQOverlay struct {
|
||||
Q *string `yaml:"q,omitempty"`
|
||||
A *string `yaml:"a,omitempty"`
|
||||
}
|
||||
|
||||
// DeployFieldOverlay translates one deploy field, found by EnvVar.
|
||||
@@ -150,6 +182,69 @@ func overlayList(in []string, base []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// overlayGuide returns the translated guide. `CheckedVersion` is carried through from the base
|
||||
// UNTOUCHED: it is the render gate, not copy, so a translation can neither set it nor clear it — an
|
||||
// untranslated guide and a translated one are shown or hidden by the same fact.
|
||||
//
|
||||
// Entries are matched by index and every field falls back on its own (see AppGuideOverlay). Nothing
|
||||
// is mutated: both slices are rebuilt.
|
||||
func overlayGuide(ov *AppGuideOverlay, base AppGuide) AppGuide {
|
||||
if ov == nil {
|
||||
return base
|
||||
}
|
||||
out := base
|
||||
if len(ov.Sections) > 0 && len(base.Sections) > 0 {
|
||||
secs := make([]GuideSection, len(base.Sections))
|
||||
copy(secs, base.Sections)
|
||||
for i := range secs {
|
||||
if i >= len(ov.Sections) {
|
||||
break
|
||||
}
|
||||
so := ov.Sections[i]
|
||||
secs[i].Title = overlayStr(so.Title, secs[i].Title)
|
||||
secs[i].Intro = overlayStr(so.Intro, secs[i].Intro)
|
||||
secs[i].Steps = overlayList(so.Steps, secs[i].Steps)
|
||||
}
|
||||
out.Sections = secs
|
||||
}
|
||||
if len(ov.FAQ) > 0 && len(base.FAQ) > 0 {
|
||||
faq := make([]GuideFAQ, len(base.FAQ))
|
||||
copy(faq, base.FAQ)
|
||||
for i := range faq {
|
||||
if i >= len(ov.FAQ) {
|
||||
break
|
||||
}
|
||||
qo := ov.FAQ[i]
|
||||
faq[i].Q = overlayStr(qo.Q, faq[i].Q)
|
||||
faq[i].A = overlayStr(qo.A, faq[i].A)
|
||||
}
|
||||
out.FAQ = faq
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GuideOverlayExtras counts the guide entries a language's overlay carries BEYOND the Hungarian
|
||||
// count — the ones overlayGuide ignores. It exists so the page that renders the guide can say so
|
||||
// once in a log line: `For` runs per request and holds no logger, and a silently dropped
|
||||
// translation is exactly the kind of absence nobody notices. Zero for the base language, for a
|
||||
// language with no block, and for a correctly sized overlay.
|
||||
func (m Metadata) GuideOverlayExtras(lang string) (sections, faq int) {
|
||||
if lang == "" || lang == BaseLanguage {
|
||||
return 0, 0
|
||||
}
|
||||
ov, ok := m.I18n[lang]
|
||||
if !ok || ov.AppInfo == nil || ov.AppInfo.Guide == nil {
|
||||
return 0, 0
|
||||
}
|
||||
if n := len(ov.AppInfo.Guide.Sections) - len(m.AppInfo.Guide.Sections); n > 0 {
|
||||
sections = n
|
||||
}
|
||||
if n := len(ov.AppInfo.Guide.FAQ) - len(m.AppInfo.Guide.FAQ); n > 0 {
|
||||
faq = n
|
||||
}
|
||||
return sections, faq
|
||||
}
|
||||
|
||||
// For returns this app's metadata as the given language renders it.
|
||||
//
|
||||
// For the base language — and for any language this template has no block for — it is the parsed
|
||||
@@ -183,6 +278,7 @@ func (m Metadata) For(lang string) Metadata {
|
||||
out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases)
|
||||
out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps)
|
||||
out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites)
|
||||
out.AppInfo.Guide = overlayGuide(ov.AppInfo.Guide, out.AppInfo.Guide)
|
||||
}
|
||||
|
||||
if len(ov.DeployFields) > 0 && len(out.DeployFields) > 0 {
|
||||
@@ -356,6 +452,26 @@ func (o MetadataOverlay) Clone() MetadataOverlay {
|
||||
DefaultCreds: cloneStrPtr(o.AppInfo.DefaultCreds),
|
||||
AddPeople: cloneStrPtr(o.AppInfo.AddPeople),
|
||||
}
|
||||
if o.AppInfo.Guide != nil {
|
||||
g := AppGuideOverlay{}
|
||||
if o.AppInfo.Guide.Sections != nil {
|
||||
g.Sections = make([]GuideSectionOverlay, len(o.AppInfo.Guide.Sections))
|
||||
for i, se := range o.AppInfo.Guide.Sections {
|
||||
g.Sections[i] = GuideSectionOverlay{
|
||||
Title: cloneStrPtr(se.Title),
|
||||
Intro: cloneStrPtr(se.Intro),
|
||||
Steps: cloneStrs(se.Steps),
|
||||
}
|
||||
}
|
||||
}
|
||||
if o.AppInfo.Guide.FAQ != nil {
|
||||
g.FAQ = make([]GuideFAQOverlay, len(o.AppInfo.Guide.FAQ))
|
||||
for i, qa := range o.AppInfo.Guide.FAQ {
|
||||
g.FAQ[i] = GuideFAQOverlay{Q: cloneStrPtr(qa.Q), A: cloneStrPtr(qa.A)}
|
||||
}
|
||||
}
|
||||
ai.Guide = &g
|
||||
}
|
||||
c.AppInfo = &ai
|
||||
}
|
||||
if o.DeployFields != nil {
|
||||
|
||||
Reference in New Issue
Block a user