Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
15 changes: 15 additions & 0 deletions .changeset/astro-field-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@apostrophecms/apostrophe-astro": minor
---

Added the `AposField` component, which displays one schema field of a document, widget, array item or object and lets the user edit it right where it appears on the page, the way `AposArea` does for areas:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supports astro, nunjucks, and JSX.


```jsx
<AposField doc={page} name="title" tag="h1" class="article__headline" />
```

The value is rendered by Apostrophe, so rich text arrives with its permalinks resolved and a string arrives escaped, and a field type added by a module renders as that module says. A single line string is inline: `Name: <AposField doc={person} name="name" />.` keeps its place on the line, and its full stop, when the editor arrives.

`tag` overrides the tag the field type chose, `class` is added to the classes it asks for rather than replacing them, `style` and `attrs` are passed through, `edit={false}` never offers editing, and any other prop reaches the editor as its options.

Apostrophe only sends the information for fields that ask for it, with `wysiwyg: true` on the field or the `wysiwygFields` option of the module, since it cannot tell from the data which fields a template renders in place. While developing, `AposField` writes a note to the terminal when it displays a field that did not ask, naming the field and the option to set. A field that was not annotated is still displayed, just not editable, so a page never breaks over it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both are supported because patching one property of an inherited field is currently awkward.

5 changes: 5 additions & 0 deletions .changeset/permalink-href-quotes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"apostrophe": patch
---

Rich text permalinks are now substituted as quoted `href` attributes. `linkPermalinks` had been dropping both quotes since permalinks were introduced, emitting `<a href=/contact>` rather than `<a href="/contact">`. Browsers tolerate an unquoted attribute value, so the markup usually behaved, but a URL containing a space became two attributes. This affects rich text everywhere, both widgets and `richText` schema fields, since both share `linkPermalinks`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funny little ancient bug

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe could use its own PR since it potentially touches a lot, but I also see why it makes sense to be included in this one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It goes from incorrect markup to correct markup, and it's ephemeral (not saved back to the db), so I'm pretty comfortable fixing it here.

29 changes: 29 additions & 0 deletions .changeset/rich-text-schema-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"apostrophe": minor
---

Added a `richText` schema field type, so rich text is no longer available only as a widget in an area. A `richText` field is edited with the same editor, sanitized with the same rules, and indexed for search the same way as the content of a rich text widget.

```javascript
fields: {
add: {
body: {
label: 'Body',
type: 'richText',
// Exactly the options a rich text widget accepts in an area,
// merged over the `defaultOptions` of `@apostrophecms/rich-text-widget`
options: {
toolbar: [ 'styles', 'bold', 'italic', 'link' ],
styles: [
{ tag: 'p', label: 'Paragraph' },
{ tag: 'h3', label: 'Heading 3' }
]
}
}
}
}
```

The behavior of rich text is still configured in exactly one place, the `@apostrophecms/rich-text-widget` module: its `defaultOptions`, its `tools`, and its methods govern `richText` fields too, so an existing project that has customized rich text gets the same customizations in schema fields without doing anything. To support this, the editor itself was factored out of `AposRichTextWidgetEditor.vue` into a new, reusable `AposRichTextEditor.vue`, which both the widget editor and the new `AposInputRichText.vue` field instantiate. The widget editor keeps its name, its props, its events and its markup, and continues to accept per-area editor options, so nothing changes for existing rich text widgets or for projects that have overridden either component.

Permalinks are stored as placeholders in a `richText` field, just as they are in a rich text widget. Widgets replace them with real URLs when they are rendered; a schema field has no render-time hook of its own, so call `apos.modules['@apostrophecms/rich-text-widget'].renderRichText(req, html)` when you want the same treatment. Inline images display either way.
26 changes: 26 additions & 0 deletions .changeset/wysiwyg-field-jsx-and-external-fronts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"apostrophe": minor
---

Fields can now be edited in place from JSX templates and from external fronts such as Astro, not only from Nunjucks.

JSX templates get a `Field` helper alongside `Area`, which is the `{% field %}` tag with the same `with` clause:

```jsx
export default function ({ page }, { Field, Area }) {
return <article>
<Field doc={page} name='headline' with={{ tag: 'h1', class: 'article__headline' }} />
<Area area={page.main} />
</article>;
}
```

External fronts are sent what it takes to display a field and edit it, under `_wysiwygFields` on the document, widget, array item or object the field belongs to. `@apostrophecms/apostrophe-astro` renders that with its own `AposField` component.

A field says that its front end renders it in place with `wysiwyg: true`, or the doc type or widget type names it in a `wysiwygFields` option, which is how to opt in a field it inherited rather than declared, such as `title`. A name in `wysiwygFields` that is not in the schema throws at startup. Nunjucks and JSX need no such flag: they run inside Apostrophe and work everything out when the template reaches the field. An external front receives its data before its templates run, so it has to say in advance, and a page carries dozens of fields no template renders in place — every SEO field, every Open Graph field, every slug, on the page, its ancestors, its children and any pieces alongside them. On a demo site, annotating all of them added 70% to the response.

A visitor who cannot edit a field is sent only what it takes to display it, so a page served to the public carries no editors to mount, no icons and no patch keys.

A field rendered in place now names its definition with `data-field-id` instead of carrying a copy of it in `data-field`. Every doc type and widget type already ships its schema to the browser, so a page with fifty fields of one type no longer repeats that type's definition of them fifty times. The definition is looked up in that schema when the editor mounts; a field held back by `allowedSchema` is not there, so the value stays where it is, displayed and not editable, exactly as it is for a user who cannot edit it.

Supporting change, useful on its own: `apos.schema.wysiwygFieldData(req, object, field, with)` returns everything needed to render a field in place and edit it. `renderWysiwygField` renders from it, and the external front annotation is built from it, so the two cannot drift apart.
40 changes: 40 additions & 0 deletions .changeset/wysiwyg-field-tag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"apostrophe": minor
---

Added the `{% field %}` custom tag, which outputs one schema field of a document, widget, array item or object and lets the user edit it in place, right where it appears on the page:

```njk
{% field data.page, 'headline' with { tag: 'h1', class: 'article__headline' } %}
{% field data.page, 'body' %}
{% for section in data.page.sections %}
{% field section, 'caption' with { tag: 'h2' } %}
{% endfor %}
```

If the field is an area, `{% field %}` is exactly `{% area %}`: same markup, same editor, same `with` clause. Otherwise the field type must offer an on-page editor, which today means `richText` and `string`. Any other type throws an exception naming the field and its type, rather than displaying something the user cannot edit.

A single line string is rendered inline and edited inline. `Name: {% field data.page, 'name' %}` keeps its place on the line when the editor arrives, in a box no wider than the text, rather than becoming a block of its own and pushing the rest of the line down. The tag is chosen by the field type, which knows what shape its value is: a `string` is a `span`, a `string` with `textarea: true` is a `div`, and so is rich text. Nothing is printed after the closing tag either, not even a newline, so a field can be followed immediately by a full stop.

The editor asks the browser what the site's own CSS made of the tag rather than reading the tag name, so a `span` a stylesheet turned into a block is treated as one. A value too long for the room left on the line does begin on the next line, as any wide inline object does, since text cannot flow around the box you type in.

Outside of edit mode nothing but the value is rendered, so the tag is safe to use on any page: rich text renders as it does in a widget, permalinks and all, and a string is escaped as text, with the line breaks of a `textarea: true` string preserved. In edit mode the editor is mounted in place, styled to inherit the page's own typography so that editing feels like typing on the page rather than filling in a form, and taking up no more room than the markup it replaced, so that nothing on the page moves when editing begins. A `string` field grows as you type; a single line string refuses line breaks and collapses pasted ones. The editors save exactly as an area on the page does, patching one field at a time through the context bar, and they emit `update:modelValue` and `changed` when a component uses them elsewhere. Read only fields, and fields of a document other than the one the page is about, are displayed but not editable, again just like an area.

Coming near a field outlines it and raises the same breadcrumb trail a widget has, so the user can see what they are about to edit, and can find out that they can edit it at all. The trail is built the way a widget builds its own, by walking up the page, so a field of a widget is preceded by that widget and by whatever contains it, and clicking a crumb focuses that widget. It opens with an icon for the field type, which a field type sets with `wysiwygIcon` and an individual field can override with a `wysiwygIcon` property of its own.

Exactly one trail is ever on screen. A field takes the trail from the widget it belongs to, since its own trail already names that widget, and the field being edited keeps the trail while the mouse passes over anything else, which is how widgets have always behaved among themselves.

The `with` clause accepts `tag`, which overrides the tag the field type chose, plus `class`, `style`, `attrs`, and `edit: false` to render a field that is never editable in place. When the field is an area, `with` means what it means for `{% area %}`.

Field types opt in with a `wysiwyg` property, and can customize the rest:

- `wysiwyg: true` — this type can be edited in place.
- `wysiwygComponent` — the editor component, `AposWysiwygInput` plus the capitalized type name by convention.
- `wysiwygRender(req, field, value)` — the markup for the value; escaped text by default.
- `wysiwygTag(field)` — the tag the value is rendered as when the template does not say, `div` by default. Given the field, so that one type can answer differently depending on how it is configured, as `string` does.
- `wysiwygModifiers(field)` — extra `apos-wysiwyg-field--*` classes, so that one type can be styled differently depending on how it is configured.
- `wysiwygIcon` — the icon that opens the breadcrumb trail, `pencil-icon` by default.

A field of an array item or an object is patched by its own `@id.fieldName` key, so editing one caption of one item leaves the rest of the document alone, just as editing one field of a widget does.

