@alstar/studio 0.0.0-beta.5 → 0.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/api/api-key.ts +74 -0
  2. package/api/block.ts +21 -29
  3. package/api/index.ts +9 -1
  4. package/api/mcp.ts +53 -0
  5. package/bin/alstar.ts +42 -0
  6. package/components/{AdminPanel/AdminPanel.ts → AdminPanel.ts} +22 -27
  7. package/components/Block.ts +51 -112
  8. package/components/Entries.ts +3 -3
  9. package/components/Entry.ts +9 -15
  10. package/components/Settings.ts +98 -0
  11. package/components/fields/Blocks.ts +118 -0
  12. package/components/fields/Text.ts +42 -0
  13. package/components/fields/index.ts +4 -0
  14. package/components/icons.ts +59 -0
  15. package/components/index.ts +1 -1
  16. package/components/layout.ts +2 -2
  17. package/index.ts +33 -14
  18. package/package.json +4 -3
  19. package/public/admin-panel.css +90 -0
  20. package/public/blocks.css +53 -0
  21. package/public/main.css +8 -0
  22. package/public/main.js +4 -0
  23. package/public/settings.css +24 -0
  24. package/queries/block-with-children.ts +74 -0
  25. package/queries/block.ts +29 -40
  26. package/queries/db-types.ts +15 -0
  27. package/queries/getBlockTrees-2.ts +0 -0
  28. package/queries/getBlockTrees.ts +316 -0
  29. package/queries/getBlocks.ts +214 -0
  30. package/queries/index.ts +1 -0
  31. package/queries/structure-types.ts +97 -0
  32. package/schemas.ts +14 -3
  33. package/types.ts +84 -5
  34. package/utils/buildBlocksTree.ts +4 -4
  35. package/utils/define.ts +18 -4
  36. package/utils/get-or-create-row.ts +28 -0
  37. package/utils/startup-log.ts +9 -0
  38. package/components/AdminPanel/AdminPanel.css +0 -78
  39. package/components/Field.ts +0 -168
  40. package/components/Fields.ts +0 -43
  41. /package/{components/Entry.css → public/entry.css} +0 -0
package/api/api-key.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { type HttpBindings } from '@hono/node-server'
2
+ import { ServerSentEventGenerator } from '@starfederation/datastar-sdk'
3
+ import { Hono } from 'hono'
4
+ import { streamSSE } from 'hono/streaming'
5
+ import { db } from '@alstar/db'
6
+ import crypto from 'node:crypto'
7
+
8
+ import { stripNewlines } from '../utils/strip-newlines.ts'
9
+ import { sql } from '../utils/sql.ts'
10
+ import { type Structure } from '../types.ts'
11
+ import Settings from '../components/Settings.ts'
12
+
13
+ export default (structure: Structure) => {
14
+ const app = new Hono<{ Bindings: HttpBindings }>()
15
+
16
+ app.post('/api-key', async (c) => {
17
+ return streamSSE(c, async (stream) => {
18
+ const formData = await c.req.formData()
19
+ const data = Object.fromEntries(formData.entries())
20
+
21
+ if (!data) return
22
+
23
+ const apiKey = crypto.randomUUID()
24
+ const hash = crypto.createHash('sha256')
25
+
26
+ hash.update(apiKey)
27
+
28
+ const digest = hash.digest().toString('base64')
29
+
30
+ const xs = (length: number) => '*'.repeat(length)
31
+
32
+ db.insertInto('api_keys', {
33
+ name: data.name?.toString(),
34
+ value: digest,
35
+ hint: `${apiKey.substring(0, 8)}-${xs(4)}-${xs(4)}-${xs(4)}-${xs(12)}`,
36
+ })
37
+
38
+ await stream.writeSSE({
39
+ event: 'datastar-patch-signals',
40
+ data: `signals { apiKey: '${apiKey}', name: '' }`,
41
+ })
42
+
43
+ await stream.writeSSE({
44
+ event: 'datastar-patch-elements',
45
+ data: `elements ${stripNewlines(Settings())}`,
46
+ })
47
+ })
48
+ })
49
+
50
+ app.delete('/api-key', async (c) => {
51
+ return streamSSE(c, async (stream) => {
52
+ const formData = await c.req.formData()
53
+
54
+ const value = formData.get('value')?.toString()
55
+
56
+ if (!value) return
57
+
58
+ db.database
59
+ .prepare(sql`
60
+ delete from api_keys
61
+ where
62
+ value = ?
63
+ `)
64
+ .run(value)
65
+
66
+ await stream.writeSSE({
67
+ event: 'datastar-patch-elements',
68
+ data: `elements ${stripNewlines(Settings())}`,
69
+ })
70
+ })
71
+ })
72
+
73
+ return app
74
+ }
package/api/block.ts CHANGED
@@ -8,8 +8,12 @@ import { type Structure } from '../types.ts'
8
8
  import { db } from '@alstar/db'
