feat(server): table/form specs resolve from .zddc.d/ + server-inject the table spec
The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).
Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
(classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
#table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
when present (server mode) and still walks the directory for ROW files; FS-
Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
passes the injected context through. Regenerated the embedded tables.html.
go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.
Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
45af24b2b1
commit
03fa366814
5 changed files with 218 additions and 56 deletions
|
|
@ -22,13 +22,19 @@
|
||||||
// inline context (tests) or open the page through zddc-server.
|
// inline context (tests) or open the page through zddc-server.
|
||||||
async function load() {
|
async function load() {
|
||||||
const inline = readInlineContext();
|
const inline = readInlineContext();
|
||||||
if (inline && Object.keys(inline).length > 0) {
|
// A fully pre-assembled context (columns + rows) is used as-is — the
|
||||||
|
// test seam, or any host that renders the whole table server-side.
|
||||||
|
if (inline && Array.isArray(inline.columns)) {
|
||||||
return inline;
|
return inline;
|
||||||
}
|
}
|
||||||
|
// Otherwise the inline context may still carry the server-injected
|
||||||
|
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
|
||||||
|
// walkServer, which uses it instead of fetching the spec and still
|
||||||
|
// walks the directory for row files.
|
||||||
if (typeof location !== 'undefined' &&
|
if (typeof location !== 'undefined' &&
|
||||||
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
||||||
try {
|
try {
|
||||||
const walked = await walkServer();
|
const walked = await walkServer(inline || {});
|
||||||
if (walked) {
|
if (walked) {
|
||||||
return walked;
|
return walked;
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +66,8 @@
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walkServer() {
|
async function walkServer(injected) {
|
||||||
|
injected = injected || {};
|
||||||
const source = window.zddc && window.zddc.source;
|
const source = window.zddc && window.zddc.source;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new Error('zddc.source not available');
|
throw new Error('zddc.source not available');
|
||||||
|
|
@ -77,27 +84,32 @@
|
||||||
}
|
}
|
||||||
const dir = probe.handle;
|
const dir = probe.handle;
|
||||||
|
|
||||||
// Spec lives at <currentdir>/table.yaml — the page URL is
|
// Spec: prefer the server-injected #table-context.spec (sourced from
|
||||||
// <currentdir>/table.html, so the spec is right next door.
|
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
|
||||||
const spec = await readYaml(dir, 'table.yaml');
|
// supporting-files reserve, then the legacy directory root — the
|
||||||
|
// FS-Access path, where there's no server to inject.
|
||||||
|
let spec = (injected.spec && Array.isArray(injected.spec.columns))
|
||||||
|
? injected.spec : null;
|
||||||
|
if (!spec) {
|
||||||
|
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
|
||||||
|
}
|
||||||
if (!spec || !Array.isArray(spec.columns)) {
|
if (!spec || !Array.isArray(spec.columns)) {
|
||||||
throw new Error('Spec table.yaml missing columns[]');
|
throw new Error('Spec table.yaml missing columns[]');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional row schema from <dir>/form.yaml — same JSON Schema
|
// Row schema: prefer the injected #table-context.rowSchema, else read
|
||||||
// the form-mode renderer uses. Phase 2 derives per-cell editor
|
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
|
||||||
// widgets from it (text/number/date/select/checkbox).
|
// with no row schema still renders with plain-text cells.
|
||||||
// Best-effort: a directory with only table.yaml still renders
|
let rowSchema = injected.rowSchema || null;
|
||||||
// as a sortable/filterable table; cells fall back to plain
|
if (!rowSchema) {
|
||||||
// text inputs without per-property hints.
|
try {
|
||||||
let rowSchema = null;
|
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
|
||||||
try {
|
if (formSpec && formSpec.schema) {
|
||||||
const formSpec = await readYaml(dir, 'form.yaml');
|
rowSchema = formSpec.schema;
|
||||||
if (formSpec && formSpec.schema) {
|
}
|
||||||
rowSchema = formSpec.schema;
|
} catch (_) {
|
||||||
|
// form.yaml missing or unreadable; carry on without it.
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
// form.yaml missing or unreadable; carry on without it.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||||
|
|
@ -156,6 +168,22 @@
|
||||||
return window.jsyaml.load(text);
|
return window.jsyaml.load(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readYamlFirst tries each relPath in order, returning the first that
|
||||||
|
// resolves + parses. Used to read a spec from the supporting-files
|
||||||
|
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
|
||||||
|
async function readYamlFirst(dir, relPaths) {
|
||||||
|
let lastErr = null;
|
||||||
|
for (var i = 0; i < relPaths.length; i++) {
|
||||||
|
try {
|
||||||
|
return await readYaml(dir, relPaths[i]);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr) throw lastErr;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Walk a "/"-separated relative path under dir, returning the
|
// Walk a "/"-separated relative path under dir, returning the
|
||||||
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
||||||
async function resolveFile(dir, relPath) {
|
async function resolveFile(dir, relPath) {
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
||||||
// any of the default-spec virtual-fallback shapes (per-party
|
// any of the default-spec virtual-fallback shapes (per-party
|
||||||
// mdl/rsk, per-party SSR schema, project-level virtual specs).
|
// mdl/rsk, per-party SSR schema, project-level virtual specs).
|
||||||
specEligible := func(specAbs string) bool {
|
specEligible := func(specAbs string) bool {
|
||||||
|
dir, base := filepath.Split(specAbs)
|
||||||
|
if fileExists(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if fileExists(specAbs) {
|
if fileExists(specAbs) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -542,13 +546,19 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
// --- Helpers -----------------------------------------------------------------
|
// --- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
||||||
data, err := os.ReadFile(path)
|
// Prefer the supporting-files reserve: a spec at <dir>/.zddc.d/form.yaml
|
||||||
|
// takes precedence over the legacy <dir>/form.yaml. `path` is the legacy
|
||||||
|
// <dir>/form.yaml location the callers build.
|
||||||
|
dir, base := filepath.Split(path)
|
||||||
|
data, err := os.ReadFile(filepath.Join(filepath.Clean(dir), ".zddc.d", base))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Default-spec virtual fallback: when no operator file exists at
|
data, err = os.ReadFile(path)
|
||||||
// path, serve the embedded default if path matches one of the
|
}
|
||||||
// recognized virtual fallback shapes (per-party mdl/rsk, per-
|
if err != nil {
|
||||||
// party SSR schema, project-level virtual specs). Mirrors the
|
// Default-spec virtual fallback: when no operator file exists in
|
||||||
// static-handler fallback for direct YAML fetches.
|
// either location, serve the embedded default if path matches one of
|
||||||
|
// the recognized virtual fallback shapes (per-party mdl/rsk, per-
|
||||||
|
// party SSR schema, project-level virtual specs).
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
|
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
|
||||||
data = bytes
|
data = bytes
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,15 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
|
@ -161,6 +165,17 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
||||||
// not name one of the recognized virtual fallback files.
|
// not name one of the recognized virtual fallback files.
|
||||||
func classifyDefaultSpec(rel string) []byte {
|
func classifyDefaultSpec(rel string) []byte {
|
||||||
parts := strings.Split(rel, "/")
|
parts := strings.Split(rel, "/")
|
||||||
|
// A spec may live either in the directory root (<dir>/table.yaml) or in
|
||||||
|
// the supporting-files reserve (<dir>/.zddc.d/table.yaml). Strip a
|
||||||
|
// ".zddc.d" segment so both classify by the same dir shape.
|
||||||
|
clean := parts[:0:0]
|
||||||
|
for _, p := range parts {
|
||||||
|
if strings.EqualFold(p, ".zddc.d") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean = append(clean, p)
|
||||||
|
}
|
||||||
|
parts = clean
|
||||||
switch len(parts) {
|
switch len(parts) {
|
||||||
case 4:
|
case 4:
|
||||||
// <project>/<peer>/<party>/<file> — per-party register specs
|
// <project>/<peer>/<party>/<file> — per-party register specs
|
||||||
|
|
@ -309,8 +324,9 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||||
|
|
||||||
specAbs := filepath.Join(dirAbs, "table.yaml")
|
specAbs := filepath.Join(dirAbs, "table.yaml")
|
||||||
|
|
||||||
// Presence-based discovery: <dir>/table.yaml on disk.
|
// Presence-based discovery: the spec in the supporting-files reserve
|
||||||
if fileExists(specAbs) {
|
// (<dir>/.zddc.d/table.yaml) or, legacy, the directory root.
|
||||||
|
if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) {
|
||||||
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
|
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,10 +378,77 @@ func isNotExistError(err error) bool {
|
||||||
return err != nil && strings.Contains(err.Error(), "no such file or directory")
|
return err != nil && strings.Contains(err.Error(), "no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeTable serves the static tables.html bytes for a recognized
|
// LoadViewSpec resolves a config file's bytes for dir, preferring the
|
||||||
// request. ACL gate is the read action at the request directory; on
|
// supporting-files reserve <dir>/.zddc.d/<name>, then the legacy <dir>/<name>,
|
||||||
// allow, the embedded HTML is written verbatim. The client takes over
|
// then the embedded default for this dir's shape. Returns nil when none
|
||||||
// from there — see tables/js/main.js.
|
// applies. This is the single seam that puts table/form specs under .zddc.d/
|
||||||
|
// (where they're admin-gated + hidden) while staying back-compatible.
|
||||||
|
func LoadViewSpec(fsRoot, dir, name string) []byte {
|
||||||
|
if b, err := os.ReadFile(filepath.Join(dir, ".zddc.d", name)); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(dir, name)); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if rel, err := filepath.Rel(fsRoot, filepath.Join(dir, name)); err == nil {
|
||||||
|
if b := classifyDefaultSpec(filepath.ToSlash(rel)); b != nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectTableContext writes the resolved table spec + row-form schema into the
|
||||||
|
// `#table-context` placeholder so the client reads them instead of fetching
|
||||||
|
// <dir>/table.yaml and <dir>/form.yaml over HTTP (impossible once the specs
|
||||||
|
// live under the admin-gated .zddc.d/). The client still walks the directory
|
||||||
|
// for ROW files — only the SPEC is injected. Shape:
|
||||||
|
//
|
||||||
|
// { "spec": <parsed table.yaml>, "rowSchema": <parsed form.yaml .schema> }
|
||||||
|
//
|
||||||
|
// Empty {} when neither resolves (the client then walks for the spec too,
|
||||||
|
// preserving legacy behavior). Returns an error only if the placeholder is
|
||||||
|
// absent from the template.
|
||||||
|
func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) {
|
||||||
|
ctx := map[string]interface{}{}
|
||||||
|
if len(tableYAML) > 0 {
|
||||||
|
var spec interface{}
|
||||||
|
if err := yaml.Unmarshal(tableYAML, &spec); err == nil && spec != nil {
|
||||||
|
ctx["spec"] = spec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(formYAML) > 0 {
|
||||||
|
var fs map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(formYAML, &fs); err == nil {
|
||||||
|
if sch, ok := fs["schema"]; ok {
|
||||||
|
ctx["rowSchema"] = sch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
js, err := json.Marshal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
|
||||||
|
needle := []byte(`<script id="table-context" type="application/json">{}</script>`)
|
||||||
|
if !bytesContains(template, needle) {
|
||||||
|
return nil, errBundle("#table-context placeholder not found in template")
|
||||||
|
}
|
||||||
|
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
|
||||||
|
replacement = append(replacement, []byte(`</script>`)...)
|
||||||
|
return bytesReplace(template, needle, replacement), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type errBundle string
|
||||||
|
|
||||||
|
func (e errBundle) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// ServeTable serves the tables HTML for a recognized request, ACL-gated on
|
||||||
|
// read at the request directory. The resolved table.yaml + form.yaml (from
|
||||||
|
// .zddc.d/, legacy root, or the embedded default) are injected as
|
||||||
|
// #table-context so the client never fetches the spec over HTTP. If the
|
||||||
|
// template predates the placeholder, the bare HTML is served (the client
|
||||||
|
// falls back to fetching) — keeps this non-breaking before ./build.
|
||||||
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
|
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
|
||||||
p := PrincipalFromContext(r)
|
p := PrincipalFromContext(r)
|
||||||
decider := DeciderFromContext(r)
|
decider := DeciderFromContext(r)
|
||||||
|
|
@ -384,7 +467,14 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body := embeddedTablesHTML
|
||||||
|
tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml")
|
||||||
|
formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml")
|
||||||
|
if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil {
|
||||||
|
body = injected
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(embeddedTablesHTML)
|
_, _ = w.Write(body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,9 @@ func TestRecognizeTableRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
|
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
|
||||||
// embedded tables.html bytes verbatim, with the empty inline context
|
// embedded tables.html with the resolved table spec server-injected into
|
||||||
// placeholder intact (so the client knows to walk the directory).
|
// #table-context (the embedded default for this virtual MDL dir), so the
|
||||||
|
// client renders without a separate spec fetch.
|
||||||
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
||||||
rows := map[string]string{
|
rows := map[string]string{
|
||||||
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
|
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
|
||||||
|
|
@ -202,8 +203,13 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
||||||
if !strings.Contains(body, `<table id="table-root"`) {
|
if !strings.Contains(body, `<table id="table-root"`) {
|
||||||
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
|
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
|
// #table-context is no longer the empty placeholder — the resolved spec
|
||||||
t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
|
// is injected (the client uses it instead of fetching table.yaml).
|
||||||
|
if strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
|
||||||
|
t.Error("#table-context still empty; expected the resolved spec to be injected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `id="table-context"`) || !strings.Contains(body, `"spec"`) {
|
||||||
|
t.Error("expected the resolved table spec injected into #table-context")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1534,7 +1534,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-04 15:19:25 · 45af24b-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -3691,13 +3691,19 @@ body.is-elevated::after {
|
||||||
// inline context (tests) or open the page through zddc-server.
|
// inline context (tests) or open the page through zddc-server.
|
||||||
async function load() {
|
async function load() {
|
||||||
const inline = readInlineContext();
|
const inline = readInlineContext();
|
||||||
if (inline && Object.keys(inline).length > 0) {
|
// A fully pre-assembled context (columns + rows) is used as-is — the
|
||||||
|
// test seam, or any host that renders the whole table server-side.
|
||||||
|
if (inline && Array.isArray(inline.columns)) {
|
||||||
return inline;
|
return inline;
|
||||||
}
|
}
|
||||||
|
// Otherwise the inline context may still carry the server-injected
|
||||||
|
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
|
||||||
|
// walkServer, which uses it instead of fetching the spec and still
|
||||||
|
// walks the directory for row files.
|
||||||
if (typeof location !== 'undefined' &&
|
if (typeof location !== 'undefined' &&
|
||||||
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
||||||
try {
|
try {
|
||||||
const walked = await walkServer();
|
const walked = await walkServer(inline || {});
|
||||||
if (walked) {
|
if (walked) {
|
||||||
return walked;
|
return walked;
|
||||||
}
|
}
|
||||||
|
|
@ -3729,7 +3735,8 @@ body.is-elevated::after {
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walkServer() {
|
async function walkServer(injected) {
|
||||||
|
injected = injected || {};
|
||||||
const source = window.zddc && window.zddc.source;
|
const source = window.zddc && window.zddc.source;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new Error('zddc.source not available');
|
throw new Error('zddc.source not available');
|
||||||
|
|
@ -3746,27 +3753,32 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
const dir = probe.handle;
|
const dir = probe.handle;
|
||||||
|
|
||||||
// Spec lives at <currentdir>/table.yaml — the page URL is
|
// Spec: prefer the server-injected #table-context.spec (sourced from
|
||||||
// <currentdir>/table.html, so the spec is right next door.
|
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
|
||||||
const spec = await readYaml(dir, 'table.yaml');
|
// supporting-files reserve, then the legacy directory root — the
|
||||||
|
// FS-Access path, where there's no server to inject.
|
||||||
|
let spec = (injected.spec && Array.isArray(injected.spec.columns))
|
||||||
|
? injected.spec : null;
|
||||||
|
if (!spec) {
|
||||||
|
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
|
||||||
|
}
|
||||||
if (!spec || !Array.isArray(spec.columns)) {
|
if (!spec || !Array.isArray(spec.columns)) {
|
||||||
throw new Error('Spec table.yaml missing columns[]');
|
throw new Error('Spec table.yaml missing columns[]');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional row schema from <dir>/form.yaml — same JSON Schema
|
// Row schema: prefer the injected #table-context.rowSchema, else read
|
||||||
// the form-mode renderer uses. Phase 2 derives per-cell editor
|
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
|
||||||
// widgets from it (text/number/date/select/checkbox).
|
// with no row schema still renders with plain-text cells.
|
||||||
// Best-effort: a directory with only table.yaml still renders
|
let rowSchema = injected.rowSchema || null;
|
||||||
// as a sortable/filterable table; cells fall back to plain
|
if (!rowSchema) {
|
||||||
// text inputs without per-property hints.
|
try {
|
||||||
let rowSchema = null;
|
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
|
||||||
try {
|
if (formSpec && formSpec.schema) {
|
||||||
const formSpec = await readYaml(dir, 'form.yaml');
|
rowSchema = formSpec.schema;
|
||||||
if (formSpec && formSpec.schema) {
|
}
|
||||||
rowSchema = formSpec.schema;
|
} catch (_) {
|
||||||
|
// form.yaml missing or unreadable; carry on without it.
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
// form.yaml missing or unreadable; carry on without it.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||||
|
|
@ -3825,6 +3837,22 @@ body.is-elevated::after {
|
||||||
return window.jsyaml.load(text);
|
return window.jsyaml.load(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readYamlFirst tries each relPath in order, returning the first that
|
||||||
|
// resolves + parses. Used to read a spec from the supporting-files
|
||||||
|
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
|
||||||
|
async function readYamlFirst(dir, relPaths) {
|
||||||
|
let lastErr = null;
|
||||||
|
for (var i = 0; i < relPaths.length; i++) {
|
||||||
|
try {
|
||||||
|
return await readYaml(dir, relPaths[i]);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr) throw lastErr;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Walk a "/"-separated relative path under dir, returning the
|
// Walk a "/"-separated relative path under dir, returning the
|
||||||
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
||||||
async function resolveFile(dir, relPath) {
|
async function resolveFile(dir, relPath) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue