@estiva-app/ui 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BlockNodes.d.ts +73 -0
- package/dist/BlockNodes.d.ts.map +1 -0
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +141 -0
- package/dist/editor.js.map +4 -4
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-P5W6O5TF.js → chunk-BS5JASHV.js} +14 -9
- package/dist/gates/{chunk-P5W6O5TF.js.map → chunk-BS5JASHV.js.map} +3 -3
- package/dist/gates/cli.js +1 -1
- package/dist/gates/index.js +2 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/registry/{chunk-XK747STJ.js → chunk-4QWD3EBY.js} +5 -1
- package/dist/registry/chunk-4QWD3EBY.js.map +7 -0
- package/dist/registry/cli.js +4 -3
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +7 -0
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +3 -1
- package/package.json +1 -1
- package/registry.json +79 -3
- package/skill/estiva-ui.md +14 -11
- package/src/BlockNodes.mdx +82 -0
- package/src/BlockNodes.stories.tsx +79 -0
- package/src/BlockNodes.test.tsx +117 -0
- package/src/BlockNodes.tsx +242 -0
- package/src/editor.ts +10 -0
- package/src/gates/app-checks.ts +1 -0
- package/src/gates/gates.test.ts +22 -0
- package/src/gates/status.ts +19 -8
- package/src/registry/cli.ts +2 -2
- package/src/registry/contract.test.ts +18 -2
- package/src/registry/contract.ts +10 -0
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +10 -8
- package/src/registry/skill.test.ts +2 -0
- package/stories/Choosing.mdx +1 -0
- package/dist/registry/chunk-XK747STJ.js.map +0 -7
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
4
|
+
import { AttachmentCard } from './AttachmentCard'
|
|
5
|
+
import { InlineChip } from './InlineChip'
|
|
6
|
+
import { richTextClassName } from './RichText'
|
|
7
|
+
import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type AttachmentViewProps, type ReferenceViewProps } from './BlockNodes'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The nodes an editor of a block document adds to StarterKit: an id on every
|
|
11
|
+
* block, a block it cannot edit kept whole, a reference and a file. The app
|
|
12
|
+
* draws the reference and the file with its own views.
|
|
13
|
+
*/
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Primitives/BlockNodes',
|
|
16
|
+
parameters: { layout: 'padded' },
|
|
17
|
+
} satisfies Meta
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj
|
|
21
|
+
|
|
22
|
+
/** What an app hands `ReferenceNode`: here a chip with a fixed label. */
|
|
23
|
+
function Chip({ uri }: ReferenceViewProps) {
|
|
24
|
+
return <InlineChip data-uri={uri}>Item one</InlineChip>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What an app hands `AttachmentNode`: here the package's card. */
|
|
28
|
+
function File({ attrs }: AttachmentViewProps) {
|
|
29
|
+
return <AttachmentCard name={String(attrs.filename ?? 'File')} size={Number(attrs.size ?? 0)} contentType={String(attrs.m ?? '')} />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
The editor's own JSON, as `@estiva-app/protocol`'s `toEditorDocument` hands
|
|
34
|
+
it over: an id on each block, and an unknown block carrying its text.
|
|
35
|
+
*/
|
|
36
|
+
const DOCUMENT = {
|
|
37
|
+
type: 'doc',
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: 'paragraph',
|
|
41
|
+
attrs: { blockId: 'b1' },
|
|
42
|
+
content: [
|
|
43
|
+
{ type: 'text', text: 'A paragraph, with a reference in it: ' },
|
|
44
|
+
{ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: [] } },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'unknownBlock',
|
|
49
|
+
attrs: { blockId: 'b2', source: { type: 'widget', id: 'b2' }, text: 'A block this editor has no design for, kept as it is.' },
|
|
50
|
+
},
|
|
51
|
+
{ type: 'attachment', attrs: { blockId: 'b3', url: '', m: 'application/pdf', x: '', size: 20480, filename: 'Label.pdf' } },
|
|
52
|
+
{ type: 'paragraph', attrs: { blockId: 'b4' }, content: [{ type: 'text', text: 'Type here: every block keeps its id.' }] },
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function WithAnEditor({ views }: { views: boolean }) {
|
|
57
|
+
const editor = useEditor({
|
|
58
|
+
extensions: [
|
|
59
|
+
StarterKit,
|
|
60
|
+
BlockId,
|
|
61
|
+
UnknownBlock,
|
|
62
|
+
ReferenceNode.configure({ view: views ? Chip : null }),
|
|
63
|
+
AttachmentNode.configure({ view: views ? File : null }),
|
|
64
|
+
],
|
|
65
|
+
content: DOCUMENT,
|
|
66
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
67
|
+
})
|
|
68
|
+
return <EditorContent editor={editor} className="max-w-[560px]" />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A reference and a file drawn by the app's views, and a block kept whole. */
|
|
72
|
+
export const Default: Story = {
|
|
73
|
+
render: () => <WithAnEditor views />,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** No views given: the reference is its URI and the file its name, as text. */
|
|
77
|
+
export const WithoutViews: Story = {
|
|
78
|
+
render: () => <WithAnEditor views={false} />,
|
|
79
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The block nodes (MAN-9, moved from Ship's `editorSchema.tsx`): what the
|
|
4
|
+
* editor must hand back unchanged for a save to keep every anchored comment.
|
|
5
|
+
*
|
|
6
|
+
* The JSON loaded here is what `@estiva-app/protocol`'s `toEditorDocument`
|
|
7
|
+
* produces, and what `getJSON()` returns is what its `fromEditorDocument`
|
|
8
|
+
* reads — so each test is a load, an edit or none, and a read back.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
11
|
+
import { render, screen, cleanup } from '@testing-library/react'
|
|
12
|
+
import { Editor } from '@tiptap/core'
|
|
13
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
14
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
15
|
+
import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type ReferenceViewProps } from './BlockNodes'
|
|
16
|
+
|
|
17
|
+
const EXTENSIONS = [StarterKit, BlockId, UnknownBlock, ReferenceNode, AttachmentNode]
|
|
18
|
+
|
|
19
|
+
const SOURCE = { type: 'widget', id: 'w1', content: [{ type: 'text', text: 'kept' }] }
|
|
20
|
+
const FILE = { url: '/media/f.png', m: 'image/png', x: 'f'.repeat(64), size: 4096 }
|
|
21
|
+
|
|
22
|
+
const DOCUMENT = {
|
|
23
|
+
type: 'doc',
|
|
24
|
+
content: [
|
|
25
|
+
{ type: 'heading', attrs: { level: 1, blockId: 'h1' }, content: [{ type: 'text', text: 'Title' }] },
|
|
26
|
+
{
|
|
27
|
+
type: 'paragraph',
|
|
28
|
+
attrs: { blockId: 'p1' },
|
|
29
|
+
content: [
|
|
30
|
+
{ type: 'text', text: 'see ' },
|
|
31
|
+
{ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{ type: 'unknownBlock', attrs: { blockId: 'w1', source: SOURCE, text: 'kept' } },
|
|
35
|
+
{ type: 'attachment', attrs: { blockId: 'a1', ...FILE, filename: 'shot.png' } },
|
|
36
|
+
{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [{ type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }] }] },
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[] }
|
|
41
|
+
|
|
42
|
+
let editor: Editor | undefined
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
editor?.destroy()
|
|
45
|
+
editor = undefined
|
|
46
|
+
cleanup()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('what the editor hands back', () => {
|
|
50
|
+
it('keeps every block id through a load and a read', () => {
|
|
51
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
52
|
+
const back = editor.getJSON() as Json
|
|
53
|
+
expect(back.content?.map((n) => n.attrs?.blockId)).toEqual(['h1', 'p1', 'w1', 'a1', 'l1'])
|
|
54
|
+
expect(back.content?.[4].content?.[0].attrs?.blockId).toBe('i1')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps a block id when its text is typed into', () => {
|
|
58
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
59
|
+
// The end of the heading's text: 1 opens the heading, 5 characters of "Title".
|
|
60
|
+
editor.chain().setTextSelection(6).insertContent(', edited').run()
|
|
61
|
+
const heading = (editor.getJSON() as Json).content?.[0]
|
|
62
|
+
expect(heading?.attrs?.blockId).toBe('h1')
|
|
63
|
+
expect(JSON.stringify(heading)).toContain('Title, edited')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('carries an unknown block whole, source and all', () => {
|
|
67
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
68
|
+
const kept = (editor.getJSON() as Json).content?.[2]
|
|
69
|
+
expect(kept?.type).toBe('unknownBlock')
|
|
70
|
+
expect(kept?.attrs?.source).toEqual(SOURCE)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('keeps a reference an atom, with its uri and the marks it carries', () => {
|
|
74
|
+
editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
|
|
75
|
+
const reference = (editor.getJSON() as Json).content?.[1].content?.[1]
|
|
76
|
+
expect(reference).toEqual({ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps every imeta field on a file, which ProseMirror drops when undeclared', () => {
|
|
80
|
+
const rich = { ...FILE, dim: '800x600', thumb: '/media/f.thumb.jpg', alt: 'a picture', filename: 'shot.png' }
|
|
81
|
+
editor = new Editor({ extensions: EXTENSIONS, content: { type: 'doc', content: [{ type: 'attachment', attrs: { blockId: 'a1', ...rich } }] } })
|
|
82
|
+
expect((editor.getJSON() as Json).content?.[0].attrs).toEqual({ blockId: 'a1', ...rich })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('what it draws', () => {
|
|
87
|
+
function WithAnEditor({ view }: { view: ((props: ReferenceViewProps) => React.ReactNode) | null }) {
|
|
88
|
+
const e = useEditor({
|
|
89
|
+
extensions: [StarterKit, BlockId, UnknownBlock, ReferenceNode.configure({ view }), AttachmentNode],
|
|
90
|
+
content: DOCUMENT,
|
|
91
|
+
})
|
|
92
|
+
return <EditorContent editor={e} />
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('draws a reference with the view it is given', async () => {
|
|
96
|
+
render(<WithAnEditor view={({ uri }) => <span data-testid="chip">{uri}</span>} />)
|
|
97
|
+
expect((await screen.findByTestId('chip')).textContent).toBe('nostr:naddr1example')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('draws a reference as its uri without a view', async () => {
|
|
101
|
+
const { container } = render(<WithAnEditor view={null} />)
|
|
102
|
+
await screen.findByText('Title')
|
|
103
|
+
expect(container.querySelector('[data-reference]')?.textContent).toBe('nostr:naddr1example')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('draws an unknown block as its text, addressed by its id', async () => {
|
|
107
|
+
const { container } = render(<WithAnEditor view={null} />)
|
|
108
|
+
expect((await screen.findByText('kept')).closest('[data-block-id]')?.getAttribute('data-block-id')).toBe('w1')
|
|
109
|
+
expect(container.querySelector('[data-block-type="widget"]')).not.toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('puts data-block-id on each block, as RichText does', async () => {
|
|
113
|
+
const { container } = render(<WithAnEditor view={null} />)
|
|
114
|
+
await screen.findByText('Title')
|
|
115
|
+
expect(container.querySelector('h1')?.getAttribute('data-block-id')).toBe('h1')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { Extension, Node } from '@tiptap/core'
|
|
3
|
+
import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The four nodes an editor of a block document needs that a stock schema does
|
|
7
|
+
* not have (SPEC §13.3) — moved here from Ship's `editorSchema.tsx` (RIC-14,
|
|
8
|
+
* SHI-2) when Peek became the second editor of the same documents (MAN-9).
|
|
9
|
+
*
|
|
10
|
+
* They are one half of a pair. The other half is `@estiva-app/protocol`'s
|
|
11
|
+
* `toEditorDocument` / `fromEditorDocument`, which turn a stored document into
|
|
12
|
+
* the JSON these nodes read and back: the node names (`unknownBlock`,
|
|
13
|
+
* `reference`, `attachment`) and the `blockId` attribute are that translation's,
|
|
14
|
+
* and must not drift from it. This package does not import protocol, as
|
|
15
|
+
* `RichText` does not: the translation hands over everything a view draws.
|
|
16
|
+
*
|
|
17
|
+
* Everything else a document can contain is already a ProseMirror node of the
|
|
18
|
+
* same name — StarterKit's and TableKit's — which is why this file is short.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The block types §13.3 puts an id on, as the editor's node names —
|
|
23
|
+
* protocol's `EDITOR_BLOCK_TYPES`, less `unknownBlock`, which declares its own.
|
|
24
|
+
*/
|
|
25
|
+
const BLOCK_TYPES = [
|
|
26
|
+
'paragraph',
|
|
27
|
+
'heading',
|
|
28
|
+
'bulletList',
|
|
29
|
+
'orderedList',
|
|
30
|
+
'listItem',
|
|
31
|
+
'blockquote',
|
|
32
|
+
'codeBlock',
|
|
33
|
+
'horizontalRule',
|
|
34
|
+
// A table and everything inside it (UIG-18): a row or a cell whose id was
|
|
35
|
+
// re-minted on each save would detach any comment anchored to it.
|
|
36
|
+
'table',
|
|
37
|
+
'tableRow',
|
|
38
|
+
'tableHeader',
|
|
39
|
+
'tableCell',
|
|
40
|
+
// Without this an attachment's id is re-minted on every save, and every
|
|
41
|
+
// comment anchored to it stops resolving.
|
|
42
|
+
'attachment',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `id` on every block, carried as the `blockId` attribute.
|
|
47
|
+
*
|
|
48
|
+
* A global attribute rather than one per node type, because §13.3 puts an id
|
|
49
|
+
* on **every** block and a type that quietly lacked one would mint a fresh id
|
|
50
|
+
* on every save — detaching each anchored comment on it (RFC 0.4 §6) with
|
|
51
|
+
* nothing on screen to say so.
|
|
52
|
+
*
|
|
53
|
+
* Rendered as `data-block-id`, which is what `RichText` already emits, so the
|
|
54
|
+
* reading and the editing surface address a block the same way.
|
|
55
|
+
*/
|
|
56
|
+
export const BlockId = Extension.create({
|
|
57
|
+
name: 'blockId',
|
|
58
|
+
addGlobalAttributes() {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
types: BLOCK_TYPES,
|
|
62
|
+
attributes: {
|
|
63
|
+
blockId: {
|
|
64
|
+
default: null,
|
|
65
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('data-block-id'),
|
|
66
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
67
|
+
attributes.blockId ? { 'data-block-id': String(attributes.blockId) } : {},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** What a reference's view is handed: the `nostr:` URI it stands for. */
|
|
76
|
+
export interface ReferenceViewProps {
|
|
77
|
+
uri: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ReferenceNodeOptions {
|
|
81
|
+
/**
|
|
82
|
+
* Draws the reference as the thing it points at — the app's own chip, which
|
|
83
|
+
* knows how to resolve it. Without one, the URI is drawn as text.
|
|
84
|
+
*/
|
|
85
|
+
view: ComponentType<ReferenceViewProps> | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A `nostr:` reference, drawn as the thing it points at — SPEC §13.1.
|
|
90
|
+
*
|
|
91
|
+
* An **atom**: it has no editable interior, because its text is the URI and
|
|
92
|
+
* nobody edits half a `naddr`. Selecting it selects the whole reference, which
|
|
93
|
+
* is also how deleting one works. `marks` carries the run's other marks, so a
|
|
94
|
+
* bold reference comes back bold.
|
|
95
|
+
*/
|
|
96
|
+
export const ReferenceNode = Node.create<ReferenceNodeOptions>({
|
|
97
|
+
name: 'reference',
|
|
98
|
+
group: 'inline',
|
|
99
|
+
inline: true,
|
|
100
|
+
atom: true,
|
|
101
|
+
selectable: true,
|
|
102
|
+
draggable: false,
|
|
103
|
+
addOptions() {
|
|
104
|
+
return { view: null }
|
|
105
|
+
},
|
|
106
|
+
addAttributes() {
|
|
107
|
+
return {
|
|
108
|
+
uri: { default: '' },
|
|
109
|
+
marks: { default: [] as string[] },
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
parseHTML() {
|
|
113
|
+
return [{ tag: 'span[data-reference]' }]
|
|
114
|
+
},
|
|
115
|
+
renderHTML({ node }) {
|
|
116
|
+
// A copy to the clipboard as HTML, and the whole drawing when no view is given.
|
|
117
|
+
return ['span', { 'data-reference': String(node.attrs.uri) }, String(node.attrs.uri)]
|
|
118
|
+
},
|
|
119
|
+
addNodeView() {
|
|
120
|
+
const View = this.options.view
|
|
121
|
+
if (!View) return null
|
|
122
|
+
return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
|
|
123
|
+
<NodeViewWrapper as="span" className="inline">
|
|
124
|
+
<View uri={String(node.attrs.uri ?? '')} />
|
|
125
|
+
</NodeViewWrapper>
|
|
126
|
+
))
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A block this editor has no design for, kept whole.
|
|
132
|
+
*
|
|
133
|
+
* §13.3: a reader MUST render an unknown block's inline text and MUST NOT drop
|
|
134
|
+
* it silently. **An editor is the dangerous case** — ProseMirror discards a
|
|
135
|
+
* node its schema does not know, and the save after that would delete somebody
|
|
136
|
+
* else's block with no error anywhere on the way. So the block's own JSON
|
|
137
|
+
* rides in `source`, and protocol's `fromEditorDocument` hands it back
|
|
138
|
+
* untouched; `text` is its inline text, worked out by `toEditorDocument`.
|
|
139
|
+
*
|
|
140
|
+
* Not editable, deliberately: an affordance that lets a person change
|
|
141
|
+
* something they cannot see is worse than one that says the content is there
|
|
142
|
+
* and belongs to another surface.
|
|
143
|
+
*/
|
|
144
|
+
export const UnknownBlock = Node.create({
|
|
145
|
+
name: 'unknownBlock',
|
|
146
|
+
group: 'block',
|
|
147
|
+
atom: true,
|
|
148
|
+
selectable: true,
|
|
149
|
+
addAttributes() {
|
|
150
|
+
return {
|
|
151
|
+
blockId: { default: null },
|
|
152
|
+
source: { default: null },
|
|
153
|
+
text: { default: '' },
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
parseHTML() {
|
|
157
|
+
return [{ tag: 'div[data-unknown-block]' }]
|
|
158
|
+
},
|
|
159
|
+
renderHTML({ node }) {
|
|
160
|
+
return ['div', { 'data-unknown-block': String(node.attrs.blockId ?? '') }, String(node.attrs.text ?? '')]
|
|
161
|
+
},
|
|
162
|
+
addNodeView() {
|
|
163
|
+
return ReactNodeViewRenderer(UnknownBlockView)
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
function UnknownBlockView({ node }: ReactNodeViewProps) {
|
|
168
|
+
const source = node.attrs.source as { id?: string; type?: string } | null
|
|
169
|
+
const text = String(node.attrs.text ?? '')
|
|
170
|
+
return (
|
|
171
|
+
<NodeViewWrapper
|
|
172
|
+
data-block-id={source?.id}
|
|
173
|
+
data-block-type={source?.type}
|
|
174
|
+
className="rounded-md border border-border-default bg-bg-inset px-2 py-1 text-text-secondary"
|
|
175
|
+
>
|
|
176
|
+
{text || `A ${source?.type ?? 'block'} this app cannot edit yet.`}
|
|
177
|
+
</NodeViewWrapper>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What an attachment's view is handed: the block's `imeta` fields. */
|
|
182
|
+
export interface AttachmentViewProps {
|
|
183
|
+
attrs: Record<string, unknown>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface AttachmentNodeOptions {
|
|
187
|
+
/** Draws the file — the app's own attachment card. Without one, its name is drawn as text. */
|
|
188
|
+
view: ComponentType<AttachmentViewProps> | null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A file placed in a document — SPEC §13.3's `attachment` block (SHI-2).
|
|
193
|
+
*
|
|
194
|
+
* An atom: the file is the block, there is nothing inside it to edit, and a
|
|
195
|
+
* caret that could sit "inside" an image is a way to produce documents no
|
|
196
|
+
* reader has a rule for. Selectable and deletable, so removing one is Backspace
|
|
197
|
+
* like anything else.
|
|
198
|
+
*
|
|
199
|
+
* Its attributes are the `imeta` fields exactly, because that is what §13.3
|
|
200
|
+
* specifies. Each is declared, and that is not boilerplate: ProseMirror **drops
|
|
201
|
+
* an attribute its schema does not name**, so an undeclared `thumb` would
|
|
202
|
+
* survive the editor and vanish on save — the silent deletion `UnknownBlock`
|
|
203
|
+
* exists to prevent.
|
|
204
|
+
*/
|
|
205
|
+
export const AttachmentNode = Node.create<AttachmentNodeOptions>({
|
|
206
|
+
name: 'attachment',
|
|
207
|
+
group: 'block',
|
|
208
|
+
atom: true,
|
|
209
|
+
selectable: true,
|
|
210
|
+
draggable: false,
|
|
211
|
+
addOptions() {
|
|
212
|
+
return { view: null }
|
|
213
|
+
},
|
|
214
|
+
addAttributes() {
|
|
215
|
+
return {
|
|
216
|
+
url: { default: '' },
|
|
217
|
+
m: { default: '' },
|
|
218
|
+
x: { default: '' },
|
|
219
|
+
size: { default: 0 },
|
|
220
|
+
dim: { default: null },
|
|
221
|
+
thumb: { default: null },
|
|
222
|
+
alt: { default: null },
|
|
223
|
+
filename: { default: null },
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
parseHTML() {
|
|
227
|
+
return [{ tag: 'div[data-attachment-block]' }]
|
|
228
|
+
},
|
|
229
|
+
renderHTML({ node }) {
|
|
230
|
+
// A copy to the clipboard, and the whole drawing when no view is given.
|
|
231
|
+
return ['div', { 'data-attachment-block': String(node.attrs.x ?? '') }, String(node.attrs.filename ?? 'attachment')]
|
|
232
|
+
},
|
|
233
|
+
addNodeView() {
|
|
234
|
+
const View = this.options.view
|
|
235
|
+
if (!View) return null
|
|
236
|
+
return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
|
|
237
|
+
<NodeViewWrapper className="my-2">
|
|
238
|
+
<View attrs={node.attrs} />
|
|
239
|
+
</NodeViewWrapper>
|
|
240
|
+
))
|
|
241
|
+
},
|
|
242
|
+
})
|
package/src/editor.ts
CHANGED
|
@@ -9,3 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
11
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
|
+
export {
|
|
13
|
+
BlockId,
|
|
14
|
+
UnknownBlock,
|
|
15
|
+
ReferenceNode,
|
|
16
|
+
AttachmentNode,
|
|
17
|
+
type ReferenceNodeOptions,
|
|
18
|
+
type ReferenceViewProps,
|
|
19
|
+
type AttachmentNodeOptions,
|
|
20
|
+
type AttachmentViewProps,
|
|
21
|
+
} from './BlockNodes'
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -56,6 +56,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
|
|
|
56
56
|
'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style',
|
|
57
57
|
'UIG-30': 'RichText — one component that draws a message\'s text, for both apps',
|
|
58
58
|
'UIG-32': 'Peek and Ship take their gate pieces from the package',
|
|
59
|
+
'UIG-33': 'Link — a whole row that is one link',
|
|
59
60
|
'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off',
|
|
60
61
|
'UIG-42': "What the gate can't see yet — a box around a part, a screen drawn by hand, two blind spots in the token check",
|
|
61
62
|
}
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
import { ESLint, type Linter } from 'eslint'
|
|
6
6
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
7
|
+
import { appChecks } from './app-checks'
|
|
7
8
|
import { countCommitted, hookRuns, skippedFolders, skipsListed } from './ci'
|
|
8
9
|
import { writeGateCount } from './count'
|
|
9
10
|
import { gateConfig, gateLint } from './gate-config'
|
|
@@ -425,4 +426,25 @@ describe('gates:status', () => {
|
|
|
425
426
|
expect(out).toContain('Tickets shipish owns (1):')
|
|
426
427
|
expect(out).toContain('✅ UIG-4')
|
|
427
428
|
})
|
|
429
|
+
|
|
430
|
+
// UIG-26, Katerina's ruling of 30 September: eight open tickets moved to the next phase, and the close runs.
|
|
431
|
+
it('shows a ticket moved to the next phase, still checks it, and the aggregate does not wait for it', async () => {
|
|
432
|
+
const checks = (later: string) => `export default (h) => ({ repo: 'estiva-ui', siblings: [],\n all: [\n { ref: 'UIG-1', owner: 'estiva-ui', title: 'Count' },\n { ref: 'UIG-26', owner: 'estiva-ui', aggregate: true, title: 'Close' },\n { ref: 'UIG-39', owner: 'estiva-ui', title: 'Later'${later} },\n ],\n tickets: [\n { ref: 'UIG-1', owner: true, checks: [{ what: 'counted', run: () => h.PASS('yes') }] },\n { ref: 'UIG-26', owner: true, checks: [] },\n { ref: 'UIG-39', owner: true, checks: [{ what: 'written', run: () => h.FAIL('not yet') }] },\n ] })\n`
|
|
433
|
+
const moved = await runStatus({ root: app('status-later', { 'scripts/gates-checks.mjs': checks(", later: 'Katerina, 30 September: the next phase'") }), detail: true })
|
|
434
|
+
expect(moved).toContain('⏭ UIG-39')
|
|
435
|
+
expect(moved).toContain('Katerina, 30 September: the next phase')
|
|
436
|
+
expect(moved).toContain('✗ estiva-ui written — not yet')
|
|
437
|
+
expect(moved).toContain('✅ UIG-26')
|
|
438
|
+
expect(moved).toContain('1 of 1 done; 1 moved to the next phase')
|
|
439
|
+
expect(moved).toContain('⏭ 1 next phase')
|
|
440
|
+
const waiting = await runStatus({ root: app('status-waiting', { 'scripts/gates-checks.mjs': checks('') }) })
|
|
441
|
+
expect(waiting).toContain('⬜ UIG-26')
|
|
442
|
+
expect(waiting).not.toContain('next phase')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// A made app's UIG-33 row printed with no title (UIG-26): every row the package checks in an app has one.
|
|
446
|
+
it('gives every ticket an app checks a title', () => {
|
|
447
|
+
const tickets = appChecks(helpers(app('titles', {})), { page: 'src/pages/HomePage.tsx' })
|
|
448
|
+
expect(tickets.filter((t) => !t.title).map((t) => t.ref)).toEqual([])
|
|
449
|
+
})
|
|
428
450
|
})
|
package/src/gates/status.ts
CHANGED
|
@@ -101,6 +101,8 @@ export interface TicketListEntry {
|
|
|
101
101
|
title: string
|
|
102
102
|
parts?: string[]
|
|
103
103
|
aggregate?: boolean
|
|
104
|
+
/** Moved to the next phase, and by whose ruling: still checked, shown ⏭, and not waited for by the aggregate. */
|
|
105
|
+
later?: string
|
|
104
106
|
}
|
|
105
107
|
export interface GateSpec {
|
|
106
108
|
repo: string
|
|
@@ -119,6 +121,7 @@ interface Row {
|
|
|
119
121
|
checks: (CheckResult & { repo: string; what: string })[]
|
|
120
122
|
status?: string
|
|
121
123
|
aggregate?: boolean
|
|
124
|
+
later?: string
|
|
122
125
|
}
|
|
123
126
|
interface Report {
|
|
124
127
|
schema: 1
|
|
@@ -199,9 +202,11 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
199
202
|
|
|
200
203
|
async contract(appDir) {
|
|
201
204
|
try {
|
|
202
|
-
const { buildAppRegistry, contractProblems, CONTRACT_KINDS } = await import('../registry/index')
|
|
205
|
+
const { buildAppRegistry, contractProblems, nameableParts, CONTRACT_KINDS } = await import('../registry/index')
|
|
203
206
|
const registry = buildAppRegistry({ root: abs(appDir) })
|
|
204
|
-
|
|
207
|
+
// The package's catalogue, beside its package.json: a page may name its parts too, as in CI.
|
|
208
|
+
const pkg = createRequire(import.meta.url)('../../registry.json') as Parameters<typeof nameableParts>[1]
|
|
209
|
+
const problems = contractProblems(registry, abs(appDir), nameableParts(registry, pkg))
|
|
205
210
|
const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
|
|
206
211
|
if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`)
|
|
207
212
|
return PASS(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`)
|
|
@@ -445,7 +450,7 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
445
450
|
|
|
446
451
|
// ── running the checks ─────────────────────────────────────────────────────
|
|
447
452
|
|
|
448
|
-
const STATUS: Record<string, string> = { done: '✅', started: '🚧', none: '⬜', unknown: '❔' }
|
|
453
|
+
const STATUS: Record<string, string> = { done: '✅', started: '🚧', none: '⬜', unknown: '❔', later: '⏭' }
|
|
449
454
|
|
|
450
455
|
function statusOf(checks: CheckResult[]) {
|
|
451
456
|
const n = (r: CheckResult['result']) => checks.filter((c) => c.result === r).length
|
|
@@ -553,6 +558,7 @@ function printRows(rows: Row[], { showOwner, detail }: { showOwner: boolean; det
|
|
|
553
558
|
const pass = r.checks.filter((c) => c.result === 'pass').length
|
|
554
559
|
const count = r.checks.length ? `${pass} of ${r.checks.length}` : 'no checks here'
|
|
555
560
|
lines.push(`${STATUS[r.status ?? 'unknown']} ${pad(r.ref, 7)} ${pad(r.title ?? '', width)} ${showOwner ? pad(r.ownerRepo ?? '', 10) : ''}${count}`)
|
|
561
|
+
if (r.later) lines.push(` ${r.later}`)
|
|
556
562
|
if (detail || r.status === 'started' || r.status === 'unknown') {
|
|
557
563
|
for (const c of r.checks) {
|
|
558
564
|
const mark = { pass: '✓', part: '½', fail: '✗', unknown: '?' }[c.result]
|
|
@@ -565,7 +571,8 @@ function printRows(rows: Row[], { showOwner, detail }: { showOwner: boolean; det
|
|
|
565
571
|
|
|
566
572
|
function summary(rows: Row[]) {
|
|
567
573
|
const n = (s: string) => rows.filter((r) => r.status === s).length
|
|
568
|
-
|
|
574
|
+
const later = n('later') ? ` · ${STATUS.later} ${n('later')} next phase` : ''
|
|
575
|
+
return `${STATUS.done} ${n('done')} done · ${STATUS.started} ${n('started')} started · ${STATUS.none} ${n('none')} not started · ${STATUS.unknown} ${n('unknown')} could not check${later}`
|
|
569
576
|
}
|
|
570
577
|
|
|
571
578
|
export interface StatusOptions {
|
|
@@ -667,13 +674,17 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
|
|
|
667
674
|
for (const f of found.filter((f) => f.missing)) {
|
|
668
675
|
if ((t.parts ?? []).includes(f.name) || t.owner === f.name) checks.push({ repo: f.name, what: `${f.name}'s part`, result: 'unknown', detail: f.note })
|
|
669
676
|
}
|
|
670
|
-
return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate }
|
|
677
|
+
return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate, later: t.later }
|
|
671
678
|
})
|
|
672
|
-
|
|
679
|
+
// A ticket moved to the next phase is still checked (--detail shows how far it is), but it
|
|
680
|
+
// reads ⏭ whatever its checks say, and the aggregate does not wait for it.
|
|
681
|
+
for (const row of rows) row.status = row.later ? 'later' : statusOf(row.checks)
|
|
673
682
|
for (const row of rows.filter((r) => r.aggregate)) {
|
|
674
|
-
const others = rows.filter((r) => r !== row)
|
|
683
|
+
const others = rows.filter((r) => r !== row && !r.later)
|
|
675
684
|
const done = others.filter((r) => r.status === 'done').length
|
|
676
|
-
|
|
685
|
+
const moved = rows.filter((r) => r.later).length
|
|
686
|
+
const said = `${done} of ${others.length} done${moved ? `; ${moved} moved to the next phase` : ''}`
|
|
687
|
+
row.checks.push({ repo: 'all', what: 'every other ticket of this phase is done', ...(done === others.length ? PASS(said) : FAIL(said)) })
|
|
677
688
|
row.status = statusOf(row.checks)
|
|
678
689
|
}
|
|
679
690
|
|
package/src/registry/cli.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
|
|
|
36
36
|
import { tmpdir } from 'node:os'
|
|
37
37
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
38
38
|
import { fileURLToPath } from 'node:url'
|
|
39
|
-
import { CONTRACT_KINDS, contractProblems, linkProblems, nameProblems } from './contract'
|
|
39
|
+
import { CONTRACT_KINDS, contractProblems, linkProblems, nameableParts, nameProblems } from './contract'
|
|
40
40
|
import { findInRegistries, formatFindings } from './find'
|
|
41
41
|
import { behindBy, findSiblings, workspaceOf } from './siblings'
|
|
42
42
|
import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
|
|
@@ -280,7 +280,7 @@ async function main(): Promise<number> {
|
|
|
280
280
|
// The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
|
|
281
281
|
// A When not may name a part of the package or of this app (B8).
|
|
282
282
|
const pkg = readPackageRegistry()
|
|
283
|
-
const known =
|
|
283
|
+
const known = nameableParts(registry, pkg)
|
|
284
284
|
const broken = contractProblems(registry, root, known)
|
|
285
285
|
if (broken.length) {
|
|
286
286
|
process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
* used to call drawn nowhere;
|
|
14
14
|
* - a Windows checkout, read with CRLF.
|
|
15
15
|
*/
|
|
16
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
20
20
|
import { buildAppRegistry } from './app'
|
|
21
|
-
import {
|
|
21
|
+
import { helpers } from '../gates/status'
|
|
22
|
+
import { contractProblems, hasSeenIn, linkProblems, nameableParts, nameProblems, pageProblem, whenNotProblem } from './contract'
|
|
23
|
+
import type { Registry } from './schema'
|
|
22
24
|
|
|
23
25
|
const made: string[] = []
|
|
24
26
|
afterAll(() => {
|
|
@@ -128,6 +130,20 @@ describe('an app against the contract', () => {
|
|
|
128
130
|
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
129
131
|
})
|
|
130
132
|
|
|
133
|
+
// UIG-26: gates:status knew only the app's own parts, and read six of Peek's pages as broken that CI passed.
|
|
134
|
+
it('lets a page name a package part, in estiva-ui check and in gates:status alike', async () => {
|
|
135
|
+
const dir = app({
|
|
136
|
+
'src/Badge.tsx': part('Badge'),
|
|
137
|
+
'src/Badge.mdx': PAGE.replace('No alternative: a count has only this badge.', 'A framed column is `Panel`.'),
|
|
138
|
+
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
|
|
139
|
+
})
|
|
140
|
+
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
|
|
141
|
+
expect(contractProblems(registry, dir)).toEqual([expect.stringContaining('"When not" names no other part')])
|
|
142
|
+
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'registry.json'), 'utf8')) as Registry
|
|
143
|
+
expect(contractProblems(registry, dir, nameableParts(registry, pkg))).toEqual([])
|
|
144
|
+
expect(await helpers(dir).contract('.')).toMatchObject({ result: 'pass' })
|
|
145
|
+
})
|
|
146
|
+
|
|
131
147
|
it('names a reusable part with no page, and the file to write', () => {
|
|
132
148
|
const dir = app({ 'src/Badge.tsx': part('Badge') })
|
|
133
149
|
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
package/src/registry/contract.ts
CHANGED
|
@@ -81,6 +81,16 @@ export function linkProblems(registry: Registry, ids: ReadonlySet<string>): stri
|
|
|
81
81
|
return problems
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The parts an app's page may name under When and When not: the package's and
|
|
86
|
+
* the app's own (B8). The one copy: `estiva-ui check` and `gates:status` both
|
|
87
|
+
* read it, so they cannot disagree. Status once knew the app's parts only, and
|
|
88
|
+
* read six of Peek's pages as broken that CI passed (UIG-26).
|
|
89
|
+
*/
|
|
90
|
+
export function nameableParts(app: Registry, pkg: Registry): Set<string> {
|
|
91
|
+
return new Set([...pkg.entries, ...app.entries].map((e) => e.name))
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
/**
|
|
85
95
|
* Every way an app's catalogue breaks the contract, one line each, naming the
|
|
86
96
|
* part, its file and what to do. Empty when it keeps it.
|
package/src/registry/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
|
|
14
14
|
export { buildAppRegistry, type AppBuildOptions } from './app'
|
|
15
|
-
export { CONTRACT_KINDS, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, namedParts, pageProblem, whenNotProblem } from './contract'
|
|
15
|
+
export { CONTRACT_KINDS, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, nameableParts, namedParts, pageProblem, whenNotProblem } from './contract'
|
|
16
16
|
export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
|
|
17
17
|
export {
|
|
18
18
|
CLASSES,
|