diff --git a/apps/dev/cms.tsx b/apps/dev/cms.tsx index 73cf6ea01..58a5a02d9 100644 --- a/apps/dev/cms.tsx +++ b/apps/dev/cms.tsx @@ -1,5 +1,5 @@ -import alinea from 'alinea' -import {Entry, createCMS} from 'alinea/core' +import alinea, {Query} from 'alinea' +import {createCMS} from 'alinea/core' import {IcRoundTranslate} from 'alinea/ui/icons/IcRoundTranslate' import {IcRoundUploadFile} from 'alinea/ui/icons/IcRoundUploadFile' import {position} from './src/PositionField' @@ -96,7 +96,7 @@ const Fields = alinea.document('Fields', { entry: alinea.entry('Internal link'), entryWithCondition: alinea.entry('With condition', { help: `Show only entries of type Fields`, - condition: Entry.type.is('Fields') + condition: Query.whereType('Fields') }), linkMultiple: alinea.link.multiple('Mixed links, multiple'), image: alinea.image('Image link'), diff --git a/apps/web/content/pages/docs/content/editing-content.json b/apps/web/content/pages/docs/content/editing-content.json new file mode 100644 index 000000000..d5070afd0 --- /dev/null +++ b/apps/web/content/pages/docs/content/editing-content.json @@ -0,0 +1,198 @@ +{ + "title": "Editing content", + "navigationTitle": "", + "body": [ + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Your CMS instance has methods to create and update content. During development these will update the file system directly, while in production the changes will result in a new git commit." + } + ] + }, + { + "type": "heading", + "textAlign": "left", + "level": 2, + "content": [ + { + "type": "text", + "text": "Creating Entries" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "New Entries can be created using the `create` function." + } + ] + }, + { + "id": "2bww0XuSHzm0f72kxNM5cQlVLvI", + "type": "CodeBlock", + "code": "import {Edit} from 'alinea'\n\n// Start a transaction to create a new entry of type BlogPost\nconst post = Edit.create(BlogPost).set({\n title: 'A new blog post',\n body: 'Hello world'\n})\n\n// The new entry ID can be read before comitting\nconsole.log(`Creating post with id: ${post.id}`)\n\n// Save the changes\nawait cms.commit(post)", + "fileName": "", + "language": "" + }, + { + "type": "heading", + "textAlign": "left", + "level": 3, + "content": [ + { + "type": "text", + "text": "Creating child Entries" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "To nest new Entries correctly it's possible to set the parent id, or construct directly from a parent update." + } + ] + }, + { + "id": "2c4tj6ItJDix4tpGojE9LGn0fTo", + "type": "CodeBlock", + "code": "import {Edit} from 'alinea'\n\nconst blog = Edit.create(Blog).set({title: 'Blog'})\nconst posts = postData.map(data =>\n blog.createChild(BlogPost).set({title: data.title})\n)\n// Or, if you're adding to an existing parent\n// blog.create(BlogPost).setParent(blogId).set({title: data.title})\nawait cms.commit(blog, ...posts)", + "fileName": "", + "language": "" + }, + { + "type": "heading", + "textAlign": "left", + "level": 2, + "content": [ + { + "type": "text", + "text": "Update Fields" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Entry fields can be edited using the `edit` function. Optionally pass the entry Type as a second argument so the field values are typed correctly." + } + ] + }, + { + "id": "2bx3AJ8D93WgmthX8tiEqZura9X", + "type": "CodeBlock", + "code": "import {Edit, Query} from 'alinea'\n\n// Select the first blog post\nconst blogPostId = await cms.get(Query(BlogPost).select(Query.id))\n\n// Edit a field and save\nconst update = Edit(blogPostId, BlogPost).set({\n body: 'New body text'\n})\n\nawait cms.commit(update)", + "fileName": "", + "language": "" + }, + { + "type": "heading", + "textAlign": "left", + "level": 3, + "content": [ + { + "type": "text", + "text": "Constructing field values" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Some fields contain values that are more complex than a string. The Edit namespace contains helper functions to construct these. In this example we construct the value of a List Field." + } + ] + }, + { + "id": "2c4t1IBcmwpjaS3YqNaJQxRpWss", + "type": "CodeBlock", + "code": "const richTextField = richText('Item body text');\nconst listField = list('My list field', {\n schema: {\n Text: type('Text', {\n title: text('Item title'),\n text: richText,\n })\n }\n})\nconst rowText = Edit.richText(richTextField)\n .addHtml(`\n

Main heading

\n

A rich text value parsed from HTML.

\n `)\n .value()\nconst listValue = Edit.list(listField)\n .add('Text', {\n title: 'The row title',\n text: rowText,\n })\n .value()\nconst update = Edit(entryId, TypeWithList).set({\n list: listValue\n})", + "fileName": "", + "language": "" + }, + { + "type": "heading", + "textAlign": "left", + "level": 2, + "content": [ + { + "type": "text", + "text": "File uploads" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Files can be uploaded using the upload function." + } + ] + }, + { + "id": "2bx3SNjDfXQsRSkJYEUX5GY3qg9", + "type": "CodeBlock", + "code": "import {Edit} from 'alinea'\n\nconst file = new File(['content'], 'test.txt')\nconst upload = Edit.upload(file)\n\n// The new entry ID can be read before comitting\nconsole.log(`Creating post with id: ${upload.id}`)\n\n// Upload file and save file metadata\nawait cms.commit(upload)", + "fileName": "", + "language": "" + }, + { + "type": "heading", + "textAlign": "left", + "level": 3, + "content": [ + { + "type": "text", + "text": "Creating image previews" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Alinea can create all the metadata for images (such as previews) by passing a `createPreview` function. On the server this will use the `sharp` package to read image data. The package will need to be installed separately." + } + ] + }, + { + "id": "2bxKAtZiQcruhSmRPO9PldQZ3mR", + "type": "CodeBlock", + "code": "import {Edit} from 'alinea'\nimport {createPreview} from 'alinea/core/media/CreatePreview'\nimport fs from 'node:fs'\n\nconst file = new File([\n fs.readFileSync('./test.png')\n], 'test.png')\nconst upload = Edit.upload(file, {createPreview})\nawait cms.commit(upload)", + "fileName": "", + "language": "" + } + ], + "metadata": { + "title": "", + "description": "", + "openGraph": { + "title": "", + "image": {}, + "description": "" + } + }, + "@alinea": { + "entryId": "2bwoBrTOuERj2E8r7yuNglC0H6G", + "type": "Doc", + "index": "a1G" + } +} \ No newline at end of file diff --git a/apps/web/content/pages/docs/content/query.json b/apps/web/content/pages/docs/content/query.json index 24b034823..5849e51f1 100644 --- a/apps/web/content/pages/docs/content/query.json +++ b/apps/web/content/pages/docs/content/query.json @@ -8,7 +8,7 @@ "content": [ { "type": "text", - "text": "The CMS instance that is exported in your config file can be used to query the stored data." + "text": "Once content has been saved in the CMS you'll want a way to retrieve it. Your CMS instance has methods to fetch specific Entries or search through all content." } ] }, @@ -29,7 +29,7 @@ { "id": "2YlCpSWtMLknsvHhIwtZlTf1MDI", "type": "CodeBlock", - "code": "import {cms} from '@/cms'\n\nexport default async function HomePage() {\n const homePage = await cms.get(HomePage())\n return

{homePage.title}

\n}", + "code": "import {cms} from '@/cms'\nimport {Query} from 'alinea'\n\nexport default async function HomePage() {\n const homePage = await cms.get(Query(HomePage))\n return

{homePage.title}

\n}", "fileName": "", "language": "", "compact": false @@ -53,7 +53,7 @@ "content": [ { "type": "text", - "text": "Retrieving a page" + "text": "Getting a specific Entry" } ] }, @@ -63,14 +63,14 @@ "content": [ { "type": "text", - "text": "A single page can be fetched using the `get` method. Criteria can be passed to filter entries." + "text": "A single Entry can be fetched using the `get` method." } ] }, { "id": "28hy8mAMZJiFhtaajix2fKGBKBE", "type": "CodeBlock", - "code": "// Fetch the first page where field equals the string 'value'\nconst page = await cms.get(Page({field: 'value'}))", + "code": "import {Query} from 'alinea'\n\nconst query = Query(HomePage).whereId(homePageId)\nconst entry = await cms.get(query)", "fileName": "", "language": "", "compact": false @@ -82,7 +82,7 @@ "content": [ { "type": "text", - "text": "Retrieving multiple pages" + "text": "Querying multiple Entries" } ] }, @@ -92,14 +92,14 @@ "content": [ { "type": "text", - "text": "Multiple pages can be fetched using the `find` method." + "text": "Multiple Entries can be fetched using the `find` method." } ] }, { "id": "28hyQUttn286uRfAArTKdMWNJVb", "type": "CodeBlock", - "code": "// Fetch all pages where field equals the string 'value'\nconst pages = await cms.find(Page({field: 'value'}))", + "code": "import {Query} from 'alinea'\n\nconst query = Query(BlogPost).whereParent(blogId)\nconst blogPosts = await cms.find(query)", "fileName": "", "language": "", "compact": false @@ -128,7 +128,7 @@ { "id": "28hyZtRcyC7fXKRHUiqhjA5O7iA", "type": "CodeBlock", - "code": "// Skip the first 10 pages and return a maximum of 10\nconst limited = await cms.find(Page().skip(10).take(10))", + "code": "// Skip the first 10 entries and return a maximum of 10\nconst query = Query(BlogPost).skip(10).take(10)", "fileName": "", "language": "", "compact": false @@ -140,7 +140,7 @@ "content": [ { "type": "text", - "text": "Full text search" + "text": "Order results" } ] }, @@ -150,16 +150,17 @@ "content": [ { "type": "text", - "text": "Pages can be queried with search terms. Any (rich) text field with the `searchable` option set to `true` is indexed." + "text": "A result set can be ordered by passing one or multiple fields. " } ] }, { - "id": "2b05iLFLHZ4Vxcyao5sTiFKsFOi", + "id": "28hzjFJi5kuByP0j3ZX79ATIyyS", "type": "CodeBlock", - "code": "const results = await cms.find(\n Page().search('query', 'content')\n)", + "code": "const ordered = Query(NewsItem).orderBy(NewsItem.publishDate.desc())", "fileName": "", - "language": "" + "language": "", + "compact": false }, { "type": "heading", @@ -168,7 +169,7 @@ "content": [ { "type": "text", - "text": "Querying specific pages" + "text": "Group by" } ] }, @@ -178,43 +179,71 @@ "content": [ { "type": "text", - "text": "To filter pages on specific fields first narrow the search to a type, then use the `where` method to specify conditions." + "text": "Results can be grouped by one or more fields." } ] }, { - "id": "IEmT75lZgxznL9v7Zq9mW", + "id": "28i0B7nRFUqmGWdCmchhzy21bkt", "type": "CodeBlock", - "code": "const old = await cms.find(\n Animal().where(Animal.age.isGreater(10))\n)\nconst teenager = await cms.find(Human().where(\n Human.age.isGreater(10).or(\n Human.age.isLess(20)\n )\n)\nconst applesOrOranges = await cms.find(\n Fruit().where(Fruit.title.isIn(['apple', 'orange']))\n)", + "code": "const grouped = Query(NewsItem).groupBy(NewsItem.category)", "fileName": "", "language": "", "compact": false }, + { + "type": "heading", + "textAlign": "left", + "level": 2, + "content": [ + { + "type": "text", + "text": "Creating queries" + } + ] + }, { "type": "paragraph", "textAlign": "left", "content": [ { "type": "text", - "text": "Conditions can be created by accessing the fields of the table instances and using the operators of Expr:" + "text": "Results can be filtered using the `where` function, or tailored functions such as `whereId` or `whereLocale`. To find Entries of any Type, use the Query functions directly." } ] }, { - "id": "2aiMtwZlHXcQkToqIEv04u3V6VY", + "id": "2c2tpFYfBG25n2Mfk8eIBtgSTH2", "type": "CodeBlock", - "code": "BlogPost.title // This is an Expr which has an `is` method to compare\nBlogPost.title.is(\"Test\") // Compare with a value, results in an Expr", + "code": "// Any entry that matches your conditions\nconst searchAllEntries = Query.where(...)", + "fileName": "", + "language": "" + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "If you're looking for Entries of a specific Type, pass it to the Query function to create a query of that Type. This will correctly infer the result TypeScript type." + } + ] + }, + { + "id": "2c2trI1Z51608LisXXDO46kek10", + "type": "CodeBlock", + "code": "// Only entries of type BlogPost will be found\nconst narrowByType = Query(BlogPost).where(...)", "fileName": "", "language": "" }, { "type": "heading", - "level": 2, "textAlign": "left", + "level": 3, "content": [ { "type": "text", - "text": "Ordering results" + "text": "Filtering by Field values" } ] }, @@ -224,26 +253,26 @@ "content": [ { "type": "text", - "text": "A result set can be ordered by passing one or multiple fields." + "text": "To search Entries by specific Fields use the `where` function. `Fields` can be compared to values using its conditional methods." } ] }, { - "id": "28hzjFJi5kuByP0j3ZX79ATIyyS", + "id": "IEmT75lZgxznL9v7Zq9mW", "type": "CodeBlock", - "code": "const ordered = await cms.find(NewsItem().orderBy(NewsItem.publishDate.desc())", + "code": "// If filtered by Type first it's possible to match fields\n// on equality directly by passing an object. This does not\n// work for any other comparison operator.\nconst withPath = Query(BlogPost).where({path: 'why-you-should-get-a-cat'})\n\n// Comparisons can be made by using the conditional methods\n// of the field you're comparing to.\nconst recent = Query(BlogPost).where(\n BlogPost.publishedDate.isGreaterOrEqual(`2024-01-01`)\n)\n\n// Multiple conditions result in matching on both (AND).\nconst previousYear = Query(BlogPost).where(\n BlogPost.publishedDate.isGreaterOrEqual(`2023-01-01`),\n BlogPost.publishedDate.isLess(`2024-01-01`)\n)\n\n// To match any condition use Query.or (OR).\nconst isPetPost = Query(BlogPost).where(\n Query.or(\n BlogPost.tags.includes('cats'),\n BlogPost.tags.includes('dogs')\n )\n)", "fileName": "", "language": "", "compact": false }, { "type": "heading", - "level": 2, "textAlign": "left", + "level": 3, "content": [ { "type": "text", - "text": "Group by" + "text": "Filtering by Entry values" } ] }, @@ -253,26 +282,53 @@ "content": [ { "type": "text", - "text": "Results can be grouped by one or more fields." + "text": "Entries contain values managed by the CMS such as an id, parent, the assigned workspace, root or locale. Query has shortcuts to query these directly." } ] }, { - "id": "28i0B7nRFUqmGWdCmchhzy21bkt", + "id": "2c2w35gHrX76eswIr8ZNleu39zn", "type": "CodeBlock", - "code": "const grouped = await cms.find(\n NewsItem().groupBy(NewsItem.category)\n)", + "code": "const german = Query.whereLocale('de')\nconst blogPosts = Query(BlogPost).whereParent(blog.id)\n\n// Multiple conditions can be chained\nconst secretPages = Query(Secret).whereWorkspace('secret').whereRoot('pages')", "fileName": "", - "language": "", - "compact": false + "language": "" }, { "type": "heading", - "level": 2, "textAlign": "left", + "level": 3, + "content": [ + { + "type": "text", + "text": "Full text search" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Entries can be queried with search terms. Any (Rich) Text Field with the `searchable` option set to `true` is indexed." + } + ] + }, + { + "id": "2b05iLFLHZ4Vxcyao5sTiFKsFOi", + "type": "CodeBlock", + "code": "// Search can be used in combination with conditions\nconst containsDogs = Query(BlogPost).where(...).search('dog')\n\n// Multiple search terms can be used\nconst containsBothDogsAndCats = Query(BlogPost).search('cat', 'dog')", + "fileName": "", + "language": "" + }, + { + "type": "heading", + "textAlign": "left", + "level": 2, "content": [ { "type": "text", - "text": "Selecting specific fields" + "text": "Selecting Fields" } ] }, @@ -289,13 +345,40 @@ { "id": "28hywuwvVMmRT7zhyZEumjM19tI", "type": "CodeBlock", - "code": "// Return only titles\nconst rows = await cms.find(\n Page().select({title: Page.title})\n)", + "code": "// Returns a select set of fields \nconst rows = Query(BlogPost).select({\n // Entry fields are available on Query\n id: Query.id,\n url: Query.url,\n title: BlogPost.title,\n description: BlogPost.shortDescription\n})\n\n// You can include all available Entry fields at once\nconst rows = Query(BlogPost).select({\n ...Query.entry,\n title: BlogPost.title,\n description: BlogPost.shortDescription\n})", "fileName": "", "language": "", "compact": false + }, + { + "type": "heading", + "textAlign": "left", + "level": 3, + "content": [ + { + "type": "text", + "text": "Selecting data from related Entries" + } + ] + }, + { + "type": "paragraph", + "textAlign": "left", + "content": [ + { + "type": "text", + "text": "Entries in Alinea are part of a content tree. This means they'll often have a parent Entry or contain children Entries. To query content from the parent(s) or children you can request it within the selection." + } + ] + }, + { + "id": "2c2xXRab38WZmsa3GLCLx5Z6LGA", + "type": "CodeBlock", + "code": "// Select a few fields from the parent Entries to render\n// a breadcrumb navigation.\nconst breadcrumbs = Query.parents().select({\n url: Query.url,\n title: Query.title\n})\n\n// Use it directly in another select\nconst blogPosts = Query(BlogPost).select({\n // Select the fields you want from this blog post\n title: BlogPost.title,\n body: BlogPost.body,\n // ... and include the data of the parents\n breadcrumbs\n})\n\n// You can use the spread operator to make the above more readable\nconst blogPosts = Query(BlogPost).select({\n // Select all fields of the BlogPost type\n ...BlogPost,\n breadcrumbs\n})\n\n// Similarly you can fetch parent and children in one query\nconst blog = Query(Blog).select({\n title: Blog.title,\n posts: Query.children(BlogPost)\n})", + "fileName": "", + "language": "" } ], - "extraField": [], "metadata": { "title": "", "description": "", diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index a6516c03e..3b7e7f99e 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -1,6 +1,5 @@ import {cms} from '@/cms' -import alinea from 'alinea' -import {Entry} from 'alinea/core' +import alinea, {Query} from 'alinea' export async function GET(request: Request) { const searchTerm = new URL(request.url).searchParams.get('query') @@ -9,18 +8,15 @@ export async function GET(request: Request) { .in(cms.workspaces.main.pages) .disableSync() .find( - Entry() - .select({ - title: Entry.title, - url: Entry.url, - snippet: alinea.snippet('[[mark]]', '[[/mark]]', '…', 25), - parents({parents}) { - return parents().select({ - id: Entry.entryId, - title: Entry.title - }) - } + Query.select({ + title: Query.title, + url: Query.url, + snippet: alinea.snippet('[[mark]]', '[[/mark]]', '…', 25), + parents: Query.parents().select({ + id: Query.id, + title: Query.title }) + }) .search(...searchTerm.split(' ')) .take(25) ) diff --git a/apps/web/src/layout/Header.tsx b/apps/web/src/layout/Header.tsx index 5642bfcc8..da1ccd142 100644 --- a/apps/web/src/layout/Header.tsx +++ b/apps/web/src/layout/Header.tsx @@ -1,6 +1,5 @@ import {cms} from '@/cms' -import {EntryReference, UrlReference} from 'alinea' -import {Entry} from 'alinea/core' +import {EntryReference, Query, UrlReference} from 'alinea' import {HStack, Stack, Styler, fromModule} from 'alinea/ui' import {IcRoundClose} from 'alinea/ui/icons/IcRoundClose' import {IcRoundHamburger} from 'alinea/ui/icons/IcRoundHamburger' @@ -56,15 +55,9 @@ export async function Header() { } async function MobileNav() { - const docs = await cms.in(cms.workspaces.main.pages.docs).find( - Entry().select({ - id: Entry.entryId, - type: Entry.type, - url: Entry.url, - title: Entry.title, - parent: Entry.parent - }) - ) + const docs = await cms + .in(cms.workspaces.main.pages.docs) + .find(Query.select(Query.entry)) const tree = [ {id: 'home', url: '/', title: 'Home'}, {id: 'roadmap', url: '/roadmap', title: 'Roadmap'}, diff --git a/apps/web/src/page/BlogPage.tsx b/apps/web/src/page/BlogPage.tsx index ef6a9389a..097e9eb4f 100644 --- a/apps/web/src/page/BlogPage.tsx +++ b/apps/web/src/page/BlogPage.tsx @@ -4,7 +4,7 @@ import {WebTypo} from '@/layout/WebTypo' import {Link} from '@/layout/nav/Link' import {BlogOverview} from '@/schema/BlogOverview' import {BlogPost} from '@/schema/BlogPost' -import {Entry} from 'alinea/core' +import {Query} from 'alinea' import {VStack, fromModule} from 'alinea/ui' import css from './BlogPage.module.scss' import {BlogPostMeta} from './blog/BlogPostMeta' @@ -13,18 +13,14 @@ const styles = fromModule(css) export default async function BlogPage() { const overview = await cms.get( - BlogOverview().select({ - title: BlogOverview.title, - posts({children}) { - return children().select({ - id: Entry.entryId, - url: Entry.url, - title: BlogPost.title, - introduction: BlogPost.introduction, - author: BlogPost.author, - publishDate: BlogPost.publishDate - }) - } + Query(BlogOverview).select({ + title: Query.title, + posts: Query.children(BlogPost).select({ + ...Query.entry, + introduction: BlogPost.introduction, + author: BlogPost.author, + publishDate: BlogPost.publishDate + }) }) ) return ( diff --git a/apps/web/src/page/BlogPostPage.tsx b/apps/web/src/page/BlogPostPage.tsx index 7275edc33..77d116773 100644 --- a/apps/web/src/page/BlogPostPage.tsx +++ b/apps/web/src/page/BlogPostPage.tsx @@ -1,13 +1,10 @@ -/* eslint-disable @next/next/no-img-element */ - import {cms} from '@/cms' import {PageContainer, PageContent} from '@/layout/Page' import {WebTypo} from '@/layout/WebTypo' import {TextView} from '@/page/blocks/TextBlockView' import {BlogPost} from '@/schema/BlogPost' -import {Entry} from 'alinea/core' +import {Query} from 'alinea' import {fromModule} from 'alinea/ui' -import {notFound} from 'next/navigation' import {Breadcrumbs} from '../layout/Breadcrumbs' import css from './BlogPostPage.module.scss' import {BlogPostMeta} from './blog/BlogPostMeta' @@ -20,23 +17,17 @@ export interface BlogPostPageProps { export const dynamicParams = false export async function generateStaticParams() { - const slugs = await cms.find(BlogPost().select(Entry.path)) + const slugs = await cms.find(Query(BlogPost).select(Query.path)) return slugs.map(slug => ({slug})) } export async function generateMetadata({params}: BlogPostPageProps) { - const page = await cms.maybeGet( - BlogPost().where(Entry.url.is(`/blog/${params.slug}`)) - ) - if (!page) return notFound() + const page = await cms.get(Query(BlogPost).whereUrl(`/blog/${params.slug}`)) return {title: page.metadata?.title || page.title} } export default async function BlogPostPage({params}: BlogPostPageProps) { - const page = await cms.maybeGet( - BlogPost().where(Entry.url.is(`/blog/${params.slug}`)) - ) - if (!page) return notFound() + const page = await cms.get(Query(BlogPost).whereUrl(`/blog/${params.slug}`)) return ( diff --git a/apps/web/src/page/DocPage.tsx b/apps/web/src/page/DocPage.tsx index 3532b4afc..95c18a4de 100644 --- a/apps/web/src/page/DocPage.tsx +++ b/apps/web/src/page/DocPage.tsx @@ -8,7 +8,7 @@ import {NavTree} from '@/layout/nav/NavTree' import {NavItem, nestNav} from '@/layout/nav/NestNav' import {BodyView} from '@/page/blocks/BodyFieldView' import {Doc} from '@/schema/Doc' -import {Entry} from 'alinea/core' +import {Query} from 'alinea' import {HStack, VStack, fromModule} from 'alinea/ui' import {Metadata} from 'next' import {notFound} from 'next/navigation' @@ -27,9 +27,9 @@ interface DocPageProps { } const summary = { - id: Entry.entryId, - title: Entry.title, - url: Entry.url + id: Query.id, + title: Query.title, + url: Query.url } async function getPage(params: DocPageParams) { @@ -43,17 +43,13 @@ async function getPage(params: DocPageParams) { const url = pathname ? `/docs/${pathname}` : '/docs' return { framework, - doc: await cms.maybeGet( - Entry() - .where(Entry.url.is(url)) - .select({ - ...Doc, - id: Entry.entryId, - level: Entry.level, - parents({parents}) { - return parents().select(summary) - } - }) + doc: await cms.get( + Query.whereUrl(url).select({ + ...Doc, + id: Query.id, + level: Query.level, + parents: Query.parents().select(summary) + }) ) } } @@ -62,7 +58,7 @@ export const dynamicParams = false export async function generateStaticParams() { const urls = await cms .in(cms.workspaces.main.pages.docs) - .find(Entry().select(Entry.url)) + .find(Query.select(Query.url)) return urls .flatMap(url => { return supportedFrameworks @@ -99,17 +95,17 @@ export default async function DocPage({params}: DocPageProps) { const {doc, framework} = await getPage(params) if (!doc) return notFound() const select = { - id: Entry.entryId, - type: Entry.type, - url: Entry.url, - title: Entry.title, + id: Query.id, + type: Query.type, + url: Query.url, + title: Query.title, navigationTitle: Doc.navigationTitle, - parent: Entry.parent + parent: Query.parent } - const root = await cms.get(Entry({url: '/docs'}).select(select)) + const root = await cms.get(Query.whereUrl('/docs').select(select)) const nav = await cms .in(cms.workspaces.main.pages.docs) - .find(Entry().select(select)) + .find(Query.select(select)) const entries = [ root, ...nav.map(item => ({ diff --git a/apps/web/src/page/GenericPage.tsx b/apps/web/src/page/GenericPage.tsx index 95db4403e..5a70d705a 100644 --- a/apps/web/src/page/GenericPage.tsx +++ b/apps/web/src/page/GenericPage.tsx @@ -1,7 +1,7 @@ import {cms} from '@/cms' import {PageContainer, PageContent} from '@/layout/Page' import {Page} from '@/schema/Page' -import {Entry} from 'alinea/core' +import {Query} from 'alinea' import {notFound} from 'next/navigation' import {TextView} from './blocks/TextBlockView' @@ -13,18 +13,22 @@ export interface GenericPageProps { export const dynamicParams = false export async function generateStaticParams() { - const slugs = await cms.find(Page().select(Entry.path)) + const slugs = await cms.find(Query(Page).select(Query.path)) return slugs.map(slug => ({slug})) } export async function generateMetadata({params}: GenericPageProps) { - const page = await cms.maybeGet(Page().where(Entry.url.is(`/${params.slug}`))) + const page = await cms.maybeGet( + Query(Page).where(Query.url.is(`/${params.slug}`)) + ) if (!page) return notFound() return {title: page.metadata?.title || page.title} } export default async function GenericPage({params}: GenericPageProps) { - const page = await cms.maybeGet(Page().where(Entry.url.is(`/${params.slug}`))) + const page = await cms.maybeGet( + Query(Page).where(Query.url.is(`/${params.slug}`)) + ) if (!page) return notFound() return ( diff --git a/apps/web/src/page/blocks/BodyFieldView.tsx b/apps/web/src/page/blocks/BodyFieldView.tsx index 500bdd941..fcc0f096f 100644 --- a/apps/web/src/page/blocks/BodyFieldView.tsx +++ b/apps/web/src/page/blocks/BodyFieldView.tsx @@ -1,6 +1,6 @@ import {WebText} from '@/layout/WebText' import {BodyBlock} from '@/schema/blocks/BodyField' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule} from 'alinea/ui' import {ComponentType, Fragment} from 'react' import {ChapterLinkView} from './ChapterLinkView' @@ -14,7 +14,7 @@ import css from './TextBlockView.module.scss' const styles = fromModule(css) -export interface BodyViewProps extends Infer { +export interface BodyViewProps extends alinea.infer { container?: ComponentType } diff --git a/apps/web/src/page/blocks/ChapterLinkView.tsx b/apps/web/src/page/blocks/ChapterLinkView.tsx index b979a3057..b28eddf7b 100644 --- a/apps/web/src/page/blocks/ChapterLinkView.tsx +++ b/apps/web/src/page/blocks/ChapterLinkView.tsx @@ -1,13 +1,13 @@ import {Link} from '@/layout/nav/Link' import {ChapterLinkBlock} from '@/schema/blocks/ChapterLinkBlock' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule, HStack, Stack} from 'alinea/ui' import {IcRoundArrowForward} from 'alinea/ui/icons/IcRoundArrowForward' import css from './ChapterLinkView.module.scss' const styles = fromModule(css) -export function ChapterLinkView({link}: Infer) { +export function ChapterLinkView({link}: alinea.infer) { if (!link || !link.url) return null return ( ) { +}: alinea.infer) { const {codeToHtml} = await codeHighlighter if (!code) return null const html = codeToHtml(code, { diff --git a/apps/web/src/page/blocks/CodeVariantsView.tsx b/apps/web/src/page/blocks/CodeVariantsView.tsx index e9a9cb037..93aa6f9bc 100644 --- a/apps/web/src/page/blocks/CodeVariantsView.tsx +++ b/apps/web/src/page/blocks/CodeVariantsView.tsx @@ -1,5 +1,5 @@ import {CodeVariantsBlock} from '@/schema/blocks/CodeVariantsBlock' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule} from 'alinea/ui' import {CodeVariantTabs} from './CodeVariantsView.client' import css from './CodeVariantsView.module.scss' @@ -8,7 +8,7 @@ import {codeHighlighter} from './code/CodeHighlighter' const styles = fromModule(css) export interface CodeVariantsViewProps - extends Infer {} + extends alinea.infer {} export async function CodeVariantsView({variants}: CodeVariantsViewProps) { const {codeToHtml} = await codeHighlighter diff --git a/apps/web/src/page/blocks/ExampleBlockView.tsx b/apps/web/src/page/blocks/ExampleBlockView.tsx index 21ac54182..eb56d375d 100644 --- a/apps/web/src/page/blocks/ExampleBlockView.tsx +++ b/apps/web/src/page/blocks/ExampleBlockView.tsx @@ -1,12 +1,14 @@ import {ExampleBlock} from '@/schema/blocks/ExampleBlock' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule} from 'alinea/ui' import lzstring from 'lz-string' import css from './ExampleBlockView.module.scss' const styles = fromModule(css) -export async function ExampleBlockView({code}: Infer) { +export async function ExampleBlockView({ + code +}: alinea.infer) { const hash = lzstring.compressToEncodedURIComponent(code) return (
diff --git a/apps/web/src/page/blocks/FrameworkBlockView.tsx b/apps/web/src/page/blocks/FrameworkBlockView.tsx index eca264be3..c9bfbbe6e 100644 --- a/apps/web/src/page/blocks/FrameworkBlockView.tsx +++ b/apps/web/src/page/blocks/FrameworkBlockView.tsx @@ -1,6 +1,6 @@ import {supportedFrameworks} from '@/layout/nav/Frameworks' import {FrameworkBlock} from '@/schema/blocks/FrameworkBlock' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule} from 'alinea/ui' import {RenderSelectedFramework} from './FrameworkBlockView.client' import css from './FrameworkBlockView.module.scss' @@ -8,7 +8,9 @@ import {TextView} from './TextBlockView' const styles = fromModule(css) -export function FrameworkBlockView(blocks: Infer) { +export function FrameworkBlockView( + blocks: alinea.infer +) { return (
) { +export function ImageBlockView({image}: alinea.infer) { const blurUrl = imageBlurUrl(image) if (!image.src) return null return ( diff --git a/apps/web/src/page/blocks/NoticeView.tsx b/apps/web/src/page/blocks/NoticeView.tsx index 1b8879afa..b35ff7fb5 100644 --- a/apps/web/src/page/blocks/NoticeView.tsx +++ b/apps/web/src/page/blocks/NoticeView.tsx @@ -1,13 +1,13 @@ import {IcOutlineInfo} from '@/icons' import {WebText} from '@/layout/WebText' import {NoticeBlock} from '@/schema/blocks/NoticeBlock' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule, HStack} from 'alinea/ui' import css from './NoticeView.module.scss' const styles = fromModule(css) -export function NoticeView({level, body}: Infer) { +export function NoticeView({level, body}: alinea.infer) { return (
diff --git a/apps/web/src/page/blocks/TextBlockView.tsx b/apps/web/src/page/blocks/TextBlockView.tsx index fba9e6280..7cc6fbd9b 100644 --- a/apps/web/src/page/blocks/TextBlockView.tsx +++ b/apps/web/src/page/blocks/TextBlockView.tsx @@ -1,6 +1,6 @@ import {WebText} from '@/layout/WebText' import {TextBlock} from '@/schema/blocks/TextBlock' -import {Infer} from 'alinea' +import alinea from 'alinea' import {fromModule} from 'alinea/ui' import {ComponentType, Fragment} from 'react' import {ChapterLinkView} from './ChapterLinkView' @@ -13,7 +13,7 @@ import css from './TextBlockView.module.scss' const styles = fromModule(css) -export interface TextBlockViewProps extends Infer { +export interface TextBlockViewProps extends alinea.infer { container?: ComponentType } diff --git a/build.js b/build.js index feaf29aad..1f01d8843 100644 --- a/build.js +++ b/build.js @@ -37,6 +37,7 @@ const external = builtinModules 'fs-extra', '@alinea', 'next', + 'sharp', '@remix-run', 'react', 'react-dom', diff --git a/package.json b/package.json index a2dc99101..eab3b448b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "release:types": "tsc", "release:build": "node build.js", "type": "tsc --diagnostics", - "test": "node build.js --test", + "test": "NODE_OPTIONS=--enable-source-maps node build.js --test", "tag": "esbx tag", "dev:check": "tsc -w", "dev:run": "node build.js --watch -- node dev.js --dir apps/dev --config cms", @@ -50,7 +50,7 @@ "src/input/richtext" ], "dependencies": { - "@alinea/iso": "^0.3.1", + "@alinea/iso": "^0.3.2", "@alinea/sqlite-wasm": "^0.1.14", "esbuild": "^0.19.12" }, @@ -81,6 +81,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.63.4", + "sharp": "0.32.0", "speedscope": "^1.14.0", "typescript": "^5.3.3", "typescript-plugin-css-modules": "^5.0.2", @@ -103,6 +104,11 @@ "browser": "./dist/cloud/view/CloudAuth.browser.js", "default": "./dist/cloud/view/CloudAuth.js" }, + "./core/media/CreatePreview": { + "worker": "./dist/core/media/CreatePreview.js", + "browser": "./dist/core/media/CreatePreview.browser.js", + "default": "./dist/core/media/CreatePreview.js" + }, "./core/media/MediaRoot": { "worker": "./dist/core/media/MediaRoot.js", "browser": "./dist/core/media/MediaRoot.browser.js", diff --git a/src/backend/Database.test.ts b/src/backend/Database.test.ts index 0f5892aa0..2694db1d5 100644 --- a/src/backend/Database.test.ts +++ b/src/backend/Database.test.ts @@ -1,195 +1,138 @@ -import { - CMS, - Entry, - EntryPhase, - EntryRow, - Schema, - Type, - createId, - slugify -} from 'alinea/core' -import {entryChildrenDir, entryFilepath} from 'alinea/core/EntryFilenames' -import {Mutation, MutationType} from 'alinea/core/Mutation' -import {createEntryRow} from 'alinea/core/util/EntryRows' +import {Edit, EntryPhase, Query} from 'alinea/core' +import {createPreview} from 'alinea/core/media/CreatePreview' +import {readFileSync} from 'fs' import {test} from 'uvu' import * as assert from 'uvu/assert' import {createExample} from './test/Example.js' -async function entry( - cms: CMS, - type: Type, - data: Partial = {title: 'Entry'}, - parent?: EntryRow -): Promise { - const typeNames = Schema.typeNames(cms.config.schema) - const title = data.title ?? 'Entry' - const details = { - entryId: createId(), - phase: EntryPhase.Published, - type: typeNames.get(type)!, - title, - path: data.path ?? slugify(title), - seeded: null, - workspace: 'main', - root: 'pages', - level: 0, - parent: parent?.entryId ?? null, - locale: null, - index: 'a0', - i18nId: createId(), - modifiedAt: 0, - active: true, - main: true, - data: data.data ?? {}, - searchableText: '' - } - const parentPaths = parent?.childrenDir.split('/').filter(Boolean) ?? [] - const filePath = entryFilepath(cms.config, details, parentPaths) - const childrenDir = entryChildrenDir(cms.config, details, parentPaths) - const row = { - ...details, - filePath, - childrenDir, - parentDir: childrenDir.split('/').slice(0, -1).join('/'), - url: childrenDir - } - return createEntryRow(cms.config, row) -} - -function create(entry: EntryRow): Mutation { - return { - type: MutationType.Create, - entry: entry, - entryId: entry.entryId, - file: entry.filePath - } -} - -function remove(entry: EntryRow): Mutation { - return { - type: MutationType.Remove, - entryId: entry.entryId, - file: entry.filePath - } -} - -function edit(entry: EntryRow): Mutation { - return { - type: MutationType.Edit, - entryId: entry.entryId, - file: entry.filePath, - entry: entry - } -} - -function archive(entry: EntryRow): Mutation { - return { - type: MutationType.Archive, - entryId: entry.entryId, - file: entry.filePath - } -} - -function publish(entry: EntryRow): Mutation { - return { - type: MutationType.Publish, - entryId: entry.entryId, - file: entry.filePath, - phase: entry.phase - } -} - test('create', async () => { const example = createExample() - const db = await example.db - const entry1 = await entry(example, example.schema.Page, { - title: 'Test title' - }) - await db.applyMutations([create(entry1)]) - const result = await example.get(Entry({entryId: entry1.entryId})) - assert.is(result.entryId, entry1.entryId) - assert.is(result.title, 'Test title') + const {Page} = example.schema + const parent = Edit.create(Page).set({title: 'New parent'}) + await example.commit(parent) + const result = await example.get(Query.whereId(parent.entryId)) + assert.is(result.entryId, parent.entryId) + assert.is(result.title, 'New parent') }) test('remove child entries', async () => { const example = createExample() - const db = await example.db - const parent = await entry(example, example.schema.Container) - const sub = await entry(example, example.schema.Container, {}, parent) - const subSub = await entry(example, example.schema.Page, {}, sub) - - await db.applyMutations([create(parent), create(sub), create(subSub)]) - - const res1 = await example.get(Entry({entryId: subSub.entryId})) + const {Page, Container} = example.schema + const parent = Edit.create(Container) + const sub = parent.createChild(Container) + const entry = sub.createChild(Page) + await example.commit(parent, sub, entry) + const res1 = await example.get(Query.whereId(entry.entryId)) assert.ok(res1) assert.is(res1.parent, sub.entryId) - - await db.applyMutations([remove(parent)]) - - const res2 = await example.get(Entry({entryId: subSub.entryId})) + await example.commit(Edit.remove(parent.entryId)) + const res2 = await example.get(Query.whereId(entry.entryId)) assert.not.ok(res2) }) test('change draft path', async () => { const example = createExample() - const db = await example.db - const parent = await entry(example, example.schema.Container, { - path: 'parent' - }) - const sub = await entry( - example, - example.schema.Container, - {path: 'sub'}, - parent - ) - await db.applyMutations([create(parent), create(sub)]) - const resParent0 = await example.get(Entry({entryId: parent.entryId})) + const {Container} = example.schema + const parent = Edit.create(Container).set({path: 'parent'}) + const sub = parent.createChild(Container).set({path: 'sub'}) + await example.commit(parent, sub) + const resParent0 = await example.get(Query.whereId(parent.entryId)) assert.is(resParent0.url, '/parent') - - const draft = { - ...parent, - phase: EntryPhase.Draft, - data: {path: 'new-path'} - } - // Changing entry paths in draft should not have an influence on // computed properties such as url, filePath etc. until we publish. - await db.applyMutations([edit(draft)]) + await example.commit( + Edit(parent.entryId, Container).set({path: 'new-path'}).draft() + ) const resParent1 = await example.graph.drafts.get( - Entry({entryId: parent.entryId}) + Query.whereId(parent.entryId) ) assert.is(resParent1.url, '/parent') - const res1 = await example.get(Entry({entryId: sub.entryId})) + const res1 = await example.get(Query.whereId(sub.entryId)) assert.is(res1.url, '/parent/sub') // Once we publish, the computed properties should be updated. - await db.applyMutations([publish(draft)]) - const resParent2 = await example.get(Entry({entryId: parent.entryId})) + await example.commit(Edit.publish(parent.entryId)) + const resParent2 = await example.get(Query.whereId(parent.entryId)) assert.is(resParent2.url, '/new-path') - const res2 = await example.get(Entry({entryId: sub.entryId})) + const res2 = await example.get(Query.whereId(sub.entryId)) assert.is(res2.url, '/new-path/sub') }) test('change published path for entry with language', async () => { const example = createExample() - const db = await example.db - const multi = example.in(example.workspaces.main.multiLanguage) - console.log(await multi.find(Entry())) - const localised3 = await multi.get(Entry({path: 'localised3'})) + const multi = example.locale('en').in(example.workspaces.main.multiLanguage) + const localised3 = await multi.get(Query.wherePath('localised3')) assert.is(localised3.url, '/en/localised2/localised3') // Archive localised3 - await db.applyMutations([archive(localised3)]) + await example.commit(Edit.archive(localised3.entryId)) const localised3Archived = await example.graph.archived .in(example.workspaces.main.multiLanguage) - .get(Entry({path: 'localised3'})) + .get(Query.wherePath('localised3')) assert.is(localised3Archived.phase, EntryPhase.Archived) // And publish again - await db.applyMutations([publish(localised3Archived)]) - const localised3Publish = await multi.get(Entry({path: 'localised3'})) + await example.commit(Edit.publish(localised3.entryId)) + const localised3Publish = await multi.get(Query.wherePath('localised3')) assert.is(localised3Publish.url, '/en/localised2/localised3') }) +test('file upload', async () => { + const example = createExample() + const upload = Edit.upload([ + 'test.txt', + new TextEncoder().encode('Hello, World!') + ]) + await example.commit(upload) + const result = await example.get(Query.whereId(upload.entryId)) + assert.is(result.title, 'test') + assert.is(result.root, 'media') +}) + +test('image upload', async () => { + const example = createExample() + const imageData = readFileSync( + 'apps/web/public/screenshot-2022-09-19-at-12-21-23.2U9fkc81kcSh2InU931HrUJstwD.png' + ) + const upload = Edit.upload(['test.png', imageData], {createPreview}) + await example.commit(upload) + const result = await example.get(Query.whereId(upload.entryId)) + assert.is(result.title, 'test') + assert.is(result.root, 'media') + assert.is(result.data.width, 2880) + assert.is(result.data.height, 1422) + assert.is(result.data.averageColor, '#4b4f59') +}) + +test('field creators', async () => { + const example = createExample() + const {Fields} = example.schema + const entry = Edit.create(Fields) + const list = Edit.list(Fields.list) + .add('Text', { + title: '', + text: Edit.richText(Fields.richText) + .addHtml( + ` +

Test

+

This will be quite useful.

+ ` + ) + .value() + }) + .value() + entry.set({title: 'Fields', list}) + await example.commit(entry) + const res = (await example.get( + Query.whereId(entry.entryId).select(Fields.list) + ))![0] + if (res?.type !== 'Text') throw new Error('Expected Text') + assert.equal(res.text[0], { + type: 'heading', + level: 1, + content: [{type: 'text', text: 'Test'}] + }) +}) + test.run() diff --git a/src/backend/Database.ts b/src/backend/Database.ts index ae8006d1f..913df8c8c 100644 --- a/src/backend/Database.ts +++ b/src/backend/Database.ts @@ -16,6 +16,7 @@ import { import {entryInfo, entryUrl} from 'alinea/core/EntryFilenames' import {EntryRecord, META_KEY, createRecord} from 'alinea/core/EntryRecord' import {Mutation, MutationType} from 'alinea/core/Mutation' +import {createFileHash, createRowHash} from 'alinea/core/util/ContentHash' import {createEntryRow, publishEntryRow} from 'alinea/core/util/EntryRows' import {Logger} from 'alinea/core/util/Logger' import {entries} from 'alinea/core/util/Objects' @@ -30,7 +31,6 @@ import {Target} from './Target.js' import {ChangeSetCreator} from './data/ChangeSet.js' import {AlineaMeta} from './db/AlineaMeta.js' import {createEntrySearch} from './db/CreateEntrySearch.js' -import {createFileHash, createRowHash} from './util/ContentHash.js' interface Seed { type: string diff --git a/src/backend/Media.ts b/src/backend/Media.ts index 2c53beb3f..546fc2620 100644 --- a/src/backend/Media.ts +++ b/src/backend/Media.ts @@ -29,21 +29,4 @@ export namespace Media { export type File = EntryRow> export type Image = EntryRow - - export const imageExtensions = [ - '.jpg', - '.jpeg', - '.png', - '.gif', - '.bmp', - '.webp', - '.avif', - '.heic', - '.svg' - ] - - export function isImage(pathOrExtension: string) { - const extension = pathOrExtension.toLowerCase().split('.').pop() - return extension && imageExtensions.includes(`.${extension}`) - } } diff --git a/src/backend/test/Example.ts b/src/backend/test/Example.ts index 09764d1a0..4bc051bfc 100644 --- a/src/backend/test/Example.ts +++ b/src/backend/test/Example.ts @@ -1,8 +1,25 @@ -import {page, root, type, workspace} from 'alinea/core' +import {Entry, document, page, root, schema, type, workspace} from 'alinea/core' import {createTestCMS} from 'alinea/core/driver/TestDriver' import {createMediaRoot} from 'alinea/core/media/MediaRoot' import {MediaFile, MediaLibrary} from 'alinea/core/media/MediaSchema' -import {path, tab, tabs, text} from 'alinea/input' +import { + check, + code, + date, + entry, + image, + link, + list, + number, + object, + path, + richText, + select, + tab, + tabs, + text, + url +} from 'alinea/input' export function createExample() { const Page = type('Type', { @@ -31,6 +48,101 @@ export function createExample() { } }) + const Fields = document('Fields', { + text: text('Text field'), + hello: text('Validated text field', { + help: 'This field only accepts "hello"', + validate: value => { + if (value !== 'hello') { + return 'Only "hello" is allowed' + } + } + }), + richText: richText('Rich text field'), + select: select('Select field', { + a: 'Option a', + b: 'Option b' + }), + number: number('Number field', { + minValue: 0, + maxValue: 10 + }), + check: check('Check field', {description: 'Check me please'}), + date: date('Date field'), + code: code('Code field'), + externalLink: url('External link'), + entry: entry('Internal link'), + entryWithCondition: entry('With condition', { + help: `Show only entries of type Fields`, + condition: Entry.type.is('Fields') + }), + linkMultiple: link.multiple('Mixed links, multiple'), + image: image('Image link'), + images: image.multiple('Image link (multiple)'), + file: entry('File link'), + withFields: link('With extra fields', { + fields: type({ + fieldA: text('Field A', {width: 0.5}), + fieldB: text('Field B', {width: 0.5}) + }) + }), + multipleWithFields: link.multiple('Multiple With extra fields', { + fields: type({ + fieldA: text('Field A', {width: 0.5}), + fieldB: text('Field B', {width: 0.5, required: true}) + }) + }), + list: list('My list field', { + schema: schema({ + Text: type('Text', { + title: text('Item title'), + text: richText('Item body text') + }), + Image: type('Image', { + image: image('Image') + }) + }) + }), + withInitial: richText('With initial value', { + required: true, + initialValue: [ + { + type: 'paragraph', + content: [ + {type: 'text', text: 'This is a paragraph with initial value'} + ] + } + ] + }), + nested: richText('With nested blocks', { + schema: { + Inner: type('Inner', { + checkbox1: check('Checkbox 1'), + checkbox2: check('Checkbox 2'), + title: text('Title'), + content: richText('Inner rich text') + }), + + NestLayout: type('Nested layout fields', { + object: object('Object field', { + fields: type('Fields', { + fieldA: text('Field A', {width: 0.5}), + fieldB: text('Field B', {width: 0.5}) + }) + }), + ...tabs( + tab('Tab A', { + tabA: text('Tab A') + }), + tab('Tab B', { + tabB: text('Tab B') + }) + ) + }) + } + }) + }) + const main = workspace('Main', { pages: root('Pages', { entry1: page(Page({title: 'Test title'})), @@ -76,7 +188,7 @@ export function createExample() { }) return createTestCMS({ - schema: {Page, Container}, + schema: {Fields, Page, Container}, workspaces: {main} }) } diff --git a/src/cli/Init.test.ts b/src/cli/Init.test.ts index 826ed0f76..06a00cfd3 100644 --- a/src/cli/Init.test.ts +++ b/src/cli/Init.test.ts @@ -1,6 +1,6 @@ import {init} from 'alinea/cli/Init' import fs from 'fs-extra' -import path from 'path' +import path from 'node:path' import {test} from 'uvu' const testPms = false diff --git a/src/cli/Init.ts b/src/cli/Init.ts index 700f6c301..414e75f99 100644 --- a/src/cli/Init.ts +++ b/src/cli/Init.ts @@ -1,7 +1,6 @@ import {createId, outcome} from 'alinea/core' import fs from 'node:fs/promises' import path from 'node:path' -import {generate} from './Generate.js' import {dirname} from './util/Dirname.js' import {findConfigFile} from './util/FindConfigFile.js' @@ -75,8 +74,6 @@ export async function init(options: InitOptions) { ) await fs.writeFile(configFileLocation, configFileContents) const pm = await detectPm() - for await (const _ of generate({cwd: path.resolve(cwd), quiet})) { - } if (options.next) { let [pkg] = await outcome( fs.readFile(path.join(cwd, 'package.json'), 'utf-8') diff --git a/src/cli/generate/CompileConfig.ts b/src/cli/generate/CompileConfig.ts index 113b80728..9ea9bd50e 100644 --- a/src/cli/generate/CompileConfig.ts +++ b/src/cli/generate/CompileConfig.ts @@ -51,6 +51,7 @@ export function compileConfig({ }, platform: 'neutral', jsx: 'automatic', + sourcemap: true, define, loader: { '.module.css': 'local-css', diff --git a/src/core.ts b/src/core.ts index 8e081acff..88e4918ac 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,9 +1,12 @@ +export {createCMS} from 'alinea/core/driver/DefaultDriver' export * from './core/Auth.js' export * from './core/CMS.js' export * from './core/Config.js' export * from './core/Connection.js' export * from './core/Doc.js' +export * from './core/Document.js' export * from './core/Draft.js' +export * from './core/Edit.js' export * from './core/Entry.js' export * from './core/EntryRow.js' export * from './core/Field.js' @@ -17,6 +20,7 @@ export * from './core/Meta.js' export * from './core/Outcome.js' export * from './core/Page.js' export * from './core/Picker.js' +export * from './core/Query.js' export * from './core/Reference.js' export * from './core/Resolver.js' export * from './core/Root.js' @@ -31,7 +35,6 @@ export * from './core/Type.js' export * from './core/User.js' export * from './core/View.js' export * from './core/Workspace.js' -export {createCMS} from './core/driver/DefaultDriver.js' export * from './core/field/ListField.js' export * from './core/field/RecordField.js' export * from './core/field/RichTextField.js' diff --git a/src/core/CMS.ts b/src/core/CMS.ts index f9e43a87f..183ee4252 100644 --- a/src/core/CMS.ts +++ b/src/core/CMS.ts @@ -1,8 +1,9 @@ -import {Store} from 'alinea/backend/Store' import {Config, createConfig} from './Config.js' +import {Connection} from './Connection.js' import {Graph, GraphRealm} from './Graph.js' import {Resolver} from './Resolver.js' import {Root} from './Root.js' +import {Operation, Transaction} from './Transaction.js' import {Workspace} from './Workspace.js' import {entries} from './util/Objects.js' @@ -28,7 +29,14 @@ export abstract class CMS extends GraphRealm { } abstract resolver(): Promise - abstract readStore(): Promise + abstract connection(): Promise + + commit(...operations: Array) { + return Transaction.commit( + this, + operations.map(op => op[Operation.Data]).flat() + ) + } #attach(config: Config) { for (const [name, workspace] of entries(config.workspaces)) { diff --git a/src/core/Edit.ts b/src/core/Edit.ts new file mode 100644 index 000000000..c160be7b7 --- /dev/null +++ b/src/core/Edit.ts @@ -0,0 +1,61 @@ +import {File} from '@alinea/iso' +import { + FieldOptions, + ListEditor, + ListField, + ListRow, + RichTextEditor, + RichTextField, + TextDoc +} from 'alinea/core' +import { + CreateOperation, + DeleteOp, + EditOperation, + UploadOperation, + UploadOptions +} from './Transaction.js' +import {Type} from './Type.js' + +export function Edit(entryId: string, type?: Type) { + return new EditOperation(entryId) +} + +export namespace Edit { + export function create(type: Type) { + return new CreateOperation(type) + } + + export function remove(entryId: string) { + return new DeleteOp(entryId) + } + + export function upload( + file: File | [string, Uint8Array], + options?: UploadOptions + ) { + return new UploadOperation(file, options) + } + + export function archive(entryId: string) { + return Edit(entryId).archive() + } + + export function publish(entryId: string) { + return Edit(entryId).publish() + } + + export function list< + Row extends ListRow, + Options extends FieldOptions> + >(field: ListField, current?: Array) { + return new ListEditor(current) + } + + export function richText( + field: RichTextField, + current?: TextDoc + ) { + return new RichTextEditor(current) + } +} diff --git a/src/core/Edits.test.ts b/src/core/Edits.test.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/core/Field.ts b/src/core/Field.ts index 8ab3b9d47..18be58eb3 100644 --- a/src/core/Field.ts +++ b/src/core/Field.ts @@ -70,6 +70,7 @@ export class Field< export namespace Field { export const Data = Symbol.for('@alinea/Field.Data') + export const Value = Symbol.for('@alinea/Field.Value') export const Ref = Symbol.for('@alinea/Field.Self') export function provideView< diff --git a/src/core/Page.ts b/src/core/Page.ts index e5e7f8476..5ee6df362 100644 --- a/src/core/Page.ts +++ b/src/core/Page.ts @@ -29,7 +29,7 @@ export function page< Definition, Children extends Record> >( - type: Type | Cursor.Partial, + type: Type | Cursor.Typed, children?: Children ): PageSeed { children = children ?? ({} as Children) diff --git a/src/core/Picker.ts b/src/core/Picker.ts index 2f5c848cb..23b77c4de 100644 --- a/src/core/Picker.ts +++ b/src/core/Picker.ts @@ -35,22 +35,20 @@ export interface Picker { postProcess?: PostProcess } -export namespace Picker { - export function withView< - R extends Reference, - T extends {}, - C extends (...args: Array) => Picker - >( - create: C, - views: { - view: ComponentType> - viewRow: ComponentType<{reference: R}> - } - ): C { - const factory = (...args: Array) => { - const field: any = create(...args) - return {...field, ...views} - } - return factory as any +export function pickerWithView< + R extends Reference, + T extends {}, + C extends (...args: Array) => Picker +>( + create: C, + views: { + view: ComponentType> + viewRow: ComponentType<{reference: R}> } +): C { + const factory = (...args: Array) => { + const field: any = create(...args) + return {...field, ...views} + } + return factory as any } diff --git a/src/core/Query.ts b/src/core/Query.ts new file mode 100644 index 000000000..d257f214a --- /dev/null +++ b/src/core/Query.ts @@ -0,0 +1,165 @@ +import {Entry} from './Entry.js' +import {Type} from './Type.js' +import {Cursor, SourceType} from './pages/Cursor.js' +import {EV, Expr} from './pages/Expr.js' +import {Projection} from './pages/Projection.js' + +/* +// Todo: support multiple types +export function Query>( + ...types: Types +): Cursor.Find> { + return undefined! +}*/ + +export function Query( + type: Type +): Cursor.Typed { + return new Cursor.Typed(type) +} + +export namespace Query { + export const and = Expr.and + export const or = Expr.or + export const not = (a: EV) => Expr.create(a).not() + export const is = (a: EV, b: EV) => Expr.create(a).is(b) + export const isNot = (a: EV, b: EV) => Expr.create(a).isNot(b) + export const isNull = (a: EV) => Expr.create(a).isNull() + export const isNotNull = (a: EV) => Expr.create(a).isNotNull() + export const isIn = (a: EV, b: EV> | Cursor.Find) => + Expr.create(a).isIn(b) + export const isNotIn = ( + a: EV, + b: EV> | Cursor.Find + ) => Expr.create(a).isNotIn(b) + export const isGreater = (a: EV, b: EV) => + Expr.create(a).isGreater(b) + export const isGreaterOrEqual = (a: EV, b: EV) => + Expr.create(a).isGreaterOrEqual(b) + export const isLess = (a: EV, b: EV) => Expr.create(a).isLess(b) + export const isLessOrEqual = (a: EV, b: EV) => + Expr.create(a).isLessOrEqual(b) + export const add = ( + a: EV, + b: EV, + ...rest: Array> + ) => Expr.create(a).add(b, ...rest) + export const subtract = ( + a: EV, + b: EV, + ...rest: Array> + ) => Expr.create(a).subtract(b, ...rest) + export const multiply = ( + a: EV, + b: EV, + ...rest: Array> + ) => Expr.create(a).multiply(b, ...rest) + export const divide = ( + a: EV, + b: EV, + ...rest: Array> + ) => Expr.create(a).divide(b, ...rest) + export const remainder = (a: EV, b: EV) => + Expr.create(a).remainder(b) + export const concat = (a: EV, b: EV) => + Expr.create(a).concat(b) + export const like = (a: EV, b: EV) => Expr.create(a).like(b) + export const at = (a: EV>, index: number) => + Expr.create(a).at(index) + export const includes = (a: EV>, value: EV) => + Expr.create(a).includes(value) + + export const url = Entry.url + export const path = Entry.path + export const title = Entry.title + export const id = Entry.entryId + export const parent = Entry.parent + export const type = Entry.type + export const workspace = Entry.workspace + export const root = Entry.root + export const locale = Entry.locale + export const level = Entry.level + + export const entry = { + id, + type, + workspace, + root, + locale, + path, + title, + url, + parent, + level + } + + export const where = (...where: Array>) => Entry().where(...where) + export const whereId = (id: string) => Entry().where(Entry.entryId.is(id)) + export const whereUrl = (url: string) => Entry().where(Entry.url.is(url)) + export const wherePath = (path: string) => Entry().where(Entry.path.is(path)) + export const whereParent = (parentId: string) => + Entry().where(Entry.parent.is(parentId)) + export const whereLocale = (locale: string) => + Entry().where(Entry.locale.is(locale)) + export const whereWorkspace = (workspace: string) => + Entry().where(Entry.workspace.is(workspace)) + export const whereRoot = (root: string) => Entry().where(Entry.root.is(root)) + export const whereType = (type: string) => Entry().where(Entry.type.is(type)) + + export const select = (select: S) => + Entry().select(select) + export const search = (...searchTerms: Array) => + Entry().search(...searchTerms) + + export function children( + type?: Type, + depth = 1 + ): Cursor.Find> { + return new Cursor.Find({ + target: {type}, + source: {type: SourceType.Children, depth} + }) + } + + export function parents(): Cursor.Find> { + return new Cursor.Find({ + source: {type: SourceType.Parents} + }) + } + + export function next( + type?: Type + ): Cursor.Get> { + return new Cursor.Get({ + target: {type}, + source: {type: SourceType.Next} + }) + } + + export function previous( + type?: Type + ): Cursor.Get> { + return new Cursor.Get({ + target: {type}, + source: {type: SourceType.Previous} + }) + } + + export function siblings( + type?: Type + ): Cursor.Find> { + return new Cursor.Find({ + target: {type}, + source: {type: SourceType.Siblings} + }) + } + + export function translations( + type?: Type + ): Cursor.Find> { + return new Cursor.Find({ + target: {type}, + source: {type: SourceType.Translations} + }) + } +} diff --git a/src/core/Transaction.ts b/src/core/Transaction.ts new file mode 100644 index 000000000..d6519f248 --- /dev/null +++ b/src/core/Transaction.ts @@ -0,0 +1,434 @@ +import {Blob, File} from '@alinea/iso' +import {ImagePreviewDetails} from 'alinea/core/media/CreatePreview' +import {CMS} from './CMS.js' +import {Config} from './Config.js' +import {Entry} from './Entry.js' +import { + entryChildrenDir, + entryFileName, + entryFilepath, + entryUrl, + workspaceMediaDir +} from './EntryFilenames.js' +import {EntryPhase, EntryRow} from './EntryRow.js' +import {GraphRealm} from './Graph.js' +import {HttpError} from './HttpError.js' +import {createId} from './Id.js' +import {Mutation, MutationType} from './Mutation.js' +import {Root} from './Root.js' +import {Schema} from './Schema.js' +import {EntryUrlMeta, Type, TypeI} from './Type.js' +import {Workspace} from './Workspace.js' +import {isImage} from './media/IsImage.js' +import {createFileHash} from './util/ContentHash.js' +import {createEntryRow, entryParentPaths} from './util/EntryRows.js' +import {basename, extname, join, normalize} from './util/Paths.js' +import {slugify} from './util/Slugs.js' + +export interface Transaction { + (cms: CMS): Promise> +} +export namespace Transaction { + export async function commit(cms: CMS, tx: Array) { + const mutations = await Promise.all(tx.map(task => task(cms))) + const cnx = await cms.connection() + return cnx.mutate(mutations.flat()) + } +} + +export interface Operation { + [Operation.Data]: Transaction +} + +export class Operation { + static readonly Data = Symbol.for('@alinea/Operation.Data') + + constructor(tx: Transaction) { + this[Operation.Data] = tx + } + + protected typeName(config: Config, type: TypeI) { + const typeNames = Schema.typeNames(config.schema) + const typeName = typeNames.get(type)! + if (!typeName) throw new Error(`Type not found: ${type}`) + return typeName + } +} + +export interface UploadOptions { + createPreview?(blob: Blob): Promise +} + +export class UploadOperation extends Operation { + entryId = createId() + private parentId?: string + private workspace?: string + private root?: string + + constructor(file: File | [string, Uint8Array], options: UploadOptions = {}) { + super(async ({config, graph, connection}): Promise> => { + const fileName = Array.isArray(file) ? file[0] : file.name + const body = Array.isArray(file) ? file[1] : await file.arrayBuffer() + const cnx = await connection() + const workspace = this.workspace ?? Object.keys(config.workspaces)[0] + const root = + this.root ?? Workspace.defaultMediaRoot(config.workspaces[workspace]) + const extension = extname(fileName) + const path = slugify(basename(fileName, extension)) + const directory = workspaceMediaDir(config, workspace) + const uploadLocation = join(directory, path + extension) + const info = await cnx.prepareUpload(uploadLocation) + const previewData = isImage(fileName) + ? await options.createPreview?.( + file instanceof Blob ? file : new Blob([body]) + ) + : undefined + await fetch(info.upload.url, { + method: info.upload.method ?? 'POST', + body + }).then(async result => { + if (!result.ok) + throw new HttpError( + result.status, + `Could not reach server for upload` + ) + }) + const parent = this.parentId + ? await graph.preferPublished.get(Entry({entryId: this.parentId})) + : undefined + const title = basename(fileName, extension) + const hash = await createFileHash(new Uint8Array(body)) + const {mediaDir} = Workspace.data(config.workspaces[workspace]) + const prefix = mediaDir && normalize(mediaDir) + const fileLocation = + prefix && info.location.startsWith(prefix) + ? info.location.slice(prefix.length) + : info.location + const entryData = { + title, + location: fileLocation, + extension, + size: body.byteLength, + hash, + ...previewData + } + const entry = await createEntry( + config, + 'MediaFile', + { + path, + entryId: this.entryId, + workspace, + root, + data: entryData + }, + parent + ) + const parentPaths = entryParentPaths(config, entry) + const entryFile = entryFileName(config, entry, parentPaths) + return [ + { + type: MutationType.Upload, + entryId: this.entryId, + url: info.previewUrl, + file: info.location + }, + { + type: MutationType.Create, + entryId: entry.entryId, + file: entryFile, + entry + } + ] + }) + } + + setParent(parentId: string) { + this.parentId = parentId + return this + } + + setWorkspace(workspace: string) { + this.workspace = workspace + return this + } + + setRoot(root: string) { + this.root = root + return this + } +} + +export class DeleteOp extends Operation { + constructor(protected entryId: string) { + super(async ({config, graph}) => { + const entry = await graph.preferPublished.get( + Entry({entryId: this.entryId}) + ) + const parentPaths = entryParentPaths(config, entry) + const file = entryFileName(config, entry, parentPaths) + return [ + { + type: MutationType.Remove, + entryId: this.entryId, + file + } + ] + }) + } +} + +export class EditOperation extends Operation { + private entryData?: Partial> + private changePhase?: EntryPhase + + constructor(protected entryId: string) { + super(async ({config, graph}) => { + let realm: GraphRealm + if (this.changePhase === EntryPhase.Draft) realm = graph.preferDraft + else if (this.changePhase === EntryPhase.Archived) + realm = graph.preferPublished + else if (this.changePhase === EntryPhase.Published) + realm = graph.preferDraft + else realm = graph.preferPublished + const entry = await realm.get(Entry({entryId: this.entryId})) + const parent = entry.parent + ? await graph.preferPublished.get(Entry({entryId: entry.parent})) + : undefined + const parentPaths = entryParentPaths(config, entry) + + const file = entryFileName( + config, + {...entry, phase: entry.phase}, + parentPaths + ) + const type = config.schema[entry.type] + const mutations: Array = [] + const createDraft = this.changePhase === EntryPhase.Draft + if (createDraft) + mutations.push({ + type: MutationType.Edit, + entryId: this.entryId, + file, + entry: await createEntry( + config, + this.typeName(config, type), + { + ...entry, + phase: EntryPhase.Draft, + data: {...entry.data, ...this.entryData} + }, + parent + ) + }) + else if (this.entryData) + mutations.push({ + type: MutationType.Patch, + entryId: this.entryId, + file, + patch: this.entryData + }) + switch (this.changePhase) { + case EntryPhase.Published: + mutations.push({ + type: MutationType.Publish, + phase: entry.phase, + entryId: this.entryId, + file + }) + break + case EntryPhase.Archived: + mutations.push({ + type: MutationType.Archive, + entryId: this.entryId, + file + }) + break + } + return mutations + }) + } + + set(entryData: Partial>) { + this.entryData = {...this.entryData, ...entryData} + return this + } + + /*moveTo(workspace: string, root: string, parentId?: string) { + throw new Error(`Not implemented`) + return this + } + + createAfter(type: Type) { + throw new Error(`Not implemented`) + return new CreateOp(type) + } + + createBefore(type: Type) { + throw new Error(`Not implemented`) + return new CreateOp(type) + } + + createChild(type: Type) { + throw new Error(`Not implemented`) + return new CreateOp(type) + }*/ + + draft() { + this.changePhase = EntryPhase.Draft + return this + } + + archive() { + this.changePhase = EntryPhase.Archived + return this + } + + publish() { + this.changePhase = EntryPhase.Published + return this + } +} + +export class CreateOperation extends Operation { + entryId = createId() + private workspace?: string + private root?: string + private locale?: string | null + private entryData: Partial> = {} + private entryRow = async (cms: CMS) => { + return createEntry( + cms.config, + this.typeName(cms.config, this.type), + {entryId: this.entryId, data: this.entryData ?? {}}, + await this.parentRow?.(cms) + ) + } + + constructor( + protected type: Type, + protected parentRow?: (cms: CMS) => Promise + ) { + super(async (cms): Promise> => { + const parent = await this.parentRow?.(cms) + const {config} = cms + const entry = await createEntry( + config, + this.typeName(config, type), + { + entryId: this.entryId, + workspace: this.workspace, + root: this.root, + locale: this.locale, + data: this.entryData ?? {} + }, + parent + ) + const parentPaths = entryParentPaths(config, entry) + const file = entryFileName(config, entry, parentPaths) + return [ + { + type: MutationType.Create, + entryId: this.entryId, + file, + entry: entry + } + ] + }) + } + + setParent(parentId: string) { + this.parentRow = async (cms: CMS) => { + return cms.graph.preferPublished.get(Entry({entryId: parentId})) + } + return this + } + + setWorkspace(workspace: string) { + this.workspace = workspace + return this + } + + setRoot(root: string) { + this.root = root + return this + } + + setLocale(locale: string | null) { + this.locale = locale + return this + } + + set(entryData: Partial>) { + this.entryData = {...this.entryData, ...entryData} + return this + } + + createChild(type: Type) { + return new CreateOperation(type, this.entryRow) + } +} + +async function createEntry( + config: Config, + typeName: string, + partial: Partial = {title: 'Entry'}, + parent?: EntryRow +): Promise { + const type = config.schema[typeName] + const workspace = + parent?.workspace ?? partial.workspace ?? Object.keys(config.workspaces)[0] + const root = + parent?.root ?? partial.root ?? Object.keys(config.workspaces[workspace])[0] + const locale = + parent?.locale ?? + partial.locale ?? + Root.defaultLocale(config.workspaces[workspace][root]) ?? + null + const title = partial.data.title ?? partial.title ?? 'Entry' + const phase = partial.phase ?? EntryPhase.Published + const path = slugify( + (phase === EntryPhase.Published && partial.data.path) || + (partial.path ?? title) + ) + const entryData = {title, path, ...partial.data} + const entryId = partial.entryId ?? createId() + const i18nId = partial.i18nId ?? createId() + const details = { + entryId, + phase, + type: typeName, + title, + path, + seeded: null, + workspace: workspace, + root: root, + level: parent ? parent.level + 1 : 0, + parent: parent?.entryId ?? null, + locale, + index: 'a0', + i18nId, + modifiedAt: 0, + active: true, + main: true, + data: entryData, + searchableText: Type.searchableText(type, entryData) + } + const parentPaths = parent?.childrenDir.split('/').filter(Boolean) ?? [] + const filePath = entryFilepath(config, details, parentPaths) + const childrenDir = entryChildrenDir(config, details, parentPaths) + const urlMeta: EntryUrlMeta = { + locale, + path, + phase, + parentPaths + } + const url = entryUrl(type, urlMeta) + return createEntryRow(config, { + ...details, + filePath, + childrenDir, + parentDir: childrenDir.split('/').slice(0, -1).join('/'), + url + }) +} diff --git a/src/core/Type.ts b/src/core/Type.ts index 12dc57eae..c09fd3512 100644 --- a/src/core/Type.ts +++ b/src/core/Type.ts @@ -1,6 +1,6 @@ import {EntryPhase, Expand} from 'alinea/core' import {Cursor} from 'alinea/core/pages/Cursor' -import {Expr, and} from 'alinea/core/pages/Expr' +import {Expr} from 'alinea/core/pages/Expr' import {EntryEditProps} from 'alinea/dashboard/view/EntryEdit' import {Callable} from 'rado/util/Callable' import type {ComponentType} from 'react' @@ -70,19 +70,19 @@ export declare class TypeI { export interface TypeI extends Callable { (): Cursor.Find> - (partial: Partial>): Cursor.Partial + (partial: Partial>): Cursor.Typed } export type Type = Definition & TypeI -export type TypeRow = Expand<{ +type TypeRow = Expand<{ [K in keyof Definition as Definition[K] extends Expr ? K : never]: Definition[K] extends Expr ? T : never }> - export namespace Type { export type Infer = TypeRow + export const Data = Symbol.for('@alinea/Type.Data') export function label(type: Type): Label { @@ -220,7 +220,7 @@ class TypeInstance implements TypeData { const conditions = isConditionalRecord ? entries(input[0]).map(([key, value]) => { const field = Expr(ExprData.Field({type: this.target}, key)) - return Expr( + return Expr( ExprData.BinOp( field[Expr.Data], BinaryOp.Equals, @@ -228,13 +228,13 @@ class TypeInstance implements TypeData { ) ) }) - : input.map(ev => Expr(createExprData(ev))) - return and(...conditions)[Expr.Data] + : input.map(ev => Expr(createExprData(ev))) + return Expr.and(...conditions)[Expr.Data] } call(...input: Array) { const isConditionalRecord = input.length === 1 && !Expr.isExpr(input[0]) - if (isConditionalRecord) return new Cursor.Partial(this.target, input[0]) + if (isConditionalRecord) return new Cursor.Typed(this.target, input[0]) else return new Cursor.Find({ target: {type: this.target}, diff --git a/src/core/Workspace.ts b/src/core/Workspace.ts index a4b9ee84d..3e33d5ff6 100644 --- a/src/core/Workspace.ts +++ b/src/core/Workspace.ts @@ -3,7 +3,9 @@ import {CMS} from './CMS.js' import {Label} from './Label.js' import {Meta, StripMeta} from './Meta.js' import {Root} from './Root.js' +import {isMediaRoot} from './media/MediaRoot.js' import {getRandomColor} from './util/GetRandomColor.js' +import {entries} from './util/Objects.js' export interface WorkspaceMeta { /** A directory which contains the json entry files */ @@ -52,6 +54,13 @@ export namespace Workspace { export function isWorkspace(value: any): value is Workspace { return Boolean(value && value[Workspace.Data]) } + + export function defaultMediaRoot(workspace: Workspace): string { + const {roots} = workspace[Workspace.Data] + for (const [name, root] of entries(roots)) + if (isMediaRoot(root)) return name + throw new Error(`Workspace ${workspace.name} has no media root`) + } } /** Create a workspace */ diff --git a/src/core/driver/DefaultDriver.server.tsx b/src/core/driver/DefaultDriver.server.tsx index 7128f3910..2dad1b1d0 100644 --- a/src/core/driver/DefaultDriver.server.tsx +++ b/src/core/driver/DefaultDriver.server.tsx @@ -1,22 +1,30 @@ import {Database} from 'alinea/backend' -import {Store, createStore} from 'alinea/backend/Store' +import {createStore} from 'alinea/backend/Store' import {EntryResolver} from 'alinea/backend/resolver/EntryResolver' +import {createCloudHandler} from 'alinea/cloud/server/CloudHandler' import {base64} from 'alinea/core/util/Encoding' import PLazy from 'p-lazy' import {CMS} from '../CMS.js' import {Client} from '../Client.js' import {Config} from '../Config.js' +import {Connection} from '../Connection.js' import {Resolver} from '../Resolver.js' import {Realm} from '../pages/Realm.js' +import {Logger} from '../util/Logger.js' -export class DefaultDriver extends CMS { - db = PLazy.from(this.createDb.bind(this)) +const store = PLazy.from(async () => { + // @ts-ignore + const {storeData} = await import('@alinea/generated/store.js') + return createStore(new Uint8Array(base64.parse(storeData))) +}) - async readStore(): Promise { - // @ts-ignore - const {storeData} = await import('@alinea/generated/store.js') - return createStore(new Uint8Array(base64.parse(storeData))) - } +export class DefaultDriver extends CMS { + apiKey = process.env.ALINEA_API_KEY + db = PLazy.from(async () => new Database(this.config, await store)) + cloudHandler = PLazy.from(async () => { + const db = await this.db + return createCloudHandler(this.config, db, this.apiKey) + }) async resolver(): Promise { const devUrl = process.env.ALINEA_DEV_SERVER @@ -32,8 +40,12 @@ export class DefaultDriver extends CMS { return new EntryResolver(await this.db, this.config.schema) } - private async createDb() { - return new Database(this.config, await this.readStore()) + async connection(): Promise { + const devUrl = process.env.ALINEA_DEV_SERVER + if (devUrl) return new Client({config: this.config, url: devUrl}) + return (await this.cloudHandler).connect({ + logger: new Logger('driver') + }) } } diff --git a/src/core/driver/DefaultDriver.tsx b/src/core/driver/DefaultDriver.tsx index b60c9e777..6d4206b48 100644 --- a/src/core/driver/DefaultDriver.tsx +++ b/src/core/driver/DefaultDriver.tsx @@ -1,6 +1,7 @@ import {Store} from 'alinea/backend/Store' import {CMS} from '../CMS.js' import {Config} from '../Config.js' +import {Connection} from '../Connection.js' import {Resolver} from '../Resolver.js' export class DefaultDriver extends CMS { @@ -11,6 +12,10 @@ export class DefaultDriver extends CMS { async resolver(): Promise { throw new Error('Not implemented') } + + async connection(): Promise { + throw new Error('Not implemented') + } } export function createCMS( diff --git a/src/core/driver/NextDriver.server.tsx b/src/core/driver/NextDriver.server.tsx index 0126db9c0..99906d3bf 100644 --- a/src/core/driver/NextDriver.server.tsx +++ b/src/core/driver/NextDriver.server.tsx @@ -1,7 +1,6 @@ 'use server' import {JWTPreviews} from 'alinea/backend' -import {createCloudHandler} from 'alinea/cloud/server/CloudHandler' import {parseChunkedCookies} from 'alinea/preview/ChunkCookieValue' import { PREVIEW_ENTRYID_NAME, @@ -10,7 +9,6 @@ import { } from 'alinea/preview/PreviewConstants' import {enums, object, string} from 'cito' import dynamic from 'next/dynamic.js' -import PLazy from 'p-lazy' import {Suspense} from 'react' import {Client} from '../Client.js' import {Config} from '../Config.js' @@ -99,11 +97,6 @@ class NextDriver extends DefaultDriver implements NextApi { return response ?? new Response('Not found', {status: 404}) } - cloudHandler = PLazy.from(async () => { - const db = await this.db - return createCloudHandler(this.config, db, this.apiKey) - }) - previewHandler = async (request: Request) => { const {draftMode, cookies} = await import('next/headers.js') const {searchParams} = new URL(request.url) diff --git a/src/core/driver/TestDriver.ts b/src/core/driver/TestDriver.ts index 085fa2eaf..87dfc459c 100644 --- a/src/core/driver/TestDriver.ts +++ b/src/core/driver/TestDriver.ts @@ -1,20 +1,21 @@ import sqlite from '@alinea/sqlite-wasm' import {Database, Handler, JWTPreviews} from 'alinea/backend' import {Store} from 'alinea/backend/Store' +import type {AddressInfo} from 'node:net' import {connect} from 'rado/driver/sql.js' import {CMS} from '../CMS.js' import {Config} from '../Config.js' import {Connection} from '../Connection.js' +import {createId} from '../Id.js' import {Resolver} from '../Resolver.js' import {Logger} from '../util/Logger.js' -import {DefaultDriver} from './DefaultDriver.js' export interface TestApi extends CMS { db: Promise connection(): Promise } -class TestDriver extends DefaultDriver implements TestApi { +class TestDriver extends CMS implements TestApi { store: Promise = sqlite().then(({Database}) => connect(new Database()).toAsync() ) @@ -28,22 +29,32 @@ class TestDriver extends DefaultDriver implements TestApi { config: this.config, db, previews: new JWTPreviews('test'), - previewAuthToken: 'test' + previewAuthToken: 'test', + target: { + mutate: async ({mutations}) => { + return {commitHash: createId()} + } + }, + media: { + async prepareUpload(file: string) { + const id = createId() + const serve = await listenForUpload() + return { + entryId: createId(), + location: `media/${file}_${id}`, + previewUrl: `media/${file}_${id}`, + upload: { + url: serve.url + } + } + } + } }) return handler.connect({logger: new Logger('test')}) }) - async exportStore(outDir: string, data: Uint8Array) {} - async readStore(): Promise { - return this.store - } - async connection(): Promise { - return this.handler - } - - async resolver(): Promise { - return this.handler - } + connection = (): Promise => this.handler + resolver = (): Promise => this.handler } export function createTestCMS( @@ -51,3 +62,18 @@ export function createTestCMS( ): Definition & TestApi & CMS { return new TestDriver(config) as any } + +async function listenForUpload(): Promise<{url: string}> { + const {createServer} = await import('http') + const server = createServer((req, res) => { + res.end() + server.close() + }) + return new Promise(resolve => { + server.listen(0, () => { + resolve({ + url: `http://localhost:${(server.address() as AddressInfo).port}` + }) + }) + }) +} diff --git a/src/core/field/ListField.ts b/src/core/field/ListField.ts index 8e02e6202..ab5a423d2 100644 --- a/src/core/field/ListField.ts +++ b/src/core/field/ListField.ts @@ -1,6 +1,8 @@ import {Field, FieldMeta, FieldOptions} from '../Field.js' +import {createId} from '../Id.js' import {ListMutator, ListRow, ListShape} from '../shape/ListShape.js' import {RecordShape} from '../shape/RecordShape.js' +import {generateKeyBetween} from '../util/FractionalIndexing.js' export class ListField< Row extends ListRow, @@ -21,3 +23,44 @@ export class ListField< }) } } + +export type ListRowInput = Omit< + Extract, + 'type' | 'id' | 'index' +> + +export class ListEditor { + constructor(private rows: Array = []) {} + + insertAt( + insertAt: number, + type: Key, + row: ListRowInput + ) { + const id = createId() + const before = insertAt - 1 + const after = before + 1 + const keyA = this.rows[before]?.index || null + const keyB = this.rows[after]?.index || null + this.rows.push({ + id, + index: generateKeyBetween(keyA, keyB), + type, + ...row + } as any) + return this + } + + add(type: Key, row: ListRowInput) { + return this.insertAt(this.rows.length, type, row) + } + + removeAt(index: number) { + this.rows.splice(index, 1) + return this + } + + value() { + return this.rows + } +} diff --git a/src/core/field/RichTextField.ts b/src/core/field/RichTextField.ts index b02708863..31e6fd74b 100644 --- a/src/core/field/RichTextField.ts +++ b/src/core/field/RichTextField.ts @@ -1,6 +1,7 @@ import {RichTextMutator, RichTextShape} from 'alinea/core' +import {Parser} from 'htmlparser2' import {Field, FieldMeta, FieldOptions} from '../Field.js' -import {TextDoc} from '../TextDoc.js' +import {TextDoc, TextNode} from '../TextDoc.js' import {RecordShape} from '../shape/RecordShape.js' export class RichTextField< @@ -22,3 +23,82 @@ export class RichTextField< }) } } + +export class RichTextEditor { + constructor(private doc: TextDoc = []) {} + + addHtml(html: string) { + this.doc.push(...parseHTML(html.trim())) + return this + } + + value() { + return this.doc + } +} + +function mapNode( + name: string, + attributes: Record +): TextNode.Element | undefined { + switch (name) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + const type = 'heading' + const level = Number(name.slice(1)) + return {type, level, content: []} + case 'p': + return {type: 'paragraph', content: []} + case 'b': + case 'strong': + return {type: 'bold', content: []} + case 'i': + case 'em': + return {type: 'italic', content: []} + case 'ul': + return {type: 'unorderedList', content: []} + case 'ol': + return {type: 'orderedList', content: []} + case 'li': + return {type: 'listItem', content: []} + case 'blockquote': + return {type: 'blockquote', content: []} + case 'hr': + return {type: 'horizontalRule'} + case 'br': + return {type: 'hardBreak'} + case 'small': + return {type: 'small', content: []} + case 'a': + // Todo: pick what we need + return {type: 'link', ...attributes, content: []} + } +} + +export function parseHTML(html: string): TextDoc { + const doc: TextDoc = [] + if (typeof html !== 'string') return doc + let parents: Array | undefined> = [doc] + const parser = new Parser({ + onopentag(name, attributes) { + const node = mapNode(name, attributes) + const parent = parents[parents.length - 1] + if (node) parent?.push(node) + parents.push(node?.content) + }, + ontext(text) { + const parent = parents[parents.length - 1] + parent?.push({type: 'text', text}) + }, + onclosetag() { + parents.pop() + } + }) + parser.write(html) + parser.end() + return doc +} diff --git a/src/core/media/CreatePreview.browser.ts b/src/core/media/CreatePreview.browser.ts new file mode 100644 index 000000000..74f1bc635 --- /dev/null +++ b/src/core/media/CreatePreview.browser.ts @@ -0,0 +1,68 @@ +import {base64} from 'alinea/core/util/Encoding' +import {rgba, toHex} from 'color2k' +import smartcrop from 'smartcrop' +import {rgbaToThumbHash, thumbHashToAverageRGBA} from 'thumbhash' +import type {ImagePreviewDetails} from './CreatePreview.js' + +export {ImagePreviewDetails} + +export async function createPreview(blob: Blob): Promise { + const url = URL.createObjectURL(blob) + + // Load the image + const image = await new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = err => reject(err) + image.src = url + }).finally(() => URL.revokeObjectURL(url)) + + const size = Math.max(image.width, image.height) + + // Scale the image to 100x100 maximum size + const thumbW = Math.round((100 * image.width) / size) + const thumbH = Math.round((100 * image.height) / size) + const thumbCanvas = document.createElement('canvas') + const thumbContext = thumbCanvas.getContext('2d')! + thumbCanvas.width = thumbW + thumbCanvas.height = thumbH + thumbContext.drawImage(image, 0, 0, thumbW, thumbH) + + // Calculate thumbhash + const pixels = thumbContext.getImageData(0, 0, thumbW, thumbH) + const thumbHash = rgbaToThumbHash(thumbW, thumbH, pixels.data) + + // Get the average color via thumbhash + const {r, g, b, a} = thumbHashToAverageRGBA(thumbHash) + const averageColor = toHex(rgba(r * 255, g * 255, b * 255, a)) + + // Create webp preview image + const previewW = Math.min(Math.round((160 * image.width) / size), image.width) + const previewH = Math.min( + Math.round((160 * image.height) / size), + image.height + ) + const previewCanvas = document.createElement('canvas') + const previewContext = previewCanvas.getContext('2d')! + previewContext.imageSmoothingEnabled = true + previewContext.imageSmoothingQuality = 'high' + previewCanvas.width = previewW + previewCanvas.height = previewH + previewContext.drawImage(image, 0, 0, previewW, previewH) + const preview = previewCanvas.toDataURL('image/webp') + + const crop = await smartcrop.crop(image, {width: 100, height: 100}) + const focus = { + x: (crop.topCrop.x + crop.topCrop.width / 2) / image.width, + y: (crop.topCrop.y + crop.topCrop.height / 2) / image.height + } + + return { + preview, + averageColor, + focus, + thumbHash: base64.stringify(thumbHash), + width: image.naturalWidth, + height: image.naturalHeight + } +} diff --git a/src/core/media/CreatePreview.ts b/src/core/media/CreatePreview.ts new file mode 100644 index 000000000..4954eef1a --- /dev/null +++ b/src/core/media/CreatePreview.ts @@ -0,0 +1,54 @@ +import {base64} from 'alinea/core/util/Encoding' +import {rgba, toHex} from 'color2k' +import {rgbaToThumbHash, thumbHashToAverageRGBA} from 'thumbhash' + +export interface ImagePreviewDetails { + width: number + height: number + averageColor: string + focus: {x: number; y: number} + thumbHash: string + preview: string +} + +export async function createPreview(blob: Blob): Promise { + const {default: sharp} = await import('sharp' + '').catch(() => { + throw new Error( + `To create image previews server side you need to install the 'sharp' package` + ) + }) + const image = sharp(await blob.arrayBuffer()) + const metadata = await image.metadata() + const width = metadata.width ?? 0 + const height = metadata.height ?? 0 + + // Scale the image to 100x100 maximum size + const scaledImage = image.resize(100, 100, { + fit: 'inside' + }) + const {data, info} = await scaledImage + .ensureAlpha() + .raw() + .toBuffer({resolveWithObject: true}) + const thumbHash = rgbaToThumbHash(info.width, info.height, data) + + // Get the average color via thumbhash + const {r, g, b, a} = thumbHashToAverageRGBA(thumbHash) + const averageColor = toHex(rgba(r * 255, g * 255, b * 255, a)) + + // Create webp preview image + const previewImage = image.resize(160, 160, { + fit: 'inside' + }) + const previewBuffer = await previewImage.webp().toBuffer() + const preview = `data:image/webp;base64,${previewBuffer.toString('base64')}` + + return { + width, + height, + thumbHash: base64.stringify(thumbHash), + averageColor, + preview, + focus: {x: 0.5, y: 0.5} + } +} diff --git a/src/core/media/IsImage.ts b/src/core/media/IsImage.ts new file mode 100644 index 000000000..b9946fc03 --- /dev/null +++ b/src/core/media/IsImage.ts @@ -0,0 +1,16 @@ +export const imageExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.bmp', + '.webp', + '.avif', + '.heic', + '.svg' +] + +export function isImage(pathOrExtension: string) { + const extension = pathOrExtension.toLowerCase().split('.').pop() + return extension && imageExtensions.includes(`.${extension}`) +} diff --git a/src/core/package.json b/src/core/package.json index 90a89676f..bb8d8a885 100644 --- a/src/core/package.json +++ b/src/core/package.json @@ -3,6 +3,7 @@ "type": "module", "devDependencies": { "cito": "^0.2.0", + "htmlparser2": "^9.1.0", "p-lazy": "^4.0.0", "pretty-ms": "^8.0.0", "yjs": "^13.6.11" diff --git a/src/core/pages/Condition.ts b/src/core/pages/Condition.ts new file mode 100644 index 000000000..cadd05f07 --- /dev/null +++ b/src/core/pages/Condition.ts @@ -0,0 +1,3 @@ +import {Expr, HasExpr} from './Expr.js' + +export type Condition = Expr | HasExpr diff --git a/src/core/pages/Cursor.ts b/src/core/pages/Cursor.ts index 776ef013b..e31186dba 100644 --- a/src/core/pages/Cursor.ts +++ b/src/core/pages/Cursor.ts @@ -1,9 +1,10 @@ import {array, boolean, enums, number, object, string} from 'cito' import {Type} from '../Type.js' import {entries} from '../util/Objects.js' +import {Condition} from './Condition.js' import {createExprData} from './CreateExprData.js' import {createSelection} from './CreateSelection.js' -import {EV, Expr, and} from './Expr.js' +import {EV, Expr, HasExpr} from './Expr.js' import {BinaryOp, ExprData} from './ExprData.js' import {Projection} from './Projection.js' import {Selection} from './Selection.js' @@ -62,13 +63,19 @@ export interface Cursor { } declare const brand: unique symbol -export class Cursor { +export class Cursor implements HasExpr { declare [brand]: T constructor(data: CursorData) { this[Cursor.Data] = data } + [Expr.ToExpr]() { + // Todo: this has to take target into account + const {where} = this[Cursor.Data] + return Expr(where ?? ExprData.Value(true)) + } + protected with(data: Partial): CursorData { return {...this[Cursor.Data], ...data} } @@ -86,11 +93,59 @@ export namespace Cursor { export const Data = Symbol.for('@alinea/Cursor.Data') export class Find extends Cursor> { - where(...where: Array>): Find { + where(...where: Array): Find { const current = this[Cursor.Data].where return new Find( this.with({ - where: and(current ? Expr(current) : true, ...where)[Expr.Data] + where: Expr.and(current ? Expr(current) : true, ...where)[Expr.Data] + }) + ) + } + + whereUrl(url: string): Find { + return new Find( + this.with({ + where: Expr(ExprData.Field({}, 'url')).is(url)[Expr.Data] + }) + ) + } + + wherePath(path: string): Find { + return new Find( + this.with({ + where: Expr(ExprData.Field({}, 'path')).is(path)[Expr.Data] + }) + ) + } + + whereParent(parentId: string): Find { + return new Find( + this.with({ + where: Expr(ExprData.Field({}, 'parent')).is(parentId)[Expr.Data] + }) + ) + } + + whereLocale(locale: string): Find { + return new Find( + this.with({ + where: Expr(ExprData.Field({}, 'locale')).is(locale)[Expr.Data] + }) + ) + } + + whereRoot(root: string): Find { + return new Find( + this.with({ + where: Expr(ExprData.Field({}, 'root')).is(root)[Expr.Data] + }) + ) + } + + whereWorkspace(workspace: string): Find { + return new Find( + this.with({ + where: Expr(ExprData.Field({}, 'workspace')).is(workspace)[Expr.Data] }) ) } @@ -143,17 +198,35 @@ export namespace Cursor { } } - export class Partial extends Find> { + export class Typed extends Find> { constructor( public type: Type, - public partial: Partial> + public partial: Partial> = {} ) { super({ target: {type}, - where: Partial.condition(type, partial) + where: Typed.condition(type, partial) }) } + where(partial: Partial>): Typed + where(...where: Array>): Find> + where(...input: Array): any { + const isConditionalRecord = input.length === 1 && !Expr.isExpr(input[0]) + const current = this[Cursor.Data].where + if (isConditionalRecord) { + return new Typed(this.type, { + ...this.partial, + ...input[0] + }) + } + return new Find( + this.with({ + where: Expr.and(current ? Expr(current) : true, ...input)[Expr.Data] + }) + ) + } + static condition( type: Type, input: Record @@ -168,7 +241,7 @@ export namespace Cursor { ) ) }) - return and(...conditions)[Expr.Data] + return Expr.and(...conditions)[Expr.Data] } } @@ -177,7 +250,7 @@ export namespace Cursor { const current = this[Cursor.Data].where return new Get( this.with({ - where: and(current ? Expr(current) : true, ...where)[Expr.Data] + where: Expr.and(current ? Expr(current) : true, ...where)[Expr.Data] }) ) } diff --git a/src/core/pages/Expr.ts b/src/core/pages/Expr.ts index 2c56c233c..017ad81d3 100644 --- a/src/core/pages/Expr.ts +++ b/src/core/pages/Expr.ts @@ -1,3 +1,4 @@ +import {Condition} from './Condition.js' import {createExprData} from './CreateExprData.js' import {createSelection} from './CreateSelection.js' import {Cursor, OrderBy, OrderDirection} from './Cursor.js' @@ -14,15 +15,19 @@ export function Expr(expr: ExprData): Expr { return new ExprI(expr) } +export interface HasExpr { + [Expr.ToExpr](): Expr +} + export interface ExprI { [Expr.Data]: ExprData - [Expr.IsExpr]: boolean + [Expr.ExprRef]: boolean } export class ExprI { constructor(expr: ExprData) { this[Expr.Data] = expr - this[Expr.IsExpr] = true + this[Expr.ExprRef] = true } asc(): OrderBy { @@ -107,6 +112,10 @@ export class ExprI { ) } + isBetween(min: EV, max: EV): Expr { + return this.isGreaterOrEqual(min).and(this.isLessOrEqual(max)) + } + isGreater(that: EV): Expr { return Expr( ExprData.BinOp(this[Expr.Data], BinaryOp.Greater, createExprData(that)) @@ -139,21 +148,45 @@ export class ExprI { ) } - add(this: Expr, that: EV): Expr { + add( + this: Expr, + that: EV, + ...rest: Array> + ): Expr + add(...args: Array>): Expr { return Expr( - ExprData.BinOp(this[Expr.Data], BinaryOp.Add, createExprData(that)) + args + .map(Expr.create) + .map(expr => expr[Expr.Data]) + .reduce((a, b) => ExprData.BinOp(a, BinaryOp.Add, b)) ) } - substract(this: Expr, that: EV): Expr { + subtract( + this: Expr, + that: EV, + ...rest: Array> + ): Expr + subtract(...args: Array>): Expr { return Expr( - ExprData.BinOp(this[Expr.Data], BinaryOp.Subt, createExprData(that)) + args + .map(Expr.create) + .map(expr => expr[Expr.Data]) + .reduce((a, b) => ExprData.BinOp(a, BinaryOp.Subt, b)) ) } - multiply(this: Expr, that: EV): Expr { + multiply( + this: Expr, + that: EV, + ...rest: Array> + ): Expr + multiply(...args: Array>): Expr { return Expr( - ExprData.BinOp(this[Expr.Data], BinaryOp.Mult, createExprData(that)) + args + .map(Expr.create) + .map(expr => expr[Expr.Data]) + .reduce((a, b) => ExprData.BinOp(a, BinaryOp.Mult, b)) ) } @@ -163,9 +196,17 @@ export class ExprI { ) } - divide(this: Expr, that: EV): Expr { + divide( + this: Expr, + that: EV, + ...rest: Array> + ): Expr + divide(...args: Array>): Expr { return Expr( - ExprData.BinOp(this[Expr.Data], BinaryOp.Div, createExprData(that)) + args + .map(Expr.create) + .map(expr => expr[Expr.Data]) + .reduce((a, b) => ExprData.BinOp(a, BinaryOp.Div, b)) ) } @@ -175,6 +216,18 @@ export class ExprI { ) } + endsWith(this: Expr, that: string): Expr { + return Expr( + ExprData.BinOp(this[Expr.Data], BinaryOp.Like, createExprData(`%${that}`)) + ) + } + + startsWith(this: Expr, that: string): Expr { + return Expr( + ExprData.BinOp(this[Expr.Data], BinaryOp.Like, createExprData(`${that}%`)) + ) + } + like(this: Expr, that: EV): Expr { return Expr( ExprData.BinOp(this[Expr.Data], BinaryOp.Like, createExprData(that)) @@ -203,7 +256,7 @@ export class ExprI { expr: EV, select: S ): CaseBuilder> { - return new CaseBuilder(this).when(expr, select) + return new CaseBuilder>(this).when(expr, select) } at(this: Expr>, index: number): Expr { @@ -256,27 +309,9 @@ export class CaseBuilder { } } -export function and(...conditions: Array>): Expr { - return conditions - .map(Expr.create) - .reduce( - (condition, expr) => condition.and(expr), - Expr(ExprData.Value(true)) - ) -} - -export function or(...conditions: Array>): Expr { - return conditions - .map(Expr.create) - .reduce( - (condition, expr) => condition.or(expr), - Expr(ExprData.Value(false)) - ) -} - export namespace Expr { export const Data = Symbol.for('@alinea/Expr.Data') - export const IsExpr = Symbol.for('@alinea/Expr.IsExpr') + export const ExprRef = Symbol.for('@alinea/Expr.ExprRef') export const ToExpr = Symbol.for('@alinea/Expr.ToExpr') export const NULL = create(null) @@ -285,11 +320,12 @@ export namespace Expr { }*/ export function create(input: EV): Expr { + if (hasExpr(input)) return input[Expr.ToExpr]() if (isExpr(input)) return input return Expr(ExprData.Value(input)) } - export function hasExpr(input: any): input is {[Expr.ToExpr](): Expr} { + export function hasExpr(input: any): input is HasExpr { return ( input && (typeof input === 'function' || typeof input === 'object') && @@ -301,7 +337,27 @@ export namespace Expr { return ( input !== null && (typeof input === 'object' || typeof input === 'function') && - input[Expr.IsExpr] + input[Expr.ExprRef] ) } + + export function and( + ...conditions: Array + ): Expr { + return conditions + .map(Expr.create) + .reduce( + (condition, expr) => condition.and(expr), + Expr(ExprData.Value(true)) + ) + } + + export function or(...conditions: Array): Expr { + return conditions + .map(Expr.create) + .reduce( + (condition, expr) => condition.or(expr), + Expr(ExprData.Value(false)) + ) + } } diff --git a/src/core/pages/Target.ts b/src/core/pages/Target.ts index bf91b4241..5e7f06721 100644 --- a/src/core/pages/Target.ts +++ b/src/core/pages/Target.ts @@ -2,7 +2,7 @@ import {createId} from 'alinea/core/Id' import {Callable} from 'rado/util/Callable' import {createExprData} from './CreateExprData.js' import {Cursor} from './Cursor.js' -import {EV, Expr, and} from './Expr.js' +import {EV, Expr} from './Expr.js' import {BinaryOp, ExprData} from './ExprData.js' import {Fields} from './Fields.js' import {TargetData} from './TargetData.js' @@ -52,7 +52,7 @@ export const Target = class { const conditions = isConditionalRecord ? entries(input[0]).map(([key, value]) => { const field = Expr(ExprData.Field(this.data, key)) - return Expr( + return Expr( ExprData.BinOp( field[Expr.Data], BinaryOp.Equals, @@ -60,8 +60,8 @@ export const Target = class { ) ) }) - : input.map(ev => Expr(createExprData(ev))) - return and(...conditions)[Expr.Data] + : input.map(ev => Expr(createExprData(ev))) + return Expr.and(...conditions)[Expr.Data] } get(field: string) { diff --git a/src/core/shape/RichTextShape.ts b/src/core/shape/RichTextShape.ts index c768db6d7..9dc502e2f 100644 --- a/src/core/shape/RichTextShape.ts +++ b/src/core/shape/RichTextShape.ts @@ -9,6 +9,25 @@ import {entries, fromEntries, keys} from '../util/Objects.js' import {RecordShape} from './RecordShape.js' import {ScalarShape} from './ScalarShape.js' +export enum RichTextElements { + h1 = 'h1', + h2 = 'h2', + h3 = 'h3', + h4 = 'h4', + h5 = 'h5', + h6 = 'h6', + p = 'p', + b = 'b', + i = 'i', + ul = 'ul', + ol = 'ol', + li = 'li', + a = 'a', + hr = 'hr', + br = 'br', + small = 'small' +} + // Adapted from: https://github.com/yjs/y-prosemirror/blob/1c393fb3254cc1ed4933e8326b57c1316793122a/src/lib.js#L245 function serialize( item: Y.XmlElement | Y.XmlText | Y.XmlHook diff --git a/src/backend/util/ContentHash.ts b/src/core/util/ContentHash.ts similarity index 100% rename from src/backend/util/ContentHash.ts rename to src/core/util/ContentHash.ts diff --git a/src/core/util/EntryRows.test.ts b/src/core/util/EntryRows.test.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/core/util/EntryRows.ts b/src/core/util/EntryRows.ts index db8db686e..fcefa8eaf 100644 --- a/src/core/util/EntryRows.ts +++ b/src/core/util/EntryRows.ts @@ -1,12 +1,12 @@ -import {JsonLoader} from 'alinea/backend' -import {createFileHash, createRowHash} from 'alinea/backend/util/ContentHash' -import * as paths from 'alinea/core/util/Paths' +import {JsonLoader} from 'alinea/backend/loader/JsonLoader' import {Config} from '../Config.js' import {entryFilepath, entryInfo, entryUrl} from '../EntryFilenames.js' import {createRecord} from '../EntryRecord.js' import {EntryPhase, EntryRow} from '../EntryRow.js' import {Root} from '../Root.js' import {EntryUrlMeta, Type} from '../Type.js' +import {createFileHash, createRowHash} from './ContentHash.js' +import * as paths from './Paths.js' export async function createEntryRow( config: Config, diff --git a/src/dashboard/hook/UseUploads.ts b/src/dashboard/hook/UseUploads.ts index 97b2554eb..6cc9707f4 100644 --- a/src/dashboard/hook/UseUploads.ts +++ b/src/dashboard/hook/UseUploads.ts @@ -1,5 +1,4 @@ import {Media} from 'alinea/backend/Media' -import {createFileHash} from 'alinea/backend/util/ContentHash' import { Connection, Entry, @@ -12,8 +11,10 @@ import { import {entryFileName, entryFilepath} from 'alinea/core/EntryFilenames' import {createId} from 'alinea/core/Id' import {Mutation, MutationType} from 'alinea/core/Mutation' +import {createPreview} from 'alinea/core/media/CreatePreview' +import {isImage} from 'alinea/core/media/IsImage' import {MediaFile} from 'alinea/core/media/MediaSchema' -import {base64} from 'alinea/core/util/Encoding' +import {createFileHash} from 'alinea/core/util/ContentHash' import {createEntryRow} from 'alinea/core/util/EntryRows' import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing' import { @@ -23,12 +24,9 @@ import { join, normalize } from 'alinea/core/util/Paths' -import {rgba, toHex} from 'color2k' import {atom, useAtom, useSetAtom} from 'jotai' import pLimit from 'p-limit' import {useEffect} from 'react' -import smartcrop from 'smartcrop' -import {rgbaToThumbHash, thumbHashToAverageRGBA} from 'thumbhash' import {useMutate} from '../atoms/DbAtoms.js' import {errorAtom} from '../atoms/ErrorAtoms.js' import {withResolvers} from '../util/WithResolvers.js' @@ -90,73 +88,15 @@ async function process( ): Promise { switch (upload.status) { case UploadStatus.Queued: - const isImage = Media.isImage(upload.file.name) - const next = isImage + const next = isImage(upload.file.name) ? UploadStatus.CreatingPreview : UploadStatus.Uploading return {...upload, status: next} case UploadStatus.CreatingPreview: { - const url = URL.createObjectURL(upload.file) - - // Load the image - const image = await new Promise((resolve, reject) => { - const image = new Image() - image.onload = () => resolve(image) - image.onerror = err => reject(err) - image.src = url - }).finally(() => URL.revokeObjectURL(url)) - - const size = Math.max(image.width, image.height) - - // Scale the image to 100x100 maximum size - const thumbW = Math.round((100 * image.width) / size) - const thumbH = Math.round((100 * image.height) / size) - const thumbCanvas = document.createElement('canvas') - const thumbContext = thumbCanvas.getContext('2d')! - thumbCanvas.width = thumbW - thumbCanvas.height = thumbH - thumbContext.drawImage(image, 0, 0, thumbW, thumbH) - - // Calculate thumbhash - const pixels = thumbContext.getImageData(0, 0, thumbW, thumbH) - const thumbHash = rgbaToThumbHash(thumbW, thumbH, pixels.data) - - // Get the average color via thumbhash - const {r, g, b, a} = thumbHashToAverageRGBA(thumbHash) - const averageColor = toHex(rgba(r * 255, g * 255, b * 255, a)) - - // Create webp preview image - const previewW = Math.min( - Math.round((160 * image.width) / size), - image.width - ) - const previewH = Math.min( - Math.round((160 * image.height) / size), - image.height - ) - const previewCanvas = document.createElement('canvas') - const previewContext = previewCanvas.getContext('2d')! - previewContext.imageSmoothingEnabled = true - previewContext.imageSmoothingQuality = 'high' - previewCanvas.width = previewW - previewCanvas.height = previewH - previewContext.drawImage(image, 0, 0, previewW, previewH) - const preview = previewCanvas.toDataURL('image/webp') - - const crop = await smartcrop.crop(image, {width: 100, height: 100}) - const focus = { - x: (crop.topCrop.x + crop.topCrop.width / 2) / image.width, - y: (crop.topCrop.y + crop.topCrop.height / 2) / image.height - } - + const previewData = await createPreview(upload.file) return { ...upload, - preview, - averageColor, - focus, - thumbHash: base64.stringify(thumbHash), - width: image.naturalWidth, - height: image.naturalHeight, + ...previewData, status: UploadStatus.Uploading } } diff --git a/src/dashboard/view/media/FileEntry.tsx b/src/dashboard/view/media/FileEntry.tsx index 6fca320c7..1f2a47cca 100644 --- a/src/dashboard/view/media/FileEntry.tsx +++ b/src/dashboard/view/media/FileEntry.tsx @@ -1,4 +1,5 @@ import {Media} from 'alinea/backend/Media' +import {isImage} from 'alinea/core/media/IsImage' import {MediaFile} from 'alinea/core/media/MediaSchema' import {FormProvider, useField} from 'alinea/dashboard' import {Typo, fromModule} from 'alinea/ui' @@ -147,7 +148,6 @@ function FileView({editor}: EntryEditProps) { export function FileEntry(props: EntryEditProps) { const nav = useNav() const {editor} = props - const isImage = Media.isImage(editor.activeVersion.data.extension) const form = useAtomValue(editor.form) return (
@@ -165,7 +165,11 @@ export function FileEntry(props: EntryEditProps) { /> - {isImage ? : } + {isImage(editor.activeVersion.data.extension) ? ( + + ) : ( + + )}
diff --git a/src/index.ts b/src/index.ts index ee99f3519..552827248 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import * as alinea from './alinea.js' export {alinea as default} +export {Edit, Query} from 'alinea/core' + // Next CMS constructor - deprecated import {createNextCMS as _createNextCMS} from 'alinea/core/driver/NextDriver' export {createNextCMS} diff --git a/src/input/link/LinkConstructors.ts b/src/input/link/LinkConstructors.ts index f0422e781..3906b71f6 100644 --- a/src/input/link/LinkConstructors.ts +++ b/src/input/link/LinkConstructors.ts @@ -1,10 +1,10 @@ -import {Media} from 'alinea/backend/Media' import {ListRow} from 'alinea/core' import {Entry} from 'alinea/core/Entry' import {WithoutLabel} from 'alinea/core/Field' import {Hint} from 'alinea/core/Hint' import {Label} from 'alinea/core/Label' import {Type} from 'alinea/core/Type' +import {imageExtensions} from 'alinea/core/media/IsImage' import {MediaFile} from 'alinea/core/media/MediaSchema' import { LinkFieldOptions, @@ -27,7 +27,7 @@ import {UrlPickerOptions, UrlReference, urlPicker} from 'alinea/picker/url' const imageCondition = Entry.type .is('MediaFile') - .and(MediaFile.extension.isIn(Media.imageExtensions)) + .and(MediaFile.extension.isIn(imageExtensions)) export function imagePicker( multiple: boolean, @@ -51,7 +51,7 @@ export function imagePicker( const fileCondition = Entry.type .is('MediaFile') - .and(MediaFile.extension.isNotIn(Media.imageExtensions)) + .and(MediaFile.extension.isNotIn(imageExtensions)) export function filePicker( multiple: boolean, diff --git a/src/picker/entry/EntryPicker.browser.tsx b/src/picker/entry/EntryPicker.browser.tsx index 43bbe9e36..34b39c3c0 100644 --- a/src/picker/entry/EntryPicker.browser.tsx +++ b/src/picker/entry/EntryPicker.browser.tsx @@ -1,9 +1,15 @@ -import {Picker, PickerProps, Root, WorkspaceData, createId} from 'alinea/core' +import { + PickerProps, + Root, + WorkspaceData, + createId, + pickerWithView +} from 'alinea/core' import {Entry} from 'alinea/core/Entry' import {workspaceMediaDir} from 'alinea/core/EntryFilenames' import {Reference} from 'alinea/core/Reference' import {isMediaRoot} from 'alinea/core/media/MediaRoot' -import {and} from 'alinea/core/pages/Expr' +import {Expr} from 'alinea/core/pages/Expr' import {entries} from 'alinea/core/util/Objects' import {useConfig} from 'alinea/dashboard/hook/UseConfig' import {useFocusList} from 'alinea/dashboard/hook/UseFocusList' @@ -48,7 +54,7 @@ import {EntryReference} from './EntryReference.js' export * from './EntryPicker.js' -export const entryPicker = Picker.withView(createEntryPicker, { +export const entryPicker = pickerWithView(createEntryPicker, { view: EntryPickerModal, viewRow: EntryPickerRow }) @@ -138,16 +144,16 @@ export function EntryPickerModal({ return Entry() .where(condition, Entry.locale.is(locale ?? null)) .search(...terms) - const rootCondition = and( + const rootCondition = Expr.and( Entry.workspace.is(destination.workspace), Entry.root.is(destination.root) ) const destinationCondition = terms.length === 0 - ? and(rootCondition, Entry.parent.is(destination.parentId ?? null)) + ? Expr.and(rootCondition, Entry.parent.is(destination.parentId ?? null)) : rootCondition const translatedCondition = destinationLocale - ? and(destinationCondition, Entry.locale.is(destinationLocale)) + ? Expr.and(destinationCondition, Entry.locale.is(destinationLocale)) : destinationCondition return Entry() .where(translatedCondition) diff --git a/src/picker/entry/EntryPicker.ts b/src/picker/entry/EntryPicker.ts index 9baf1c75b..78c560dd8 100644 --- a/src/picker/entry/EntryPicker.ts +++ b/src/picker/entry/EntryPicker.ts @@ -5,7 +5,7 @@ import {Label} from 'alinea/core/Label' import {Reference} from 'alinea/core/Reference' import {Type} from 'alinea/core/Type' import {MediaFile} from 'alinea/core/media/MediaSchema' -import {Expr} from 'alinea/core/pages/Expr' +import {Condition} from 'alinea/core/pages/Condition' import {Projection} from 'alinea/core/pages/Projection' import {RecordShape} from 'alinea/core/shape/RecordShape' import {ScalarShape} from 'alinea/core/shape/ScalarShape' @@ -44,7 +44,7 @@ export interface EntryPickerOptions { hint: Hint selection: Projection defaultView?: 'row' | 'thumb' - condition?: Expr + condition?: Condition withNavigation?: boolean showMedia?: boolean max?: number diff --git a/src/picker/url/UrlPicker.browser.tsx b/src/picker/url/UrlPicker.browser.tsx index c47e498c6..09ba90745 100644 --- a/src/picker/url/UrlPicker.browser.tsx +++ b/src/picker/url/UrlPicker.browser.tsx @@ -1,4 +1,4 @@ -import {Picker, PickerProps, createId, type} from 'alinea/core' +import {PickerProps, createId, pickerWithView, type} from 'alinea/core' import {useForm} from 'alinea/dashboard/atoms/FormAtoms' import {InputForm} from 'alinea/dashboard/editor/InputForm' import {Modal} from 'alinea/dashboard/view/Modal' @@ -11,7 +11,7 @@ import {UrlPickerRow} from './UrlPickerRow.js' export * from './UrlPicker.js' -export const urlPicker = Picker.withView(createUrlPicker, { +export const urlPicker = pickerWithView(createUrlPicker, { view: UrlPickerModal, viewRow: UrlPickerRow }) diff --git a/src/ui/RichText.tsx b/src/ui/RichText.tsx index 242630249..9671549d0 100644 --- a/src/ui/RichText.tsx +++ b/src/ui/RichText.tsx @@ -1,26 +1,14 @@ -import {Infer, Schema, TextDoc, TextNode, slugify} from 'alinea/core' +import { + Infer, + RichTextElements, + Schema, + TextDoc, + TextNode, + slugify +} from 'alinea/core' import {ComponentType, Fragment, ReactElement, isValidElement} from 'react' -export enum Elements { - h1 = 'h1', - h2 = 'h2', - h3 = 'h3', - h4 = 'h4', - h5 = 'h5', - h6 = 'h6', - p = 'p', - b = 'b', - i = 'i', - ul = 'ul', - ol = 'ol', - li = 'li', - a = 'a', - hr = 'hr', - br = 'br', - small = 'small' -} - -type Element = keyof typeof Elements +type Element = keyof typeof RichTextElements function textContent(doc: TextDoc): string { return doc.reduce((text, node) => { @@ -133,7 +121,7 @@ export type RichTextProps = { doc: TextDoc text?: ComponentType<{children: string}> } & { - [K in keyof typeof Elements]?: + [K in keyof typeof RichTextElements]?: | ComponentType | ReactElement } & {[K in keyof Blocks]?: ComponentType>} diff --git a/src/ui/util/Styler.tsx b/src/ui/util/Styler.tsx index 6c5e815d1..e0d0a8b63 100644 --- a/src/ui/util/Styler.tsx +++ b/src/ui/util/Styler.tsx @@ -138,6 +138,7 @@ export const fromModule = ( ): ModuleStyles => { const res: {[key: string]: any} = {} const variants: Map = new Map() + if (!styles) return res as any for (const key of Object.keys(styles)) { const parts = key.split('-') if (parts[0] === 'is') variants.set(key, styles[key]) diff --git a/yarn.lock b/yarn.lock index 67ff1d6e0..14337dbc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55,6 +55,7 @@ __metadata: resolution: "@alinea/core@workspace:src/core" dependencies: cito: ^0.2.0 + htmlparser2: ^9.1.0 p-lazy: ^4.0.0 pretty-ms: ^8.0.0 yjs: ^13.6.11 @@ -136,10 +137,10 @@ __metadata: languageName: node linkType: hard -"@alinea/iso@npm:^0.3.1": - version: 0.3.1 - resolution: "@alinea/iso@npm:0.3.1" - checksum: ca75302e799dd8d90442d14ce58a91ef9e23ea8033c76e37e52ae651c7a214e70951299a28edafc7e54bec788ebe04a6d47f1234adaf333c82a1307b63c49d62 +"@alinea/iso@npm:^0.3.2": + version: 0.3.2 + resolution: "@alinea/iso@npm:0.3.2" + checksum: 67c2cb591da4edcef72b09ac592e467a3bfd77983d3bdb77972257b6604501f1fb745a5bd959c0b32d96866e1a00b2bbe940b69ef869710666409d579490847f languageName: node linkType: hard @@ -2979,7 +2980,7 @@ __metadata: version: 0.0.0-use.local resolution: "alinea@workspace:." dependencies: - "@alinea/iso": ^0.3.1 + "@alinea/iso": ^0.3.2 "@alinea/sqlite-wasm": ^0.1.14 "@esbx/reporter": ^0.0.20 "@esbx/target": ^0.0.20 @@ -3004,6 +3005,7 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 sass: ^1.63.4 + sharp: 0.32.0 speedscope: ^1.14.0 typescript: ^5.3.3 typescript-plugin-css-modules: ^5.0.2 @@ -3317,7 +3319,7 @@ __metadata: languageName: node linkType: hard -"bl@npm:^4.1.0": +"bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" dependencies: @@ -3640,6 +3642,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -3794,13 +3803,23 @@ __metadata: languageName: node linkType: hard -"color-name@npm:~1.1.4": +"color-name@npm:^1.0.0, color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 languageName: node linkType: hard +"color-string@npm:^1.9.0": + version: 1.9.1 + resolution: "color-string@npm:1.9.1" + dependencies: + color-name: ^1.0.0 + simple-swizzle: ^0.2.2 + checksum: c13fe7cff7885f603f49105827d621ce87f4571d78ba28ef4a3f1a104304748f620615e6bf065ecd2145d0d9dad83a3553f52bb25ede7239d18e9f81622f1cc5 + languageName: node + linkType: hard + "color2k@npm:^1.2.4": version: 1.2.4 resolution: "color2k@npm:1.2.4" @@ -3808,6 +3827,16 @@ __metadata: languageName: node linkType: hard +"color@npm:^4.2.3": + version: 4.2.3 + resolution: "color@npm:4.2.3" + dependencies: + color-convert: ^2.0.1 + color-string: ^1.9.0 + checksum: 0579629c02c631b426780038da929cca8e8d80a40158b09811a0112a107c62e10e4aad719843b791b1e658ab4e800558f2e87ca4522c8b32349d497ecb6adeb4 + languageName: node + linkType: hard + "comma-separated-tokens@npm:^2.0.0": version: 2.0.3 resolution: "comma-separated-tokens@npm:2.0.3" @@ -4051,6 +4080,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: ^3.1.0 + checksum: d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812 + languageName: node + linkType: hard + "deep-equal@npm:^2.0.5": version: 2.2.1 resolution: "deep-equal@npm:2.2.1" @@ -4084,6 +4122,13 @@ __metadata: languageName: node linkType: hard +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -4197,6 +4242,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.1": + version: 2.0.2 + resolution: "detect-libc@npm:2.0.2" + checksum: 2b2cd3649b83d576f4be7cc37eb3b1815c79969c8b1a03a40a4d55d83bc74d010753485753448eacb98784abf22f7dbd3911fd3b60e29fda28fed2d1a997944d + languageName: node + linkType: hard + "detect-node@npm:^2.0.4, detect-node@npm:^2.1.0": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -4263,6 +4315,44 @@ __metadata: languageName: node linkType: hard +"dom-serializer@npm:^2.0.0": + version: 2.0.0 + resolution: "dom-serializer@npm:2.0.0" + dependencies: + domelementtype: ^2.3.0 + domhandler: ^5.0.2 + entities: ^4.2.0 + checksum: cd1810544fd8cdfbd51fa2c0c1128ec3a13ba92f14e61b7650b5de421b88205fd2e3f0cc6ace82f13334114addb90ed1c2f23074a51770a8e9c1273acbc7f3e6 + languageName: node + linkType: hard + +"domelementtype@npm:^2.3.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: ee837a318ff702622f383409d1f5b25dd1024b692ef64d3096ff702e26339f8e345820f29a68bcdcea8cfee3531776b3382651232fbeae95612d6f0a75efb4f6 + languageName: node + linkType: hard + +"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": + version: 5.0.3 + resolution: "domhandler@npm:5.0.3" + dependencies: + domelementtype: ^2.3.0 + checksum: 0f58f4a6af63e6f3a4320aa446d28b5790a009018707bce2859dcb1d21144c7876482b5188395a188dfa974238c019e0a1e610d2fc269a12b2c192ea2b0b131c + languageName: node + linkType: hard + +"domutils@npm:^3.1.0": + version: 3.1.0 + resolution: "domutils@npm:3.1.0" + dependencies: + dom-serializer: ^2.0.0 + domelementtype: ^2.3.0 + domhandler: ^5.0.3 + checksum: e5757456ddd173caa411cfc02c2bb64133c65546d2c4081381a3bafc8a57411a41eed70494551aa58030be9e58574fcc489828bebd673863d39924fb4878f416 + languageName: node + linkType: hard + "dotenv@npm:^16.0.3": version: 16.3.1 resolution: "dotenv@npm:16.3.1" @@ -4335,6 +4425,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: ^1.4.0 + checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.12.0": version: 5.13.0 resolution: "enhanced-resolve@npm:5.13.0" @@ -4345,7 +4444,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.4.0": +"entities@npm:^4.2.0, entities@npm:^4.4.0, entities@npm:^4.5.0": version: 4.5.0 resolution: "entities@npm:4.5.0" checksum: 853f8ebd5b425d350bffa97dd6958143179a5938352ccae092c62d1267c4e392a039be1bae7d51b6e4ffad25f51f9617531fedf5237f15df302ccfb452cbf2d7 @@ -4995,6 +5094,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099 + languageName: node + linkType: hard + "extend@npm:^3.0.0": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -5166,6 +5272,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d + languageName: node + linkType: hard + "fs-extra@npm:^10.0.0": version: 10.0.0 resolution: "fs-extra@npm:10.0.0" @@ -5358,6 +5471,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 14e448192a35c1e42efee94c9d01a10f42fe790375891a24b25261246ce9336ab9df5d274585aedd4568f7922246c2a78b8a8cd2571bfe99c693a9718e7dd0e3 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -5921,6 +6041,18 @@ fsevents@~2.3.2: languageName: node linkType: hard +"htmlparser2@npm:^9.1.0": + version: 9.1.0 + resolution: "htmlparser2@npm:9.1.0" + dependencies: + domelementtype: ^2.3.0 + domhandler: ^5.0.3 + domutils: ^3.1.0 + entities: ^4.5.0 + checksum: e5f8d5193967e4a500226f37bdf2c0f858cecb39dde14d0439f24bf2c461a4342778740d988fbaba652b0e4cb6052f7f2e99e69fc1a329a86c629032bb76e7c8 + languageName: node + linkType: hard + "http-assert@npm:^1.3.0": version: 1.5.0 resolution: "http-assert@npm:1.5.0" @@ -6107,6 +6239,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"ini@npm:~1.3.0": + version: 1.3.8 + resolution: "ini@npm:1.3.8" + checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3 + languageName: node + linkType: hard + "inline-style-parser@npm:0.1.1": version: 0.1.1 resolution: "inline-style-parser@npm:0.1.1" @@ -6218,6 +6357,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"is-arrayish@npm:^0.3.1": + version: 0.3.2 + resolution: "is-arrayish@npm:0.3.2" + checksum: 977e64f54d91c8f169b59afcd80ff19227e9f5c791fa28fa2e5bce355cbaf6c2c356711b734656e80c9dd4a854dd7efcf7894402f1031dfc5de5d620775b4d5f + languageName: node + linkType: hard + "is-bigint@npm:^1.0.1": version: 1.0.4 resolution: "is-bigint@npm:1.0.4" @@ -8245,6 +8391,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 25739fee32c17f433626bf19f016df9036b75b3d84a3046c7d156e72ec963dd29d7fc8a302f55a3d6c5a4ff24259676b15d915aad6480815a969ff2ec0836867 + languageName: node + linkType: hard + "minimatch@npm:^3.0.4": version: 3.0.4 resolution: "minimatch@npm:3.0.4" @@ -8270,6 +8423,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"minimist@npm:^1.2.3": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 + languageName: node + linkType: hard + "minimist@npm:^1.2.6": version: 1.2.6 resolution: "minimist@npm:1.2.6" @@ -8347,6 +8507,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac + languageName: node + linkType: hard + "mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -8444,6 +8611,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"napi-build-utils@npm:^1.0.1": + version: 1.0.2 + resolution: "napi-build-utils@npm:1.0.2" + checksum: 06c14271ee966e108d55ae109f340976a9556c8603e888037145d6522726aebe89dd0c861b4b83947feaf6d39e79e08817559e8693deedc2c94e82c5cbd090c7 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -8533,6 +8707,24 @@ fsevents@~2.3.2: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.54.0 + resolution: "node-abi@npm:3.54.0" + dependencies: + semver: ^7.3.5 + checksum: 260caae87299bb2fac6a269ba5dd378dbe1d99030396832fca7199b6cb5fd46556d2ec0d431f4a76ab2d53e49948047543afe3f1d70d0e6ebad04d33139650da + languageName: node + linkType: hard + +"node-addon-api@npm:^6.0.0": + version: 6.1.0 + resolution: "node-addon-api@npm:6.1.0" + dependencies: + node-gyp: latest + checksum: 3a539510e677cfa3a833aca5397300e36141aca064cdc487554f2017110709a03a95da937e98c2a14ec3c626af7b2d1b6dabe629a481f9883143d0d5bff07bf2 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 8.2.0 resolution: "node-gyp@npm:8.2.0" @@ -8817,7 +9009,7 @@ fsevents@~2.3.2: languageName: node linkType: hard -"once@npm:^1.3.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -9320,6 +9512,28 @@ fsevents@~2.3.2: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.1 + resolution: "prebuild-install@npm:7.1.1" + dependencies: + detect-libc: ^2.0.0 + expand-template: ^2.0.3 + github-from-package: 0.0.0 + minimist: ^1.2.3 + mkdirp-classic: ^0.5.3 + napi-build-utils: ^1.0.1 + node-abi: ^3.3.0 + pump: ^3.0.0 + rc: ^1.2.7 + simple-get: ^4.0.0 + tar-fs: ^2.0.0 + tunnel-agent: ^0.6.0 + bin: + prebuild-install: bin.js + checksum: dbf96d0146b6b5827fc8f67f72074d2e19c69628b9a7a0a17d0fad1bf37e9f06922896972e074197fc00a52eae912993e6ef5a0d471652f561df5cb516f3f467 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -9608,6 +9822,16 @@ fsevents@~2.3.2: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.0 + resolution: "pump@npm:3.0.0" + dependencies: + end-of-stream: ^1.1.0 + once: ^1.3.1 + checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9 + languageName: node + linkType: hard + "punycode@npm:^2.1.0": version: 2.1.1 resolution: "punycode@npm:2.1.1" @@ -9659,6 +9883,20 @@ fsevents@~2.3.2: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: ^0.6.0 + ini: ~1.3.0 + minimist: ^1.2.0 + strip-json-comments: ~2.0.1 + bin: + rc: ./cli.js + checksum: 2e26e052f8be2abd64e6d1dabfbd7be03f80ec18ccbc49562d31f617d0015fbdbcf0f9eed30346ea6ab789e0fdfe4337f033f8016efdbee0df5354751842080e + languageName: node + linkType: hard + "react-dom@npm:18.2.0, react-dom@npm:^18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" @@ -9786,7 +10024,7 @@ fsevents@~2.3.2: languageName: node linkType: hard -"readable-stream@npm:^3.4.0": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -10245,7 +10483,7 @@ resolve@^1.10.0: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -10379,6 +10617,17 @@ resolve@^1.10.0: languageName: node linkType: hard +"semver@npm:^7.3.8": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 + languageName: node + linkType: hard + "set-blocking@npm:~2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -10393,6 +10642,23 @@ resolve@^1.10.0: languageName: node linkType: hard +"sharp@npm:0.32.0": + version: 0.32.0 + resolution: "sharp@npm:0.32.0" + dependencies: + color: ^4.2.3 + detect-libc: ^2.0.1 + node-addon-api: ^6.0.0 + node-gyp: latest + prebuild-install: ^7.1.1 + semver: ^7.3.8 + simple-get: ^4.0.1 + tar-fs: ^2.1.1 + tunnel-agent: ^0.6.0 + checksum: 82c6046038f45f1fdeb732cff8be6a8a3fb5eb25f46f4c3ecb4b71363c2c387b52de8c0c5c4674f2400663e467b8a7b93dbc5d70b3f0e145cb45fc27d39c5709 + languageName: node + linkType: hard + "shebang-command@npm:^1.2.0": version: 1.2.0 resolution: "shebang-command@npm:1.2.0" @@ -10468,6 +10734,24 @@ resolve@^1.10.0: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0, simple-get@npm:^4.0.1": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: ^6.0.0 + once: ^1.3.1 + simple-concat: ^1.0.0 + checksum: e4132fd27cf7af230d853fa45c1b8ce900cb430dd0a3c6d3829649fe4f2b26574c803698076c4006450efb0fad2ba8c5455fbb5755d4b0a5ec42d4f12b31d27e + languageName: node + linkType: hard + "simple-git@npm:^3.19.1": version: 3.19.1 resolution: "simple-git@npm:3.19.1" @@ -10479,6 +10763,15 @@ resolve@^1.10.0: languageName: node linkType: hard +"simple-swizzle@npm:^0.2.2": + version: 0.2.2 + resolution: "simple-swizzle@npm:0.2.2" + dependencies: + is-arrayish: ^0.3.1 + checksum: a7f3f2ab5c76c4472d5c578df892e857323e452d9f392e1b5cf74b74db66e6294a1e1b8b390b519fa1b96b5b613f2a37db6cffef52c3f1f8f3c5ea64eb2d54c0 + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -10913,6 +11206,13 @@ resolve@^1.10.0: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1 + languageName: node + linkType: hard + "style-to-object@npm:^0.4.0": version: 0.4.4 resolution: "style-to-object@npm:0.4.4" @@ -11011,6 +11311,31 @@ resolve@^1.10.0: languageName: node linkType: hard +"tar-fs@npm:^2.0.0, tar-fs@npm:^2.1.1": + version: 2.1.1 + resolution: "tar-fs@npm:2.1.1" + dependencies: + chownr: ^1.1.1 + mkdirp-classic: ^0.5.2 + pump: ^3.0.0 + tar-stream: ^2.1.4 + checksum: f5b9a70059f5b2969e65f037b4e4da2daf0fa762d3d232ffd96e819e3f94665dbbbe62f76f084f1acb4dbdcce16c6e4dac08d12ffc6d24b8d76720f4d9cf032d + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: ^4.0.3 + end-of-stream: ^1.4.1 + fs-constants: ^1.0.0 + inherits: ^2.0.3 + readable-stream: ^3.1.1 + checksum: 699831a8b97666ef50021c767f84924cfee21c142c2eb0e79c63254e140e6408d6d55a065a2992548e72b06de39237ef2b802b99e3ece93ca3904a37622a66f3 + languageName: node + linkType: hard + "tar@npm:^6.0.2, tar@npm:^6.1.2": version: 6.1.11 resolution: "tar@npm:6.1.11" @@ -11242,6 +11567,15 @@ resolve@^1.10.0: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: ^5.0.1 + checksum: 05f6510358f8afc62a057b8b692f05d70c1782b70db86d6a1e0d5e28a32389e52fa6e7707b6c5ecccacc031462e4bc35af85ecfe4bbc341767917b7cf6965711 + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0"