Преглед изворни кода

Let a canned editor block say what it collects

A block like preview.tx is spliced into most editors, and the fields it collects
belong to every type that includes it.  Nothing said so, and it cost twice over.

The wizard had one test for whether a field was custom: is it in the base post
schema?  The blocks whose fields happen to be base fields -- tags, aliases,
visibility, acls -- passed by coincidence, and the ones whose fields are not --
preview, preview_file, attachments -- came back as custom rows.  So reopening
blog listed a preview image as a text field you had typed in, while leaving the
'Preview image upload' box clear: wrong in both directions at once, about the
same field.

Worse, a type the wizard generated declared none of them, and validate() drops
every field the schema does not describe -- so a generated type with that box
ticked threw away the preview image its own editor had just uploaded, and said
nothing.

So each block gets a sidecar beside it, in components/ rather than in forms/,
exactly as blog.tx has blog.json:

    preview.tx     the block: the markup an editor splices in
    preview.json   the sidecar: an OpenAPIv3 fragment for what it collects

includes_for() reads a type's ': include' lines back out of the template, which
is the only account there is for a hand-written type -- only a generated sidecar
ever recorded the list, and a generated template can be hand-edited afterwards.
include_schema_for() turns each name into the fields that block collects, or
into nothing at all, which is right for edit_head.tx and the rest of the
furniture.

Two things then follow from the one declaration.  The wizard tells a canned
field apart from one somebody typed in, so blog reopens with no custom fields
and three boxes ticked.  And schema_for() merges them in, so a generated type
keeps what its editor collected.  The merge order is base, then the type's own
sidecar, then the blocks it includes: a block never overrules the type that
borrows it, and the base still overrules both, for the reason it always did.

Nothing in the stock tree changes shape -- every one of those declares these
fields by hand already, and their schemas come out byte for byte identical.
It is the generated types this was silently costing.

tags.json, aliases.json, visibility.json and acls.json declare base post fields
and so add nothing to any schema.  They are there because the wizard's filter
should come from one table rather than from four fields happening to be in the
base schema and two happening not to be, which is the bug this is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RprkeFWfYwKF9ZsgND8om
George Baugh пре 1 месец
родитељ
комит
7b75d6d8bc

+ 6 - 0
Readme.md

@@ -70,6 +70,12 @@ form in from its sidecar so you can change one thing and save it back.  Each typ
 carries a description saying what it is for, which the dropdown shows and a
 collapsed textarea lets you edit.
 
+The canned blocks an editor reuses -- preview.tx, tags.tx, attachments.tx and the
+rest -- have sidecars of their own beside them in components/, saying which fields
+each one collects.  That is how the wizard knows a preview image is a box to tick
+rather than a custom field to list, and how a type built by the wizard keeps the
+preview image its editor uploaded.
+
 From there you make Series of the content type you want; tag the series with 'topbar' if you want it to show up in the top links.
 
 Currently supported:

+ 31 - 0
design.md

@@ -188,6 +188,36 @@ records everything the wizard was told -- the description, the display template,
 the checkboxes, the placeholder -- and `schema_for` strips that whole key before
 validation, so none of it can affect whether a post saves.
 
+Canned blocks
+-------------
+
+Most of an editor is not the type's own: `preview.tx`, `tags.tx`, `aliases.tx`,
+`visibility.tx`, `acls.tx` and `attachments.tx` are blocks every form splices in,
+and the wizard's "Canned Fields" checkboxes are which of them to include.
+
+Each has a sidecar of its own beside it, in the components dir rather than in
+`forms/`:
+
+    preview.tx     the block: the markup an editor splices in
+    preview.json   the sidecar: an OpenAPIv3 fragment for what it collects
+
+`Trog::DataModule::includes_for` reads a type's `: include` lines back out of its
+template -- the only account there is for a hand-written type -- and
+`include_schema_for` turns each one into the fields it collects. Two things
+follow from one declaration:
+
+- `schema_for` merges them in, so a generated type keeps the preview image its
+  own editor collected. `validate` drops every field the schema does not
+  describe, and nothing described those.
+- The wizard tells a canned field apart from one somebody typed in, so ticking
+  "Preview image upload" and listing `preview` as a custom text field are no
+  longer the same thing said twice.
+
+A block never overrules the type that borrows it, and the base schema still
+overrules both: the merge order is base, then the type's own sidecar, then the
+blocks it includes. A block with no sidecar collects nothing, which is right for
+`edit_head.tx` and the rest of the furniture.
+
 Series, and how content is organised
 ------------------------------------
 