9
9
  import Entries from '../components/Entries.ts'
10
10
  import Entry from '../components/Entry.ts'
11
+ import {
12
+ blockWithChildren,
13
+ deleteBlockWithChildren,
14
+ } from '../queries/block-with-children.ts'
11
15
 
12
- export const sectionRoutes = (structure: Structure) => {
16
+ export default (structure: Structure) => {
13
17
  const app = new Hono<{ Bindings: HttpBindings }>()
14
18
 
15
19
  app.post('/block', async (c) => {
@@ -17,14 +21,14 @@ export const sectionRoutes = (structure: Structure) => {
17
21
  const formData = await c.req.formData()
18
22
  const data = Object.fromEntries(formData.entries())
19
23
 
20
- const row = structure.find((block) => block.type === data.type)
24
+ const row = structure[data.name?.toString()]
21
25
 
22
26
  if (!row) return
23
27
 
24
28
  db.insertInto('blocks', {
25
- name: row.name?.toString(),
26
- label: row.label?.toString(),
27
- type: row.type?.toString(),
29
+ name: data.name?.toString(),
30
+ label: row.label,
31
+ type: row.type,
28
32
  })
29
33
 
30
34
  await stream.writeSSE({
@@ -37,26 +41,19 @@ export const sectionRoutes = (structure: Structure) => {
37
41
  app.post('/new-block', async (c) => {
38
42
  return streamSSE(c, async (stream) => {
39
43
  const formData = await c.req.formData()
40
- const newBlock = formData.get('block')?.toString().split(';')
41
- const columns = newBlock?.map((field) => field.split(':')) as string[][]
42
- const data = Object.fromEntries(columns)
43
-
44
- const parent_block_id = formData?.get('parent_block_id')?.toString()
45
- const sort_order = formData?.get('sort_order')?.toString()
46
-
47
- if (!parent_block_id || !sort_order) return
44
+ const data = Object.fromEntries(formData)
48
45
 
49
46
  db.insertInto('blocks', {
50
- type: data.type,
51
- name: data.name,
52
- label: data.label,
53
- parent_block_id,
54
- sort_order,
47
+ type: data.type.toString(),
48
+ name: data.name.toString(),
49
+ label: data.label.toString(),
50
+ parent_id: data.parent_id.toString(),
51
+ sort_order: data.sort_order.toString(),
55
52
  })
56
53
 
57
54
  await stream.writeSSE({
58
55
  event: 'datastar-patch-elements',
59
- data: `elements ${stripNewlines(Entry({ entryId: data.entry_id, structure }))}`,
56
+ data: `elements ${stripNewlines(Entry({ entryId: parseInt(data.entry_id.toString()) }))}`,
60
57
  })
61
58
  })
62
59
  })
@@ -99,22 +96,17 @@ export const sectionRoutes = (structure: Structure) => {
99
96
  const formData = await c.req.formData()
100
97
 
101
98
  const id = formData.get('id')?.toString()
99
+ const entryId = formData.get('entry_id')?.toString()
102
100
 
103
- if (!id) return
101
+ if (!id || !entryId) return
104
102
 
105
- const transaction = db.database.prepare(sql`
106
- update blocks
107
- set
108
- status = 'disabled'
109
- where
110
- id = ?
111
- `)
103
+ const transaction = db.database.prepare(deleteBlockWithChildren)
112
104
 
113
- transaction.run(id)
105
+ transaction.all(id)
114
106
 
115
107
  await stream.writeSSE({
116
108
  event: 'datastar-patch-elements',
117
- data: `elements ${stripNewlines(Entries())}`,
109
+ data: `elements ${stripNewlines(Entry({ entryId: parseInt(entryId.toString()) }))}`,
118
110
  })
119
111
  })
120
112
  })
package/api/index.ts CHANGED
@@ -1,2 +1,10 @@
1
- export * from './block.ts'
1
+ import block from './block.ts'
2
+ import apiKey from './api-key.ts'
2
3
 
4
+ export const api = (structure) => {
5
+ const app = block(structure)
6
+
7
+ app.route('/', apiKey(structure))
8
+
9
+ return app
10
+ }
package/api/mcp.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { type HttpBindings } from '@hono/node-server'
2
+ import { ServerSentEventGenerator } from '@starfederation/datastar-sdk'
3
+ import { Hono } from 'hono'
4
+ import { sql } from '../utils/sql.ts'
5
+ import { db } from '@alstar/db'
6
+ import { bearerAuth } from 'hono/bearer-auth'
7
+ import crypto from 'node:crypto'
8
+
9
+ export default () => {
10
+ const app = new Hono<{ Bindings: HttpBindings }>()
11
+
12
+ app.use(
13
+ '/*',
14
+ bearerAuth({
15
+ verifyToken: async (token, c) => {
16
+ const hash = crypto.createHash('sha256')
17
+
18
+ hash.update(token)
19
+
20
+ const digest = hash.digest().toString('base64')
21
+
22
+ const exists = db.database
23
+ .prepare(sql`
24
+ select
25
+ value
26
+ from
27
+ api_keys
28
+ where
29
+ value = ?
30
+ `)
31
+ .get(digest)
32
+
33
+ return !!exists
34
+ },
35
+ }),
36
+ )
37
+
38
+ app.get('/entry', async (c) => {
39
+ return c.json({
40
+ status: 'success',
41
+ message: 'Response from MCP server!',
42
+ })
43
+ })
44
+
45
+ app.post('/entry', async (c) => {
46
+ return c.json({
47
+ status: 'success',
48
+ message: 'New entry created!',
49
+ })
50
+ })
51
+
52
+ return app
53
+ }
package/bin/alstar.ts ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { fileURLToPath } from 'node:url'
4
+ import path from 'node:path'
5
+
6
+ // process.removeAllListeners('warning')
7
+
8
+ const __filename = fileURLToPath(import.meta.url)
9
+
10
+ // Dev-only auto watch (opt out with ALSTAR_NO_WATCH=true)
11
+ const isDev =
12
+ process.env.NODE_ENV !== 'production' &&
13
+ process.env.ALSTAR_NO_WATCH !== 'true'
14
+ const alreadyBootstrapped = process.env.ALSTAR_WATCH_BOOTSTRAPPED === '1'
15
+
16
+ if (isDev && !alreadyBootstrapped) {
17
+ // Start a watcher ON THIS FILE and mark the environment so the child won't re-spawn
18
+ const childProcess = spawn(
19
+ process.execPath,
20
+ ['--watch', __filename, ...process.argv.slice(2)],
21
+ // [__filename, ...process.argv.slice(2)],
22
+ {
23
+ stdio: 'inherit',
24
+ env: { ...process.env, ALSTAR_WATCH_BOOTSTRAPPED: '1' },
25
+ },
26
+ )
27
+ // Important: exit this launcher so only the watched child keeps running
28
+ process.on('SIGINT', () => {
29
+ childProcess.kill('SIGINT')
30
+ })
31
+
32
+ process.on('SIGTERM', () => {
33
+ childProcess.kill('SIGTERM')
34
+ })
35
+
36
+ process.exit(0)
37
+
38
+ }
39
+
40
+ // --- Your actual CLI logic below ---
41
+ // console.log(import.meta.dirname, path.resolve('.'))
42
+ // import("./main.js").then(m => m.default());
@@ -1,17 +1,14 @@
1
1
  import { html } from 'hono/html'
2
- import type { Structure } from '../../types.ts'
3
- import { logo } from '../icons.ts'
4
- import { rootdir } from '../../index.ts'
5
- import Entries from '../Entries.ts'
6
- import * as icons from '../icons.ts'
2
+ import type { Structure } from '../types.ts'
3
+ import { logo } from './icons.ts'
4
+ import Entries from './Entries.ts'
5
+ import * as icons from './icons.ts'
7
6
 
8
7
  export default (structure: Structure) => {
9
- return html`
10
- <link
11
- rel="stylesheet"
12
- href="${rootdir}/components/AdminPanel/AdminPanel.css"
13
- />
8
+ const entries = Object.entries(structure)
9
+ const type = typeof entries[0][1] !== 'string' ? entries[0][1].type : null
14
10
 
11
+ return html`
15
12
  <div class="admin-panel">
16
13
  <h1>
17
14
  <a href="/admin" aria-label="Go to dashboard"> ${logo} </a>
@@ -21,24 +18,9 @@ export default (structure: Structure) => {
21
18
  <form
22
19
  data-on-submit="@post('/admin/api/block', { contentType: 'form' })"
23
20
  >
24
- <!-- <select name="type">
25
- ${structure.map(
26
- (block) => html`
27
- <option value="${block.type}">
28
- ${block.label}
29
- </option>
30
- `,
31
- )}
32
- </select>
33
- <input type="submit" value="Create" /> -->
34
-
35
21
  <!-- TODO: currently only handles a single entry type -->
36
- ${structure.length
37
- ? html`<input
38
- type="hidden"
39
- name="type"
40
- value="${structure[0].type}"
41
- />
22
+ ${entries.length
23
+ ? html`<input type="hidden" name="name" value="${type}" />
42
24
  <button
43
25
  class="ghost"
44
26
  style="padding: 10px; margin: 0 -13px; display: flex;"
@@ -52,6 +34,19 @@ export default (structure: Structure) => {
52
34
  </aside>
53
35
 
54
36
  ${Entries()}
37
+
38
+ <footer>
39
+ <a
40
+ role="button"
41
+ href="/admin/settings"
42
+ class="ghost"
43
+ style="padding: 10px; margin: 0 -13px; display: flex;"
44
+ data-tooltip="Settings"
45
+ data-placement="right"
46
+ >
47
+ ${icons.cog}
48
+ </a>
49
+ </footer>
55
50
  </div>
56
51
  `
57
52
  }
@@ -1,116 +1,55 @@
1
1
  import { html } from 'hono/html'
2
- import { query } from '../queries/index.ts'
3
- import Fields from './Fields.ts'
4
- import * as icons from './icons.ts'
5
- import { type Block } from '../types.ts'
6
- import Field from './Field.ts'
7
- import {
8
- buildStructurePath,
9
- type StructurePath,
10
- } from '../utils/build-structure-path.ts'
11
-
12
- export default (
13
- entryId: string | number,
14
- id: string | number,
15
- fieldStructure: Block,
16
- structurePath: StructurePath,
17
- ) => {
18
- const data = query.block({ id: id.toString() })
19
- const blocks = query.blocks({ parent_block_id: id })
20
-
21
- if (!data) return html`<p>No block data</p>`
22
-
23
- const fieldStructurePath = buildStructurePath(fieldStructure, structurePath)
2
+ import type { Block, BlockDef, FieldDef } from '../types.ts'
3
+ import { type HtmlEscapedString } from 'hono/utils/html'
4
+ import { Field } from './fields/index.ts'
5
+
6
+ export default (props: {
7
+ entryId: number
8
+ parentId: number
9
+ blockStructure: BlockDef | Block | FieldDef
10
+ name: string
11
+ sortOrder?: number
12
+ }): HtmlEscapedString | Promise<HtmlEscapedString> => {
13
+ const { entryId, parentId, blockStructure, name, sortOrder = 0 } = props
14
+
15
+ if (!blockStructure) return html`<p>No block</p>`
16
+
17
+ let entries: [string, BlockDef | Block | FieldDef][] = []
18
+
19
+ const fieldTypes = ['text', 'image', 'markdown', 'slug']
20
+
21
+ try {
22
+ if (fieldTypes.includes(blockStructure.type)) {
23
+ entries = [[name, blockStructure]]
24
+ } else if (blockStructure.type === 'blocks') {
25
+ entries = Object.entries(blockStructure.children)
26
+ } else if (blockStructure.fields) {
27
+ entries = Object.entries(blockStructure.fields)
28
+ } else {
29
+ console.log(blockStructure)
30
+ }
31
+ } catch (error) {
32
+ console.log(error)
33
+ }
24
34
 
25
35
  return html`
26
-
27
- <section class="block">
28
- <header>
29
- <h5>${fieldStructure.label}</h5>
30
-
31
- <form
32
- data-on-submit="@post('/admin/api/new-block', { contentType: 'form' })"
33
- >
34
- <input type="hidden" name="parent_block_id" value="${id}" />
35
- <input
36
- type="hidden"
37
- name="sort_order"
38
- value="${blocks?.length || 0}"
39
- />
40
-
41
- <fieldset role="group">
42
- <select name="block">
43
- ${fieldStructure.fields?.map((field) => {
44
- return html`<option
45
- value="entry_id:${entryId};type:${field.type};name:${field.name};label:${field.label};sort_order:${blocks?.length ||
46
- 0}"
47
- >
48
- ${field.label}
49
- </option>`
50
- })}
51
- </select>
52
-
53
- <button
54
- class="outline"
55
- style="padding: 0 1rem"
56
- data-tooltip="New entry"
57
- data-placement="right"
58
- type="submit"
59
- >
60
- Add
61
- </button>
62
- </fieldset>
63
- </form>
64
- </header>
65
-
66
- <div data-sortable="${id}">
67
- ${blocks?.map((block, idx) => {
68
- const structure = fieldStructure.fields?.find(
69
- (field) => field.name === block.name,
70
- )
71
-
72
- if (!structure) return
73
-
74
- return html`
75
- <article
76
- data-signals="{ open${block.id}: true }"
77
- >
78
- <header>
79
- <h6>${structure.label}</h6>
80
- <div>
81
- <button
82
- data-on-click="$open${block.id} = !$open${block.id}"
83
- style="margin-right: 0.5rem"
84
- data-style-rotate="$open${block.id} ? '0deg' : '180deg'"
85
- class="shadow"
86
- >
87
- ${icons.chevron}
88
- </button>
89
- <button class="shadow">${icons.bars}</button>
90
- </div>
91
- </header>
92
-
93
- <div data-show="$open${block.id}">
94
- ${structure.fields
95
- ? Fields({
96
- entryId,
97
- parentId: block.id,
98
- blockStructure: structure,
99
- structurePath: [...fieldStructurePath, structure.name],
100
- })
101
- : Field({
102
- entryId,
103
- parentId: block.id,
104
- blockStructure: structure,
105
- sortOrder: idx,
106
- structurePath: [...fieldStructurePath, structure.name],
107
- })}
108
-
109
- </div>
110
- </article>
111
- `
112
- })}
113
- </div>
114
- </section>
115
- `
36
+ ${entries.map(([name, field]) => {
37
+ switch (field.type) {
38
+ case 'text': {
39
+ return Field.Text({ entryId, parentId, name, field, sortOrder })
40
+ }
41
+ case 'slug': {
42
+ return Field.Text({ entryId, parentId, name, field, sortOrder })
43
+ }
44
+ case 'markdown': {
45
+ return Field.Text({ entryId, parentId, name, field, sortOrder })
46
+ }
47
+ case 'image': {
48
+ return Field.Text({ entryId, parentId, name, field, sortOrder })
49
+ }
50
+ case 'blocks': {
51
+ return Field.Blocks({ entryId, parentId, name, field, sortOrder })
52
+ }
53
+ }
54
+ })}`
116
55
  }
@@ -3,14 +3,14 @@ import { query } from '../index.ts'
3
3
  import * as icons from './icons.ts'
4
4
 
5
5
  export default () => {
6
- const entries = query.blocks({ parent_block_id: null, status: 'enabled' })
6
+ const entries = query.blocks({ parent_id: null, status: 'enabled' })
7
7
 
8
8
  return html`
9
9
  <section id="entries">
10
10
  <ul>
11
11
  ${entries?.map((block) => {
12
12
  const title = query.block({
13
- parent_block_id: block.id.toString(),
13
+ parent_id: block.id.toString(),
14
14
  name: 'title',
15
15
  })
16
16
 
@@ -27,7 +27,7 @@ export default () => {
27
27
  <button
28
28
  data-tooltip="Remove"
29
29
  data-placement="right"
30
- class="ghost"
30
+ class="ghost text-secondary"
31
31
  style="padding: 0"
32
32
  type="submit"
33
33
  >
@@ -1,34 +1,28 @@
1
1
  import { html } from 'hono/html'
2
2
  import { query } from '../queries/index.ts'
3
- import { rootdir } from '../index.ts'
4
- import { type Structure } from '../types.ts'
5
- import Fields from './Fields.ts'
6
- import { buildStructurePath } from '../utils/build-structure-path.ts'
3
+ import { rootdir, structure } from '../index.ts'
4
+ import Block from './Block.ts'
7
5
 
8
- export default (props: { entryId: number | string; structure: Structure }) => {
6
+ export default (props: { entryId: number }) => {
9
7
  const data = query.block({ id: props.entryId?.toString() })
10
8
 
11
9
  if (!data) return html`<p>No entry with id: "${props.entryId}"</p>`
12
10
 
13
- const blockStructure = props.structure.find(
14
- (block) => block.name === data.name,
15
- )
16
-
17
- const structurePath = buildStructurePath(blockStructure)
11
+ const blockStructure = structure[data.name]
18
12
 
19
13
  return html`
20
14
  <div id="entry">
21
- <link rel="stylesheet" href="${rootdir}/components/Entry.css" />
22
-
23
15
  <div class="entry">
24
16
  ${blockStructure &&
25
- Fields({
17
+ Block({
26
18
  entryId: props.entryId,
27
19
  parentId: props.entryId,
28
- blockStructure,
29
- structurePath,
20
+ blockStructure: blockStructure,
21
+ name: data.name,
30
22
  })}
31
23
  </div>
24
+
25
+ <!-- <pre><code>{JSON.stringify(blockStructure, null, 2)}</code></pre> -->
32
26
  </div>
33
27
  `
34
28
  }