diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js
index 95bb540..22f3903 100644
--- a/browse/js/preview-yaml.js
+++ b/browse/js/preview-yaml.js
@@ -107,6 +107,7 @@
paths: 'pathmap',
display: 'stringmap',
tables: 'stringmap',
+ views: 'viewmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool'
@@ -223,6 +224,32 @@
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
+ case 'viewmap':
+ if (t === 'null') return;
+ if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
+ for (var shape in val) {
+ if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
+ if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
+ issues.push({ keyPath: path.concat([shape]), severity: 'warning',
+ message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
+ }
+ var vv = val[shape];
+ if (typeOf(vv) !== 'object') {
+ issues.push({ keyPath: path.concat([shape]), severity: 'error',
+ message: 'views.' + shape + ' must be a map ({tool, config}).' });
+ continue;
+ }
+ if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
+ issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
+ message: 'views.' + shape + '.tool should be a known tool ('
+ + Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
+ }
+ if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
+ issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
+ message: 'views.' + shape + '.config must be a filename string.' });
+ }
+ }
+ return;
case 'rolemap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go
index 4f76f02..eba1009 100644
--- a/zddc/internal/handler/zddcfile.go
+++ b/zddc/internal/handler/zddcfile.go
@@ -296,6 +296,7 @@ func isZeroZddcFile(zf zddc.ZddcFile) bool {
len(zf.ACL.Permissions) == 0 &&
len(zf.Admins) == 0 &&
len(zf.Tables) == 0 &&
+ len(zf.Views) == 0 &&
len(zf.Display) == 0 &&
len(zf.Roles) == 0 &&
len(zf.FieldCodes) == 0 &&
diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go
index c717277..cd2bb78 100644
--- a/zddc/internal/zddc/cascade.go
+++ b/zddc/internal/zddc/cascade.go
@@ -394,6 +394,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
add("admins", len(zf.Admins) > 0)
add("tables", len(zf.Tables) > 0)
+ add("views", len(zf.Views) > 0)
add("display", len(zf.Display) > 0)
add("convert", zf.Convert != nil)
add("roles", len(zf.Roles) > 0)
diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go
index 47b32c1..e990ef7 100644
--- a/zddc/internal/zddc/file.go
+++ b/zddc/internal/zddc/file.go
@@ -92,6 +92,15 @@ type ConvertMetadata struct {
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
}
+// ViewSpec is one entry in ZddcFile.Views: which tool renders a given URL
+// shape, and the filename (under
/.zddc.d/) of its supporting config.
+// Config is optional (e.g. browse needs none). Both are plain data — no
+// behaviour. See ZddcFile.Views.
+type ViewSpec struct {
+ Tool string `yaml:"tool,omitempty" json:"tool,omitempty"`
+ Config string `yaml:"config,omitempty" json:"config,omitempty"`
+}
+
// ZddcFile represents the parsed contents of a .zddc configuration file.
//
// Admins is honored only in the root .zddc file (/.zddc); subdir
@@ -197,6 +206,21 @@ type ZddcFile struct {
// Cascades leaf→root like DefaultTool.
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
+ // Views declares, per URL shape, which tool renders and where its
+ // supporting config lives — the generalization of default_tool/dir_tool
+ // plus the form/table recognizers. Keys are URL shapes:
+ // "dir" — GET (no slash) e.g. {tool: tables, config: table.yaml}
+ // "dir_slash" — GET / e.g. {tool: browse}
+ // "file" — GET / (no slash) e.g. {tool: form, config: form.yaml}
+ // config is a filename resolved under /.zddc.d/ (the supporting-files
+ // reserve), server-resolved and injected (#view-context) since .zddc.d/ is
+ // not client-fetchable. A view is presentation/routing ONLY — it never
+ // grants access; ACL/WORM/admin stay server-enforced. default_tool /
+ // dir_tool are normalized into views.dir / views.dir_slash (kept as sugar).
+ // Cascades leaf→root like DefaultTool. No arbitrary code: tool ∈ the known
+ // app set, config is a path-bounded relative name.
+ Views map[string]ViewSpec `yaml:"views,omitempty" json:"views,omitempty"`
+
// AutoOwn controls whether the file API's mkdir post-hook writes
// an auto-owned .zddc granting the creator rwcda at the new
// directory. Useful for working/staging/incoming-style drafting
diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go
index aac026f..eed0b75 100644
--- a/zddc/internal/zddc/lookups.go
+++ b/zddc/internal/zddc/lookups.go
@@ -56,6 +56,44 @@ func DirToolAt(fsRoot, dirPath string) string {
return "browse"
}
+// ViewAt resolves the view for a URL shape ("dir", "dir_slash", "file") at
+// dirPath. Walks the cascade leaf→root (then the embedded defaults): the first
+// level whose Views declares the shape wins; default_tool / dir_tool are
+// honored as sugar for the "dir" / "dir_slash" shapes. Returns
+// (ViewSpec{}, false) when nothing declares the shape (the caller decides any
+// floor, e.g. dir_slash → browse). Mirrors DefaultToolAt's first-match-wins
+// cascade so default_tool/dir_tool semantics are unchanged.
+func ViewAt(fsRoot, dirPath, shape string) (ViewSpec, bool) {
+ chain, err := EffectivePolicy(fsRoot, dirPath)
+ if err != nil {
+ return ViewSpec{}, false
+ }
+ atLevel := func(lvl ZddcFile) (ViewSpec, bool) {
+ if lvl.Views != nil {
+ if v, ok := lvl.Views[shape]; ok && v.Tool != "" {
+ return v, true
+ }
+ }
+ switch shape {
+ case "dir":
+ if lvl.DefaultTool != "" {
+ return ViewSpec{Tool: lvl.DefaultTool}, true
+ }
+ case "dir_slash":
+ if lvl.DirTool != "" {
+ return ViewSpec{Tool: lvl.DirTool}, true
+ }
+ }
+ return ViewSpec{}, false
+ }
+ for i := len(chain.Levels) - 1; i >= 0; i-- {
+ if v, ok := atLevel(chain.Levels[i]); ok {
+ return v, true
+ }
+ }
+ return atLevel(chain.Embedded)
+}
+
// AutoOwnAt reports whether mkdir at THIS specific directory should
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
// propagate to descendants (creating working/alice/notes/sub/ does
@@ -390,7 +428,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if zf.ACL.Inherit != nil {
return false
}
- if len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
+ if len(zf.Tables) > 0 || len(zf.Views) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
return false
}
if len(zf.Roles) > 0 {
diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go
index daeb8e4..8d9ec41 100644
--- a/zddc/internal/zddc/validate.go
+++ b/zddc/internal/zddc/validate.go
@@ -133,6 +133,25 @@ func ValidateFile(zf ZddcFile) []FieldError {
Message: "title exceeds 200 characters",
})
}
+ // views: each entry names a known tool and (optionally) a config file
+ // resolved under /.zddc.d/ — so it must be a safe relative filename
+ // (no slashes, no traversal, no leading dot).
+ for shape, v := range zf.Views {
+ if v.Tool == "" || !IsKnownApp(v.Tool) {
+ errs = append(errs, FieldError{
+ Field: fmt.Sprintf("views.%s.tool", shape),
+ Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")),
+ })
+ }
+ if v.Config != "" {
+ if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") {
+ errs = append(errs, FieldError{
+ Field: fmt.Sprintf("views.%s.config", shape),
+ Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot",
+ })
+ }
+ }
+ }
// worm: is a list of principal patterns (email-globs, @role:name,
// or bare role names) that get write-once-create inside the WORM
// zone. Validate each as an email-glob unless it's a role