From af8a18a8f102dd8006aa1266b6dd79b0014328da Mon Sep 17 00:00:00 2001 From: Dan Knauss Date: Wed, 17 Jun 2026 01:31:48 -0600 Subject: [PATCH 1/5] =?UTF-8?q?feat(cite-export):=20foundation=20=E2=80=94?= =?UTF-8?q?=20attribute,=20RIS=20export,=20deprecation=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 04 Plan 01 (TDD). Lays groundwork before any save() shape change: - block.json: add outputCiteExport (bool, false) and bibliographyId (string) - export.js: export cslToRisEntry for synchronous per-entry RIS in save() - deprecated.js: prepend entry freezing the current pre-Phase-4
  • shape (mirrors save.js) so existing blocks stay valid when
    is added - edit.js: assign crypto.randomUUID() to bibliographyId on first render Co-Authored-By: Claude Opus 4.8 --- .../04-01-SUMMARY.md | 36 +++++++++++++++++++ block.json | 8 +++++ src/deprecated.js | 12 +++++++ src/deprecated.test.js | 33 +++++++++++++---- src/edit.js | 7 ++++ src/lib/export.js | 2 +- src/lib/export.test.js | 27 ++++++++++++++ 7 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 .planning/phases/04-frontend-cite-export-affordances/04-01-SUMMARY.md diff --git a/.planning/phases/04-frontend-cite-export-affordances/04-01-SUMMARY.md b/.planning/phases/04-frontend-cite-export-affordances/04-01-SUMMARY.md new file mode 100644 index 0000000..c1767e8 --- /dev/null +++ b/.planning/phases/04-frontend-cite-export-affordances/04-01-SUMMARY.md @@ -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 `
    `). Existing entries shifted to `[1]`–`[5]`. This is the migration gate that keeps already-saved blocks valid once `
    ` 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 `
  • ` with no `
    `; existing index references shifted by one. + +## Deviation from plan + +The plan's `` 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 `
    ` panels) and Plan 03 (editor pre-computation of BibTeX/BibLaTeX export strings). diff --git a/block.json b/block.json index 6d26f48..7815bf1 100644 --- a/block.json +++ b/block.json @@ -58,6 +58,14 @@ "type": "boolean", "default": false }, + "outputCiteExport": { + "type": "boolean", + "default": false + }, + "bibliographyId": { + "type": "string", + "default": "" + }, "citations": { "type": "array", "default": [], diff --git a/src/deprecated.js b/src/deprecated.js index 7597b14..6d4694c 100644 --- a/src/deprecated.js +++ b/src/deprecated.js @@ -15,6 +15,18 @@ function migrateSortedAttributes(attributes) { } export const deprecated = [ + { + // Freezes the current pre-Phase-4 save() shape (no
    ) 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 }) => diff --git a/src/deprecated.test.js b/src/deprecated.test.js index 7b7cef6..02f50f1 100644 --- a/src/deprecated.test.js +++ b/src/deprecated.test.js @@ -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:
  • with no
    ', () => { 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(' { + const markup = renderToStaticMarkup( + deprecated[1].save({ attributes: { citationStyle: 'chicago-notes-bibliography', headingText: 'References', @@ -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', @@ -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: [ @@ -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' }), @@ -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: [ diff --git a/src/edit.js b/src/edit.js index 1f7842d..367bb4f 100644 --- a/src/edit.js +++ b/src/edit.js @@ -179,6 +179,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; diff --git a/src/lib/export.js b/src/lib/export.js index d17a43e..6c86615 100644 --- a/src/lib/export.js +++ b/src/lib/export.js @@ -101,7 +101,7 @@ function formatRisAuthor(author) { return author.family || author.given || ''; } -function cslToRisEntry(csl) { +export function cslToRisEntry(csl) { const lines = []; const { start, end } = splitPageRange(csl.page); diff --git a/src/lib/export.test.js b/src/lib/export.test.js index c365501..6ab12b7 100644 --- a/src/lib/export.test.js +++ b/src/lib/export.test.js @@ -4,6 +4,7 @@ import { buildBibtexExportContent, buildBiblatexExportContent, buildRisExportContent, + cslToRisEntry, normalizeBibtexUnicodeQuotes, downloadTextExport, downloadCslJsonExport, @@ -680,3 +681,29 @@ describe('export helpers', () => { ); }); }); + +describe('cslToRisEntry (per-entry RIS for save() cite/export)', () => { + it('is importable as a named export and returns a string', () => { + const ris = cslToRisEntry({ type: 'article-journal', title: 'X' }); + expect(typeof ris).toBe('string'); + }); + + it('opens with the mapped RIS type and closes with the ER terminator', () => { + const ris = cslToRisEntry({ + type: 'article-journal', + title: 'A Study', + issued: { 'date-parts': [[2023]] }, + }); + expect(ris.startsWith('TY - JOUR')).toBe(true); + expect(ris.endsWith('ER - ')).toBe(true); + }); + + it('includes an AU line for a named author', () => { + const ris = cslToRisEntry({ + type: 'article-journal', + title: 'A Study', + author: [{ family: 'Doe', given: 'Jane' }], + }); + expect(ris).toContain('AU - Doe, Jane'); + }); +}); From 639c7a78ed1f867ef77b0aa326c309c2513e6b9e Mon Sep 17 00:00:00 2001 From: Dan Knauss Date: Wed, 17 Jun 2026 08:20:38 -0600 Subject: [PATCH 2/5] feat(cite-export): per-entry
    cite/export panels in save() Phase 04 Plan 02 (TDD). When outputCiteExport is enabled, each saved
  • gains a zero-JS
    disclosure with the visible cite text and synchronous data-URI download links for RIS and CSL-JSON (plus BibTeX/ BibLaTeX when per-citation export strings are present). All hrefs are encodeURIComponent-encoded; opt-in is off by default. Co-Authored-By: Claude Opus 4.8 --- .../04-02-SUMMARY.md | 34 ++++++ src/save-markup.js | 87 ++++++++++++++ src/save.js | 1 + src/save.test.js | 106 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 .planning/phases/04-frontend-cite-export-affordances/04-02-SUMMARY.md diff --git a/.planning/phases/04-frontend-cite-export-affordances/04-02-SUMMARY.md b/.planning/phases/04-frontend-cite-export-affordances/04-02-SUMMARY.md new file mode 100644 index 0000000..2a9de04 --- /dev/null +++ b/.planning/phases/04-frontend-cite-export-affordances/04-02-SUMMARY.md @@ -0,0 +1,34 @@ +--- +phase: 04-frontend-cite-export-affordances +plan: 02 +status: complete +--- + +# 04-02 Summary — Save markup: `
    ` cite/export panels + +Adds the core visible feature: per-entry `
    `/`` 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 `
  • ` (after the optional COinS span) gains a `
    ` containing: + - a visible cite-text `

    ` (`displayOverride || formattedText`), readable without JS; + - RIS and CSL-JSON download ``s built synchronously as `data:` URIs (`encodeURIComponent`-encoded); + - BibTeX and BibLaTeX download ``s, rendered only when the per-citation `exportBibtex` / `exportBiblatex` strings exist (produced by Plan 04-03). + - All export links carry a `download="citation-."` 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), `

    ` + `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 `', + }, + }), + ], + }); + expect(markup).toContain('%3Cscript%3E'); + expect(markup).not.toContain(''); + }); +}); From 65ee69999ac1f7672c231cc25b700e4457cbd8f5 Mon Sep 17 00:00:00 2001 From: Dan Knauss Date: Wed, 17 Jun 2026 08:34:48 -0600 Subject: [PATCH 3/5] feat(cite-export): pre-compute per-citation BibTeX/BibLaTeX in editor Phase 04 Plan 03 (TDD). save() is synchronous, so the async citation-js export builders run in the editor and their output is stored on each citation. A shared computeExportStrings helper is wired into all four format sites (style change, structured edit, import, manual add), each with a post-await cancel guard, and each now assigns a stable citation id (idempotent). The save()
    BibTeX/BibLaTeX links now populate. Co-Authored-By: Claude Opus 4.8 --- .../04-03-SUMMARY.md | 37 ++++++ src/hooks/compute-export-strings.js | 37 ++++++ src/hooks/compute-export-strings.test.js | 59 ++++++++++ src/hooks/use-citation-editor-state.js | 29 +++++ src/hooks/use-citation-editor-state.test.js | 50 +++++++++ src/hooks/use-citation-import-actions.js | 13 +++ src/hooks/use-citation-import-actions.test.js | 105 ++++++++++++++++++ src/hooks/use-manual-citation-actions.js | 12 ++ src/hooks/use-manual-citation-actions.test.js | 97 ++++++++++++++++ 9 files changed, 439 insertions(+) create mode 100644 .planning/phases/04-frontend-cite-export-affordances/04-03-SUMMARY.md create mode 100644 src/hooks/compute-export-strings.js create mode 100644 src/hooks/compute-export-strings.test.js create mode 100644 src/hooks/use-citation-import-actions.test.js create mode 100644 src/hooks/use-manual-citation-actions.test.js diff --git a/.planning/phases/04-frontend-cite-export-affordances/04-03-SUMMARY.md b/.planning/phases/04-frontend-cite-export-affordances/04-03-SUMMARY.md new file mode 100644 index 0000000..e0f1285 --- /dev/null +++ b/.planning/phases/04-frontend-cite-export-affordances/04-03-SUMMARY.md @@ -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 `
    ` 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. diff --git a/src/hooks/compute-export-strings.js b/src/hooks/compute-export-strings.js new file mode 100644 index 0000000..b2e334f --- /dev/null +++ b/src/hooks/compute-export-strings.js @@ -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>} + * 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: '' }; + } + }) + ); +} diff --git a/src/hooks/compute-export-strings.test.js b/src/hooks/compute-export-strings.test.js new file mode 100644 index 0000000..9ad37e5 --- /dev/null +++ b/src/hooks/compute-export-strings.test.js @@ -0,0 +1,59 @@ +import { computeExportStrings } from './compute-export-strings'; +import { + buildBibtexExportContent, + buildBiblatexExportContent, +} from '../lib/export'; + +jest.mock('../lib/export', () => ({ + buildBibtexExportContent: jest.fn(), + buildBiblatexExportContent: jest.fn(), +})); + +describe('computeExportStrings', () => { + beforeEach(() => { + jest.clearAllMocks(); + buildBibtexExportContent.mockResolvedValue('@article{a}\n'); + buildBiblatexExportContent.mockResolvedValue('@article{b}\n'); + }); + + it('returns a {exportBibtex, exportBiblatex} pair per citation, in order', async () => { + const result = await computeExportStrings( + [{ title: 'One' }, { title: 'Two' }], + 'apa-7' + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + exportBibtex: '@article{a}\n', + exportBiblatex: '@article{b}\n', + }); + expect(result[1].exportBibtex).toBe('@article{a}\n'); + }); + + it('passes each CSL as a single-item [{ csl }] array to the builders', async () => { + await computeExportStrings([{ title: 'One' }], 'apa-7'); + + expect(buildBibtexExportContent).toHaveBeenCalledWith( + [{ csl: { title: 'One' } }], + 'apa-7' + ); + expect(buildBiblatexExportContent).toHaveBeenCalledWith( + [{ csl: { title: 'One' } }], + 'apa-7' + ); + }); + + it('falls back to empty strings (never undefined) when a builder throws', async () => { + buildBibtexExportContent.mockRejectedValueOnce(new Error('boom')); + + const result = await computeExportStrings([{ title: 'One' }], 'apa-7'); + + expect(result[0]).toEqual({ exportBibtex: '', exportBiblatex: '' }); + expect(result[0].exportBibtex).not.toBeUndefined(); + }); + + it('handles an empty or missing input gracefully', async () => { + expect(await computeExportStrings([], 'apa-7')).toEqual([]); + expect(await computeExportStrings(undefined, 'apa-7')).toEqual([]); + }); +}); diff --git a/src/hooks/use-citation-editor-state.js b/src/hooks/use-citation-editor-state.js index adc04c8..9ef250c 100644 --- a/src/hooks/use-citation-editor-state.js +++ b/src/hooks/use-citation-editor-state.js @@ -15,6 +15,7 @@ import { MAX_CITATIONS_PER_BIBLIOGRAPHY, getBibliographyOverLimitMessage, } from '../lib/citation-limits'; +import { computeExportStrings } from './compute-export-strings'; const FORMATTER_FALLBACK_MESSAGE = 'Formatter unavailable; using fallback citation text.'; @@ -387,10 +388,27 @@ export function useCitationEditorState({ return; } + const exportStrings = await computeExportStrings( + nextEntries.map((entry) => entry.csl), + citationStyle + ); + + // Re-check guards after the export await — a cancel or a newer operation + // could have arrived while building the export strings. + if ( + structuredEditingIdRef.current !== activeStructuredEditingId || + !isCurrentAsyncOperation(operationId) + ) { + return; + } + const updated = sortCitations( nextEntries.map((entry, index) => ({ ...entry, + id: entry.id || crypto.randomUUID(), formattedText: formattedTexts[index], + exportBibtex: exportStrings[index]?.exportBibtex ?? '', + exportBiblatex: exportStrings[index]?.exportBiblatex ?? '', })), citationStyle ); @@ -499,10 +517,21 @@ export function useCitationEditorState({ return; } + const exportStrings = await computeExportStrings( + citationsRef.current.map((citation) => citation.csl), + nextStyle + ); + if (!isCurrentAsyncOperation(operationId)) { + return; + } + const updated = sortCitations( citationsRef.current.map((citation, index) => ({ ...citation, + id: citation.id || crypto.randomUUID(), formattedText: formattedTexts[index], + exportBibtex: exportStrings[index]?.exportBibtex ?? '', + exportBiblatex: exportStrings[index]?.exportBiblatex ?? '', })), nextStyle ); diff --git a/src/hooks/use-citation-editor-state.test.js b/src/hooks/use-citation-editor-state.test.js index 3e9d9ff..ddf38a7 100644 --- a/src/hooks/use-citation-editor-state.test.js +++ b/src/hooks/use-citation-editor-state.test.js @@ -60,6 +60,15 @@ jest.mock('../lib/formatting/csl', () => ({ ), })); +jest.mock('./compute-export-strings', () => ({ + computeExportStrings: jest.fn(async (cslObjects) => + (cslObjects || []).map(() => ({ + exportBibtex: 'BIBTEX', + exportBiblatex: 'BIBLATEX', + })) + ), +})); + // --- Test helpers --- function makeCitation(overrides = {}) { @@ -769,3 +778,44 @@ describe('resetEditingState', () => { expect(result.current.structuredFields).toEqual({}); }); }); + +// --- Export-string pre-computation (Phase 04-03) --- + +describe('export-string pre-computation', () => { + it('stores exportBibtex/exportBiblatex on citations after a style change', async () => { + const args = makeHookArgs(); + const { result } = renderHook(() => useCitationEditorState(args)); + + await act(() => result.current.handleCitationStyleChange('apa-7')); + + const saved = args.setAttributes.mock.calls[0][0].citations[0]; + expect(saved.exportBibtex).toBe('BIBTEX'); + expect(saved.exportBiblatex).toBe('BIBLATEX'); + }); + + it('stores export strings after a structured edit save', async () => { + const args = makeHookArgs(); + const { result } = renderHook(() => useCitationEditorState(args)); + + act(() => result.current.handleStructuredEditStart('cit-1')); + act(() => + result.current.handleStructuredFieldChange('title', 'Edited Title') + ); + await act(() => result.current.handleStructuredEditSave()); + + const saved = args.setAttributes.mock.calls[0][0].citations[0]; + expect(saved.exportBibtex).toBe('BIBTEX'); + expect(saved.exportBiblatex).toBe('BIBLATEX'); + }); + + it('assigns a stable id to a citation lacking one on the next format pass', async () => { + const args = makeHookArgs([makeCitation({ id: '' })]); + const { result } = renderHook(() => useCitationEditorState(args)); + + await act(() => result.current.handleCitationStyleChange('apa-7')); + + const saved = args.setAttributes.mock.calls[0][0].citations[0]; + expect(typeof saved.id).toBe('string'); + expect(saved.id.length).toBeGreaterThan(0); + }); +}); diff --git a/src/hooks/use-citation-import-actions.js b/src/hooks/use-citation-import-actions.js index c3dacd8..2e9e0d2 100644 --- a/src/hooks/use-citation-import-actions.js +++ b/src/hooks/use-citation-import-actions.js @@ -3,6 +3,7 @@ import { __ } from '@wordpress/i18n'; import { partitionDuplicateCitations } from '../lib/deduplicate'; import { SUPPORTED_INPUT_MESSAGE } from '../lib/input-support'; import { sortCitations } from '../lib/sorter'; +import { computeExportStrings } from './compute-export-strings'; import { MAX_CITATIONS_PER_BIBLIOGRAPHY, getBibliographyLimitExceededMessage, @@ -189,10 +190,22 @@ export function useCitationImportActions({ return; } + const exportStrings = await computeExportStrings( + mergedEntries.map((entry) => entry.csl), + citationStyle + ); + if (!isCurrentAsyncOperation(operationId)) { + return; + } + const formattedMergedEntries = mergedEntries.map( (entry, index) => ({ ...entry, + id: entry.id || crypto.randomUUID(), formattedText: formattedTexts[index], + exportBibtex: exportStrings[index]?.exportBibtex ?? '', + exportBiblatex: + exportStrings[index]?.exportBiblatex ?? '', }) ); if (!isCurrentAsyncOperation(operationId)) { diff --git a/src/hooks/use-citation-import-actions.test.js b/src/hooks/use-citation-import-actions.test.js new file mode 100644 index 0000000..2ce03f3 --- /dev/null +++ b/src/hooks/use-citation-import-actions.test.js @@ -0,0 +1,105 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCitationImportActions } from './use-citation-import-actions'; +import { parsePastedInput } from '../lib/parser'; +import { partitionDuplicateCitations } from '../lib/deduplicate'; +import { computeExportStrings } from './compute-export-strings'; + +jest.mock( + '@wordpress/element', + () => { + const React = require('react'); + return { useCallback: React.useCallback }; + }, + { virtual: true } +); + +jest.mock('@wordpress/i18n', () => ({ __: (s) => s }), { virtual: true }); + +jest.mock('../lib/deduplicate', () => ({ + partitionDuplicateCitations: jest.fn(), +})); + +jest.mock('../lib/input-support', () => ({ + SUPPORTED_INPUT_MESSAGE: 'supported input', +})); + +jest.mock('../lib/sorter', () => ({ + sortCitations: jest.fn((citations) => citations), +})); + +jest.mock('./compute-export-strings', () => ({ + computeExportStrings: jest.fn(async (cslObjects) => + (cslObjects || []).map(() => ({ + exportBibtex: 'BIBTEX', + exportBiblatex: 'BIBLATEX', + })) + ), +})); + +jest.mock('../lib/citation-limits', () => ({ + MAX_CITATIONS_PER_BIBLIOGRAPHY: 100, + getBibliographyLimitExceededMessage: jest.fn(() => 'exceeded'), + getBibliographyLimitReachedMessage: jest.fn(() => 'reached'), +})); + +jest.mock('../lib/parser', () => ({ parsePastedInput: jest.fn() })); + +jest.mock('../lib/formatting/csl', () => ({ + formatBibliographyEntries: jest.fn((items) => + items.map(() => 'Formatted entry') + ), +})); + +function makeArgs(overrides = {}) { + const citationsRef = { current: [] }; + return { + announce: jest.fn(), + beginAsyncOperation: jest.fn(() => 1), + citationStyle: 'apa-7', + citationsRef, + clearNotice: jest.fn(), + inputValue: '10.1234/example', + isCurrentAsyncOperation: jest.fn(() => true), + queueFocus: jest.fn(), + setAttributes: jest.fn(), + setIsLoading: jest.fn(), + updatePasteInput: jest.fn(), + ...overrides, + }; +} + +describe('useCitationImportActions — export-string pre-computation', () => { + beforeEach(() => { + jest.clearAllMocks(); + parsePastedInput.mockResolvedValue({ + entries: [{ id: 'new-1', csl: { title: 'Imported' } }], + errors: [], + truncated: false, + remainingInput: '', + skippedDuplicateCount: 0, + }); + partitionDuplicateCitations.mockReturnValue({ + uniqueEntries: [{ id: 'new-1', csl: { title: 'Imported' } }], + duplicateEntries: [], + }); + computeExportStrings.mockImplementation(async (cslObjects) => + (cslObjects || []).map(() => ({ + exportBibtex: 'BIBTEX', + exportBiblatex: 'BIBLATEX', + })) + ); + }); + + it('stores exportBibtex/exportBiblatex on imported citations', async () => { + const args = makeArgs(); + const { result } = renderHook(() => useCitationImportActions(args)); + + await act(() => result.current.handleParse()); + + expect(args.setAttributes).toHaveBeenCalledTimes(1); + const saved = args.setAttributes.mock.calls[0][0].citations[0]; + expect(saved.exportBibtex).toBe('BIBTEX'); + expect(saved.exportBiblatex).toBe('BIBLATEX'); + expect(saved.formattedText).toBe('Formatted entry'); + }); +}); diff --git a/src/hooks/use-manual-citation-actions.js b/src/hooks/use-manual-citation-actions.js index 5355951..1ee28f8 100644 --- a/src/hooks/use-manual-citation-actions.js +++ b/src/hooks/use-manual-citation-actions.js @@ -9,6 +9,7 @@ import { validateManualEntry, } from '../lib/manual-entry'; import { sortCitations } from '../lib/sorter'; +import { computeExportStrings } from './compute-export-strings'; import { MAX_CITATIONS_PER_BIBLIOGRAPHY, getBibliographyLimitReachedMessage, @@ -169,10 +170,21 @@ export function useManualCitationActions({ return; } + const exportStrings = await computeExportStrings( + mergedEntries.map((citation) => citation.csl), + citationStyle + ); + if (!isCurrentAsyncOperation(operationId)) { + return; + } + const updated = sortCitations( mergedEntries.map((citation, index) => ({ ...citation, + id: citation.id || crypto.randomUUID(), formattedText: formattedTexts[index] || '', + exportBibtex: exportStrings[index]?.exportBibtex ?? '', + exportBiblatex: exportStrings[index]?.exportBiblatex ?? '', })), citationStyle ); diff --git a/src/hooks/use-manual-citation-actions.test.js b/src/hooks/use-manual-citation-actions.test.js new file mode 100644 index 0000000..6ad4998 --- /dev/null +++ b/src/hooks/use-manual-citation-actions.test.js @@ -0,0 +1,97 @@ +import { act, renderHook } from '@testing-library/react'; +import { useManualCitationActions } from './use-manual-citation-actions'; +import { findDuplicateCitation } from '../lib/deduplicate'; +import { validateManualEntry } from '../lib/manual-entry'; + +jest.mock( + '@wordpress/element', + () => { + const React = require('react'); + return { + useCallback: React.useCallback, + useMemo: React.useMemo, + useState: React.useState, + }; + }, + { virtual: true } +); + +jest.mock('@wordpress/i18n', () => ({ __: (s) => s }), { virtual: true }); + +jest.mock('../lib/deduplicate', () => ({ + findDuplicateCitation: jest.fn(() => null), +})); + +jest.mock('../lib/manual-entry', () => ({ + buildManualCsl: jest.fn(() => ({ title: 'Manual entry' })), + createEmptyManualEntryFields: jest.fn((type = 'article-journal') => ({ + type, + })), + createManualCitationFromCsl: jest.fn(async (csl) => ({ + id: 'manual-1', + csl, + })), + MANUAL_ENTRY_TYPE_OPTIONS: [], + validateManualEntry: jest.fn(() => null), +})); + +jest.mock('../lib/sorter', () => ({ + sortCitations: jest.fn((citations) => citations), +})); + +jest.mock('./compute-export-strings', () => ({ + computeExportStrings: jest.fn(async (cslObjects) => + (cslObjects || []).map(() => ({ + exportBibtex: 'BIBTEX', + exportBiblatex: 'BIBLATEX', + })) + ), +})); + +jest.mock('../lib/citation-limits', () => ({ + MAX_CITATIONS_PER_BIBLIOGRAPHY: 100, + getBibliographyLimitReachedMessage: jest.fn(() => 'reached'), +})); + +jest.mock('../lib/formatting/csl', () => ({ + formatBibliographyEntries: jest.fn((items) => + items.map(() => 'Formatted entry') + ), +})); + +function makeArgs(overrides = {}) { + return { + announce: jest.fn(), + beginAsyncOperation: jest.fn(() => 1), + citationStyle: 'apa-7', + citationsRef: { current: [] }, + clearNotice: jest.fn(), + currentNotice: null, + isCurrentAsyncOperation: jest.fn(() => true), + pasteZoneRef: { current: null }, + queueFocus: jest.fn(), + setAttributes: jest.fn(), + ...overrides, + }; +} + +describe('useManualCitationActions — export-string pre-computation', () => { + beforeEach(() => { + jest.clearAllMocks(); + validateManualEntry.mockReturnValue(null); + findDuplicateCitation.mockReturnValue(null); + }); + + it('stores exportBibtex/exportBiblatex on a manually added citation', async () => { + const args = makeArgs(); + const { result } = renderHook(() => useManualCitationActions(args)); + + await act(() => result.current.handleManualAdd()); + + expect(args.setAttributes).toHaveBeenCalledTimes(1); + const saved = args.setAttributes.mock.calls[0][0].citations[0]; + expect(saved.exportBibtex).toBe('BIBTEX'); + expect(saved.exportBiblatex).toBe('BIBLATEX'); + expect(saved.formattedText).toBe('Formatted entry'); + }); +}); From 16ad93157ccadad990cea8db72324c6e6bc03728 Mon Sep 17 00:00:00 2001 From: Dan Knauss Date: Wed, 17 Jun 2026 08:44:40 -0600 Subject: [PATCH 4/5] feat(cite-export): inspector toggle + frontend panel styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 04 Plan 04. Adds the 'Cite / Export affordances' toggle to the Metadata output inspector panel (outputCiteExport) and flat CSS for the disclosure panels — critically resetting text-indent/padding so the panel is flush-left rather than dragged by the hanging-indent rule, with a readable non-italic summary and an inline export-link row. Co-Authored-By: Claude Opus 4.8 --- .../04-04-SUMMARY.md | 34 ++++++++++++++++ src/edit.js | 15 +++++++ src/style.scss | 39 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 .planning/phases/04-frontend-cite-export-affordances/04-04-SUMMARY.md diff --git a/.planning/phases/04-frontend-cite-export-affordances/04-04-SUMMARY.md b/.planning/phases/04-frontend-cite-export-affordances/04-04-SUMMARY.md new file mode 100644 index 0000000..a1909f0 --- /dev/null +++ b/.planning/phases/04-frontend-cite-export-affordances/04-04-SUMMARY.md @@ -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. + - ``: 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 `
    ` 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. diff --git a/src/edit.js b/src/edit.js index 367bb4f..972cbf8 100644 --- a/src/edit.js +++ b/src/edit.js @@ -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(); @@ -685,6 +686,20 @@ export default function Edit({ attributes, setAttributes }) { 'borges-bibliography-builder' )} /> + + setAttributes({ outputCiteExport: value }) + } + help={__( + 'Add per-entry disclosure panels with copy and download links for BibTeX, RIS, and CSL-JSON.', + 'borges-bibliography-builder' + )} + />