diff --git a/README.md b/README.md index 7e2e08c..1e9f882 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ when the `content-tree` is in from its [ecosystem of utilities][unist-utilities]. `content-tree` relates to [JavaScript][js] in that it has an [ecosystem of -utilities][unist-utilities] for working with trees in JavaScript. However, +utilities][unist-utilities] for working with trees in JavaScript. However, `content-tree` is not limited to JavaScript and can be used in other programming languages. @@ -106,6 +106,8 @@ type BodyBlock = | Video | YoutubeVideo | Text + | Gallery + | AudioPlayer ``` `BodyBlock` nodes are the only things that are valid as the top level of a `Body`. @@ -348,7 +350,6 @@ _non normative note:_ the reason this is string properties and not children is that it is more confusing if a pullquote falls back to text than if it doesn't. The text is taken from elsewhere in the article. - ### `ImageSet` ```ts @@ -412,10 +413,8 @@ type ImageSource = { **ImageSource** defines a single resource for an [image](#image). - ### `Recommended` - ```ts interface Recommended extends Node { type: "recommended" @@ -491,7 +490,6 @@ type Teaser = { } ``` - ### `Tweet` ```ts @@ -641,7 +639,6 @@ The `layoutName` acts as a sort of theme for the component. ### `LayoutSlot` - ```ts interface LayoutSlot extends Parent { type: "layout-slot" @@ -736,7 +733,7 @@ interface Table extends Parent { ```ts type CustomCodeComponentAttributes = { - [key: string]: string | boolean | undefined + [key: string]: string | boolean | undefined } interface CustomCodeComponent extends Node { @@ -757,12 +754,89 @@ interface CustomCodeComponent extends Node { } ``` -- The **CustomCodeComponent*** allows for more experimental forms of journalism, allowing editors to provide properties via Spark. +- The **CustomCodeComponent\*** allows for more experimental forms of journalism, allowing editors to provide properties via Spark. - The component itself lives off-platform, and an example might be a git repository with a standard structure. This structure would include the rendering instructions, and the data structure that is expected to be provided to the component for it to render if necessary. - The basic interface in Spark to make reference to this system above (eg. the git repo URL or a public S3 bucket), and provide some data for it if necessary. This will be the Custom Component storyblock. - The data Spark receives from entering a specific ID will be used to render dynamic fields (the `attributes`). - +### Gallery + +```ts +type galleryItem = { + /** + * @description link for the image + */ + imageLink?: "text" + /** + * @description this is the first Image + * @default false + */ + firstImage: boolean + /** + * @description image description + */ + + imageDescription?: string + /** + * @description select or upload image + */ + picture?: Image +} + +/** + * @sparkGenerateStoryblock true + */ +interface Gallery extends Node { + type: "Gallery" + + /** + * @description gallery description + * @default default text for the source field + */ + galleryDescription?: string + /** + * @description autoplay the gallery + * @default false + */ + autoPlay?: boolean + + /** + * @description each gallery item + * @maxItems 10 + * @minItems 1 + */ + galleryItems: galleryItem[] +} +``` +- The **Gallery\*** is the first story block in Spark to be powered entirely by the schema-driven structure of the ContentTree system.Instead of hardcoding its configuration, Spark dynamically inspects the BodyBlock definition in the ContentTree schema and extracts all block types annotated with the @sparkGenerateStoryblock: true flag.These block definitions are automatically converted into ProseMirror node specs and injected into the editor's schema at runtime. The following ContentTree types are currently mapped to Spark components: + +- "string" → Rich text +- "text" → Text input +- "Image" → Responsive image container +- "Flourish" → Flourish chart +- "Video" → Video block + + +```ts +/** + * @sparkGenerateStoryblock true + */ +interface AudioPlayer extends Node { + type: "AudioPlayer"; + /** + * @description Name of the Author + */ + author: "text"; + /** + * @description description of audio file + */ + description: "text"; + /** + * @description Url of the audio file + */ + audioFile: "text"; +} +``` ## License This software is published by the Financial Times under the [MIT licence](mit). diff --git a/build.bash b/build.bash index 7f7860a..b24769f 100755 --- a/build.bash +++ b/build.bash @@ -1,8 +1,8 @@ #!/bin/bash node tools/maketypes content-tree.ts tsc -d content-tree.ts -typescript-json-schema --noExtraProps --required content-tree.ts ContentTree.full.Root > schemas/content-tree.schema.json -typescript-json-schema --noExtraProps --required content-tree.ts ContentTree.transit.Root > schemas/transit-tree.schema.json -typescript-json-schema --noExtraProps --required content-tree.ts ContentTree.transit.Body > schemas/body-tree.schema.json +typescript-json-schema --validationKeywords sparkGenerateStoryblock --noExtraProps --required content-tree.ts ContentTree.full.Root > schemas/content-tree.schema.json +typescript-json-schema --validationKeywords sparkGenerateStoryblock --noExtraProps --required content-tree.ts ContentTree.transit.Root > schemas/transit-tree.schema.json +typescript-json-schema --validationKeywords sparkGenerateStoryblock --noExtraProps --required content-tree.ts ContentTree.transit.Body > schemas/body-tree.schema.json rm content-tree.ts rm content-tree.js diff --git a/content-tree.d.ts b/content-tree.d.ts index bbe0cff..4dcff96 100644 --- a/content-tree.d.ts +++ b/content-tree.d.ts @@ -1,5 +1,5 @@ export declare namespace ContentTree { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text | Gallery | AudioPlayer; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -278,8 +278,67 @@ export declare namespace ContentTree { /** Configuration data to be passed to the component. */ attributes: CustomCodeComponentAttributes; } + type galleryItem = { + /** + * @description link for the image + */ + imageLink?: "text"; + /** + * @description this is the first Image + * @default false + */ + firstImage: boolean; + /** + * @description image description + */ + imageDescription?: string; + /** + * @description select or upload image + */ + picture?: Image; + }; + /** + * @sparkGenerateStoryblock true + */ + interface Gallery extends Node { + type: "Gallery"; + /** + * @description gallery description + * @default default text for the source field + */ + galleryDescription?: string; + /** + * @description autoplay the gallery + * @default false + */ + autoPlay?: boolean; + /** + * @description each gallery item + * @maxItems 10 + * @minItems 1 + */ + galleryItems: galleryItem[]; + } + /** + * @sparkGenerateStoryblock true + */ + interface AudioPlayer extends Node { + type: "AudioPlayer"; + /** + * @description Name of the Author + */ + author: "text"; + /** + * @description description of audio file + */ + description: "text"; + /** + * @description Url of the audio file + */ + audioFile: "text"; + } namespace full { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text | Gallery | AudioPlayer; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -558,9 +617,68 @@ export declare namespace ContentTree { /** Configuration data to be passed to the component. */ attributes: CustomCodeComponentAttributes; } + type galleryItem = { + /** + * @description link for the image + */ + imageLink?: "text"; + /** + * @description this is the first Image + * @default false + */ + firstImage: boolean; + /** + * @description image description + */ + imageDescription?: string; + /** + * @description select or upload image + */ + picture?: Image; + }; + /** + * @sparkGenerateStoryblock true + */ + interface Gallery extends Node { + type: "Gallery"; + /** + * @description gallery description + * @default default text for the source field + */ + galleryDescription?: string; + /** + * @description autoplay the gallery + * @default false + */ + autoPlay?: boolean; + /** + * @description each gallery item + * @maxItems 10 + * @minItems 1 + */ + galleryItems: galleryItem[]; + } + /** + * @sparkGenerateStoryblock true + */ + interface AudioPlayer extends Node { + type: "AudioPlayer"; + /** + * @description Name of the Author + */ + author: "text"; + /** + * @description description of audio file + */ + description: "text"; + /** + * @description Url of the audio file + */ + audioFile: "text"; + } } namespace transit { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text | Gallery | AudioPlayer; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -824,9 +942,68 @@ export declare namespace ContentTree { /** How the component should be presented in the article page according to the column layout system */ layoutWidth: LayoutWidth; } + type galleryItem = { + /** + * @description link for the image + */ + imageLink?: "text"; + /** + * @description this is the first Image + * @default false + */ + firstImage: boolean; + /** + * @description image description + */ + imageDescription?: string; + /** + * @description select or upload image + */ + picture?: Image; + }; + /** + * @sparkGenerateStoryblock true + */ + interface Gallery extends Node { + type: "Gallery"; + /** + * @description gallery description + * @default default text for the source field + */ + galleryDescription?: string; + /** + * @description autoplay the gallery + * @default false + */ + autoPlay?: boolean; + /** + * @description each gallery item + * @maxItems 10 + * @minItems 1 + */ + galleryItems: galleryItem[]; + } + /** + * @sparkGenerateStoryblock true + */ + interface AudioPlayer extends Node { + type: "AudioPlayer"; + /** + * @description Name of the Author + */ + author: "text"; + /** + * @description description of audio file + */ + description: "text"; + /** + * @description Url of the audio file + */ + audioFile: "text"; + } } namespace loose { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text | Gallery | AudioPlayer; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -1105,5 +1282,64 @@ export declare namespace ContentTree { /** Configuration data to be passed to the component. */ attributes?: CustomCodeComponentAttributes; } + type galleryItem = { + /** + * @description link for the image + */ + imageLink?: "text"; + /** + * @description this is the first Image + * @default false + */ + firstImage: boolean; + /** + * @description image description + */ + imageDescription?: string; + /** + * @description select or upload image + */ + picture?: Image; + }; + /** + * @sparkGenerateStoryblock true + */ + interface Gallery extends Node { + type: "Gallery"; + /** + * @description gallery description + * @default default text for the source field + */ + galleryDescription?: string; + /** + * @description autoplay the gallery + * @default false + */ + autoPlay?: boolean; + /** + * @description each gallery item + * @maxItems 10 + * @minItems 1 + */ + galleryItems: galleryItem[]; + } + /** + * @sparkGenerateStoryblock true + */ + interface AudioPlayer extends Node { + type: "AudioPlayer"; + /** + * @description Name of the Author + */ + author: "text"; + /** + * @description description of audio file + */ + description: "text"; + /** + * @description Url of the audio file + */ + audioFile: "text"; + } } } diff --git a/schemas/body-tree.schema.json b/schemas/body-tree.schema.json index 1c331ad..4c98715 100644 --- a/schemas/body-tree.schema.json +++ b/schemas/body-tree.schema.json @@ -2,6 +2,39 @@ "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { + "ContentTree.transit.AudioPlayer": { + "additionalProperties": false, + "properties": { + "audioFile": { + "const": "text", + "description": "Url of the audio file", + "type": "string" + }, + "author": { + "const": "text", + "description": "Name of the Author", + "type": "string" + }, + "data": {}, + "description": { + "const": "text", + "description": "description of audio file", + "type": "string" + }, + "type": { + "const": "AudioPlayer", + "type": "string" + } + }, + "required": [ + "audioFile", + "author", + "description", + "type" + ], + "sparkGenerateStoryblock": true, + "type": "object" + }, "ContentTree.transit.BigNumber": { "additionalProperties": false, "properties": { @@ -122,6 +155,12 @@ }, { "$ref": "#/definitions/ContentTree.transit.Text" + }, + { + "$ref": "#/definitions/ContentTree.transit.Gallery" + }, + { + "$ref": "#/definitions/ContentTree.transit.AudioPlayer" } ] }, @@ -227,6 +266,122 @@ ], "type": "string" }, + "ContentTree.transit.Gallery": { + "additionalProperties": false, + "properties": { + "autoPlay": { + "default": false, + "description": "autoplay the gallery", + "type": "boolean" + }, + "data": {}, + "galleryDescription": { + "default": "default text for the source field", + "description": "gallery description", + "type": "string" + }, + "galleryItems": { + "description": "each gallery item", + "items": { + "additionalProperties": false, + "properties": { + "firstImage": { + "default": false, + "description": "this is the first Image", + "type": "boolean" + }, + "imageDescription": { + "description": "image description", + "type": "string" + }, + "imageLink": { + "const": "text", + "description": "link for the image", + "type": "string" + }, + "picture": { + "additionalProperties": false, + "description": "select or upload image", + "properties": { + "format": { + "enum": [ + "desktop", + "mobile", + "square", + "square-ftedit", + "standard", + "standard-inline", + "wide" + ], + "type": "string" + }, + "height": { + "type": "number" + }, + "id": { + "type": "string" + }, + "sourceSet": { + "items": { + "additionalProperties": false, + "properties": { + "dpr": { + "type": "number" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "dpr", + "url", + "width" + ], + "type": "object" + }, + "type": "array" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "format", + "height", + "id", + "url", + "width" + ], + "type": "object" + } + }, + "required": [ + "firstImage" + ], + "type": "object" + }, + "maxItems": 10, + "minItems": 1, + "type": "array" + }, + "type": { + "const": "Gallery", + "type": "string" + } + }, + "required": [ + "galleryItems", + "type" + ], + "sparkGenerateStoryblock": true, + "type": "object" + }, "ContentTree.transit.Heading": { "additionalProperties": false, "properties": { diff --git a/schemas/content-tree.schema.json b/schemas/content-tree.schema.json index 703a637..0fb4211 100644 --- a/schemas/content-tree.schema.json +++ b/schemas/content-tree.schema.json @@ -2,6 +2,39 @@ "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { + "ContentTree.full.AudioPlayer": { + "additionalProperties": false, + "properties": { + "audioFile": { + "const": "text", + "description": "Url of the audio file", + "type": "string" + }, + "author": { + "const": "text", + "description": "Name of the Author", + "type": "string" + }, + "data": {}, + "description": { + "const": "text", + "description": "description of audio file", + "type": "string" + }, + "type": { + "const": "AudioPlayer", + "type": "string" + } + }, + "required": [ + "audioFile", + "author", + "description", + "type" + ], + "sparkGenerateStoryblock": true, + "type": "object" + }, "ContentTree.full.BigNumber": { "additionalProperties": false, "properties": { @@ -147,6 +180,12 @@ }, { "$ref": "#/definitions/ContentTree.full.Text" + }, + { + "$ref": "#/definitions/ContentTree.full.Gallery" + }, + { + "$ref": "#/definitions/ContentTree.full.AudioPlayer" } ] }, @@ -338,6 +377,122 @@ ], "type": "string" }, + "ContentTree.full.Gallery": { + "additionalProperties": false, + "properties": { + "autoPlay": { + "default": false, + "description": "autoplay the gallery", + "type": "boolean" + }, + "data": {}, + "galleryDescription": { + "default": "default text for the source field", + "description": "gallery description", + "type": "string" + }, + "galleryItems": { + "description": "each gallery item", + "items": { + "additionalProperties": false, + "properties": { + "firstImage": { + "default": false, + "description": "this is the first Image", + "type": "boolean" + }, + "imageDescription": { + "description": "image description", + "type": "string" + }, + "imageLink": { + "const": "text", + "description": "link for the image", + "type": "string" + }, + "picture": { + "additionalProperties": false, + "description": "select or upload image", + "properties": { + "format": { + "enum": [ + "desktop", + "mobile", + "square", + "square-ftedit", + "standard", + "standard-inline", + "wide" + ], + "type": "string" + }, + "height": { + "type": "number" + }, + "id": { + "type": "string" + }, + "sourceSet": { + "items": { + "additionalProperties": false, + "properties": { + "dpr": { + "type": "number" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "dpr", + "url", + "width" + ], + "type": "object" + }, + "type": "array" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "format", + "height", + "id", + "url", + "width" + ], + "type": "object" + } + }, + "required": [ + "firstImage" + ], + "type": "object" + }, + "maxItems": 10, + "minItems": 1, + "type": "array" + }, + "type": { + "const": "Gallery", + "type": "string" + } + }, + "required": [ + "galleryItems", + "type" + ], + "sparkGenerateStoryblock": true, + "type": "object" + }, "ContentTree.full.Heading": { "additionalProperties": false, "properties": { diff --git a/schemas/transit-tree.schema.json b/schemas/transit-tree.schema.json index 273226b..51732ca 100644 --- a/schemas/transit-tree.schema.json +++ b/schemas/transit-tree.schema.json @@ -2,6 +2,39 @@ "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { + "ContentTree.transit.AudioPlayer": { + "additionalProperties": false, + "properties": { + "audioFile": { + "const": "text", + "description": "Url of the audio file", + "type": "string" + }, + "author": { + "const": "text", + "description": "Name of the Author", + "type": "string" + }, + "data": {}, + "description": { + "const": "text", + "description": "description of audio file", + "type": "string" + }, + "type": { + "const": "AudioPlayer", + "type": "string" + } + }, + "required": [ + "audioFile", + "author", + "description", + "type" + ], + "sparkGenerateStoryblock": true, + "type": "object" + }, "ContentTree.transit.BigNumber": { "additionalProperties": false, "properties": { @@ -147,6 +180,12 @@ }, { "$ref": "#/definitions/ContentTree.transit.Text" + }, + { + "$ref": "#/definitions/ContentTree.transit.Gallery" + }, + { + "$ref": "#/definitions/ContentTree.transit.AudioPlayer" } ] }, @@ -252,6 +291,122 @@ ], "type": "string" }, + "ContentTree.transit.Gallery": { + "additionalProperties": false, + "properties": { + "autoPlay": { + "default": false, + "description": "autoplay the gallery", + "type": "boolean" + }, + "data": {}, + "galleryDescription": { + "default": "default text for the source field", + "description": "gallery description", + "type": "string" + }, + "galleryItems": { + "description": "each gallery item", + "items": { + "additionalProperties": false, + "properties": { + "firstImage": { + "default": false, + "description": "this is the first Image", + "type": "boolean" + }, + "imageDescription": { + "description": "image description", + "type": "string" + }, + "imageLink": { + "const": "text", + "description": "link for the image", + "type": "string" + }, + "picture": { + "additionalProperties": false, + "description": "select or upload image", + "properties": { + "format": { + "enum": [ + "desktop", + "mobile", + "square", + "square-ftedit", + "standard", + "standard-inline", + "wide" + ], + "type": "string" + }, + "height": { + "type": "number" + }, + "id": { + "type": "string" + }, + "sourceSet": { + "items": { + "additionalProperties": false, + "properties": { + "dpr": { + "type": "number" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "dpr", + "url", + "width" + ], + "type": "object" + }, + "type": "array" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "format", + "height", + "id", + "url", + "width" + ], + "type": "object" + } + }, + "required": [ + "firstImage" + ], + "type": "object" + }, + "maxItems": 10, + "minItems": 1, + "type": "array" + }, + "type": { + "const": "Gallery", + "type": "string" + } + }, + "required": [ + "galleryItems", + "type" + ], + "sparkGenerateStoryblock": true, + "type": "object" + }, "ContentTree.transit.Heading": { "additionalProperties": false, "properties": { diff --git a/server.js b/server.js new file mode 100644 index 0000000..da4c5cf --- /dev/null +++ b/server.js @@ -0,0 +1,16 @@ +const http = require('http'); +const jsonFile = require('./schemas/content-tree.schema.json') + +const server = http.createServer((req, res) => { + if (req.url === '/content-tree' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(jsonFile, null, 2)); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(3000, () => { + console.log('Server running at http://localhost:5000'); +});