diff --git a/convertEdit.spec.ts b/convertEdit.spec.ts index 9a9964b..c0090e4 100644 --- a/convertEdit.spec.ts +++ b/convertEdit.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { expect } from '@open-wc/testing'; -import { Insert, Remove, Update } from './editv1.js'; +import { Update } from './editv1.js'; import { convertEdit } from './convertEdit.js'; -import { SetAttributes } from './editv2.js'; +import { Insert, Remove, SetAttributes } from './editv2.js'; const doc = new DOMParser().parseFromString( '', @@ -43,9 +43,9 @@ const update: Update = { value: 'value2', namespaceURI: 'http://example.org/myns2', }, - attr3: { - value: 'value3', - namespaceURI: null, + invalid: { + value: 'great, but with empty namespace URI', + namespaceURI: '', }, }, }; @@ -55,6 +55,7 @@ const setAttributes: SetAttributes = { attributes: { name: 'A2', desc: null, + ['__proto__']: 'a string', }, attributesNS: { 'http://example.org/myns': { diff --git a/convertEdit.ts b/convertEdit.ts index 6abbf13..6e4a0d1 100644 --- a/convertEdit.ts +++ b/convertEdit.ts @@ -1,20 +1,22 @@ import { Edit, - isComplex, - isInsert, + isComplexEdit, isNamespaced, isUpdate, - isRemove, Update, } from './editv1.js'; -import { EditV2 } from './editv2.js'; +import { + AttributesV2, + AttributesNS, + EditV2, + isInsert, + isRemove, +} from './editv2.js'; function convertUpdate(edit: Update): EditV2 { - const attributes: Partial> = {}; - const attributesNS: Partial< - Record>> - > = {}; + let attributes: AttributesV2 = {}; + const attributesNS: AttributesNS = {}; Object.entries(edit.attributes).forEach(([key, value]) => { if (isNamespaced(value!)) { @@ -26,9 +28,9 @@ function convertUpdate(edit: Update): EditV2 { if (!attributesNS[ns]) { attributesNS[ns] = {}; } - attributesNS[ns][key] = value.value; + attributesNS[ns] = { ...attributesNS[ns], [key]: value.value }; } else { - attributes[key] = value; + attributes = { ...attributes, [key]: value }; } }); @@ -45,7 +47,7 @@ export function convertEdit(edit: Edit): EditV2 { if (isUpdate(edit)) { return convertUpdate(edit); } - if (isComplex(edit)) { + if (isComplexEdit(edit)) { return edit.map(convertEdit); } diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 3ac419f..be76c9e 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -52,20 +52,20 @@ Plugins communicate user intent to OpenSCD core by dispatching the following [cu The **edit event** allows a plugin to describe the changes it wants to make to the current `doc`. ```typescript -export type EditDetailV2 = { +export type EditDetailV2 = { edit: E; title?: string; squash?: boolean; } -export type EditEventV2 = CustomEvent>; +export type EditEventV2 = CustomEvent>; export type EditEventOptions = { title?: string; squash?: boolean; } -export function newEditEventV2(edit: E, options: EditEventOptions): EditEventV2 { +export function newEditEventV2(edit: E, options: EditEventOptions): EditEventV2 { return new CustomEvent('oscd-edit-v2', { composed: true, bubbles: true, @@ -84,7 +84,7 @@ Its `title` property is a human-readable description of the edit. The `squash` flag indicates whether the edit should be merged with the previous edit in the history. -#### `Edit` type +#### `EditV2` type The `EditDetailV2` defined above contains an `edit` of this type: ```typescript @@ -153,86 +153,6 @@ declare global { } ``` -### `WizardEvent` - -The **wizard event** allows the plugin to request opening a modal dialog enabling the user to edit an arbitrary SCL `element`, regardless of how the dialog for editing this particular type of element looks and works. - -```typescript -/* eslint-disable no-undef */ -interface WizardRequestBase { - subWizard?: boolean; // TODO: describe what this currently means -} - -export interface EditWizardRequest extends WizardRequestBase { - element: Element; -} - -export interface CreateWizardRequest extends WizardRequestBase { - parent: Element; - tagName: string; -} - -export type WizardRequest = EditWizardRequest | CreateWizardRequest; - -type EditWizardEvent = CustomEvent; -type CreateWizardEvent = CustomEvent; -export type WizardEvent = EditWizardEvent | CreateWizardEvent; - -type CloseWizardEvent = CustomEvent; - -export function newEditWizardEvent( - element: Element, - subWizard?: boolean, - eventInitDict?: CustomEventInit> -): EditWizardEvent { - return new CustomEvent('oscd-edit-wizard-request', { - bubbles: true, - composed: true, - ...eventInitDict, - detail: { element, subWizard, ...eventInitDict?.detail }, - }); -} - -export function newCreateWizardEvent( - parent: Element, - tagName: string, - subWizard?: boolean, - eventInitDict?: CustomEventInit> -): CreateWizardEvent { - return new CustomEvent('oscd-create-wizard-request', { - bubbles: true, - composed: true, - ...eventInitDict, - detail: { - parent, - tagName, - subWizard, - ...eventInitDict?.detail, - }, - }); -} - -export function newCloseWizardEvent( - wizard: WizardRequest, - eventInitDict?: CustomEventInit> -): CloseWizardEvent { - return new CustomEvent('oscd-close-wizard', { - bubbles: true, - composed: true, - ...eventInitDict, - detail: wizard, - }); -} - -declare global { - interface ElementEventMap { - ['oscd-edit-wizard-request']: EditWizardRequest; - ['oscd-create-wizard-request']: CreateWizardRequest; - ['oscd-close-wizard']: WizardEvent; - } -} -``` - ## Theming OpenSCD core sets the following CSS variables on the plugin: @@ -256,4 +176,4 @@ OpenSCD core sets the following CSS variables on the plugin: --oscd-text-font-mono: var(--oscd-theme-text-font-mono, 'Roboto Mono'); --oscd-icon-font: var(--oscd-theme-icon-font, 'Material Icons'); } -``` \ No newline at end of file +``` diff --git a/edit-event-v2.ts b/edit-event-v2.ts new file mode 100644 index 0000000..214d8ae --- /dev/null +++ b/edit-event-v2.ts @@ -0,0 +1,33 @@ +import { EditV2 } from './editv2.js'; + +export type EditDetailV2 = { + edit: E; + title?: string; + squash?: boolean; +}; + +export type EditEventV2 = CustomEvent< + EditDetailV2 +>; + +export type EditEventOptions = { + title?: string; + squash?: boolean; +}; + +export function newEditEventV2( + edit: E, + options?: EditEventOptions, +): EditEventV2 { + return new CustomEvent>('oscd-edit-v2', { + composed: true, + bubbles: true, + detail: { ...options, edit }, + }); +} + +declare global { + interface ElementEventMap { + ['oscd-edit-v2']: EditEventV2; + } +} diff --git a/edit-event.ts b/edit-event.ts index 9483986..ce3ce6d 100644 --- a/edit-event.ts +++ b/edit-event.ts @@ -1,33 +1,17 @@ -import { EditV2 } from './editv2.js'; +import { Edit } from './editv1.js'; -export type EditDetailV2 = { - edit: E; - title?: string; - squash?: boolean; -}; +export type EditEvent = CustomEvent; -export type EditEventV2 = CustomEvent< - EditDetailV2 ->; - -export type EditEventOptions = { - title?: string; - squash?: boolean; -}; - -export function newEditEventV2( - edit: E, - options?: EditEventOptions, -): EditEventV2 { - return new CustomEvent>('oscd-edit-v2', { +export function newEditEvent(edit: E): EditEvent { + return new CustomEvent('oscd-edit-v2', { composed: true, bubbles: true, - detail: { ...options, edit }, + detail: edit, }); } declare global { interface ElementEventMap { - ['oscd-edit-v2']: EditEventV2; + ['oscd-edit']: EditEvent; } } diff --git a/editv1.spec.ts b/editv1.spec.ts index f90119b..ac26efe 100644 --- a/editv1.spec.ts +++ b/editv1.spec.ts @@ -28,14 +28,11 @@ describe('type guard functions for editv1', () => { it('returns true for Remove', () => expect(remove).to.satisfy(isEdit)); - it('returns false for SetAttributes', () => - expect(setAttributes).to.not.satisfy(isEdit)); - it('returns true for SetTextContent', () => expect(setTextContent).to.not.satisfy(isEdit)); it('returns false on mixed edit and editV2 array', () => - expect([update, setAttributes]).to.not.satisfy(isEdit)); + expect([update, setTextContent]).to.not.satisfy(isEdit)); it('returns true on edit array', () => expect([update, remove, insert]).to.satisfy(isEdit)); diff --git a/editv1.ts b/editv1.ts index bba871d..3674e03 100644 --- a/editv1.ts +++ b/editv1.ts @@ -1,68 +1,63 @@ -import { isSetAttributes } from './editv2.js'; - -/** Intent to `parent.insertBefore(node, reference)` */ -export type Insert = { - parent: Node; - node: Node; - reference: Node | null; -}; +import { isInsert, isRemove, Insert, Remove } from './editv2.js'; export type NamespacedAttributeValue = { value: string | null; namespaceURI: string | null; }; + export type AttributeValue = string | null | NamespacedAttributeValue; + +export type Attributes = Partial>; + /** Intent to set or remove (if null) attributes on element */ export type Update = { element: Element; attributes: Partial>; }; -/** Intent to remove a node from its ownerDocument */ -export type Remove = { - node: Node; -}; - /** Represents the user's intent to change an XMLDocument */ export type Edit = Insert | Update | Remove | Edit[]; -export function isComplex(edit: Edit): edit is Edit[] { - return edit instanceof Array; -} - -export function isInsert(edit: Edit): edit is Insert { - return (edit as Insert).parent !== undefined; -} - export function isNamespaced( - value: AttributeValue, + value: unknown, ): value is NamespacedAttributeValue { - return value !== null && typeof value !== 'string'; + return ( + value !== null && + typeof value === 'object' && + 'namespaceURI' in value && + typeof value.namespaceURI === 'string' && + 'value' in value && + typeof value.value === 'string' + ); } -export function isUpdate(edit: Edit): edit is Update { - return ( - (edit as Update).element !== undefined && - (edit as Update).attributes !== undefined +export function isAttributes(attributes: unknown): attributes is Attributes { + if (attributes === null || typeof attributes !== 'object') { + return false; + } + + return Object.entries(attributes).every( + ([key, value]) => + typeof key === 'string' && + (value === null || typeof value === 'string' || isNamespaced(value)), ); } -export function isRemove(edit: Edit): edit is Remove { +export function isComplexEdit(edit: unknown): edit is Edit[] { + return edit instanceof Array && edit.every(isEdit); +} + +export function isUpdate(edit: unknown): edit is Update { return ( - (edit as Insert).parent === undefined && (edit as Remove).node !== undefined + (edit as Update).element instanceof Element && + isAttributes((edit as Update).attributes) ); } -export type EditEvent = CustomEvent; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isEdit(edit: any): edit is Edit { - if (isComplex(edit)) { - return !edit.some(e => !isEdit(e)); +export function isEdit(edit: unknown): edit is Edit { + if (isComplexEdit(edit)) { + return true; } - return ( - !isSetAttributes(edit) && - (isUpdate(edit) || isInsert(edit) || isRemove(edit)) - ); + return isUpdate(edit) || isInsert(edit) || isRemove(edit); } diff --git a/editv2.spec.ts b/editv2.spec.ts index 575c676..0cdf5af 100644 --- a/editv2.spec.ts +++ b/editv2.spec.ts @@ -19,16 +19,16 @@ const insert: Insert = { parent: element, node: element, reference: null }; const remove: Remove = { node: element }; const setAttributes: SetAttributes = { element, - attributes: {}, - attributesNS: {}, + attributes: { name: 'value' }, + attributesNS: { namespaceURI: { name: 'value' } }, }; const setTextContent: SetTextContent = { element, textContent: '' }; -describe('type guard functions for editv2', () => { - it('returns false on invalid Edit type', () => +describe('isEditV2', () => { + it('returns false for invalid Edit type', () => expect('invalid edit').to.not.satisfy(isEditV2)); - it('returns false on Update', () => expect(update).to.not.satisfy(isEditV2)); + it('returns false for Update', () => expect(update).to.not.satisfy(isEditV2)); it('returns true for Insert', () => expect(insert).to.satisfy(isEditV2)); @@ -40,13 +40,13 @@ describe('type guard functions for editv2', () => { it('returns true for SetTextContent', () => expect(setTextContent).to.satisfy(isEditV2)); - it('returns false on mixed edit and editV2 array', () => + it('returns false for a mixed edit and editV2 array', () => expect([update, setAttributes]).to.not.satisfy(isEditV2)); - it('returns false on edit array', () => + it('returns false for edit array', () => expect([update, update]).to.not.satisfy(isEditV2)); - it('returns true on editV2 array', () => + it('returns true for editV2 array', () => expect([setAttributes, remove, insert, setTextContent]).to.satisfy( isEditV2, )); diff --git a/editv2.ts b/editv2.ts index 719ab12..a2b10d0 100644 --- a/editv2.ts +++ b/editv2.ts @@ -16,11 +16,17 @@ export type SetTextContent = { textContent: string; }; +/** Record from attribute names to attribute values */ +export type AttributesV2 = Partial>; + +/** Record from namespace URIs to `Attributes` records */ +export type AttributesNS = Partial>; + /** Intent to set or remove (if `null`) `attributes`(-`NS`) on `element` */ export type SetAttributes = { element: Element; - attributes: Partial>; - attributesNS: Partial>>>; + attributes?: AttributesV2; + attributesNS?: AttributesNS; }; /** Intent to change some XMLDocuments */ @@ -31,43 +37,71 @@ export type EditV2 = | Remove | EditV2[]; -export function isComplex(edit: EditV2): edit is EditV2[] { - return edit instanceof Array; +export function isAttributesV2( + attributes: unknown, +): attributes is AttributesV2 { + if (typeof attributes !== 'object' || attributes === null) { + return false; + } + return Object.entries(attributes).every( + ([key, value]) => + typeof key === 'string' && (value === null || typeof value === 'string'), + ); +} + +export function isAttributesNS( + attributesNS: unknown, +): attributesNS is AttributesNS { + if (typeof attributesNS !== 'object' || attributesNS === null) { + return false; + } + return Object.entries(attributesNS).every( + ([namespace, attributes]) => + typeof namespace === 'string' && + isAttributesV2(attributes as Record), + ); +} + +export function isComplexEditV2(edit: unknown): edit is EditV2[] { + return edit instanceof Array && edit.every(e => isEditV2(e)); } -export function isSetTextContent(edit: EditV2): edit is SetTextContent { +export function isSetTextContent(edit: unknown): edit is SetTextContent { return ( - (edit as SetTextContent).element !== undefined && - (edit as SetTextContent).textContent !== undefined + (edit as SetTextContent).element instanceof Element && + typeof (edit as SetTextContent).textContent === 'string' ); } -export function isRemove(edit: EditV2): edit is Remove { +export function isRemove(edit: unknown): edit is Remove { return ( - (edit as Insert).parent === undefined && (edit as Remove).node !== undefined + (edit as Insert).parent === undefined && + (edit as Remove).node instanceof Node ); } -export function isSetAttributes(edit: EditV2): edit is SetAttributes { +export function isSetAttributes(edit: unknown): edit is SetAttributes { return ( - (edit as SetAttributes).element !== undefined && - (edit as SetAttributes).attributes !== undefined && - (edit as SetAttributes).attributesNS !== undefined + (edit as SetAttributes).element instanceof Element && + isAttributesV2((edit as SetAttributes).attributes) && + isAttributesNS((edit as SetAttributes).attributesNS) ); } -export function isInsert(edit: EditV2): edit is Insert { +export function isInsert(edit: unknown): edit is Insert { return ( - (edit as Insert).parent !== undefined && - (edit as Insert).node !== undefined && - (edit as Insert).reference !== undefined + ((edit as Insert).parent instanceof Element || + (edit as Insert).parent instanceof Document || + (edit as Insert).parent instanceof DocumentFragment) && + (edit as Insert).node instanceof Node && + ((edit as Insert).reference instanceof Node || + (edit as Insert).reference === null) ); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isEditV2(edit: any): edit is EditV2 { - if (isComplex(edit)) { - return !edit.some(e => !isEditV2(e)); +export function isEditV2(edit: unknown): edit is EditV2 { + if (isComplexEditV2(edit)) { + return true; } return ( diff --git a/open-event.ts b/open-event.ts new file mode 100644 index 0000000..99b7414 --- /dev/null +++ b/open-event.ts @@ -0,0 +1,21 @@ +export type OpenDetail = { + doc: XMLDocument; + docName: string; +}; + +/** Represents the intent to open `doc` with filename `docName`. */ +export type OpenEvent = CustomEvent; + +export function newOpenEvent(doc: XMLDocument, docName: string): OpenEvent { + return new CustomEvent('oscd-open', { + bubbles: true, + composed: true, + detail: { doc, docName }, + }); +} + +declare global { + interface ElementEventMap { + ['oscd-open']: OpenEvent; + } +} diff --git a/oscd-api.ts b/oscd-api.ts index 9d64091..93c6cb3 100644 --- a/oscd-api.ts +++ b/oscd-api.ts @@ -1,18 +1,20 @@ -export { +export type { + AttributesV2, + AttributesNS, EditV2, Insert, Remove, SetAttributes, SetTextContent, - isComplex, - isEditV2, - isInsert, - isRemove, - isSetAttributes, - isSetTextContent, } from './editv2.js'; -export { Edit, Update, isEdit } from './editv1.js'; +export type { + Attributes, + AttributeValue, + Edit, + NamespacedAttributeValue, + Update, +} from './editv1.js'; export type { Commit, @@ -21,4 +23,12 @@ export type { TransactedCallback, } from './Transactor.js'; -export { newEditEventV2 } from './edit-event.js'; +export type { EditEvent } from './edit-event.js'; + +export type { + EditDetailV2, + EditEventOptions, + EditEventV2, +} from './edit-event-v2.js'; + +export type { OpenDetail, OpenEvent } from './open-event.js'; diff --git a/package.json b/package.json index cd038c1..bd07b2e 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,16 @@ "version": "0.0.14", "description": "OpenSCD API for IEC 61850 SCL files", "type": "module", - "main": "./dist/oscd-api.js", - "types": "./dist/oscd-api.d.ts", + "exports": { + ".": { + "default": "./dist/oscd-api.js", + "types": "./dist/oscd-api.d.ts" + }, + "./utils.js": { + "default": "./dist/utils.js", + "types": "./dist/utils.d.ts" + } + }, "scripts": { "lint": "eslint . && prettier \"**/*.ts\" --check --ignore-path .gitignore", "format": "eslint ./*.ts --fix", @@ -86,6 +94,7 @@ ], "rules": { "no-unused-vars": "off", + "no-use-before-define": "off", "class-methods-use-this": [ "error", { diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..72a74b2 --- /dev/null +++ b/utils.ts @@ -0,0 +1,26 @@ +export { + isAttributesV2, + isAttributesNS, + isComplexEditV2, + isEditV2, + isInsert, + isRemove, + isSetAttributes, + isSetTextContent, +} from './editv2.js'; + +export { + isAttributes, + isComplexEdit, + isEdit, + isNamespaced, + isUpdate, +} from './editv1.js'; + +export { convertEdit } from './convertEdit.js'; + +export { newEditEvent } from './edit-event.js'; + +export { newEditEventV2 } from './edit-event-v2.js'; + +export { newOpenEvent } from './open-event.js';