@@ -406,6 +436,7 @@ The contractor's cheat-sheet.
 | To restrict a section | give the series an ACL; grant it to users | none |
 | A different look | a theme directory; override the templates you care about | none |
 | A reusable chunk of UI | `Trog::Component::Whatever` with `render(%args)` | a little |
+| A canned editor block forms can include | `foo.tx` in `components/`, plus `foo.json` saying what it collects; add it to `%wizard_includes` to get a checkbox | a little |
 | A page listing a directory of files | a series with a directory + a wizard type using `DirIndex` | none |
 | Content from an external system | `Trog::DataSource::Whatever` with `posts($series,$query)` | some |
 | To extend one that exists | subclass it; `can()` resolves through `@ISA`, so override only what differs (`ProvisionedVirt` does this to `Virt`) | some |

+ 112 - 4
lib/Trog/DataModule.pm

@@ -395,15 +395,21 @@ Sidecars live beside their templates (blog.tx has blog.json) and are written
 either by hand or by the Post Type Wizard.  A post with no form, or a form with
 no sidecar, just gets the base schema.
 
+The canned editor blocks a type includes are merged in too, since a form which
+splices in preview.tx collects a preview image no matter what its own sidecar
+says -- see includes_for() and include_schema_for().  A type's own declaration
+of a field beats the block's, and the base schema beats both.
+
 Cached, but keyed on the mtimes of the component dirs rather than memoized
 outright: adding or editing a sidecar bumps one of them, so every forked worker
 picks the change up on its very next add() with no restart needed.
 
 =cut
 
-# Stat the forms dirs, not their parents -- writing blog.json bumps the mtime
-# of forms/, and nothing above it.  Both candidates, since either one gaining a
-# sidecar changes the answer.
+# Stat the forms dirs, and the components dirs holding them: writing blog.json
+# bumps the mtime of forms/ and nothing above it, while the sidecar beside a
+# canned include (preview.json) lives in components/ itself.  Both candidates of
+# each, since either one gaining a sidecar changes the answer.
 #
 # Time::HiRes::stat, because core stat truncates mtime to the second: the post
 # type wizard writes a sidecar and something reads it back moments later, and at
