Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 deletions .planning/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ after completion.
in `3d5d3de` and `539b6b3`)
- **Phase 03** — 1.3.x release prep (complete; `v1.3.3` is the current
public release baseline)
- **Phase 04** — frontend Cite/Export affordances (active next feature
track)
- **Phase 04** — frontend Cite/Export affordances (implementation
code-complete on branch `phase-04/cite-export-affordances` / PR #37;
pending the plan 04-04 human browser-verify checkpoint before merge)
- **Phase 05** — writable bibliography REST/Abilities design (memo complete;
implementation deferred)

Expand Down
10 changes: 7 additions & 3 deletions .planning/STATE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project State

_Last reviewed: 2026-06-14._
_Last reviewed: 2026-06-17._

## Current Focus

Expand All @@ -23,8 +23,12 @@ _Last reviewed: 2026-06-14._
- `b44a899` Add direct access guard to PMID helpers
3. Keep the release artifact, WordPress.org SVN output, Playground blueprints,
and docs aligned whenever DOI/PMID/BibTeX import behavior changes.
4. Next feature track remains frontend Cite/Export affordances, unless a
release or Playground regression takes priority.
4. Phase 04 (frontend Cite/Export affordances) is **implementation
code-complete** on branch `phase-04/cite-export-affordances` (PR #37),
covering plans 04-01 through 04-04. All automated gates pass (570 Jest
tests, lint, build); the only outstanding item is the plan 04-04 human
browser-verify checkpoint (visual confirmation in the editor + frontend),
which must be completed in a browser-capable session before merge.

## Current Priority Order

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
phase: 04-frontend-cite-export-affordances
plan: 01
status: complete
---

# 04-01 Summary — Foundation: attribute + export + deprecation

Lays the three foundations required before any `save()` shape change in Plan 02.

## What changed

- **block.json** — added two attributes following the `outputCoins`/`outputCslJson` pattern:
- `outputCiteExport` (boolean, default `false`) — the opt-in for per-entry cite/export affordances.
- `bibliographyId` (string, default `""`) — stable per-block id for the future writable REST work (REST-API-M0-BLOCK-ID).
- **src/edit.js** — added a one-shot `useEffect([])` that assigns `crypto.randomUUID()` to `bibliographyId` on first insertion when empty (idempotent on later renders).
- **src/lib/export.js** — added the `export` keyword to `cslToRisEntry`; the function body is unchanged. `save()` can now generate per-entry RIS synchronously in Plan 02.
- **src/deprecated.js** — prepended a new `deprecated[0]` that freezes the current pre-Phase-4 `save()` shape (mirrors `src/save.js`: `sortEntries: true, headingTag: 'p', entryTag: 'cite'`, no `<details>`). Existing entries shifted to `[1]`–`[5]`. This is the migration gate that keeps already-saved blocks valid once `<details>` is introduced.

## Tests

- **src/lib/export.test.js** — three `cslToRisEntry` cases: named-export importability, `TY - JOUR` opener / `ER - ` terminator, and `AU` line for a named author.
- **src/deprecated.test.js** — new case asserting `deprecated[0]` renders `<li>` with no `<details>`; existing index references shifted by one.

## Deviation from plan

The plan's `<interfaces>` snippet (authored 2026-05-10) described a "current" `deprecated[0]` without `includeDeprecatedBiblioEntryRole`. The live `deprecated.js` had since gained that option, while live `src/save.js` does **not** emit the role. The new `deprecated[0]` was therefore matched to the **actual current `save.js` shape** (no role, no `linkVisibleUrls` arg since it defaults true) rather than the stale snapshot — same intent, accurate to current state.

## Verification

- `npm test` — 554 passed, 2 skipped (perf benchmarks), 0 failed.
- `lint:js`, `lint:css`, `lint:php`, `npm run build` — all pass.

## Next

Wave 2 — Plan 02 (save markup `<details>` panels) and Plan 03 (editor pre-computation of BibTeX/BibLaTeX export strings).
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
phase: 04-frontend-cite-export-affordances
plan: 02
status: complete
---

# 04-02 Summary — Save markup: `<details>` cite/export panels

Adds the core visible feature: per-entry `<details>`/`<summary>` disclosure panels in the static `save()` output, gated on the `outputCiteExport` opt-in. Zero-JS — everything is baked into post content at save time.

## What changed

- **src/save-markup.js**
- Imports `cslToRisEntry` from `./lib/export`.
- Adds `includeCiteExport = false` option.
- When enabled, each `<li>` (after the optional COinS span) gains a `<details class="bibliography-builder-cite-export">` containing:
- a visible cite-text `<p>` (`displayOverride || formattedText`), readable without JS;
- RIS and CSL-JSON download `<a>`s built synchronously as `data:` URIs (`encodeURIComponent`-encoded);
- BibTeX and BibLaTeX download `<a>`s, rendered only when the per-citation `exportBibtex` / `exportBiblatex` strings exist (produced by Plan 04-03).
- All export links carry a `download="citation-<id>.<ext>"` attribute and `rel="noopener"`.
- **src/save.js** — passes `includeCiteExport: attributes.outputCiteExport ?? false`.

## Tests (src/save.test.js — new `save cite/export disclosure panels` block)

Opt-in gating (off by default), `<details>` + `Cite / Export` summary, RIS + CSL-JSON data-URI links with correct download names, conditional BibTeX/BibLaTeX links, download-attribute count (4 when all present), visible cite text, and an XSS check (a `<script>` title appears `%3Cscript%3E`-encoded in the data URI, never raw).

## Verification

- `npm test` — 561 passed, 2 skipped, 0 failed. `deprecated.test.js` still green (deprecated[0] has no `<details>`).
- `lint:js`, `lint:css`, `lint:php`, `npm run build` — all pass.

## Next

Plan 04-03 — editor pre-computation of `exportBibtex` / `exportBiblatex` strings (so the BibTeX/BibLaTeX links above populate), then Wave 3 / Plan 04-04 (metadata output panel).
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
phase: 04-frontend-cite-export-affordances
plan: 03
status: complete
---

# 04-03 Summary — Editor pre-computation of BibTeX/BibLaTeX export strings

`save()` is synchronous and cannot call the async citation-js export builders, so per-citation BibTeX and BibLaTeX strings are now pre-computed in the editor and stored on each citation. The `<details>` panels from Plan 02 then render those links with no runtime work.

## What changed

- **New `src/hooks/compute-export-strings.js`** — shared `computeExportStrings(cslObjects, citationStyle)` helper. Resolves each entry independently via `buildBibtexExportContent`/`buildBiblatexExportContent`; on failure an entry falls back to `''` (never `undefined`), so one bad citation never blocks the rest.
- Wired into all **four format-then-`setAttributes` sites**, each with a cancel-guard re-check after the export `await`:
- `use-citation-editor-state.js` — style change (Site 1) and structured-edit save (Site 2)
- `use-citation-import-actions.js` — paste/import (Site 3)
- `use-manual-citation-actions.js` — manual add (Site 4)
- Each site now also assigns a **stable `id`** (`entry.id || crypto.randomUUID()`) on the format pass — idempotent (existing ids preserved), satisfying REST-API-M0-CITATION-ID.

## Tests

- **New `compute-export-strings.test.js`** — pair-per-citation ordering, single-`[{ csl }]` builder calls, error→`''` fallback, empty/undefined input.
- **`use-citation-editor-state.test.js`** — mocks the helper; new cases assert export strings + stable id propagate after style change and structured-edit save.
- **New `use-citation-import-actions.test.js`** and **`use-manual-citation-actions.test.js`** — first test harnesses for these hooks; each asserts imported / manually-added citations carry `exportBibtex`/`exportBiblatex`.

## Deviation from plan

The plan suggested defining `computeExportStrings` inside `use-citation-editor-state.js`. It was extracted to its own module (`compute-export-strings.js`) instead, so all three hooks import one implementation rather than duplicating it or creating a hook-to-hook dependency. Same behavior, cleaner boundaries, and independently unit-tested.

## Verification

- `npm test` — 570 passed, 2 skipped (perf benchmarks), 0 failed.
- `lint:js`, `lint:css`, `lint:php`, `npm run build` — all pass.

## Next

Wave 3 — Plan 04-04 (Metadata output panel). After that, the phase PR.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
phase: 04-frontend-cite-export-affordances
plan: 04
status: code-complete-pending-human-verify
---

# 04-04 Summary — Editor toggle + frontend CSS

Makes the cite/export feature discoverable to authors and visually correct on the frontend.

## What changed

- **src/edit.js**
- Destructures `outputCiteExport = false` from attributes.
- Adds a "Cite / Export affordances" `ToggleControl` to the existing **Metadata output** inspector panel (after CSL-JSON), wired to `setAttributes({ outputCiteExport })`.
- **src/style.scss** — flat BEM rules (matching the file's existing style) for `details.bibliography-builder-cite-export`:
- `text-indent: 0; padding-left: 0` — the critical reset so the panel isn't dragged left by the `text-indent: -2em` hanging-indent rule.
- `<summary>`: non-italic, normal weight, `cursor: pointer`, inline-block.
- cite text: non-italic, `white-space: pre-wrap`.
- export links: inline flex row (not bulleted/indented).

## Verification

- `npm run lint:css`, `lint:js`, `lint:php`, `npm run build`, `npm test` — all pass (570 passed, 2 skipped).

## Human-verify checkpoint — PENDING (blocking gate)

Plan 04-04 ends with a **blocking** browser checkpoint that cannot run in this (non-browser) session. It requires a browser-capable session (e.g. WordPress Playground from the PR, or the local Playwright handoff) to confirm:
1. The "Cite / Export affordances" toggle appears in the block inspector.
2. Toggling it on renders the `<details>` panels on the frontend.
3. Panel text is flush-left (hanging indent reset works), summary is readable, export links render as an inline row.
4. RIS/CSL-JSON (and BibTeX/BibLaTeX once present) links work; toggling off removes the panels; content remains readable with the plugin deactivated.

Phase 04 is **code-complete** with all automated gates green; this visual confirmation is the only outstanding item.
8 changes: 8 additions & 0 deletions block.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@
"type": "boolean",
"default": false
},
"outputCiteExport": {
"type": "boolean",
"default": false
},
"bibliographyId": {
"type": "string",
"default": ""
},
"citations": {
"type": "array",
"default": [],
Expand Down
12 changes: 12 additions & 0 deletions src/deprecated.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ function migrateSortedAttributes(attributes) {
}

export const deprecated = [
{
// Freezes the current pre-Phase-4 save() shape (no <details>) so that
// existing saved blocks keep validating once Plan 02 adds per-entry
// cite/export disclosure panels. Mirrors src/save.js exactly.
attributes: deprecatedAttributes,
save: ({ attributes }) =>
renderBibliographySave(attributes, {
sortEntries: true,
headingTag: 'p',
entryTag: 'cite',
}),
},
{
attributes: deprecatedAttributes,
save: ({ attributes }) =>
Expand Down
33 changes: 27 additions & 6 deletions src/deprecated.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,30 @@ describe('deprecated block versions', () => {
);
});

it('supports the immediate prior save markup with deprecated entry roles', () => {
it('freezes the current pre-Phase-4 save shape: <li> with no <details>', () => {
const markup = renderToStaticMarkup(
deprecated[0].save({
attributes: {
citationStyle: 'chicago-notes-bibliography',
headingText: 'References',
citations: [
createCitation({
id: 'smith',
family: 'Smith',
title: 'Example Resource',
}),
],
},
})
);

expect(markup).toContain('<li');
expect(markup).not.toContain('<details');
});

it('supports the immediate prior save markup with deprecated entry roles', () => {
const markup = renderToStaticMarkup(
deprecated[1].save({
attributes: {
citationStyle: 'chicago-notes-bibliography',
headingText: 'References',
Expand Down Expand Up @@ -75,7 +96,7 @@ describe('deprecated block versions', () => {

it('supports the prior save markup with linked URLs and static aria-label', () => {
const markup = renderToStaticMarkup(
deprecated[1].save({
deprecated[2].save({
attributes: {
citationStyle: 'chicago-notes-bibliography',
headingText: 'References',
Expand Down Expand Up @@ -108,7 +129,7 @@ describe('deprecated block versions', () => {

it('supports the prior save markup variant without linked visible URLs', () => {
const markup = renderToStaticMarkup(
deprecated[2].save({
deprecated[3].save({
attributes: {
citationStyle: 'chicago-notes-bibliography',
citations: [
Expand Down Expand Up @@ -137,7 +158,7 @@ describe('deprecated block versions', () => {
});

it('migrate re-sorts citations into style order', () => {
const migrated = deprecated[3].migrate({
const migrated = deprecated[4].migrate({
citationStyle: 'chicago-author-date',
citations: [
createCitation({ id: 'z', family: 'Zulu', title: 'Zeta Book' }),
Expand All @@ -154,14 +175,14 @@ describe('deprecated block versions', () => {
});

it('migrate handles missing citations attribute with empty array fallback', () => {
const migrated = deprecated[3].migrate({ citationStyle: 'apa-7' });
const migrated = deprecated[4].migrate({ citationStyle: 'apa-7' });

expect(migrated.citations).toEqual([]);
});

it('supports the prior unsorted save markup variant', () => {
const markup = renderToStaticMarkup(
deprecated[3].save({
deprecated[4].save({
attributes: {
citationStyle: 'chicago-notes-bibliography',
citations: [
Expand Down
22 changes: 22 additions & 0 deletions src/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default function Edit({ attributes, setAttributes }) {
outputJsonLd = true,
outputCoins = false,
outputCslJson = false,
outputCiteExport = false,
} = attributes;
const selectableStyles = useMemo(() => getSelectableStyles(), []);
const blockProps = useBlockProps();
Expand Down Expand Up @@ -179,6 +180,13 @@ export default function Edit({ attributes, setAttributes }) {
citationsRef.current = citations;
}, [citations]);

useEffect(() => {
if (!attributes.bibliographyId) {
setAttributes({ bibliographyId: crypto.randomUUID() });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
if (isNumericFamily) {
return;
Expand Down Expand Up @@ -678,6 +686,20 @@ export default function Edit({ attributes, setAttributes }) {
'borges-bibliography-builder'
)}
/>
<ToggleControl
label={__(
'Cite / Export affordances',
'borges-bibliography-builder'
)}
checked={outputCiteExport}
onChange={(value) =>
setAttributes({ outputCiteExport: value })
}
help={__(
'Add per-entry disclosure panels with copy and download links for BibTeX, RIS, and CSL-JSON.',
'borges-bibliography-builder'
)}
/>
</PanelBody>
<PanelBody title={__('Exports', 'borges-bibliography-builder')}>
<Button
Expand Down
37 changes: 37 additions & 0 deletions src/hooks/compute-export-strings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
buildBibtexExportContent,
buildBiblatexExportContent,
} from '../lib/export';

/**
* Pre-compute per-citation BibTeX and BibLaTeX export strings in the editor.
*
* save() is synchronous and cannot call the async citation-js export builders,
* so the strings are computed here (after the format pass, before
* setAttributes) and stored on each citation alongside `formattedText`. The
* static save() output then embeds them without any runtime work.
*
* Each entry is resolved independently; if either builder throws for an entry,
* that entry falls back to an empty string for the failed format (never
* `undefined`), so a single bad citation never blocks the others.
*
* @param {Array} cslObjects Raw CSL objects (not citation wrapper objects).
* @param {string} citationStyle Active citation style key.
* @return {Promise<Array<{exportBibtex: string, exportBiblatex: string}>>}
* Results indexed to match `cslObjects`.
*/
export async function computeExportStrings(cslObjects, citationStyle) {
return Promise.all(
(cslObjects || []).map(async (csl) => {
try {
const [exportBibtex, exportBiblatex] = await Promise.all([
buildBibtexExportContent([{ csl }], citationStyle),
buildBiblatexExportContent([{ csl }], citationStyle),
]);
return { exportBibtex, exportBiblatex };
} catch {
return { exportBibtex: '', exportBiblatex: '' };
}
})
);
}
Loading
Loading