Supporting changes, useful on their own: `apos.area.renderAreaTag()` and `apos.schema.renderFieldTag()` carry out the work of the two tags, so either can be invoked directly, and the `with` clause is now parsed by one shared implementation. The breadcrumb trail is now defined once, as a set of SCSS mixins in `@apostrophecms/ui`, and included by both the widget trail and the field trail, so the two cannot drift apart. Which trail is on screen is decided in one place as well: the widget store answers with `labeled`, and a widget asks it rather than working the question out for itself, which is what keeps two trails from ever appearing at once. `AposRichTextEditor` accepts an `inline` prop, which drops the padding, the empty-state block, the inter-block spacing, the size containment and the widget `className` that the editor wears in a widget or a modal, none of which belong on a field the editor is standing in for.
116 changes: 116 additions & 0 deletions claude-tools/astro-demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/bin/bash
# Start, stop and inspect ~/apostrophecms/astro-public-demo (Apostrophe backend
# on 3000, Astro frontend on 4321), logging both to claude-tools/logs so the
# output can be read later without re-running anything.
#
# ./claude-tools/astro-demo.sh start # both, in the background
# ./claude-tools/astro-demo.sh start-backend
# ./claude-tools/astro-demo.sh stop
# ./claude-tools/astro-demo.sh status
# ./claude-tools/astro-demo.sh log backend|frontend [lines]
#
# Run ./claude-tools/link-astro-demo.sh link first to test the working tree
# rather than the published packages.

set -u

root="$(cd "$(dirname "$0")/.." && pwd)"
demo="${ASTRO_DEMO:-$HOME/apostrophecms/astro-public-demo}"
logdir="$root/claude-tools/logs"
mkdir -p "$logdir"

backend_log="$logdir/astro-demo-backend.log"
frontend_log="$logdir/astro-demo-frontend.log"
backend_pid="$logdir/astro-demo-backend.pid"
frontend_pid="$logdir/astro-demo-frontend.pid"

start_backend() {
stop_one "$backend_pid" backend
: > "$backend_log"
# nodemon restarts on every file change under node_modules/apostrophe, which
# is a symlink to the working tree here — noisy and slow. Run node directly
(cd "$demo/backend" && \
APOS_EXTERNAL_FRONT_KEY=dev APOS_DEV=1 \
node app.js >> "$backend_log" 2>&1 & echo $! > "$backend_pid")
echo "backend starting (pid $(cat "$backend_pid")), log: $backend_log"
}

start_frontend() {
stop_one "$frontend_pid" frontend
: > "$frontend_log"
(cd "$demo/frontend" && \
APOS_EXTERNAL_FRONT_KEY=dev \
npx astro dev >> "$frontend_log" 2>&1 & echo $! > "$frontend_pid")
echo "frontend starting (pid $(cat "$frontend_pid")), log: $frontend_log"
}

stop_one() {
local file="$1" name="$2"
if [[ -f "$file" ]]; then
local pid
pid="$(cat "$file")"
if kill -0 "$pid" 2>/dev/null; then
pkill -P "$pid" 2>/dev/null
kill "$pid" 2>/dev/null
sleep 1
kill -9 "$pid" 2>/dev/null
echo "stopped $name (pid $pid)"
fi
rm -f "$file"
fi
}

wait_for() {
local url="$1" name="$2" tries="${3:-60}"
for ((i = 0; i < tries; i++)); do
if curl -fsS -o /dev/null "$url" 2>/dev/null; then
echo "$name is up: $url"
return 0
fi
sleep 1
done
echo "$name did NOT come up at $url after ${tries}s" >&2
return 1
}

case "${1:-status}" in
start)
start_backend
wait_for http://localhost:3000/ backend 90 || { tail -30 "$backend_log"; exit 1; }
start_frontend
wait_for http://localhost:4321/ frontend 90 || { tail -30 "$frontend_log"; exit 1; }
;;
start-backend)
start_backend
wait_for http://localhost:3000/ backend 90 || { tail -30 "$backend_log"; exit 1; }
;;
start-frontend)
start_frontend
wait_for http://localhost:4321/ frontend 90 || { tail -30 "$frontend_log"; exit 1; }
;;
stop)
stop_one "$frontend_pid" frontend
stop_one "$backend_pid" backend
;;
status)
for pair in "$backend_pid:backend" "$frontend_pid:frontend"; do
file="${pair%%:*}"; name="${pair##*:}"
if [[ -f "$file" ]] && kill -0 "$(cat "$file")" 2>/dev/null; then
echo "$name running (pid $(cat "$file"))"
else
echo "$name not running"
fi
done
;;
log)
case "${2:-backend}" in
backend) tail -n "${3:-40}" "$backend_log" ;;
frontend) tail -n "${3:-40}" "$frontend_log" ;;
*) echo "usage: $0 log backend|frontend [lines]" >&2; exit 2 ;;
esac
;;
*)
echo "usage: $0 [start|start-backend|start-frontend|stop|status|log]" >&2
exit 2
;;
esac
Loading
Loading