@@ -413,7 +419,7 @@ picks the change up on its very next add() with no restart needed.
 # had a moment ago.  Falls back to whatever stat gives where the filesystem
 # carries no sub-second timestamps.
 sub _sidecar_generation {
-    return join( ':', map { ( Time::HiRes::stat("$_/forms") )[9] // 0 } Trog::Themes::template_dirs( 'text/html', 1 ) );
+    return join( ':', map { ( ( Time::HiRes::stat("$_/forms") )[9] // 0, ( Time::HiRes::stat($_) )[9] // 0 ) } Trog::Themes::template_dirs( 'text/html', 1 ) );
 }
 
 # A signature default only covers an absent arg, and plenty of posts have an
@@ -456,6 +462,92 @@ sub type_meta_for ( $form = '' ) {
     return $cache{$generation}{$type};
 }
 
+=head2 include_schema_for($include)
+
+The schema fragment describing what a canned include collects, read from the
+JSON sidecar beside it: preview.tx has preview.json, exactly as blog.tx has
+blog.json.
+
+A canned include is a block of editor markup which several post types splice in,
+and the fields it collects belong to every type which does.  Nothing said so
+until this existed, which cost twice over: a hand-written type had to repeat the
+declaration in its own sidecar, where the Post Type Wizard could no longer tell
+it apart from a field somebody had typed in; and a generated type had no
+declaration at all, so validate() dropped the preview image its own editor had
+just collected.
+
+Returns an empty hashref for an include which has no sidecar -- which is most of
+them, since edit_head.tx and friends collect nothing.
+
+=cut
+
+sub include_schema_for ( $include = '' ) {
+    state %cache;
+
+    my $generation = _sidecar_generation();
+    %cache = () unless exists $cache{$generation};
+
+    # Same shape as a post type, so the same check: a name and nothing else,
+    # since it is about to be turned into a path.
+    my $name = _type_of($include);
+    return {} unless $name;
+    return $cache{$generation}{$name} if exists $cache{$generation}{$name};
+
+    my $path    = Trog::Themes::themed_component("$name.json");
+    my $sidecar = $path ? _read_sidecar($path) : undef;
+
+    $cache{$generation}{$name} = $sidecar // {};
+    return $cache{$generation}{$name};
+}
+
+=head2 includes_for($form)
+
+The canned includes a post type splices into its editor, as an arrayref of
+template names in the order they appear.
+
+Read out of the template rather than out of the sidecar, because the template is
+what actually renders: a hand-written type never recorded the list anywhere, and
+a generated one can still be edited by hand afterwards.  The list the wizard
+recorded is the fallback for a type whose template cannot be read at all.
+
+Everything the template includes is returned, not just the blocks the wizard
+offers -- it is include_schema_for() which decides whether a given one collects
+anything.
+
+=cut
+
+sub includes_for ( $form = '' ) {
+    state %cache;
+
+    my $generation = _sidecar_generation();
+    %cache = () unless exists $cache{$generation};
+
+    my $type = _type_of($form);
+    return [] unless $type;
+    return $cache{$generation}{$type} if exists $cache{$generation}{$type};
+
+    $cache{$generation}{$type} = _includes_of($type);
+    return $cache{$generation}{$type};
+}
+
+sub _includes_of ($type) {
+    my $path     = Trog::Themes::themed_file_in_dir( 'forms', "$type.tx", 'text/html', 1 );
+    my $template = $path ? eval { File::Slurper::read_text($path) } : undef;
+
+    if ( !defined $template ) {
+        my $meta     = type_meta_for("$type.tx")->{'x-tcms-post-type'};
+        my $recorded = Ref::Util::is_hashref($meta) ? $meta->{includes} : undef;
+        return [] unless Ref::Util::is_arrayref($recorded);
+        return [ grep { defined && !ref } @$recorded ];
+    }
+
+    # Kolon's line syntax, which is how every include in the tree is written --
+    # including the ones the wizard generates.  Deduped, since a template is
+    # free to splice the same block in twice.
+    my %seen;
+    return [ grep { !$seen{$_}++ } $template =~ m/^\s*:\s*include\s+"([A-Za-z0-9_-]+\.tx)"/gm ];
+}
+
 =head2 private_fields_for($form)
 
 The fields of a post type which only an editor may see, as declared by
@@ -560,6 +652,22 @@ sub schema_for ( $form = '' ) {
         $merged{required} = \@required if @required;
     }
 
+    # And what the canned blocks this type's editor splices in collect.  A form
+    # which includes preview.tx collects a preview image whether or not anybody
+    # wrote that down in its sidecar, and validate() drops every field the schema
+    # does not describe -- so until this was merged in, a post type generated by
+    # the wizard threw its own preview upload away on the way to the datastore.
+    #
+    # Merged under both of the above: only the properties, since it is the type
+    # rather than a block it borrows which gets to say what it insists on, and
+    # last of the three in precedence, so a type declaring one of these itself
+    # still wins.
+    foreach my $include ( @{ includes_for($form) } ) {
+        my $properties = include_schema_for($include)->{properties};
+        next unless Ref::Util::is_hashref($properties);
+        %{ $merged{properties} } = ( %$properties, %{ $merged{properties} } );
+    }
+
     $cache{$generation}{$type} = \%merged;
     return $cache{$generation}{$type};
 }

+ 34 - 9
lib/Trog/Routes/HTML.pm

@@ -2644,11 +2644,18 @@ This is the other direction from _wizard_sidecar: it reads a type back out and
 says what the wizard would have had to be told to produce it, so that picking one
 from the dropdown can put the form into that state.
 
-Everything but the display template comes out of the sidecar.  The display comes
-out of the sidecar too for anything written since it started being recorded
-there, and is read back out of the template itself otherwise -- see
-_wizard_display_from_template, and note that a hand-written type will often
-yield nothing, which is honest: there is no wizard form that produces it.
+Everything but the display template and the canned includes comes out of the
+sidecar.  The display comes out of the sidecar too for anything written since it
+started being recorded there, and is read back out of the template itself
+otherwise -- see _wizard_display_from_template, and note that a hand-written type
+will often yield nothing, which is honest: there is no wizard form that produces
+it.
+
+The includes always come out of the template, via
+Trog::DataModule::includes_for(), since only a generated sidecar ever recorded
+them.  That is also what tells the field list apart from the checkboxes: a
+preview image is a field of every type which includes preview.tx, so it is that
+box being ticked rather than a custom field somebody added.
 
 =cut
 
@@ -2661,7 +2668,11 @@ sub _wizard_types ($forms) {
         my $type   = $meta->{'x-tcms-post-type'};
         $type = {} unless Ref::Util::is_hashref($type);
 
-        my %includes = map { $_ => 1 } @{ Ref::Util::is_arrayref( $type->{includes} ) ? $type->{includes} : [] };
+        # What the editor actually splices in, which for a hand-written type is
+        # the only account there is -- nothing but a generated sidecar records
+        # the list.  Keyed on the template name, which is what %wizard_includes
+        # maps a checkbox to.
+        my %includes = map { $_ => 1 } @{ Trog::DataModule::includes_for($form) };
 
         my $display = $type->{display};
         $display = _wizard_display_from_template($form) unless length( $display // '' );
@@ -2683,7 +2694,7 @@ sub _wizard_types ($forms) {
             inc_post_tags  => $type->{inc_post_tags}  ? 1 : 0,
             ( map { $_ => $includes{ $wizard_includes{$_} } ? 1 : 0 } @wizard_optional_includes ),
 
-            fields => _wizard_fields_of( $schema, $meta ),
+            fields => _wizard_fields_of( $schema, $meta, \%includes ),
         };
     }
 
@@ -2691,18 +2702,32 @@ sub _wizard_types ($forms) {
 }
 
 # The custom fields of a type, as rows the wizard would have submitted.
-sub _wizard_fields_of ( $schema, $meta ) {
+sub _wizard_fields_of ( $schema, $meta, $includes = {} ) {
     my $properties = $schema->{properties} // {};
     my %required   = map { $_ => 1 } @{ $schema->{required} // [] };
     my $relations  = $meta->{'x-tcms-relations'};
     $relations = {} unless Ref::Util::is_hashref($relations);
 
+    # Whatever the canned blocks this type includes already collect.  These are
+    # real fields of the type and really are in its schema, but nobody typed
+    # them into the wizard, and offering them back as custom fields is how
+    # ticking 'Preview image upload' and listing 'preview' as a text field came
+    # to be the same thing said twice.
+    my %canned;
+    foreach my $include ( keys(%$includes) ) {
+        my $collects = Trog::DataModule::include_schema_for($include)->{properties};
+        next unless Ref::Util::is_hashref($collects);
+        $canned{$_} = 1 foreach keys(%$collects);
+    }
+
     my @fields;
     foreach my $name ( sort keys(%$properties) ) {
 
         # Only what this type added.  Everything in the base post schema belongs
-        # to every post and was never a wizard field.
+        # to every post and was never a wizard field, and everything a canned
+        # block collects belongs to the block rather than to the type.
         next if exists $Trog::DataModule::post_schema{properties}{$name};
+        next if $canned{$name};
 
         my $property = $properties->{$name};
         next unless Ref::Util::is_hashref($property);

+ 19 - 0
lib/Trog/Themes.pm

@@ -279,4 +279,23 @@ sub themed_file_in_dir ( $path, $file, $ct, $is_component = 0 ) {
     return undef;
 }
 
+=head2 themed_component($file, $content_type)
+
+The path to a file sitting directly in the components dir rather than in one of
+its subdirectories, preferring the theme's copy.  Returns undef when neither has
+it.
+
+This is themed_file_in_dir() for the components which are not post types: the
+canned editor blocks every form splices in, and the JSON sidecars beside them
+which say what each one collects -- see Trog::DataModule::include_schema_for().
+
+=cut
+
+sub themed_component ( $file, $ct = 'text/html' ) {
+    foreach my $dir ( template_dirs( $ct, 1 ) ) {
+        return "$dir/$file" if -f "$dir/$file";    ## no critic (ProhibitFiletest_f) -- search path, first hit wins
+    }
+    return undef;
+}
+
 1;

+ 127 - 1
t/Trog-DataModule-schema.t

@@ -40,8 +40,18 @@ $thememock->redefine(
 my $logmock = Test::MockModule->new('Trog::DataModule');
 $logmock->redefine( 'WARN', sub { note(shift) } );
 
+# The canned editor blocks and their sidecars, which live in the components dir
+# itself rather than in forms/ -- preview.json beside preview.tx.
+my %components;
+
 my $slurpmock = Test::MockModule->new('File::Slurper');
-$slurpmock->redefine( 'read_text', sub { $sidecars{ Path::Tiny::path(shift)->basename } } );
+$slurpmock->redefine(
+    'read_text',
+    sub {
+        my $name = Path::Tiny::path(shift)->basename;
+        return exists $sidecars{$name} ? $sidecars{$name} : $components{$name};
+    }
+);
 
 # -f has to agree with %sidecars, and the dir mtime has to move, or the
 # mtime-keyed cache will hand back a stale answer.
@@ -56,6 +66,17 @@ sub set_sidecars {
     return;
 }
 
+# The same, one directory up.  forms/ is a child of this one, so only the files
+# get cleared out.
+sub set_components {
+    %components = @_;
+    $_->remove foreach grep { !$_->is_dir } $tempdir->children();
+    $tempdir->child($_)->spew_utf8('{}') foreach keys %components;
+    $generation++;
+    utime( time() + $generation, time() + $generation, "$tempdir" );
+    return;
+}
+
 sub sidecar { return JSON::MaybeXS::encode_json( { type => 'object', @_ } ) }
 
 subtest 'the base schema applies with no sidecar at all' => sub {
@@ -549,6 +570,111 @@ subtest 'the types are handed to the page as JSON it cannot break out of' => sub
     is( Trog::Routes::HTML::_wizard_description( [ 'an', 'array' ] ), '', 'as is one that is not a string' );
 };
 
+subtest 'a canned block declares what it collects, and the types including it get it' => sub {
+    set_components(
+        'preview.json' => sidecar(
+            properties => {
+                preview      => { type => 'string', 'x-tcms-label' => 'Preview Image' },
+                preview_file => { type => 'upload', 'x-tcms-label' => 'Preview Image', 'x-tcms-input' => 'file' },
+            }
+        )
+    );
+
+    # What the wizard writes for a type with 'Preview image upload' ticked: the
+    # block spliced into the template, and a sidecar naming only the field
+    # somebody actually typed into the form.
+    my $spec = Trog::Routes::HTML::_wizard_sidecar(
+        'recipe',
+        [
+            {
+                name     => 'cook_time', type => 'number', label => 'Cook Time', placeholder => '45',
+                required => 0, private => 0, indexed => 0, relation_form => '', relation_mode => 'one',
+            },
+        ],
+        [qw{inc_preview inc_visibility inc_acls}],
+        'form_common.tx',
+        '',
+        { description => 'What it is for.', display => '', title_placeholder => '' },
+    );
+
+    set_sidecars(
+        'recipe.json' => JSON::MaybeXS::encode_json($spec),
+        'recipe.tx'   => qq|            : include "preview.tx";\n            : include "form_common.tx";\n|,
+    );
+
+    is_deeply(
+        Trog::DataModule::includes_for('recipe.tx'),
+        [qw{preview.tx form_common.tx}],
+        "the blocks a type splices in are read out of the template it renders"
+    );
+
+    my $schema = Trog::DataModule::schema_for('recipe.tx');
+    ok( exists $schema->{properties}{preview_file}, "a block's fields land in the schema of every type that includes it" );
+    ok( exists $schema->{properties}{cook_time},    "alongside the ones the type declares itself" );
+
+    # The whole point: validate() drops every field the schema doesn't describe,
+    # so until the block said what it collects, a generated type threw away the
+    # preview image its own editor had just uploaded.
+    my $post = {
+        form         => 'recipe.tx',
+        title        => 'Bread',
+        preview_file => { tempname => '/tmp/nope', filename => 'loaf.jpg' },
+    };
+    is_deeply( [ Trog::DataModule::validate($post) ], [], "a post carrying one still validates" );
+    ok( exists $post->{preview_file}, "and keeps it rather than having it filtered away" );
+
+    # A block never gets to overrule the type that borrows it, nor the base
+    # schema -- see the merge order in schema_for().
+    set_components( 'preview.json' => sidecar( properties => { preview => { type => 'integer' }, title => { type => 'integer' } } ) );
+    $schema = Trog::DataModule::schema_for('recipe.tx');
+    is( $schema->{properties}{title}{type}, 'string', "and it cannot redefine a base post field" );
+};
+
+subtest 'the wizard tells a canned field apart from one somebody typed in' => sub {
+    set_components(
+        'preview.json' => sidecar(
+            properties => {
+                preview      => { type => 'string' },
+                preview_file => { type => 'upload' },
+            }
+        )
+    );
+
+    # A hand-written type: it declares the block's fields itself, the way every
+    # stock form does, and records nothing about which blocks it includes.
+    set_sidecars(
+        'blog.json' => sidecar(
+            properties => {
+                preview      => { type => 'string' },
+                preview_file => { type => 'upload' },
+                video_href   => { type => 'string', 'x-tcms-input' => 'url', 'x-tcms-label' => 'Video' },
+            }
+        ),
+        'blog.tx' => qq|        : include "preview.tx";\n        : include "form_common.tx";\n|,
+
+        # And one which declares a field of the same name without including the
+        # block -- a datasource fills this one in, so it really is the type's.
+        'guests.json' => sidecar( properties => { preview => { type => 'string', 'x-tcms-label' => 'Console capture' } } ),
+        'guests.tx'   => qq|        : include "post_title.tx";\n|,
+    );
+
+    my $types = Trog::Routes::HTML::_wizard_types( [ 'blog.tx', 'guests.tx' ] );
+
+    is( $types->{'blog.tx'}{inc_preview}, 1, "the box for a block the template includes comes back ticked" );
+    is_deeply(
+        [ map { $_->{name} } @{ $types->{'blog.tx'}{fields} } ],
+        ['video_href'],
+        "and what that block collects is not offered back as a custom field"
+    );
+
+    is( $types->{'guests.tx'}{inc_preview}, 0, "a type which does not include the block has the box clear" );
+    is_deeply(
+        [ map { $_->{name} } @{ $types->{'guests.tx'}{fields} } ],
+        ['preview'],
+        "and keeps the field of that name, which is its own rather than the block's"
+    );
+};
+
 subtest '_kolon_safe defuses template syntax' => sub {
     my $safe = Trog::Routes::HTML::_kolon_safe(qq{<: \$post.id :>\n: include "evil.tx";});
     unlike( $safe, qr/</,      "no angle brackets survive" );

+ 14 - 0
www/templates/html/components/acls.json

@@ -0,0 +1,14 @@
+{
+   "x-tcms-include" : {
+      "name" : "acls",
+      "description" : "The ACL selector.  'acls' is a base post field, declared here so that the wizard can tell it apart from a field somebody typed in."
+   },
+   "type" : "object",
+   "properties" : {
+      "acls" : {
+         "type" : "array",
+         "items" : { "type" : "string" },
+         "x-tcms-label" : "ACLs"
+      }
+   }
+}

+ 14 - 0
www/templates/html/components/aliases.json

@@ -0,0 +1,14 @@
+{
+   "x-tcms-include" : {
+      "name" : "aliases",
+      "description" : "The alias URL editor.  'aliases' is a base post field, declared here so that the wizard can tell it apart from a field somebody typed in."
+   },
+   "type" : "object",
+   "properties" : {
+      "aliases" : {
+         "type" : "array",
+         "items" : { "type" : "string" },
+         "x-tcms-label" : "Post Alias URLs"
+      }
+   }
+}

+ 14 - 0
www/templates/html/components/attachments.json

@@ -0,0 +1,14 @@
+{
+   "x-tcms-include" : {
+      "name" : "attachments",
+      "description" : "Assets a post refers to by index, uploaded alongside it."
+   },
+   "type" : "object",
+   "properties" : {
+      "attachments" : {
+         "type" : "array",
+         "items" : { "type" : "upload" },
+         "x-tcms-label" : "Attachments"
+      }
+   }
+}

+ 4 - 0
www/templates/html/components/post_wizard.tx

@@ -86,6 +86,10 @@ selects when you edit a Series.
     as <code>&lt;: $post.your_field_name :&gt;</code>.
     Uploads aren't supported here &mdash; use the Attachment uploader above for those.
     <br />
+    Whatever a canned block already collects isn't listed here &mdash; each one has a sidecar
+    beside it saying so (preview.tx has preview.json), so a preview image is that box being
+    ticked rather than a field you have to add.
+    <br />
     A field set to <em>editors only</em> is dropped before the page is rendered for anyone who
     cannot edit it, so it is safe to refer to in the display template below without guarding it
     yourself &mdash; useful for a connection string or anything else that shouldn't be public.

+ 18 - 0
www/templates/html/components/preview.json

@@ -0,0 +1,18 @@
+{
+   "x-tcms-include" : {
+      "name" : "preview",
+      "description" : "The preview image: the upload itself, and the href it turned into on the last save."
+   },
+   "type" : "object",
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-label" : "Preview Image"
+      },
+      "preview_file" : {
+         "type" : "upload",
+         "x-tcms-label" : "Preview Image",
+         "x-tcms-input" : "file"
+      }
+   }
+}

+ 14 - 0
www/templates/html/components/tags.json

@@ -0,0 +1,14 @@
+{
+   "x-tcms-include" : {
+      "name" : "tags",
+      "description" : "The tag editor.  'tags' is a base post field, declared here so that the wizard can tell it apart from a field somebody typed in."
+   },
+   "type" : "object",
+   "properties" : {
+      "tags" : {
+         "type" : "array",
+         "items" : { "type" : "string" },
+         "x-tcms-label" : "Tags"
+      }
+   }
+}

+ 14 - 0
www/templates/html/components/visibility.json

@@ -0,0 +1,14 @@
+{
+   "x-tcms-include" : {
+      "name" : "visibility",
+      "description" : "The visibility selector.  'visibility' is a base post field, declared here so that the wizard can tell it apart from a field somebody typed in."
+   },
+   "type" : "object",
+   "properties" : {
+      "visibility" : {
+         "type" : "string",
+         "enum" : [ "public", "unlisted", "private" ],
+         "x-tcms-label" : "Visibility"
+      }
+   }
+}