├── .github
└── workflows
│ └── codeql-analysis.yml
├── .gitignore
├── .prettierrc
├── .travis.yml
├── LICENSE
├── README.md
├── docs
├── CNAME
├── components
│ ├── my-counter.js
│ ├── simple-clock.js
│ ├── todo-list.js
│ ├── x-logo.js
│ └── x-pager.js
├── data.js
├── docs.html
├── events.html
├── forms.html
├── index.html
├── my-counter.html
├── repeated-blocks.html
├── simple-clock.html
├── styles.html
├── template-syntax.html
└── todo-list.html
├── examples
├── .prettierrc
├── 02-components-and-partials
│ ├── README.md
│ ├── components
│ │ ├── app.js
│ │ ├── myFooter.js
│ │ └── myHeader.js
│ ├── index.html
│ ├── synergy.js
│ └── utils.js
└── todo-app
│ └── index.js
├── package-lock.json
├── package.json
├── rollup.config.js
├── src
├── attribute.js
├── constants.js
├── context.js
├── css.js
├── define.js
├── formControl.js
├── helpers.js
├── index.js
├── list.js
├── mergeSlots.js
├── partial.js
├── proxy.js
├── render.js
├── template.js
├── token.js
└── update.js
├── test
├── actions.js
├── define.js
├── helpers.js
├── hydrate.js
├── if.js
├── interpolations.js
├── iterations.js
├── middleware.js
├── partials.js
├── scope.js
├── slots.js
├── todo-app.js
└── user-input.js
├── types
└── index.d.ts
└── website
├── components
├── my-counter.js
├── simple-clock.js
├── todo-list.js
├── x-logo.js
└── x-pager.js
├── customElements.js
├── data
└── nav.js
├── head.js
├── package-lock.json
├── package.json
├── pages
├── api.md
├── events.md
├── forms.md
├── getting-started.md
├── hydration.md
├── index.md
├── my-counter.md
├── repeated-blocks.md
├── simple-clock.md
├── styles.md
├── template-syntax.md
└── todo-list.md
├── public
└── data.js
├── server.js
├── templates
├── document.js
└── nav.js
└── utils.js
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '33 14 * * 1'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 |
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | language: [ 'javascript' ]
32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
33 | # Learn more:
34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
35 |
36 | steps:
37 | - name: Checkout repository
38 | uses: actions/checkout@v2
39 |
40 | # Initializes the CodeQL tools for scanning.
41 | - name: Initialize CodeQL
42 | uses: github/codeql-action/init@v1
43 | with:
44 | languages: ${{ matrix.language }}
45 | # If you wish to specify custom queries, you can do so here or in a config file.
46 | # By default, queries listed here will override any specified in a config file.
47 | # Prefix the list here with "+" to use these queries and those in the config file.
48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
49 |
50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
51 | # If this step fails, then you should remove it and run the build manually (see below)
52 | - name: Autobuild
53 | uses: github/codeql-action/autobuild@v1
54 |
55 | # ℹ️ Command-line programs to run using the OS shell.
56 | # 📚 https://git.io/JvXDl
57 |
58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
59 | # and modify them (or add more) to build your code if your project
60 | # uses a compiled language
61 |
62 | #- run: |
63 | # make bootstrap
64 | # make release
65 |
66 | - name: Perform CodeQL Analysis
67 | uses: github/codeql-action/analyze@v1
68 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | coverage
3 | node_modules
4 | dist
5 | cjs
6 | scratch*
7 | synergy.js
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false
3 | }
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 17.9.1
4 | git:
5 | depth: 1
6 | branches:
7 | only:
8 | - master
9 | after_success:
10 | - "npm run coveralls"
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Matt Donkin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [synergy](https://synergyjs.org)
2 |
3 | ## [](http://npm.im/synergy) [](https://travis-ci.com/defx/synergy) [](https://coveralls.io/github/defx/synergy?branch=master) []()
4 |
5 | Synergy is a JavaScript library for building Web Components
6 |
7 | ## Features
8 |
9 | - Simple templates for declarative data & event binding
10 | - Reactive data bindings update your view efficiently and
11 | automatically
12 | - Full component workflow using standard Custom Elements
13 | - Small footprint (<5k)
14 | - No special compiler, plugins, required
15 | - Minimal learning curve (almost entirely standard HTML, JS,
16 | and CSS!)
17 | - Interoperable with other libraries and frameworks
18 |
19 | [Learn how to use Synergy in your own project](https://synergyjs.org/learn/introduction).
20 |
21 | ## Browser Support
22 |
23 | Works in any
24 | [modern browser](https://caniuse.com/mdn-javascript_builtins_proxy_proxy)
25 | that supports JavaScript Proxy.
26 |
27 | ## Installation
28 |
29 | Synergy is available from npm:
30 |
31 | ```bash
32 | $ npm i synergy
33 | ```
34 |
35 | You can also import Synergy directly in the browser via CDN:
36 |
37 | ```html
38 |
41 | ```
42 |
43 | ## Documentation
44 |
45 | You can find the Synergy documentation
46 | [on the website](https://synergyjs.org/).
47 |
48 | ## Example
49 |
50 | ### Step 1. Define your custom element
51 |
52 | ```html
53 |
58 | ```
59 |
60 | ### Step 2. Use the Custom Element
61 |
62 | ```html
63 |
64 | ```
65 |
66 | This example will render "Hello Kimberley!" into a container
67 | on the page.
68 |
69 | You'll notice that everything here is valid HTML and JS, and
70 | you can copy and paste this example and run it directly in
71 | the browser with no need to compile or install anything
72 | special to make it work.
73 |
74 | ### License
75 |
76 | Synergy is [MIT licensed](./LICENSE).
77 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | synergyjs.org
2 |
--------------------------------------------------------------------------------
/docs/components/my-counter.js:
--------------------------------------------------------------------------------
1 | import { define } from "/synergy.js"
2 |
3 | const name = "my-counter"
4 |
5 | const factory = () => ({
6 | state: { count: 0 },
7 | update: {
8 | increment: (state) => ({
9 | ...state,
10 | count: state.count + 1,
11 | }),
12 | },
13 | })
14 |
15 | const template = /* html */ `
16 | Count is: {{ count }}
17 | `
18 |
19 | const css = /* css */ `
20 | button {
21 | cursor: pointer;
22 | padding: 0.5rem 1rem;
23 | background-color: gold;
24 | color: tomato;
25 | }
26 | `
27 |
28 | define(name, factory, template)
29 |
--------------------------------------------------------------------------------
/docs/components/simple-clock.js:
--------------------------------------------------------------------------------
1 | import { define } from "/synergy.js"
2 |
3 | define(
4 | "simple-clock",
5 | () => {
6 | let t
7 |
8 | return {
9 | update: {
10 | setTime: (state, { payload }) => {
11 | return {
12 | ...state,
13 | time: payload,
14 | }
15 | },
16 | },
17 | connectedCallback: ({ dispatch, getState }) => {
18 | t = setInterval(() => {
19 | dispatch({
20 | type: "setTime",
21 | payload: new Date().toLocaleTimeString(),
22 | })
23 | }, 100)
24 | },
25 | disconnectedCallback: () => {
26 | clearInterval(t)
27 | },
28 | }
29 | },
30 | /* html */ `
31 |
{{ time }}
32 | `
33 | )
34 |
--------------------------------------------------------------------------------
/docs/components/todo-list.js:
--------------------------------------------------------------------------------
1 | import { define } from "/synergy.js"
2 |
3 | define(
4 | "todo-list",
5 | () => {
6 | return {
7 | state: {
8 | todos: [],
9 | },
10 | update: {
11 | addTodo: (state, { event: { key } }) => {
12 | if (key !== "Enter" || !state.newTodo?.length) return state
13 |
14 | return {
15 | ...state,
16 | newTodo: null,
17 | todos: state.todos.concat({
18 | title: state.newTodo,
19 | completed: false,
20 | }),
21 | }
22 | },
23 | removeTodo: (state, { scope: { todo } }) => {
24 | return {
25 | ...state,
26 | todos: state.todos.filter(({ title }) => title !== todo.title),
27 | }
28 | },
29 | },
30 | getState: (state) => {
31 | const n = state.todos.filter(({ completed }) => !completed).length
32 |
33 | return {
34 | ...state,
35 | itemsLeft: `${n} item${n === 1 ? "" : "s"} left`,
36 | }
37 | },
38 | }
39 | },
40 | /* html */ `
41 |
42 |
49 | {{ itemsLeft }}
50 |
51 | `
52 | )
53 |
--------------------------------------------------------------------------------
/docs/components/x-logo.js:
--------------------------------------------------------------------------------
1 | import { partial } from "../synergy.js"
2 |
3 | partial(
4 | "x-logo",
5 | /* HTML */ `
6 |
11 |
21 |
25 |
29 |
33 |
34 |
35 | `,
36 | /* CSS */ `
37 | x-logo {
38 | height: 2rem;
39 | }
40 |
41 | svg {
42 | height: 2rem;
43 | /* margin: 0 0.5rem; */
44 | margin-right: 0.5rem;
45 | }
46 |
47 | path {
48 | stroke: teal;
49 | fill: teal;
50 | }
51 |
52 | `
53 | )
54 |
--------------------------------------------------------------------------------
/docs/components/x-pager.js:
--------------------------------------------------------------------------------
1 | import { define } from "../synergy.js"
2 | import { flatNav } from "../data.js"
3 |
4 | define(
5 | "x-pager",
6 | () => {
7 | const items = flatNav
8 | const { pathname } = location
9 | const index = items.findIndex(({ href }) => href === pathname)
10 |
11 | return {
12 | state: {
13 | prev: items[index - 1],
14 | next: items[index + 1],
15 | },
16 | }
17 | },
18 | /* html */ `
19 |
20 | NEXT {{ next.title }}
21 |
22 |
23 |
24 |
25 |
26 | PREV {{ prev.title }}
27 |
28 |
29 |
30 |
31 |
32 | `,
33 | /* css */ `
34 | x-pager {
35 | display: flex;
36 | flex-direction: column;
37 | padding: var(--s3) 0;
38 | }
39 |
40 | x-pager > * {
41 | flex: 1;
42 | }
43 |
44 | a {
45 | padding: var(--s3);
46 | border: 1px solid #eaeaea;
47 | }
48 |
49 | svg {
50 | stroke: currentColor;
51 | stroke-width: 1px;
52 | margin: auto 0;
53 | }
54 |
55 | a {
56 | margin-top: 0;
57 | text-decoration: none;
58 | vertical-align: center;
59 | font-size: var(--s2);
60 | color: #111;
61 | }
62 |
63 | a:hover {
64 | border: 1px solid rgb(102, 204, 51);
65 | color: rgb(102, 204, 51);
66 | }
67 |
68 | a span {
69 | color: #222;
70 | display: block;
71 | font-size: var(--s1);
72 | }
73 |
74 | a:nth-of-type(2) {
75 | text-align: right;
76 | }
77 |
78 | a:nth-of-type(1) {
79 | text-align: left;
80 | }
81 |
82 | a:nth-of-type(2) svg {
83 | float: left;
84 | }
85 |
86 | a:nth-of-type(1) svg {
87 | float: right;
88 | }
89 |
90 | a:nth-of-type(1) {
91 | margin-bottom: var(--s1);
92 | }
93 |
94 | @media screen and (min-width: 1024px) {
95 | x-pager {
96 | flex-direction: row-reverse;
97 | }
98 |
99 | a:nth-of-type(1) {
100 | margin-bottom: 0;
101 | }
102 |
103 | a:nth-of-type(2) {
104 | margin-right: 1rem;
105 | }
106 | }
107 | `
108 | )
109 |
--------------------------------------------------------------------------------
/docs/data.js:
--------------------------------------------------------------------------------
1 | export const navigation = [
2 | {
3 | title: "Getting Started",
4 | items: [
5 | {
6 | title: "Introduction",
7 | href: "/",
8 | },
9 | ],
10 | },
11 | {
12 | title: "Learn by Example",
13 | items: [
14 | {
15 | title: "my-counter",
16 | href: "/my-counter",
17 | },
18 | {
19 | title: "simple-clock",
20 | href: "/simple-clock",
21 | },
22 | {
23 | title: "todo-list",
24 | href: "/todo-list",
25 | },
26 | ],
27 | },
28 | {
29 | title: "Reference",
30 | items: [
31 | {
32 | title: "Template syntax",
33 | href: "/template-syntax",
34 | },
35 | {
36 | title: "Repeated blocks",
37 | href: "/repeated-blocks",
38 | },
39 | {
40 | title: "Events",
41 | href: "/events",
42 | },
43 | {
44 | title: "Styles",
45 | href: "/styles",
46 | },
47 | {
48 | title: "Forms",
49 | href: "/forms",
50 | },
51 | {
52 | title: "Slots",
53 | href: "/slots",
54 | },
55 | ],
56 | },
57 | ]
58 |
59 | function flatten(items) {
60 | return items.reduce((acc, item) => {
61 | if (item.items) {
62 | acc.push(...item.items.map((v) => ({ ...v, category: item.title })))
63 | }
64 | return acc
65 | }, [])
66 | }
67 |
68 | export const flatNav = flatten(navigation)
69 |
--------------------------------------------------------------------------------
/docs/docs.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
16 |
170 |
171 |
172 |
173 |
182 |
183 |
184 |
185 | docs
186 |
187 |
188 |
189 | Examples
190 |
199 | Reference
200 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
--------------------------------------------------------------------------------
/docs/events.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Events | Synergy JS
9 |
16 |
170 |
171 |
172 |
173 |
217 |
Events
218 | Synergy allows you to map events to update functions of the same name.
219 | Model:
220 | const factory = ( ) => ({
221 | update : {
222 | sayHello : (state ) => ({
223 | ...state,
224 | greeting : "Hello"
225 | }),
226 | })
227 |
228 | Template:
229 | <button :onclick ="sayHello" > Say hello</button >
230 |
231 | Every update function accepts the current state as its first argument, its return value will provide the next state for the custom element.
232 | Template scope
233 | Because each repeated block creates a new variable scope , it is useful to be able to access those values within your handler. You can do this via the scope
property of the second argument to your event handler.
234 | Model:
235 | const factory = ( ) => {
236 | return {
237 | state : {
238 | artists : [
239 | {
240 | name : "pablo picasso" ,
241 | tags : ["painter" , "sculptor" , "printmaker" , "ceramicist" ],
242 | },
243 | {
244 | name : "salvador dali" ,
245 | tags : ["painter" , "sculptor" , "photographer" , "writer" ],
246 | },
247 | ],
248 | },
249 | update : {
250 | select : (state, { scope } ) => {
251 | const { artist, tag } = scope
252 |
253 | return {
254 | ...state,
255 | selected : {
256 | artist,
257 | tag,
258 | },
259 | }
260 | },
261 | },
262 | }
263 | }
264 |
265 | Template:
266 | <article :each ="artist in artists" >
267 | <h4 > {{ artist.name }}</h4 >
268 | <ul >
269 | <li :each ="tag in artist.tags" :onclick ="select" > {{ tag }}</li >
270 | </ul >
271 | </article >
272 |
273 |
274 |
275 |
276 |
277 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Synergy - A JavaScript library for crafting user interfaces
9 |
16 |
170 |
171 |
172 |
173 |
217 |
What is Synergy?
218 | Synergy
219 | combines declarative data and event binding with functional state
220 | management and reactive updates to allow you to build all types of
221 | user interface for the web, no matter how simple or complex.
222 | Here's a simple example:
223 |
224 | <my-counter > </my-counter >
225 |
226 | <script type ="module" >
227 | import { define } from "https://unpkg.com/synergy@8.0.0"
228 |
229 | define (
230 | "my-counter" ,
231 | () => ({
232 | state : { count : 0 },
233 | update : {
234 | increment : ({ count } ) => ({
235 | count : count + 1 ,
236 | }),
237 | }),
238 | `<button :onclick="increment">Count is: {{ count }}</button>`
239 | )
240 | </script >
241 |
242 | The above example demonstrates the three core features of Synergy:
243 |
244 | Declarative data and event binding: Synergy
245 | provides a declarative template syntax that allows you to describe
246 | HTML output based on JavaScript state. The syntax is very simple
247 | and easy to learn.
248 | Functional state management: Synergy allows you
249 | to describe changes to state as individual functions that are as
250 | easy to reason about as they are to test.
251 | Reactive updates: Synergy efficiently batches
252 | updates to your HTML whenever your state changes
253 |
254 | Getting started
255 | If you're new to Synergy then the best place to start is the Learn by Example section. It will introduce you to all of the features of Synergy by showing different examples that will help you to understand and learn quickly.
256 |
257 |
258 |
259 |
260 |
--------------------------------------------------------------------------------
/docs/my-counter.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Learn by example - My Counter | Synergy JS
9 |
16 |
170 |
171 |
172 |
173 |
217 |
my-counter
218 |
219 | A key feature of any user interface is the ability to update its state in response to different events. In Synergy, this process is defined by the object returned from your custom elements factory function.
220 | Lets take another look at the my-counter
example from the home page:
221 | const factory = ( ) => ({
222 | state : { count : 0 },
223 | update : {
224 | increment : ({ count } ) => ({
225 | count : count + 1 ,
226 | }),
227 | })
228 |
229 | In the example above, the returned object includes both the state
and update
properties.
230 | State
231 | State
is an object that provides the initial data for your custom element. Any properties defined here can be used directly in the template via text or attribute bindings, which is exactly how the value of count
is included inside the template using text interpolation:
232 | <button :onclick ="increment" > Count is: {{ count }}</button >
233 |
234 | Update
235 | Update
is a dictionary of named state update functions that can be referenced directly in the template as event handlers, as per the :onclick="increment"
binding in the example above.
236 | Each state update function takes the current state as its first argument and returns the next state for the custom element.
237 |
238 |
239 |
240 |
241 |
--------------------------------------------------------------------------------
/docs/simple-clock.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Learn by example - Simple Clock | Synergy JS
9 |
16 |
170 |
171 |
172 |
173 |
217 |
simple-clock
218 |
219 | In this example we're going to create a custom element that displays the current time. We need a way to setup a scheduled event once the element is mounted to the DOM, and we will also need a way to programatically update state whenever that event fires to keep our time value synchronised with the real world.
220 | connectedCallback
is a lifecycle event that fires whenever the element is connected to the DOM...
221 | const factory = ( ) => {
222 | return {
223 | update : {
224 | setTime : (state ) => {
225 | return {
226 | ...state,
227 | time : new Date ().toLocaleTimeString (),
228 | }
229 | },
230 | },
231 | connectedCallback : ({ dispatch } ) => {
232 | setInterval (() => {
233 | dispatch ({
234 | type : "setTime" ,
235 | })
236 | }, 100 )
237 | },
238 | }
239 | }
240 |
241 | <p > Time: {{ time }}</p >
242 |
243 | In order to programatically update state we can use the dispatch
function provided within the first argument to connectedCallback
. The dispatch
function accepts a single argument which is an object that must include a type
property to identify the update function that we want to call.
244 | disconnectedCallback
245 | Of course, our element can be removed from the DOM at some later point, and we wouldn't want it to carry on scheduling re-renders in the background if there was nobody there to see it. So let's improve our simple-clock
element to use the
246 | disconnectedCallback
to clear our timer interval.
247 | const factory = ( ) => {
248 | let intervalID
249 |
250 | return {
251 | update : {
252 | setTime : (state, { payload } ) => {
253 | return {
254 | ...state,
255 | time : payload,
256 | }
257 | },
258 | },
259 | connectedCallback : ({ dispatch } ) => {
260 | intervalID = setInterval (() => {
261 | dispatch ({
262 | type : "setTime" ,
263 | payload : new Date ().toLocaleTimeString (),
264 | })
265 | }, 100 )
266 | },
267 | disconnectedCallback : () => {
268 | clearInterval (intervalID)
269 | },
270 | }
271 | }
272 |
273 |
274 |
275 |
276 |
277 |
--------------------------------------------------------------------------------
/docs/styles.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Styles | Synergy JS
9 |
16 |
170 |
171 |
172 |
173 |
217 |
Styles
218 | Multiple classes with Array
219 | State:
220 | {
221 | classes : ["w32" , "h32" , "rounded-full" , "mx-auto" ]
222 | }
223 |
224 | Template:
225 | <img :class ="classes" />
226 |
227 | Output:
228 | <img class ="w32 h32 rounded-full mx-auto" />
229 |
230 | Conditional Classes with Object
231 | State:
232 | {
233 | classes : {
234 | 'mx-auto' : true ,
235 | 'has-error' : false
236 | }
237 | }
238 |
239 | Template:
240 | <div :class ="classes" > </div >
241 |
242 | Output:
243 | <div class ="mx-auto" > </div >
244 |
245 | Inline Styles
246 | State:
247 | {
248 | primary : true ,
249 | style : {
250 | display : "inline-block" ,
251 | borderRadius : "3px" ,
252 | background : this .primary ? "white" : "transparent" ,
253 | color : this .primary ? "black" : "white" ,
254 | border : "2px solid white" ,
255 | }
256 | }
257 |
258 | Template:
259 | <button :primary :style > </button >
260 |
261 | Output:
262 | <button
263 | primary
264 | style ="
265 | display: inline-block;
266 | border-radius: 3px;
267 | background: white;
268 | color: black;
269 | border: 2px solid white;"
270 | > </button >
271 |
272 |
273 |
274 |
275 |
276 |
--------------------------------------------------------------------------------
/examples/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "semi": false
4 | }
5 |
--------------------------------------------------------------------------------
/examples/02-components-and-partials/README.md:
--------------------------------------------------------------------------------
1 | # Components and partials
2 |
3 | Synergy allows you to create two types of components, _active_ and _passive_ components. Active components are the ones we've already looked at, which we will refer to simply as "components". Components are characterised primarily by the fact that they manage their own state. Passive components, on the other hand, don't manage state at all, they simply provide a template that is mixed into the parent components template, much like an HTML include. We refer to these types of components as "partials". Partials share state with the parent that includes them. Why? Because the power of components comes from the ability to break down your code into reusable chunks that can be recombined in different ways, but having to wire every single component together with props and independent state management can be time consuming to develop and maintain, and results in loading much more javascript than necessary. That why Synergy provides the option to create both components and partials, because sometimes we just want reusable chunks of HTML (and maybe also CSS). Remember, one of the key principles of good engineering design is to use the minimum number of moving parts required to achieve your goal. the fewer parts you have, the easier your design will be to build and maintain. "Less is more", as they say.
4 |
--------------------------------------------------------------------------------
/examples/02-components-and-partials/components/app.js:
--------------------------------------------------------------------------------
1 | import { define } from "../synergy.js"
2 | import { html } from "../utils.js"
3 |
4 | import "./myHeader.js"
5 | import "./myFooter.js"
6 |
7 | define(
8 | "my-app",
9 | () => {
10 | return {
11 | title: "First & Foremost",
12 | }
13 | },
14 | html`
15 |
16 |
17 | `
18 | )
19 |
--------------------------------------------------------------------------------
/examples/02-components-and-partials/components/myFooter.js:
--------------------------------------------------------------------------------
1 | import { partial } from "../synergy.js"
2 | import { html } from "../utils.js"
3 |
4 | partial("my-footer", html` `)
5 |
--------------------------------------------------------------------------------
/examples/02-components-and-partials/components/myHeader.js:
--------------------------------------------------------------------------------
1 | import { partial } from "../synergy.js"
2 | import { html, css } from "../utils.js"
3 |
4 | partial(
5 | "my-header",
6 | html`
7 |
10 | `,
11 | css`
12 | header {
13 | background-color: gold;
14 | }
15 | a {
16 | color: tomato;
17 | }
18 | `
19 | )
20 |
--------------------------------------------------------------------------------
/examples/02-components-and-partials/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Components and partials - Synergy JS
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/examples/02-components-and-partials/utils.js:
--------------------------------------------------------------------------------
1 | const idTpl = (strings, ...values) => {
2 | return strings.reduce((a, s, i) => a + s + (values[i] || ""), "")
3 | }
4 |
5 | export const html = idTpl
6 |
7 | export const css = (strings, ...values) => {
8 | return (name) => {
9 | const styles = strings.reduce((a, s, i) => a + s + (values[i] || ""), "")
10 | appendStyles(name, prefixSelectors(name, styles))
11 | }
12 | }
13 |
14 | function prefixSelectors(prefix, css) {
15 | let insideBlock = false
16 | let look = true
17 | let output = ""
18 | let count = 0
19 |
20 | for (let char of css) {
21 | if (char === "}") {
22 | insideBlock = false
23 | look = true
24 | } else if (char === ",") {
25 | look = true
26 | } else if (char === "{") {
27 | insideBlock = true
28 | } else if (look && !insideBlock && !char.match(/\s/)) {
29 | let w = nextWord(css, count + 1)
30 | if (
31 | w !== prefix &&
32 | w.charAt(0) !== "@" &&
33 | w.charAt(0) !== ":" &&
34 | w.charAt(0) !== "*" &&
35 | w !== "html" &&
36 | w !== "body"
37 | ) {
38 | output += prefix + " "
39 | }
40 | look = false
41 | }
42 | output += char
43 | count += 1
44 | }
45 | }
46 |
47 | function appendStyles(name, css) {
48 | if (document.querySelector(`style#${name}`)) return
49 |
50 | const el = document.createElement("style")
51 | el.id = name
52 | el.innerHTML = prefixSelectors(name, css)
53 | document.head.appendChild(el)
54 | }
55 |
--------------------------------------------------------------------------------
/examples/todo-app/index.js:
--------------------------------------------------------------------------------
1 | export const storage = {
2 | get: (k) => {
3 | let v = JSON.parse(localStorage.getItem(k))
4 | return v
5 | },
6 | set: (k, v) => {
7 | localStorage.setItem(k, JSON.stringify(v))
8 | },
9 | }
10 |
11 | const filters = {
12 | all: (todos) => todos,
13 | active: (todos) => todos.filter(({ completed }) => !completed),
14 | done: (todos) => todos.filter(({ completed }) => completed),
15 | }
16 |
17 | const KEYS = {
18 | RETURN: 13,
19 | ESCAPE: 27,
20 | }
21 |
22 | export const state = {
23 | filters: Object.keys(filters),
24 | todos: [],
25 | activeFilter: "all",
26 | }
27 |
28 | export const middleware = {
29 | keydown: (action, next) => {
30 | const { keyCode } = action.event
31 |
32 | switch (keyCode) {
33 | case KEYS.ESCAPE: {
34 | next({ ...action, type: "cancelEdit" })
35 | break
36 | }
37 | case KEYS.RETURN: {
38 | next({ ...action, type: "saveEdit" })
39 | break
40 | }
41 | default: {
42 | next(action)
43 | }
44 | }
45 | },
46 | startEdit: (action, next, { refs }) => {
47 | next(action).then(() => refs.titleEditInput?.focus())
48 | },
49 | }
50 |
51 | export const subscribe = (state) => {
52 | storage.set("todos", state.todos)
53 | }
54 |
55 | export const getState = (state) => {
56 | const { todos, activeFilter } = state
57 | const n = todos.filter(({ completed }) => !completed).length
58 |
59 | return {
60 | ...state,
61 | allDone: todos.every((todo) => todo.completed),
62 | filteredTodos: filters[activeFilter](todos),
63 | numCompleted: todos.filter(({ completed }) => completed).length,
64 | itemsLeft: `${n} item${n === 1 ? "" : "s"} left`,
65 | }
66 | }
67 |
68 | export const update = {
69 | toggleAll: (state, { event }) => {
70 | let allDone = event.target.checked
71 | return {
72 | ...state,
73 | allDone,
74 | todos: state.todos.map((todo) => ({ ...todo, completed: allDone })),
75 | }
76 | },
77 | todoInput: (state, { event }) => {
78 | if (event.keyCode === KEYS.RETURN) {
79 | return {
80 | ...state,
81 | todos: state.todos.concat({
82 | title: state.newTodo,
83 | id: Date.now(),
84 | completed: false,
85 | }),
86 | newTodo: null,
87 | }
88 | } else {
89 | return {
90 | ...state,
91 | newTodo: title,
92 | }
93 | }
94 | },
95 | startEdit: (state, { scope }) => {
96 | const todos = state.todos.map((todo) => ({
97 | ...todo,
98 | editing: todo.id === scope.todo.id,
99 | }))
100 | const titleEdit = todos.find((todo) => todo.editing)?.title
101 | return {
102 | ...state,
103 | titleEdit,
104 | todos,
105 | }
106 | },
107 | deleteTodo: (state, { scope }) => ({
108 | ...state,
109 | todos: state.todos.filter((todo) => todo.id !== scope.todo.id),
110 | }),
111 | removeCompleted: (state) => ({
112 | ...state,
113 | todos: state.todos.filter(({ completed }) => !completed),
114 | }),
115 | cancelEdit: (state) => ({
116 | ...state,
117 | titleEdit: "",
118 | todos: state.todos.map((todo) => ({
119 | ...todo,
120 | editing: false,
121 | })),
122 | }),
123 | saveEdit: (state) => {
124 | let titleEdit = state.titleEdit.trim()
125 | return {
126 | ...state,
127 | todos: titleEdit
128 | ? state.todos.map((todo) => {
129 | return {
130 | ...todo,
131 | title: todo.editing ? state.titleEdit : todo.title,
132 | editing: false,
133 | }
134 | })
135 | : state.todos.filter(({ editing }) => !editing),
136 | }
137 | },
138 | }
139 |
140 | export const markup = html`
141 |
159 |
169 |
170 | Mark all as complete
171 |
172 |
192 |
193 |
194 | {{ itemsLeft }}
195 |
201 |
206 | clear completed ({{ numCompleted }})
207 |
208 |
209 | `
210 |
211 | export const factory = () => ({
212 | update,
213 | middleware,
214 | subscribe,
215 | state,
216 | getState,
217 | })
218 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "synergy",
3 | "version": "8.0.0",
4 | "description": "A JavaScript library for building Web Components",
5 | "main": "dist/synergy.min.js",
6 | "browser": "dist/synergy.min.js",
7 | "unpkg": "dist/synergy.min.js",
8 | "module": "src/index.js",
9 | "types": "types/index.d.ts",
10 | "files": [
11 | "src",
12 | "dist",
13 | "cjs"
14 | ],
15 | "directories": {
16 | "test": "test"
17 | },
18 | "scripts": {
19 | "test": "mocha-express --coverage",
20 | "test:watch": "mocha-express --watch",
21 | "build": "rm -rf dist && rollup -c && npm run size",
22 | "size": "gzip -c9 ./dist/synergy.min.js | wc -c",
23 | "coveralls": "cat ./coverage/lcov.info | coveralls",
24 | "prepublish": "npm t && npm run build",
25 | "release": "npm t && standard-version"
26 | },
27 | "repository": {
28 | "type": "git",
29 | "url": "git+https://github.com/defx/synergy.git"
30 | },
31 | "keywords": [
32 | "simple",
33 | "declarative",
34 | "binding",
35 | "dom",
36 | "ui",
37 | "user interface",
38 | "web components",
39 | "framework",
40 | "vue",
41 | "react"
42 | ],
43 | "author": "Matt Donkin",
44 | "license": "MIT",
45 | "bugs": {
46 | "url": "https://github.com/defx/synergy/issues"
47 | },
48 | "homepage": "https://synergyjs.org",
49 | "devDependencies": {
50 | "coveralls": "^3.1.0",
51 | "mocha-express": "^0.1.2",
52 | "rollup": "^2.26.4",
53 | "rollup-plugin-terser": "^7.0.1",
54 | "standard-version": "^9.1.1"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { terser } from 'rollup-plugin-terser';
2 |
3 | export default [
4 | {
5 | input: 'src/index.js',
6 | output: {
7 | file: 'dist/synergy.js',
8 | format: 'es',
9 | },
10 | },
11 | {
12 | input: 'src/index.js',
13 | plugins: [terser()],
14 | output: {
15 | file: 'dist/synergy.min.js',
16 | format: 'es',
17 | },
18 | },
19 | {
20 | input: 'src/index.js',
21 | output: {
22 | dir: 'cjs',
23 | format: 'cjs',
24 | preserveModules: true,
25 | },
26 | },
27 | ];
28 |
--------------------------------------------------------------------------------
/src/attribute.js:
--------------------------------------------------------------------------------
1 | import { isPrimitive, typeOf } from "./helpers.js"
2 |
3 | const pascalToKebab = (string) =>
4 | string.replace(/[\w]([A-Z])/g, function (m) {
5 | return m[0] + "-" + m[1].toLowerCase()
6 | })
7 |
8 | const kebabToPascal = (string) =>
9 | string.replace(/[\w]-([\w])/g, function (m) {
10 | return m[0] + m[2].toUpperCase()
11 | })
12 |
13 | const parseStyles = (value) => {
14 | let type = typeof value
15 |
16 | if (type === "string")
17 | return value.split(";").reduce((o, value) => {
18 | const [k, v] = value.split(":").map((v) => v.trim())
19 | if (k) o[k] = v
20 | return o
21 | }, {})
22 |
23 | if (type === "object") return value
24 |
25 | return {}
26 | }
27 |
28 | const joinStyles = (value) =>
29 | Object.entries(value)
30 | .map(([k, v]) => `${k}: ${v};`)
31 | .join(" ")
32 |
33 | const convertStyles = (o) =>
34 | Object.keys(o).reduce((a, k) => {
35 | a[pascalToKebab(k)] = o[k]
36 | return a
37 | }, {})
38 |
39 | export const applyAttribute = (node, name, value) => {
40 | if (name === "style") {
41 | value = joinStyles(
42 | convertStyles({
43 | ...parseStyles(node.getAttribute("style")),
44 | ...parseStyles(value),
45 | })
46 | )
47 | } else if (name === "class") {
48 | switch (typeOf(value)) {
49 | case "Array":
50 | value = value.join(" ")
51 | break
52 | case "Object":
53 | value = Object.keys(value)
54 | .reduce((a, k) => {
55 | if (value[k]) a.push(k)
56 | return a
57 | }, [])
58 | .join(" ")
59 | break
60 | }
61 | } else if (!isPrimitive(value)) {
62 | return (node[kebabToPascal(name)] = value)
63 | }
64 |
65 | name = pascalToKebab(name)
66 |
67 | if (typeof value === "boolean") {
68 | if (name.startsWith("aria-")) {
69 | value = "" + value
70 | } else if (value) {
71 | value = ""
72 | }
73 | }
74 |
75 | let current = node.getAttribute(name)
76 |
77 | if (value === current) return
78 |
79 | if (typeof value === "string" || typeof value === "number") {
80 | node.setAttribute(name, value)
81 | } else {
82 | node.removeAttribute(name)
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const TEXT = 1
2 | export const ATTRIBUTE = 2
3 | export const INPUT = 3
4 | export const EVENT = 4
5 | export const REPEAT = 5
6 | export const CONDITIONAL = 6
7 |
--------------------------------------------------------------------------------
/src/context.js:
--------------------------------------------------------------------------------
1 | import { getValueAtPath, isPrimitive } from "./helpers.js"
2 |
3 | const handler = ({ path, identifier, key, index, i, k }) => ({
4 | get(target, property) {
5 | let x = getValueAtPath(path, target)
6 |
7 | // x === the collection
8 |
9 | if (property === identifier) {
10 | for (let n in x) {
11 | let v = x[n]
12 | if (key) {
13 | if (v[key] === k) return v
14 | } else {
15 | if (n == i) return v
16 | }
17 | }
18 | }
19 |
20 | if (property === index) {
21 | for (let n in x) {
22 | let v = x[n]
23 | if (key) {
24 | if (v[key] === k) return n
25 | } else {
26 | if (n == i) return n
27 | }
28 | }
29 | }
30 |
31 | let t = key ? x.find((v) => v[key] === k) : x?.[i]
32 | if (t?.hasOwnProperty?.(property)) return t[property]
33 |
34 | return Reflect.get(...arguments)
35 | },
36 | set(target, property, value) {
37 | let x = getValueAtPath(path, target)
38 | let t = key ? x.find((v) => v[key] === k) : x[i]
39 | if (t && !isPrimitive(t)) {
40 | t[property] = value
41 | return true
42 | }
43 |
44 | return Reflect.set(...arguments)
45 | },
46 | })
47 |
48 | export const createContext = (v = []) => {
49 | let context = v
50 | return {
51 | get: () => context,
52 | push: (v) => context.push(v),
53 | wrap: (state) => {
54 | return context.reduce(
55 | (target, ctx) => new Proxy(target, handler(ctx)),
56 | state
57 | )
58 | },
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/css.js:
--------------------------------------------------------------------------------
1 | function nextWord(css, count) {
2 | return css.slice(count - 1).split(/[\s+|\n+|,]/)[0]
3 | }
4 |
5 | function nextOpenBrace(css, count) {
6 | let index = css.slice(count - 1).indexOf("{")
7 | if (index > -1) {
8 | return count + index
9 | }
10 | }
11 |
12 | export function prefixSelectors(prefix, css) {
13 | let insideBlock = false
14 | let look = true
15 | let output = ""
16 | let count = 0
17 | let skip = false
18 |
19 | for (let char of css) {
20 | if (char === "@" && nextWord(css, count + 1) === "@media") {
21 | skip = nextOpenBrace(css, count)
22 | }
23 |
24 | if (skip) {
25 | if (skip === count) skip = false
26 | }
27 |
28 | if (!skip) {
29 | if (char === "}") {
30 | insideBlock = false
31 | look = true
32 | } else if (char === ",") {
33 | look = true
34 | } else if (char === "{") {
35 | insideBlock = true
36 | } else if (look && !insideBlock && !char.match(/\s/)) {
37 | let w = nextWord(css, count + 1)
38 |
39 | // console.log({ w })
40 |
41 | if (
42 | w !== prefix &&
43 | w.charAt(0) !== "@" &&
44 | w.charAt(0) !== ":" &&
45 | w.charAt(0) !== "*" &&
46 | w !== "html" &&
47 | w !== "body"
48 | ) {
49 | output += prefix + " "
50 | }
51 | look = false
52 | }
53 | }
54 | output += char
55 | count += 1
56 | }
57 |
58 | return output
59 | }
60 |
61 | export function appendStyles(name, css) {
62 | if (document.querySelector(`style#${name}`)) return
63 |
64 | const el = document.createElement("style")
65 | el.id = name
66 | el.innerHTML = prefixSelectors(name, css)
67 | document.head.appendChild(el)
68 | }
69 |
--------------------------------------------------------------------------------
/src/define.js:
--------------------------------------------------------------------------------
1 | import { configure } from "./update.js"
2 | import { render } from "./render.js"
3 | import { mergeSlots } from "./mergeSlots.js"
4 | import { appendStyles } from "./css.js"
5 | import {
6 | applyAttribute,
7 | attributeToProp,
8 | isPrimitive,
9 | pascalToKebab,
10 | getDataScript,
11 | createDataScript,
12 | } from "./helpers.js"
13 |
14 | function serialise(node, state) {
15 | let ds = getDataScript(node) || createDataScript(node)
16 | ds.innerText = JSON.stringify(state)
17 | }
18 |
19 | export const define = (name, factory, template, css) => {
20 | if (customElements.get(name)) return
21 |
22 | if (css) appendStyles(name, css)
23 |
24 | customElements.define(
25 | name,
26 | class extends HTMLElement {
27 | async connectedCallback() {
28 | if (!this.initialised) {
29 | let config = factory(this)
30 |
31 | if (config instanceof Promise) config = await config
32 |
33 | let { subscribe, shadow, observe = [] } = config
34 |
35 | this.connectedCallback = config.connectedCallback
36 | this.disconnectedCallback = config.disconnectedCallback
37 |
38 | const ds = getDataScript(this)
39 |
40 | const { dispatch, getState, onChange, updated, refs } = configure(
41 | {
42 | ...config,
43 | state: ds ? JSON.parse(ds.innerText) : config.state,
44 | },
45 | this
46 | )
47 |
48 | this.store = { dispatch, getState }
49 |
50 | let state = getState()
51 |
52 | let observedAttributes = observe.map(pascalToKebab)
53 |
54 | let sa = this.setAttribute
55 | this.setAttribute = (k, v) => {
56 | if (observedAttributes.includes(k)) {
57 | let { name, value } = attributeToProp(k, v)
58 |
59 | dispatch({
60 | type: "SET",
61 | payload: { name, value },
62 | })
63 | }
64 | sa.apply(this, [k, v])
65 | }
66 | let ra = this.removeAttribute
67 | this.removeAttribute = (k) => {
68 | if (observedAttributes.includes(k)) {
69 | let { name, value } = attributeToProp(k, null)
70 | dispatch({
71 | type: "SET",
72 | payload: { name, value },
73 | })
74 | }
75 | ra.apply(this, [k])
76 | }
77 |
78 | observedAttributes.forEach((name) => {
79 | let property = attributeToProp(name).name
80 | let value
81 |
82 | if (this.hasAttribute(name)) {
83 | value = this.getAttribute(name)
84 | } else {
85 | value = this[property] || state[property]
86 | }
87 |
88 | Object.defineProperty(this, property, {
89 | get() {
90 | return getState()[property]
91 | },
92 | set(v) {
93 | dispatch({
94 | type: "SET",
95 | payload: { name: property, value: v },
96 | })
97 | if (isPrimitive(v)) {
98 | applyAttribute(this, property, v)
99 | }
100 | },
101 | })
102 |
103 | this[property] = value
104 | })
105 |
106 | let beforeMountCallback
107 |
108 | if (shadow) {
109 | this.attachShadow({
110 | mode: shadow,
111 | })
112 | } else {
113 | beforeMountCallback = (frag) => mergeSlots(this, frag)
114 | }
115 |
116 | onChange(
117 | render(
118 | this.shadowRoot || this,
119 | { getState, dispatch, refs },
120 | template,
121 | () => {
122 | const state = getState()
123 |
124 | serialise(this, state)
125 | observe.forEach((k) => {
126 | let v = state[k]
127 | if (isPrimitive(v)) applyAttribute(this, k, v)
128 | })
129 | subscribe?.(getState())
130 | updated()
131 | },
132 | beforeMountCallback
133 | )
134 | )
135 |
136 | if (!ds) serialise(this, getState())
137 | }
138 |
139 | if (!this._C) this.connectedCallback?.(this.store)
140 | this._C = false
141 | }
142 | disconnectedCallback() {
143 | this._C = true
144 | requestAnimationFrame(() => {
145 | if (this._C) this.disconnectedCallback?.()
146 | this._C = false
147 | })
148 | }
149 | }
150 | )
151 | }
152 |
--------------------------------------------------------------------------------
/src/formControl.js:
--------------------------------------------------------------------------------
1 | export const updateFormControl = (node, value) => {
2 | if (node.nodeName === "SELECT") {
3 | Array.from(node.querySelectorAll("option")).forEach((option) => {
4 | option.selected = value.includes(option.value)
5 | })
6 | return
7 | }
8 |
9 | let checked
10 |
11 | switch (node.getAttribute("type")) {
12 | case "checkbox":
13 | checked = value
14 | if (node.checked === checked) break
15 | if (checked) {
16 | node.setAttribute("checked", "")
17 | } else {
18 | node.removeAttribute("checked")
19 | }
20 | break
21 | case "radio":
22 | checked = value === node.getAttribute("value")
23 | if (node.checked === checked) break
24 | node.checked = checked
25 | if (checked) {
26 | node.setAttribute("checked", "")
27 | } else {
28 | node.removeAttribute("checked")
29 | }
30 | break
31 | default:
32 | if (node.value !== value) {
33 | if (!isNaN(value) && node.value.slice(-1) === ".") break
34 | node.setAttribute("value", (node.value = value ?? ""))
35 | }
36 |
37 | break
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
1 | export const wrapToken = (v) => {
2 | v = v.trim()
3 | if (v.startsWith("{{")) return v
4 | return `{{${v}}}`
5 | }
6 |
7 | export const last = (v = []) => v[v.length - 1]
8 |
9 | export const isWhitespace = (node) => {
10 | return node.nodeType === node.TEXT_NODE && node.nodeValue.trim() === ""
11 | }
12 |
13 | export const walk = (node, callback, deep = true) => {
14 | if (!node) return
15 | // if (node.matches?.(`script[type="application/synergy"]`))
16 | // return walk(node.nextSibling, callback, deep)
17 | if (!isWhitespace(node)) {
18 | let v = callback(node)
19 | if (v === false) return
20 | if (v?.nodeName) return walk(v, callback, deep)
21 | }
22 | if (deep) walk(node.firstChild, callback, deep)
23 | walk(node.nextSibling, callback, deep)
24 | }
25 |
26 | const transformBrackets = (str = "") => {
27 | let parts = str.split(/(\[[^\]]+\])/).filter((v) => v)
28 | return parts.reduce((a, part) => {
29 | let v = part.charAt(0) === "[" ? "." + part.replace(/\./g, ":") : part
30 | return a + v
31 | }, "")
32 | }
33 |
34 | const getTarget = (path, target) => {
35 | let parts = transformBrackets(path)
36 | .split(".")
37 | .map((k) => {
38 | if (k.charAt(0) === "[") {
39 | let p = k.slice(1, -1).replace(/:/g, ".")
40 | return getValueAtPath(p, target)
41 | } else {
42 | return k
43 | }
44 | })
45 |
46 | let t =
47 | parts.slice(0, -1).reduce((o, k) => {
48 | return o && o[k]
49 | }, target) || target
50 | return [t, last(parts)]
51 | }
52 |
53 | export const getValueAtPath = (path, target) => {
54 | let [a, b] = getTarget(path, target)
55 | let v = a?.[b]
56 | if (typeof v === "function") return v.bind(a)
57 | return v
58 | }
59 |
60 | export const setValueAtPath = (path, value, target) => {
61 | let [a, b] = getTarget(path, target)
62 | return (a[b] = value)
63 | }
64 |
65 | export const fragmentFromTemplate = (v) => {
66 | if (typeof v === "string") {
67 | if (v.charAt(0) === "#") {
68 | v = document.querySelector(v)
69 | } else {
70 | let tpl = document.createElement("template")
71 | tpl.innerHTML = v.trim()
72 | return tpl.content.cloneNode(true)
73 | }
74 | }
75 | if (v.nodeName === "TEMPLATE") return v.cloneNode(true).content
76 | if (v.nodeName === "defs") return v.firstElementChild.cloneNode(true)
77 | }
78 |
79 | export const debounce = (fn) => {
80 | let wait = false
81 | let invoke = false
82 | return () => {
83 | if (wait) {
84 | invoke = true
85 | } else {
86 | wait = true
87 | fn()
88 | requestAnimationFrame(() => {
89 | if (invoke) fn()
90 | wait = false
91 | })
92 | }
93 | }
94 | }
95 |
96 | export const isPrimitive = (v) => v === null || typeof v !== "object"
97 |
98 | export const typeOf = (v) =>
99 | Object.prototype.toString.call(v).match(/\s(.+[^\]])/)[1]
100 |
101 | export const pascalToKebab = (string) =>
102 | string.replace(/[\w]([A-Z])/g, function (m) {
103 | return m[0] + "-" + m[1].toLowerCase()
104 | })
105 |
106 | export const kebabToPascal = (string) =>
107 | string.replace(/[\w]-([\w])/g, function (m) {
108 | return m[0] + m[2].toUpperCase()
109 | })
110 |
111 | export const applyAttribute = (node, name, value) => {
112 | name = pascalToKebab(name)
113 |
114 | if (typeof value === "boolean") {
115 | if (name.startsWith("aria-")) {
116 | value = "" + value
117 | } else if (value) {
118 | value = ""
119 | }
120 | }
121 |
122 | if (typeof value === "string" || typeof value === "number") {
123 | node.setAttribute(name, value)
124 | } else {
125 | node.removeAttribute(name)
126 | }
127 | }
128 |
129 | export const attributeToProp = (k, v) => {
130 | let name = kebabToPascal(k)
131 | if (v === "") v = true
132 | if (k.startsWith("aria-")) {
133 | if (v === "true") v = true
134 | if (v === "false") v = false
135 | }
136 | return {
137 | name,
138 | value: v,
139 | }
140 | }
141 |
142 | export function getDataScript(node) {
143 | return node.querySelector(
144 | `script[type="application/synergy"][id="${node.nodeName}"]`
145 | )
146 | }
147 |
148 | export function createDataScript(node) {
149 | let ds = document.createElement("script")
150 | ds.setAttribute("type", "application/synergy")
151 | ds.setAttribute("id", node.nodeName)
152 | node.append(ds)
153 | return ds
154 | }
155 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { define } from "./define.js"
2 | export { partial } from "./partial.js"
3 |
--------------------------------------------------------------------------------
/src/list.js:
--------------------------------------------------------------------------------
1 | import { isWhitespace, walk } from "./helpers.js"
2 |
3 | export function parseEach(node) {
4 | let each = node.getAttribute(":each")
5 | let m = each?.match(/(.+)\s+in\s+(.+)/)
6 | if (!m) {
7 | if (!each) return m
8 | return {
9 | path: each.trim(),
10 | key: node.getAttribute(":key"),
11 | }
12 | }
13 | let [_, left, right] = m
14 | let parts = left.match(/\(([^\)]+)\)/)
15 | let [a, b] = (parts ? parts[1].split(",") : [left]).map((v) => v.trim())
16 |
17 | return {
18 | path: right.trim(),
19 | identifier: b ? b : a,
20 | index: b ? a : b,
21 | key: node.getAttribute(":key"),
22 | }
23 | }
24 |
25 | const getBlockSize = (template) => {
26 | let i = 0
27 | walk(template.content?.firstChild || template.firstChild, () => i++, false)
28 | return i
29 | }
30 |
31 | const nextNonWhitespaceSibling = (node) => {
32 | return isWhitespace(node.nextSibling)
33 | ? nextNonWhitespaceSibling(node.nextSibling)
34 | : node.nextSibling
35 | }
36 |
37 | const getBlockFragments = (template, numBlocks) => {
38 | let blockSize = getBlockSize(template)
39 |
40 | let r = []
41 | if (numBlocks) {
42 | while (numBlocks--) {
43 | let f = document.createDocumentFragment()
44 | let n = blockSize
45 | while (n--) {
46 | f.appendChild(nextNonWhitespaceSibling(template))
47 | }
48 | r.push(f)
49 | }
50 | }
51 | return r
52 | }
53 |
54 | export const getBlocks = (template) => {
55 | let numBlocks = +(template.dataset.length || 0)
56 | let blockSize = getBlockSize(template)
57 | let r = []
58 | let node = template
59 | if (numBlocks) {
60 | while (numBlocks--) {
61 | let f = []
62 | let n = blockSize
63 | while (n--) {
64 | node = nextNonWhitespaceSibling(node)
65 | f.push(node)
66 | }
67 | r.push(f)
68 | }
69 | }
70 | return r
71 | }
72 |
73 | export const compareKeyedLists = (key, a = [], b = []) => {
74 | let delta = b.map(([k, item]) =>
75 | !key ? (k in a ? k : -1) : a.findIndex(([_, v]) => v[key] === item[key])
76 | )
77 | if (a.length !== b.length || !delta.every((a, b) => a === b)) return delta
78 | }
79 |
80 | function lastChild(v) {
81 | return (v.nodeType === v.DOCUMENT_FRAGMENT_NODE && v.lastChild) || v
82 | }
83 |
84 | export const updateList = (template, delta, entries, createListItem) => {
85 | let n = +(template.dataset.length || 0)
86 |
87 | const unchanged = delta.length === n && delta.every((a, b) => a == b)
88 |
89 | if (unchanged) return
90 |
91 | let blocks = getBlockFragments(template, n)
92 | let t = template
93 |
94 | delta.forEach((i, newIndex) => {
95 | let frag =
96 | i === -1
97 | ? createListItem(entries[newIndex][1], entries[newIndex][0])
98 | : blocks[i]
99 | let x = lastChild(frag)
100 | t.after(frag)
101 | t = x
102 | })
103 |
104 | template.dataset.length = delta.length
105 | }
106 |
--------------------------------------------------------------------------------
/src/mergeSlots.js:
--------------------------------------------------------------------------------
1 | const childNodes = (node) => {
2 | let frag = document.createDocumentFragment()
3 | while (node.firstChild) {
4 | frag.appendChild(node.firstChild)
5 | }
6 | return frag
7 | }
8 |
9 | export const mergeSlots = (targetNode, sourceNode) => {
10 | let namedSlots = sourceNode.querySelectorAll("slot[name]")
11 |
12 | namedSlots.forEach((slot) => {
13 | let name = slot.attributes.name.value
14 | let node = targetNode.querySelector(`[slot="${name}"]`)
15 | if (!node) {
16 | slot.parentNode.replaceChild(childNodes(slot), slot)
17 | return
18 | }
19 | node.removeAttribute("slot")
20 | slot.parentNode.replaceChild(node, slot)
21 | })
22 |
23 | let defaultSlot = sourceNode.querySelector("slot:not([name])")
24 |
25 | if (defaultSlot) {
26 | defaultSlot.parentNode.replaceChild(
27 | childNodes(targetNode.innerHTML.trim() ? targetNode : defaultSlot),
28 | defaultSlot
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/partial.js:
--------------------------------------------------------------------------------
1 | import { appendStyles } from "./css.js"
2 |
3 | export const partials = {}
4 |
5 | export const partial = (name, html, css) => {
6 | if (css) appendStyles(name, css)
7 | partials[name.toUpperCase()] = html
8 | }
9 |
--------------------------------------------------------------------------------
/src/proxy.js:
--------------------------------------------------------------------------------
1 | import { typeOf } from "./helpers.js"
2 |
3 | export const proxy = (root, callback) => {
4 | let proxyCache = new WeakMap()
5 |
6 | function createProxy(target, handler) {
7 | let proxy = proxyCache.get(target)
8 | if (proxy === undefined) {
9 | proxy = new Proxy(target, handler)
10 | proxyCache.set(target, proxy)
11 | }
12 | return proxy
13 | }
14 |
15 | const handler = {
16 | get(target, property) {
17 | if (["Object", "Array"].includes(typeOf(target[property]))) {
18 | return createProxy(target[property], handler)
19 | } else {
20 | return Reflect.get(...arguments)
21 | }
22 | },
23 | set(target, property, value) {
24 | if (value === target[property]) return true
25 |
26 | callback()
27 | return Reflect.set(...arguments)
28 | },
29 | deleteProperty() {
30 | callback()
31 | return Reflect.deleteProperty(...arguments)
32 | },
33 | }
34 |
35 | return new Proxy(root, handler)
36 | }
37 |
--------------------------------------------------------------------------------
/src/template.js:
--------------------------------------------------------------------------------
1 | export const convertToTemplate = (node) => {
2 | let ns = node.namespaceURI
3 |
4 | if (ns.endsWith("/svg")) {
5 | let tpl = document.createElementNS(ns, "defs")
6 | tpl.innerHTML = node.outerHTML
7 | node.parentNode.replaceChild(tpl, node)
8 | return tpl
9 | } else {
10 | if (node.nodeName === "TEMPLATE") return node
11 |
12 | let tpl = document.createElement("template")
13 |
14 | tpl.innerHTML = node.outerHTML
15 | node.parentNode.replaceChild(tpl, node)
16 |
17 | return tpl
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/token.js:
--------------------------------------------------------------------------------
1 | import { getValueAtPath } from "./helpers.js"
2 |
3 | const VALUE = 1
4 | const KEY = 2
5 | const FUNCTION = 3
6 |
7 | export const hasMustache = (v) => v.match(/({{[^{}]+}})/)
8 |
9 | export const getParts = (value) =>
10 | value
11 | .trim()
12 | .split(/({{[^{}]+}})/)
13 | .filter((v) => v)
14 | .map((value) => {
15 | let match = value.match(/{{([^{}]+)}}/)
16 |
17 | if (!match)
18 | return {
19 | type: VALUE,
20 | value,
21 | negated: false,
22 | }
23 |
24 | value = match[1].trim()
25 | let negated = value.charAt(0) === "!"
26 | if (negated) value = value.slice(1)
27 |
28 | return {
29 | type: KEY,
30 | value,
31 | negated,
32 | }
33 | })
34 |
35 | export const getValueFromParts = (target, parts) => {
36 | return parts.reduce((a, part) => {
37 | let { type, value, negated } = part
38 |
39 | let v
40 |
41 | if (type === VALUE) v = value
42 | if (type === KEY) {
43 | v = getValueAtPath(value, target)
44 | }
45 | if (type === FUNCTION) {
46 | let args = part.args.map((value) => getValueAtPath(value, target))
47 |
48 | v = getValueAtPath(part.method, target)?.(...args)
49 | }
50 |
51 | if (negated) v = !v
52 |
53 | return a || a === 0 ? a + v : v
54 | }, "")
55 | }
56 |
--------------------------------------------------------------------------------
/src/update.js:
--------------------------------------------------------------------------------
1 | import { setValueAtPath } from "./helpers.js"
2 |
3 | function systemReducer(state, action) {
4 | switch (action.type) {
5 | case "SET": {
6 | const { name, value } = action.payload
7 |
8 | let o = { ...state }
9 | setValueAtPath(name, value, o)
10 |
11 | return o
12 | }
13 | case "MERGE": {
14 | return {
15 | ...state,
16 | ...action.payload,
17 | }
18 | }
19 | }
20 | }
21 |
22 | export function configure({
23 | update = {},
24 | middleware = [],
25 | state: initialState = {},
26 | getState: getStateWrapper = (v) => v,
27 | }) {
28 | let subscribers = []
29 | let state
30 | let onChangeCallback = () => {}
31 |
32 | function updateState(o) {
33 | state = getStateWrapper({ ...o })
34 | onChangeCallback()
35 | }
36 |
37 | updateState(initialState)
38 |
39 | const refs = {}
40 |
41 | function getState() {
42 | return { ...state }
43 | }
44 |
45 | function subscribe(fn) {
46 | subscribers.push(fn)
47 | }
48 |
49 | function updated() {
50 | subscribers.forEach((fn) => fn())
51 | subscribers = []
52 | }
53 |
54 | function onChange(fn) {
55 | onChangeCallback = fn
56 | }
57 |
58 | function dispatch(action) {
59 | const { type } = action
60 |
61 | if (type === "SET" || type === "MERGE") {
62 | updateState(systemReducer(getState(), action))
63 | } else {
64 | const done = (action) => {
65 | if (action.type in update) {
66 | updateState(update[action.type](getState(), action))
67 | }
68 | return {
69 | then: (fn) =>
70 | new Promise((resolve) => {
71 | subscribe(() => {
72 | fn()
73 | resolve()
74 | })
75 | }),
76 | }
77 | }
78 |
79 | middleware[action.type]?.(action, done, {
80 | getState,
81 | dispatch,
82 | refs,
83 | }) || done(action)
84 | }
85 | }
86 |
87 | return {
88 | dispatch, // dispatch an action to the reducers
89 | getState, // optionally provide a wrapper function to derive additional properties in state
90 | onChange, // use this callback to update your UI whenever state changes
91 | updated, // call this once you've updated the UI so that all subscribers will be invoked and then removed
92 | refs, // an empty object that you can attach element refs to (supplied on object passed as the third argument to middleware functions)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/test/actions.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("actions", () => {
4 | it("dispatches the action", () => {
5 | let stack = []
6 |
7 | let name = createName()
8 |
9 | let state = {
10 | artists: [
11 | {
12 | name: "pablo picasso",
13 | tags: [
14 | "painter",
15 | "sculptor",
16 | "printmaker",
17 | "ceramicist",
18 | "theatre designer",
19 | ],
20 | },
21 | {
22 | name: "salvador dali",
23 | tags: ["painter", "sculptor", "photographer", "writer"],
24 | },
25 | ],
26 | }
27 |
28 | define(
29 | name,
30 | () => ({
31 | update: {
32 | foo: (state) => {
33 | stack.push("foo")
34 | return state
35 | },
36 | },
37 | state,
38 | }),
39 | html`
40 |
41 | {{artist.name}}
42 |
45 |
46 | `
47 | )
48 |
49 | mount(html`<${name}>${name}>`)
50 |
51 | $("article:nth-of-type(2) li").click() //salvador dali painter
52 |
53 | assert.equal(stack.length, 1)
54 | assert.equal(stack[0], "foo")
55 | })
56 |
57 | it("provides the template scope", () => {
58 | let stack = []
59 |
60 | let name = createName()
61 |
62 | let state = {
63 | artists: [
64 | {
65 | name: "pablo picasso",
66 | tags: [
67 | "painter",
68 | "sculptor",
69 | "printmaker",
70 | "ceramicist",
71 | "theatre designer",
72 | ],
73 | },
74 | {
75 | name: "salvador dali",
76 | tags: ["painter", "sculptor", "photographer", "writer"],
77 | },
78 | ],
79 | }
80 |
81 | define(
82 | name,
83 | () => ({
84 | update: {
85 | foo: (state, { scope }) => {
86 | stack.push(scope)
87 | return state
88 | },
89 | },
90 | state,
91 | }),
92 | html`
93 |
94 | {{artist.name}}
95 |
98 |
99 | `
100 | )
101 |
102 | mount(html`<${name}>${name}>`)
103 |
104 | $("article:nth-of-type(2) li").click() //salvador dali painter
105 |
106 | assert.equal(stack.length, 1)
107 | assert.equal(stack[0].artists.length, 2)
108 | assert.equal(stack[0].artist.name, "salvador dali")
109 | assert.equal(stack[0].tag, "painter")
110 | })
111 |
112 | it("provides the event object", () => {
113 | let stack = []
114 |
115 | let name = createName()
116 |
117 | let state = {
118 | artists: [
119 | {
120 | name: "pablo picasso",
121 | tags: [
122 | "painter",
123 | "sculptor",
124 | "printmaker",
125 | "ceramicist",
126 | "theatre designer",
127 | ],
128 | },
129 | {
130 | name: "salvador dali",
131 | tags: ["painter", "sculptor", "photographer", "writer"],
132 | },
133 | ],
134 | }
135 |
136 | define(
137 | name,
138 | () => ({
139 | update: {
140 | foo: (state, { event }) => {
141 | stack.push(event)
142 | return state
143 | },
144 | },
145 | state,
146 | }),
147 | html`
148 |
149 | {{artist.name}}
150 |
153 |
154 | `
155 | )
156 |
157 | mount(html`<${name}>${name}>`)
158 |
159 | $("article:nth-of-type(2) li").click() //salvador dali painter
160 |
161 | assert.equal(stack[0] instanceof MouseEvent, true)
162 | assert.equal(stack[0].target.nodeName, "LI")
163 | })
164 | })
165 |
--------------------------------------------------------------------------------
/test/define.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("define", () => {
4 | it("should define a custom element", () => {
5 | let name = createName()
6 | define(name, () => {}, "")
7 | assert.ok(customElements.get(name))
8 | })
9 |
10 | it("initialise factory with node", () => {
11 | let name = createName()
12 | let el
13 | define(
14 | name,
15 | (node) => {
16 | el = node
17 | return { update: () => {} }
18 | },
19 | "
"
20 | )
21 | mount(`
22 | <${name}>${name}>
23 | `)
24 |
25 | assert.equal($(name), el)
26 | })
27 |
28 | it("accepts template element", () => {
29 | let name = createName()
30 |
31 | let template = document.createElement("template")
32 | template.innerHTML = "{{ title }}
"
33 | define(
34 | name,
35 | () => ({
36 | state: {
37 | title: "ok!",
38 | },
39 | }),
40 | template
41 | )
42 | mount(`
43 | <${name}>${name}>
44 | `)
45 |
46 | assert.equal($("p").textContent, "ok!")
47 | })
48 |
49 | it("reflects observed attribute changes on to viewmodel", async () => {
50 | let name = createName()
51 | define(
52 | name,
53 | () => ({ observe: ["title"], state: { title: "" } }),
54 | "{{ title }}
"
55 | )
56 | mount(`
57 | <${name} title="ok!">${name}>
58 | `)
59 | $(name).setAttribute("title", "foo!")
60 | await nextFrame()
61 | assert.equal($(`${name} p`).textContent, "foo!")
62 | })
63 |
64 | it("reflects viewmodel changes back on to attributes", async () => {
65 | let name = createName()
66 |
67 | define(
68 | name,
69 | () => ({
70 | state: {
71 | show: true,
72 | },
73 | update: {
74 | toggle: (state) => ({
75 | ...state,
76 | show: !state.show,
77 | }),
78 | },
79 | }),
80 | 'hello world!
toggle '
81 | )
82 | mount(`
83 | <${name}>${name}>
84 | `)
85 | let el = $("p")
86 | assert.ok(el.hasAttribute("hidden") === false)
87 | $(`${name} button`).click()
88 | await nextFrame()
89 | assert.ok(el.hasAttribute("hidden"))
90 | })
91 |
92 | it("merges default slot", () => {
93 | let name = createName()
94 | define(
95 | name,
96 | () => ({}),
97 | html`hello !
98 | !`
99 | )
100 | mount(`
101 | <${name}>world${name}>
102 | `)
103 |
104 | assert.equal($(`${name} p`).innerText.trim(), "hello world!")
105 | })
106 |
107 | it("merges named slots", () => {
108 | let name = createName()
109 | define(
110 | name,
111 | () => ({}),
112 | html`
113 | hello
114 |
`
115 | )
116 | mount(`
117 | <${name}>! ${name}>
118 | `)
119 |
120 | assert.equal($(`${name} p`).innerHTML.trim(), "! hello")
121 | })
122 |
123 | it("converts between kebab and pascal casing", async () => {
124 | let name = createName()
125 |
126 | define(
127 | name,
128 | () => ({
129 | observe: ["fooBar"],
130 | state: {
131 | fooBar: false,
132 | },
133 | update: {
134 | toggle: (state) => ({ ...state, fooBar: !state.fooBar }),
135 | },
136 | }),
137 | html`ok `
138 | )
139 | mount(`
140 | <${name} foo-bar>${name}>
141 | `)
142 |
143 | assert.equal($(name).fooBar, true)
144 |
145 | $("button").click()
146 | await nextFrame()
147 |
148 | assert.equal($(`${name}`).hasAttribute("foo-bar"), false)
149 | })
150 |
151 | it("correctly handles aria string booleans", async () => {
152 | let name = createName()
153 |
154 | define(
155 | name,
156 | () => ({
157 | observe: ["ariaHidden"],
158 | state: {
159 | ariaHidden: true,
160 | },
161 | update: {
162 | toggle: (state) => {
163 | return {
164 | ...state,
165 | ariaHidden: !state.ariaHidden,
166 | }
167 | },
168 | },
169 | }),
170 | html`ok `
171 | )
172 | mount(`
173 | <${name} aria-hidden="false">${name}>
174 | `)
175 |
176 | $("button").click()
177 | await nextFrame()
178 | assert.equal($(`${name}`).getAttribute("aria-hidden"), "true")
179 | })
180 |
181 | it("forwards lifecycle events", async () => {
182 | let name = createName()
183 |
184 | let connected = false
185 | let disconnected = false
186 | let factory = () => {
187 | return {
188 | connectedCallback() {
189 | connected = true
190 | },
191 | disconnectedCallback() {
192 | disconnected = true
193 | },
194 | }
195 | }
196 | define(name, factory, " ")
197 | mount(`
198 | <${name}>${name}>
199 | `)
200 | assert.ok(connected)
201 | assert.notOk(disconnected)
202 | $(name).remove()
203 | /*
204 |
205 | we need to wait here as synergy will avoid triggering lifecycle events if element is reconnected within the same frame (as can happen during a manual content slot)
206 |
207 | */
208 | await nextFrame()
209 | assert.ok(disconnected)
210 | })
211 |
212 | it("optionally supports shadow root", () => {
213 | let factory = () => ({ shadow: "open" })
214 |
215 | let template = html`
216 |
223 |
224 | `
225 |
226 | define("x-shadow", factory, template)
227 |
228 | mount(html`hello shadow `)
229 |
230 | let node = $("x-shadow")
231 |
232 | assert.ok(node.shadowRoot)
233 | })
234 |
235 | it("accepts rich data as properties", async () => {
236 | define(
237 | "rich-props",
238 | () => ({
239 | observe: ["arr", "obj"],
240 | state: {
241 | arr: [],
242 | obj: {},
243 | },
244 | }),
245 | `
246 | {{ obj.repo }}
247 | {{ item }}
248 | `
249 | )
250 |
251 | let name = createName()
252 |
253 | const letters = "synergy".split("")
254 |
255 | const framework = {
256 | repo: "defx/synergy",
257 | }
258 |
259 | define(
260 | name,
261 | () => ({
262 | state: {
263 | letters,
264 | framework,
265 | },
266 | }),
267 | html`
268 |
269 | `
270 | )
271 |
272 | mount(html`<${name}>${name}>`)
273 |
274 | await nextFrame()
275 |
276 | const renderedLetters = $$("p").map((v) => v.textContent)
277 | assert.equal($("h3").textContent, framework.repo)
278 | assert.equal(letters.join(""), renderedLetters.join(""))
279 | })
280 |
281 | it("reflects observed properties from viewmodel to element", async () => {
282 | let name = createName()
283 |
284 | define(
285 | name,
286 | () => ({
287 | observe: ["foo"],
288 | state: {
289 | foo: "",
290 | },
291 | update: {
292 | updateFoo: (state) => ({
293 | ...state,
294 | foo: "baz",
295 | }),
296 | },
297 | }),
298 | html` {{ foo }}
`
299 | )
300 |
301 | mount(`<${name}>${name}>`)
302 |
303 | $(`p`).click()
304 |
305 | await nextFrame()
306 |
307 | assert.equal($(name).foo, "baz")
308 | })
309 |
310 | it("supports async initialisation", async () => {
311 | // @todo: improve this
312 | let name = createName()
313 |
314 | define(
315 | name,
316 | () =>
317 | Promise.resolve({
318 | state: {
319 | foo: "bar",
320 | },
321 | }),
322 | html` {{ foo }}
`
323 | )
324 |
325 | mount(`<${name}>${name}>`)
326 |
327 | assert.ok($(name))
328 | assert.equal($(name).textContent.trim(), "")
329 |
330 | await nextFrame()
331 | assert.equal($(name).innerText.trim(), "bar")
332 | })
333 | })
334 |
--------------------------------------------------------------------------------
/test/helpers.js:
--------------------------------------------------------------------------------
1 | import { applyAttribute } from "../src/helpers.js"
2 |
3 | describe("applyAttribute", () => {
4 | it("sets boolean true as empty string", () => {
5 | let node = document.createElement("div")
6 |
7 | applyAttribute(node, "foo", true)
8 |
9 | assert.equal(node.getAttribute("foo"), "")
10 | })
11 | it("removes attribute when boolean false", () => {
12 | let node = document.createElement("div")
13 |
14 | applyAttribute(node, "foo", false)
15 |
16 | assert.equal(node.getAttribute("foo"), null)
17 | })
18 | it("sets aria- prefixed attributes with boolean false to string", () => {
19 | let node = document.createElement("div")
20 |
21 | applyAttribute(node, "ariaFoo", false)
22 |
23 | assert.equal(node.getAttribute("aria-foo"), "false")
24 | })
25 | it("sets aria- prefixed attributes with boolean true to string", () => {
26 | let node = document.createElement("div")
27 |
28 | applyAttribute(node, "ariaFoo", true)
29 |
30 | assert.equal(node.getAttribute("aria-foo"), "true")
31 | })
32 | it("sets non-aria attribute with falsy value to string", () => {
33 | let node = document.createElement("div")
34 |
35 | applyAttribute(node, "dataIndex", 0)
36 |
37 | assert.equal(node.getAttribute("data-index"), "0")
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/test/hydrate.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("hydrate", () => {
4 | it("rehydrates event listeners", async () => {
5 | let name = createName()
6 | let stack = []
7 |
8 | define(
9 | name,
10 | () => ({
11 | update: {
12 | foo: () => stack.push("foo!"),
13 | },
14 | }),
15 | html`
16 |
19 | `
20 | )
21 |
22 | mount(html`<${name}>click me!${name}>`)
23 |
24 | let node = $(name)
25 | let outerHTML = node.outerHTML
26 |
27 | node.remove()
28 |
29 | mount(outerHTML)
30 |
31 | assert.deepEqual(stack, [])
32 |
33 | $("#foo").click()
34 |
35 | assert.deepEqual(stack, ["foo!"])
36 | })
37 | it("rehydrates repeated blocks", async () => {
38 | let name = createName()
39 | let stack = []
40 |
41 | define(
42 | name,
43 | () => {
44 | return {
45 | observe: ["todos"],
46 | state: {
47 | todos: [
48 | {
49 | title: "feed the duck",
50 | },
51 | {
52 | title: "walk the cat",
53 | },
54 | ],
55 | },
56 | update: {
57 | click: (state, action) => {
58 | stack.push(action.scope.todo.title)
59 | return state
60 | },
61 | },
62 | }
63 | },
64 | `
65 |
68 | `
69 | )
70 |
71 | mount(`<${name}>${name}>`)
72 |
73 | let html = $(name).outerHTML
74 |
75 | $(name).remove()
76 |
77 | mount(html)
78 |
79 | const { todos } = $(name)
80 |
81 | $(name).todos = todos.concat({
82 | title: "eat the frog",
83 | })
84 |
85 | // @todo: discuss this in the docs ...
86 | // $(name).todos.push({
87 | // title: "eat the frog",
88 | // })
89 |
90 | await nextFrame()
91 |
92 | $$(`li`)[2].click()
93 |
94 | assert.deepEqual(stack, ["eat the frog"])
95 | })
96 |
97 | it("rehydrates from the last state", async () => {
98 | let name = createName()
99 | let stack = []
100 |
101 | define(
102 | name,
103 | () => {
104 | return {
105 | observe: ["todos"],
106 | state: {
107 | todos: [
108 | {
109 | title: "feed the duck",
110 | },
111 | {
112 | title: "walk the cat",
113 | },
114 | ],
115 | },
116 | update: {
117 | click: (state, { scope: { todo } }) => {
118 | stack.push(todo.title)
119 | return state
120 | },
121 | },
122 | }
123 | },
124 | `
125 |
128 | `
129 | )
130 |
131 | mount(`<${name}>${name}>`)
132 |
133 | const { todos } = $(name)
134 |
135 | $(name).todos = todos.concat({
136 | title: "eat the frog",
137 | })
138 |
139 | await nextFrame()
140 |
141 | let html = $(name).outerHTML
142 |
143 | $(name).remove()
144 |
145 | mount(html)
146 |
147 | await nextFrame()
148 |
149 | $("li:nth-of-type(3)").click()
150 |
151 | assert.deepEqual(stack, ["eat the frog"])
152 | })
153 | })
154 |
--------------------------------------------------------------------------------
/test/if.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("if blocks", () => {
4 | it("renders the elements", () => {
5 | let name = createName()
6 | define(
7 | name,
8 | () => ({
9 | state: {
10 | loggedIn: false,
11 | },
12 | }),
13 | `
14 | LOG IN
15 | LOG OUT
16 | `
17 | )
18 |
19 | mount(`<${name}>${name}>`)
20 |
21 | assert.ok($("button#login"))
22 | assert.notOk($("button#logout"))
23 | })
24 | it("renders the elements", () => {
25 | let name = createName()
26 | define(
27 | name,
28 | () => ({
29 | state: {
30 | loggedIn: true,
31 | },
32 | }),
33 | `
34 | LOG IN
35 | LOG OUT
36 | `
37 | )
38 |
39 | mount(`<${name}>${name}>`)
40 |
41 | assert.ok($("button#logout"))
42 | assert.notOk($("button#login"))
43 | })
44 | it("toggles the elements", async () => {
45 | //...
46 | let name = createName()
47 | define(
48 | name,
49 | () => ({
50 | state: {
51 | loggedIn: false,
52 | },
53 | update: {
54 | logIn(state) {
55 | return {
56 | ...state,
57 | loggedIn: true,
58 | }
59 | },
60 | logOut(state) {
61 | return {
62 | ...state,
63 | loggedIn: false,
64 | }
65 | },
66 | },
67 | }),
68 | `
69 | LOG IN
70 | LOG OUT
71 | `
72 | )
73 |
74 | mount(`<${name}>${name}>`)
75 |
76 | $("button#login").click()
77 |
78 | await nextFrame()
79 |
80 | assert.ok($("button#logout"))
81 | assert.notOk($("button#login"))
82 | })
83 | it("binds other attributes on host", () => {
84 | const name = createName()
85 | const href = "https://bbc.co.uk/"
86 | define(
87 | name,
88 | () => {
89 | return {
90 | state: {
91 | show: true,
92 | href,
93 | },
94 | }
95 | },
96 | /* HTML */ ` ok `
97 | )
98 | mount(`<${name}>${name}>`)
99 |
100 | assert.equal($(`${name} a`).href, href)
101 | })
102 | it("binds other attributes on host (reverse order)", () => {
103 | const name = createName()
104 | const href = "https://bbc.co.uk/"
105 | define(
106 | name,
107 | () => {
108 | return {
109 | state: {
110 | show: true,
111 | href,
112 | },
113 | }
114 | },
115 | /* HTML */ ` ok `
116 | )
117 | mount(`<${name}>${name}>`)
118 |
119 | assert.equal($(`${name} a`).href, href)
120 | })
121 | })
122 |
--------------------------------------------------------------------------------
/test/interpolations.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("interpolations", () => {
4 | it("should always cast primitive values to strings, unless null or undefined", () => {
5 | let name = createName()
6 |
7 | define(
8 | name,
9 | () => ({
10 | state: {
11 | boolean: false,
12 | undefined: undefined,
13 | null: null,
14 | number: 0,
15 | string: "string",
16 | foo: "bar",
17 | },
18 | }),
19 | html`
20 |
21 | {{ boolean }}
22 | {{ undefined }}
23 | {{ null }}
24 | {{ number }}
25 | {{ string }}
26 |
27 | `
28 | )
29 |
30 | mount(html`<${name}>${name}>`)
31 |
32 | assert.equal($("#boolean").textContent, "false")
33 | assert.equal($("#undefined").textContent, "")
34 | assert.equal($("#null").textContent, "")
35 | assert.equal($("#number").textContent, "0")
36 | assert.equal($("#string").textContent, "string")
37 | })
38 |
39 | it("should support multiple bindings", () => {
40 | let name = createName()
41 |
42 | define(
43 | name,
44 | () => ({
45 | state: {
46 | c1: "red",
47 | c2: "green",
48 | },
49 | }),
50 | html` {{c1}} + {{c2}}
`
51 | )
52 |
53 | mount(html`<${name}>${name}>`)
54 |
55 | assert.equal($("p").textContent, "red + green")
56 | })
57 |
58 | it("should apply all the values", () => {
59 | let name = createName()
60 |
61 | define(
62 | name,
63 | () => ({
64 | state: {
65 | classes: ["one", "two", "three"],
66 | },
67 | }),
68 | html``
69 | )
70 |
71 | mount(html`<${name}>${name}>`)
72 |
73 | assert.equal($("section").className, "one two three")
74 | })
75 |
76 | it("should apply all the keys with truthy values", () => {
77 | let name = createName()
78 |
79 | define(
80 | name,
81 | () => ({
82 | state: {
83 | classes: {
84 | one: true,
85 | two: false,
86 | three: {},
87 | four: null,
88 | five: "",
89 | six: "ok",
90 | },
91 | },
92 | }),
93 | html` `
94 | )
95 |
96 | mount(html`<${name}>${name}>`)
97 |
98 | assert.equal($("section").className, "one three six")
99 | })
100 |
101 | it("should apply styles", () => {
102 | let name = createName()
103 |
104 | define(
105 | name,
106 | () => ({
107 | state: {
108 | foo: `
109 | background-color: gold;
110 | color: tomato;
111 | width: 100px;
112 | height: 100px;
113 | `,
114 | },
115 | }),
116 | html` `
117 | )
118 |
119 | mount(html`<${name}>${name}>`)
120 |
121 | assert.equal(
122 | $("section").getAttribute("style"),
123 | "background-color: gold; color: tomato; width: 100px; height: 100px;"
124 | )
125 | })
126 |
127 | it("should preserve browser styles", async () => {
128 | let name = createName()
129 |
130 | define(
131 | name,
132 | () => ({
133 | observe: ["foo"],
134 | state: {
135 | foo: `
136 | background-color: gold;
137 | color: tomato;
138 | width: 100px;
139 | height: 100px;
140 | `,
141 | },
142 | }),
143 | html` `
144 | )
145 |
146 | mount(html`<${name}>${name}>`)
147 |
148 | $("section").style.opacity = "0.5"
149 |
150 | assert.ok(
151 | $("section").getAttribute("style").includes("background-color: gold;")
152 | )
153 |
154 | $(name).foo = `
155 | background-color: tomato;
156 | color: gold;
157 | width: 100px;
158 | height: 100px;
159 | `
160 |
161 | await nextFrame()
162 |
163 | assert.ok(
164 | $("section").getAttribute("style").includes("background-color: tomato;")
165 | )
166 |
167 | assert.ok($("section").getAttribute("style").includes("opacity: 0.5;"))
168 | })
169 |
170 | it("should apply styles (Object / kebab)", () => {
171 | let name = createName()
172 |
173 | define(
174 | name,
175 | () => ({
176 | state: {
177 | foo: {
178 | "background-color": "gold",
179 | color: "tomato",
180 | width: "100px",
181 | height: "100px",
182 | },
183 | },
184 | }),
185 | html` `
186 | )
187 |
188 | mount(html`<${name}>${name}>`)
189 |
190 | assert.equal(
191 | $("section").getAttribute("style"),
192 | "background-color: gold; color: tomato; width: 100px; height: 100px;"
193 | )
194 | })
195 |
196 | it("should apply styles (Object / pascal)", () => {
197 | let name = createName()
198 |
199 | define(
200 | name,
201 | () => ({
202 | state: {
203 | foo: {
204 | backgroundColor: "gold",
205 | color: "tomato",
206 | width: "100px",
207 | height: "100px",
208 | },
209 | },
210 | }),
211 | html` `
212 | )
213 |
214 | mount(html`<${name}>${name}>`)
215 |
216 | assert.equal(
217 | $("section").getAttribute("style"),
218 | "background-color: gold; color: tomato; width: 100px; height: 100px;"
219 | )
220 | })
221 |
222 | it("should allow whitespace formatting", () => {
223 | let name = createName()
224 |
225 | define(
226 | name,
227 | () => ({
228 | state: {
229 | c1: "red",
230 | c2: "green",
231 | },
232 | }),
233 | html` {{ c2 }}
`
234 | )
235 |
236 | mount(html`<${name}>${name}>`)
237 |
238 | assert.equal($("p").getAttribute("name"), "red")
239 | assert.equal($("p").textContent, "green")
240 | })
241 |
242 | it("should support negation", async () => {
243 | let name = createName()
244 |
245 | define(
246 | name,
247 | () => ({
248 | observe: ["foo"],
249 | state: { foo: true },
250 | }),
251 | html` boo!
`
252 | )
253 |
254 | mount(html`<${name} foo>${name}>`)
255 |
256 | assert.notOk($("p").hidden) // [hidden]
257 |
258 | $(name).foo = false
259 |
260 | await nextFrame()
261 |
262 | assert.ok($("p").hidden)
263 | })
264 |
265 | it("should support square brackets", () => {
266 | let name = createName()
267 |
268 | define(
269 | name,
270 | () => ({
271 | state: {
272 | columns: ["one", "two", "three"],
273 | rows: [
274 | {
275 | one: 1,
276 | two: 2,
277 | three: 3,
278 | },
279 | {
280 | one: 3,
281 | two: 2,
282 | three: 1,
283 | },
284 | {
285 | one: 1,
286 | two: 3,
287 | three: 2,
288 | },
289 | ],
290 | },
291 | }),
292 | html`
293 |
294 |
295 | {{ column }}
296 |
297 |
298 | {{ row[col] }}
299 |
300 |
301 | `
302 | )
303 |
304 | mount(html`<${name}>${name}>`)
305 |
306 | assert.equal($$("th").length, 3)
307 | assert.equal($$("tr").length, 4)
308 | assert.deepEqual(
309 | $$("td").map((v) => v.textContent.trim()),
310 | ["1", "2", "3", "3", "2", "1", "1", "3", "2"]
311 | )
312 | })
313 |
314 | it("resolves properties from the current item first", () => {
315 | let name = createName()
316 |
317 | let state = {
318 | foo: "bar",
319 | items: [
320 | {
321 | x: 0,
322 | },
323 | {
324 | x: 16,
325 | },
326 | {
327 | x: 32,
328 | },
329 | ],
330 | }
331 |
332 | define(
333 | name,
334 | () => ({
335 | state,
336 | }),
337 | html`
338 |
344 | `
345 | )
346 |
347 | mount(html`<${name}>${name}>`)
348 |
349 | let nodes = $$(".foo")
350 |
351 | assert.equal(nodes.length, state.items.length)
352 |
353 | state.items.forEach(({ x }, i) => {
354 | let node = nodes[i]
355 | assert.equal(node.getAttribute("x"), x)
356 | assert.equal(node.getAttribute("foo"), state.foo)
357 | })
358 | })
359 |
360 | it("supports simple each (just the collection property)", () => {
361 | let name = createName()
362 |
363 | let state = {
364 | foo: "bar",
365 | items: [
366 | {
367 | x: 0,
368 | },
369 | {
370 | x: 16,
371 | },
372 | {
373 | x: 32,
374 | },
375 | ],
376 | }
377 |
378 | define(
379 | name,
380 | () => ({
381 | state,
382 | }),
383 | html`
384 |
385 | `
386 | )
387 |
388 | mount(html`<${name}>${name}>`)
389 |
390 | let nodes = $$(".foo")
391 |
392 | assert.equal(nodes.length, state.items.length)
393 |
394 | state.items.forEach(({ x }, i) => {
395 | let node = nodes[i]
396 | assert.equal(node.getAttribute("x"), x)
397 | assert.equal(node.getAttribute("foo"), state.foo)
398 | })
399 | })
400 |
401 | it("supports shorthand attributes (when attribute name matches property name)", () => {
402 | let name = createName()
403 |
404 | let state = {
405 | foo: "bar",
406 | items: [
407 | {
408 | x: 0,
409 | },
410 | {
411 | x: 16,
412 | },
413 | {
414 | x: 32,
415 | },
416 | ],
417 | }
418 |
419 | define(
420 | name,
421 | () => ({
422 | state,
423 | }),
424 | html`
`
425 | )
426 |
427 | mount(html`<${name}>${name}>`)
428 |
429 | let nodes = $$(".foo")
430 |
431 | assert.equal(nodes.length, state.items.length)
432 |
433 | state.items.forEach(({ x }, i) => {
434 | let node = nodes[i]
435 | assert.equal(node.getAttribute("x"), x)
436 | assert.equal(node.getAttribute("foo"), state.foo)
437 | })
438 | })
439 |
440 | it("supports top-level text bindings", () => {
441 | define(
442 | "x-bind",
443 | () => ({
444 | state: {
445 | one: "foo",
446 | two: "bar",
447 | three: "baz",
448 | },
449 | }),
450 | /* html */ `{{ one }}/{{ two }}
/{{ three }}`
451 | )
452 |
453 | mount(/* html */ ` `)
454 |
455 | const innerText = $(`x-bind`).innerText.replace(/\n+/g, "")
456 |
457 | assert.equal(innerText, "foo/bar/baz")
458 | })
459 | })
460 |
--------------------------------------------------------------------------------
/test/middleware.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("middleware", () => {
4 | it("enables actions to be modified", () => {
5 | const KEYS = {
6 | RETURN: 13,
7 | ESCAPE: 27,
8 | }
9 | let name = createName()
10 | let stack = []
11 | define(
12 | name,
13 | () => ({
14 | update: {
15 | saveEdit: (state, action) => {
16 | stack.push(action)
17 | return state
18 | },
19 | cancelEdit: (state, action) => {
20 | stack.push(action)
21 | return state
22 | },
23 | },
24 | middleware: {
25 | keydown: (action, next) => {
26 | let { keyCode } = action.event
27 | switch (keyCode) {
28 | case KEYS.ESCAPE: {
29 | next({ ...action, type: "cancelEdit" })
30 | break
31 | }
32 | case KEYS.RETURN: {
33 | next({ ...action, type: "saveEdit" })
34 | break
35 | }
36 | }
37 | },
38 | },
39 | }),
40 | `hi! `
41 | )
42 |
43 | mount(html`<${name}>${name}>`)
44 |
45 | $(`button`).dispatchEvent(
46 | new KeyboardEvent("keydown", {
47 | keyCode: KEYS.RETURN,
48 | bubbles: true,
49 | })
50 | )
51 |
52 | $(`button`).dispatchEvent(
53 | new KeyboardEvent("keydown", {
54 | keyCode: KEYS.ESCAPE,
55 | bubbles: true,
56 | })
57 | )
58 |
59 | assert.equal(stack.length, 2)
60 | assert.equal(stack[0].type, "saveEdit")
61 | assert.equal(stack[1].type, "cancelEdit")
62 | })
63 |
64 | it("allows promise chaining after the next update following an action", () => {
65 | let name = createName()
66 | define(
67 | name,
68 | () => ({
69 | update: (state = state, action) => {
70 | switch (action.type) {
71 | case "toggle": {
72 | return {
73 | ...state,
74 | hidden: !state.hidden,
75 | }
76 | }
77 | default:
78 | return state
79 | }
80 | },
81 | middleware: {
82 | toggle: (action, next) => {
83 | next(action).then(() => $("input").focus())
84 | },
85 | },
86 | }),
87 | `toggle `
88 | )
89 | mount(`<${name}>${name}>`)
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/test/partials.js:
--------------------------------------------------------------------------------
1 | import { define, partial } from "../src/index.js"
2 |
3 | describe("partial", () => {
4 | it("is included within parent template", () => {
5 | let partialName = createName()
6 |
7 | partial(partialName, html`{{ foo }}
`)
8 |
9 | let name = createName()
10 |
11 | define(
12 | name,
13 | () => ({
14 | observe: ["foo"],
15 | state: {
16 | foo: "bar",
17 | },
18 | }),
19 | html``
20 | )
21 |
22 | mount(`<${name}>${name}>`)
23 |
24 | assert.equal($(`${partialName} p`).textContent, "bar")
25 |
26 | $(name).foo = "baz"
27 |
28 | assert.equal($(`${partialName} p`).textContent, "baz")
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/test/scope.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("scope", () => {
4 | let rootNode
5 | beforeEach(() => {
6 | rootNode = mount(html`
`)
7 | })
8 |
9 | it("should observe context", () => {
10 | let name = createName()
11 |
12 | let state = {
13 | todo: "feed the dog",
14 | message: "Hej!",
15 | todos: [
16 | {
17 | title: "walk the cat",
18 | subtitle: "ok",
19 | colour: "tomato",
20 | },
21 | {
22 | title: "shampoo the dog",
23 | subtitle: "thanks",
24 | colour: "gold",
25 | },
26 | ],
27 | }
28 |
29 | define(
30 | name,
31 | () => ({
32 | state,
33 | }),
34 | html` {{todo}}
35 |
36 |
37 |
38 | {{todo.title}}
39 | {{message}}
40 |
41 |
42 |
43 | {{todo}} `
44 | )
45 |
46 | mount(html`<${name}>${name}>`)
47 |
48 | assert.equal($("h1[first]").textContent.trim(), "feed the dog")
49 | assert.equal($("li p").textContent.trim(), "walk the cat")
50 | assert.equal($("li p:last-child").textContent.trim(), "Hej!")
51 | assert.equal($("h1[second]").textContent.trim(), "feed the dog")
52 | })
53 |
54 | it("should support nested scopes", async () => {
55 | let name = createName()
56 |
57 | let state = {
58 | artists: [
59 | {
60 | name: "pablo picasso",
61 | tags: [
62 | "painter",
63 | "sculptor",
64 | "printmaker",
65 | "ceramicist",
66 | "theatre designer",
67 | ],
68 | },
69 | {
70 | name: "salvador dali",
71 | tags: ["painter", "sculptor", "photographer", "writer"],
72 | },
73 | ],
74 | }
75 |
76 | define(
77 | name,
78 | () => ({
79 | state,
80 | }),
81 | html`
82 |
83 |
84 | {{artist.name}}
85 |
86 |
87 | {{tag}} {{i}}:{{j}}
88 |
89 |
90 |
91 |
92 | `
93 | )
94 |
95 | mount(html`<${name}>${name}>`)
96 |
97 | assert.equal($("h4").textContent, state.artists[0].name)
98 | assert.equal(
99 | $$("article:nth-of-type(1) li").length,
100 | state.artists[0].tags.length
101 | )
102 | assert.equal(
103 | $$("article:nth-of-type(2) li").length,
104 | state.artists[1].tags.length
105 | )
106 | assert.equal($("article:nth-of-type(1) li").textContent, "painter 0:0")
107 | assert.equal($("article:nth-of-type(2) li").textContent, "painter 1:0")
108 | })
109 | })
110 |
--------------------------------------------------------------------------------
/test/slots.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("slots", () => {
4 | it("doesn't trigger the disconnectedCallback if reconnected within the same frame", async () => {
5 | mount(/* html */ `
6 |
7 |
8 |
9 | `)
10 |
11 | let callbacks = []
12 |
13 | define(
14 | "simple-clock",
15 | () => {
16 | let t
17 | return {
18 | update: {
19 | setTime: (state, { payload }) => {
20 | return {
21 | ...state,
22 | time: payload,
23 | }
24 | },
25 | },
26 | connectedCallback: ({ dispatch }) => {
27 | callbacks.push("connected")
28 | t = setInterval(() => {
29 | dispatch({
30 | type: "setTime",
31 | payload: new Date().toLocaleTimeString(),
32 | })
33 | }, 100)
34 | },
35 | disconnectedCallback: () => {
36 | callbacks.push("disconnected")
37 | clearInterval(t)
38 | },
39 | }
40 | },
41 | /* html */ `
42 | {{ time }}
43 | `
44 | )
45 |
46 | define(
47 | "x-app",
48 | () => ({}),
49 | /* html */ `
50 |
53 | `
54 | )
55 |
56 | await nextFrame()
57 |
58 | assert.equal(callbacks.length, 1)
59 | assert.equal(callbacks[0], "connected")
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/test/user-input.js:
--------------------------------------------------------------------------------
1 | import { define } from "../src/index.js"
2 |
3 | describe("user input", () => {
4 | it("should bind the value to the named property", () => {
5 | let name = createName()
6 |
7 | let state = {
8 | message: "?",
9 | }
10 |
11 | define(
12 | name,
13 | () => ({
14 | state,
15 | }),
16 | html` the message is:
17 | {{message}} `
18 | )
19 |
20 | mount(html`<${name}>${name}>`)
21 |
22 | assert.equal($("span.message").textContent, "?")
23 | })
24 |
25 | it("should bind the value to the named property (nested)", () => {
26 | let name = createName()
27 |
28 | let state = {
29 | nested: {
30 | message: "??",
31 | },
32 | }
33 |
34 | define(
35 | name,
36 | () => ({
37 | state,
38 | }),
39 | html`
40 |
41 | the message is:
42 | {{nested.message}}
43 | `
44 | )
45 |
46 | mount(html`<${name}>${name}>`)
47 |
48 | assert.equal($("span.message").textContent, "??")
49 | })
50 |
51 | it("should bind the value to the named + scoped property", () => {
52 | let name = createName()
53 |
54 | let state = {
55 | todos: [
56 | {
57 | title: "feed the cat",
58 | done: true,
59 | },
60 | {
61 | title: "walk the dog",
62 | },
63 | ],
64 | }
65 |
66 | define(
67 | name,
68 | () => ({
69 | state,
70 | }),
71 | html`
72 |
80 | `
81 | )
82 |
83 | mount(html`<${name}>${name}>`)
84 |
85 | const li = $$("li")
86 |
87 | assert.equal(li[0].querySelector("input").checked, true)
88 | assert.equal(li[1].querySelector("input").checked, false)
89 | })
90 |
91 | it("should check the correct radio button", () => {
92 | let name = createName()
93 |
94 | define(
95 | name,
96 | () => ({
97 | state: {
98 | filter: "active",
99 | },
100 | }),
101 | html`
102 |
103 | all
104 |
105 | active
106 |
112 | complete
113 | `
114 | )
115 |
116 | mount(html`<${name}>${name}>`)
117 |
118 | let checked = $(`input[type="radio"]:checked`)
119 | assert.equal(checked.value, "active")
120 | })
121 |
122 | it("should check the correct radio button", () => {
123 | let name = createName()
124 |
125 | let state = {}
126 |
127 | define(
128 | name,
129 | () => ({
130 | state,
131 | }),
132 | html`
133 |
134 | all
135 |
136 | active
137 |
143 | complete
144 | `
145 | )
146 |
147 | mount(html`<${name}>${name}>`)
148 |
149 | let checked = $(`#container input[type="radio"]:checked`)
150 | assert.equal(checked, null)
151 | })
152 |
153 | it("should reflect the correct radio button", async () => {
154 | let name = createName()
155 |
156 | let state = {
157 | filter: "active",
158 | }
159 |
160 | define(
161 | name,
162 | () => ({
163 | state,
164 | }),
165 | html`
166 |
167 | all
168 |
169 | active
170 |
176 | complete
177 | `
178 | )
179 |
180 | mount(html`<${name}>${name}>`)
181 |
182 | $(`input[value="complete"]`).click()
183 |
184 | await nextFrame()
185 |
186 | assert.equal($(`input:checked`).value, "complete")
187 | })
188 |
189 | it("should select the correct option", async () => {
190 | let name = createName()
191 |
192 | let state = {
193 | pets: "hamster",
194 | }
195 |
196 | define(
197 | name,
198 | () => ({
199 | observe: ["pets"],
200 | state,
201 | }),
202 | html`
203 | Choose a pet:
204 |
205 | --Please choose an option--
206 | Dog
207 | Cat
208 | Hamster
209 | Parrot
210 | Spider
211 | Goldfish
212 |
213 | `
214 | )
215 |
216 | mount(html`<${name}>${name}>`)
217 |
218 | assert.equal($("select option:checked").value, "hamster")
219 |
220 | $(name).pets = "parrot"
221 |
222 | await nextFrame()
223 |
224 | assert.equal($("select option:checked").value, "parrot")
225 | })
226 |
227 | it("should select the correct option (each option)", async () => {
228 | let name = createName()
229 |
230 | define(
231 | name,
232 | () => ({
233 | observe: ["pets"],
234 | state: {
235 | pets: "Hamster",
236 | options: ["Dog", "Cat", "Hamster", "Parrot", "Spider", "Goldfish"],
237 | },
238 | }),
239 | html`
240 | Choose a pet:
241 |
242 | --Please choose an option--
243 |
244 | {{ option }}
245 |
246 |
247 | `
248 | )
249 |
250 | mount(html`<${name}>${name}>`)
251 |
252 | assert.equal($("select option:checked").value, "Hamster")
253 |
254 | $(name).pets = "Parrot"
255 |
256 | await nextFrame()
257 |
258 | assert.equal($("select option:checked").value, "Parrot")
259 | })
260 |
261 | it("should select multiple", () => {
262 | let name = createName()
263 |
264 | let state = {
265 | pets: ["dog", "hamster"],
266 | }
267 |
268 | define(
269 | name,
270 | () => ({
271 | state,
272 | }),
273 | html`
274 | Choose a pet:
275 |
276 | --Please choose an option--
277 | Dog
278 | Cat
279 | Hamster
280 | Parrot
281 | Spider
282 | Goldfish
283 |
284 | `
285 | )
286 |
287 | mount(html`<${name}>${name}>`)
288 |
289 | assert.deepEqual(
290 | $$("select option:checked").map((option) => option.value),
291 | ["dog", "hamster"]
292 | )
293 | })
294 |
295 | it("should reflect selected option", async () => {
296 | let name = createName()
297 |
298 | define(
299 | name,
300 | () => ({
301 | observe: ["pets"],
302 | state: {
303 | pets: ["hamster"],
304 | },
305 | }),
306 | html`
307 | Choose a pet:
308 |
309 | --Please choose an option--
310 | Dog
311 | Cat
312 | Hamster
313 | Parrot
314 | Spider
315 | Goldfish
316 |
317 | `
318 | )
319 |
320 | mount(html`<${name}>${name}>`)
321 |
322 | $("#pet-select").value = "parrot"
323 |
324 | $("#pet-select").dispatchEvent(
325 | new Event("input", {
326 | bubbles: true,
327 | })
328 | )
329 |
330 | await nextFrame()
331 |
332 | assert.deepEqual($(name).pets, "parrot")
333 | })
334 |
335 | it("should bind the named textarea", () => {
336 | let name = createName()
337 |
338 | let state = {
339 | text: "ok",
340 | }
341 |
342 | define(
343 | name,
344 | () => ({
345 | state,
346 | }),
347 | html` `
348 | )
349 |
350 | mount(html`<${name}>${name}>`)
351 |
352 | assert.equal($("textarea").value, "ok")
353 | })
354 | })
355 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | export = synergy
2 | export as namespace synergy
3 | declare namespace synergy {
4 | function define(
5 | /**
6 | * The name for the new custom element. As per the Custom Element spec,
7 | * the name must include a hyphen.
8 | */
9 | name: string,
10 | /**
11 | * A factory function that will be called whenever a new instance of your
12 | * custom element is created. Returns the object that will provide the data for your custom element.
13 | */
14 | factory: ModelFactory,
15 | /**
16 | * The template represents the HTML markup for your element.
17 | */
18 | template: HTMLTemplateElement | string,
19 | /**
20 | * CSS for your Custom Element will be partially scoped by prefixing selectors with the elements name to stop your styles from leaking out, whilst still allowing inheritance of more generic global styles. CSS will be copied once into the document head and shared between all instances of the Custom Element.
21 | */
22 | css?: string
23 | ): void
24 |
25 | type State = {
26 | [key: string]: any
27 | }
28 |
29 | type ActionInput = {
30 | type: string
31 | payload: {
32 | [key: string]: any
33 | }
34 | }
35 |
36 | type Action = {
37 | type: string
38 | payload: {
39 | [key: string]: any
40 | }
41 | event: Event
42 | /*
43 | * The current state including the variable scope of the event origin (i.e., created with a Repeated Block)
44 | */
45 | scope: {
46 | [key: string]: any
47 | }
48 | }
49 |
50 | type ActionHandler = {
51 | (currentState: State, action: Action): State
52 | }
53 |
54 | type Store = {
55 | getState(): State
56 | dispatch(action: ActionInput): void
57 | }
58 |
59 | type PromiseLike = {
60 | /**
61 | *
62 | * Returns a Promise that will be resolved after the next UI update
63 | */
64 | then(fn: Function): Promise
65 | }
66 |
67 | type Next = {
68 | /**
69 | * Passes the action to the next handler in the stack
70 | */
71 | (action: ActionInput): PromiseLike
72 | }
73 |
74 | type Middleware = {
75 | /**
76 | * A function that intercepts an action before it reaches its ActionHandler (if any),
77 | * providing the opportunity to execute side effect(s), make asynchronous calls, re-route actions
78 | * , dispatch new actions, etc
79 | */
80 | (action: ActionInput, next: Next, store: Store)
81 | }
82 |
83 | type Model = {
84 | /**
85 | * Provides the initial state to the component for its very first render.
86 | */
87 | state?: State
88 | /**
89 | * Invoked each time the custom element is appended into a
90 | * document-connected element
91 | */
92 | connectedCallback?(store: Store): void
93 | /**
94 | * Invoked each time the custom element is disconnected from the document
95 | */
96 | disconnectedCallback?(): void
97 | /**
98 | *
99 | */
100 | update?: {
101 | [actionName: string]: ActionHandler
102 | }
103 | /**
104 | * A custom wrapper around calls to getState, giving you the ability to derive additional properties
105 | */
106 | getState?(state: State): State
107 | /**
108 | * A debounced function that is called after every render cycle
109 | */
110 | subscribe?: {
111 | (state: State): void
112 | }
113 | /**
114 | *
115 | */
116 | middleware?: {
117 | [actionName: string]: Middleware | [Middleware]
118 | }
119 | /**
120 | * If this is omitted then Shadow DOM is not utilised and functionality is polyfilled.
121 | */
122 | shadow?: "open" | "closed"
123 | }
124 |
125 | type ModelFactory = {
126 | (
127 | /**
128 | * The Custom Element node
129 | */
130 | element: HTMLElement
131 | ): Model | Promise
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/website/components/my-counter.js:
--------------------------------------------------------------------------------
1 | import { define } from "/synergy.js"
2 |
3 | const name = "my-counter"
4 |
5 | const factory = () => ({
6 | state: { count: 0 },
7 | update: {
8 | increment: (state) => ({
9 | ...state,
10 | count: state.count + 1,
11 | }),
12 | },
13 | })
14 |
15 | const template = /* html */ `
16 | Count is: {{ count }}
17 | `
18 |
19 | const css = /* css */ `
20 | button {
21 | cursor: pointer;
22 | padding: 0.5rem 1rem;
23 | background-color: gold;
24 | color: tomato;
25 | }
26 | `
27 |
28 | define(name, factory, template)
29 |
--------------------------------------------------------------------------------
/website/components/simple-clock.js:
--------------------------------------------------------------------------------
1 | import { define } from "/synergy.js"
2 |
3 | define(
4 | "simple-clock",
5 | () => {
6 | let t
7 |
8 | return {
9 | update: {
10 | setTime: (state, { payload }) => {
11 | return {
12 | ...state,
13 | time: payload,
14 | }
15 | },
16 | },
17 | connectedCallback: ({ dispatch, getState }) => {
18 | t = setInterval(() => {
19 | dispatch({
20 | type: "setTime",
21 | payload: new Date().toLocaleTimeString(),
22 | })
23 | }, 100)
24 | },
25 | disconnectedCallback: () => {
26 | clearInterval(t)
27 | },
28 | }
29 | },
30 | /* html */ `
31 | {{ time }}
32 | `
33 | )
34 |
--------------------------------------------------------------------------------
/website/components/todo-list.js:
--------------------------------------------------------------------------------
1 | import { define } from "/synergy.js"
2 |
3 | define(
4 | "todo-list",
5 | () => {
6 | return {
7 | state: {
8 | todos: [],
9 | },
10 | update: {
11 | addTodo: (state, { event: { key } }) => {
12 | if (key !== "Enter" || !state.newTodo?.length) return state
13 |
14 | return {
15 | ...state,
16 | newTodo: null,
17 | todos: state.todos.concat({
18 | title: state.newTodo,
19 | completed: false,
20 | }),
21 | }
22 | },
23 | removeTodo: (state, { scope: { todo } }) => {
24 | return {
25 | ...state,
26 | todos: state.todos.filter(({ title }) => title !== todo.title),
27 | }
28 | },
29 | },
30 | getState: (state) => {
31 | const n = state.todos.filter(({ completed }) => !completed).length
32 |
33 | return {
34 | ...state,
35 | itemsLeft: `${n} item${n === 1 ? "" : "s"} left`,
36 | }
37 | },
38 | }
39 | },
40 | /* html */ `
41 |
42 |
49 | {{ itemsLeft }}
50 |
51 | `
52 | )
53 |
--------------------------------------------------------------------------------
/website/components/x-logo.js:
--------------------------------------------------------------------------------
1 | import { partial } from "../synergy.js"
2 |
3 | partial(
4 | "x-logo",
5 | /* HTML */ `
6 |
11 |
21 |
25 |
29 |
33 |
34 |
35 | `,
36 | /* CSS */ `
37 | x-logo {
38 | height: 2rem;
39 | }
40 |
41 | svg {
42 | height: 2rem;
43 | /* margin: 0 0.5rem; */
44 | margin-right: 0.5rem;
45 | }
46 |
47 | path {
48 | stroke: teal;
49 | fill: teal;
50 | }
51 |
52 | `
53 | )
54 |
--------------------------------------------------------------------------------
/website/components/x-pager.js:
--------------------------------------------------------------------------------
1 | import { define } from "../synergy.js"
2 | import { flatNav } from "../data.js"
3 |
4 | define(
5 | "x-pager",
6 | () => {
7 | const items = flatNav
8 | const { pathname } = location
9 | const index = items.findIndex(({ href }) => href === pathname)
10 |
11 | return {
12 | state: {
13 | prev: items[index - 1],
14 | next: items[index + 1],
15 | },
16 | }
17 | },
18 | /* html */ `
19 |
20 | NEXT {{ next.title }}
21 |
22 |
23 |
24 |
25 |
26 | PREV {{ prev.title }}
27 |
28 |
29 |
30 |
31 |
32 | `,
33 | /* css */ `
34 | x-pager {
35 | display: flex;
36 | flex-direction: column;
37 | padding: var(--s3) 0;
38 | }
39 |
40 | x-pager > * {
41 | flex: 1;
42 | }
43 |
44 | a {
45 | padding: var(--s3);
46 | border: 1px solid #eaeaea;
47 | }
48 |
49 | svg {
50 | stroke: currentColor;
51 | stroke-width: 1px;
52 | margin: auto 0;
53 | }
54 |
55 | a {
56 | margin-top: 0;
57 | text-decoration: none;
58 | vertical-align: center;
59 | font-size: var(--s2);
60 | color: #111;
61 | }
62 |
63 | a:hover {
64 | border: 1px solid rgb(102, 204, 51);
65 | color: rgb(102, 204, 51);
66 | }
67 |
68 | a span {
69 | color: #222;
70 | display: block;
71 | font-size: var(--s1);
72 | }
73 |
74 | a:nth-of-type(2) {
75 | text-align: right;
76 | }
77 |
78 | a:nth-of-type(1) {
79 | text-align: left;
80 | }
81 |
82 | a:nth-of-type(2) svg {
83 | float: left;
84 | }
85 |
86 | a:nth-of-type(1) svg {
87 | float: right;
88 | }
89 |
90 | a:nth-of-type(1) {
91 | margin-bottom: var(--s1);
92 | }
93 |
94 | @media screen and (min-width: 1024px) {
95 | x-pager {
96 | flex-direction: row-reverse;
97 | }
98 |
99 | a:nth-of-type(1) {
100 | margin-bottom: 0;
101 | }
102 |
103 | a:nth-of-type(2) {
104 | margin-right: 1rem;
105 | }
106 | }
107 | `
108 | )
109 |
--------------------------------------------------------------------------------
/website/customElements.js:
--------------------------------------------------------------------------------
1 | const ANON = /<([a-z]\w*-\w*)/gm
2 |
3 | function unique(arr) {
4 | return [...new Set(arr)]
5 | }
6 |
7 | export function listCustomElements(html = "") {
8 | const names = (html.match(ANON) || []).map((v) => v.slice(1))
9 | return unique(names)
10 | }
11 |
--------------------------------------------------------------------------------
/website/data/nav.js:
--------------------------------------------------------------------------------
1 | export const navigation = [
2 | {
3 | title: "Examples",
4 | items: [
5 | {
6 | title: "my-counter",
7 | href: "my-counter",
8 | },
9 | {
10 | title: "simple-clock",
11 | href: "simple-clock",
12 | },
13 | {
14 | title: "todo-list",
15 | href: "todo-list",
16 | },
17 | ],
18 | },
19 | {
20 | title: "Reference",
21 | items: [
22 | {
23 | title: "Template syntax",
24 | href: "template-syntax",
25 | },
26 | {
27 | title: "Repeated blocks",
28 | href: "repeated-blocks",
29 | },
30 | {
31 | title: "Events",
32 | href: "events",
33 | },
34 | {
35 | title: "Styles",
36 | href: "styles",
37 | },
38 | {
39 | title: "Forms",
40 | href: "forms",
41 | },
42 | ],
43 | },
44 | ]
45 |
46 | function flatten(items) {
47 | return items.reduce((acc, item) => {
48 | if (item.items) {
49 | acc.push(...item.items.map((v) => ({ ...v, category: item.title })))
50 | }
51 | return acc
52 | }, [])
53 | }
54 |
55 | export const flatNav = flatten(navigation)
56 |
--------------------------------------------------------------------------------
/website/head.js:
--------------------------------------------------------------------------------
1 | const HEAD_OPEN = ""
2 | const HEAD_CLOSE = ""
3 |
4 | export const decapitate = (str) => {
5 | const start = str.indexOf(HEAD_OPEN)
6 | const end = str.indexOf(HEAD_CLOSE)
7 | if (start === -1)
8 | return {
9 | head: "",
10 | body: str,
11 | }
12 | return {
13 | head: str.slice(start + HEAD_OPEN.length, end).trim(),
14 | body:
15 | str.slice(0, start).trim() + str.slice(end + HEAD_CLOSE.length).trim(),
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "synergydocs",
3 | "type": "module",
4 | "version": "1.0.0",
5 | "description": "",
6 | "scripts": {
7 | "linklib": "cp ../dist/synergy.js public/",
8 | "start": "nodemon --ignore 'build/*' server.js",
9 | "build": "rm -r build ; mkdir build && cd build && wget -r -k -nH --html-extension http://localhost:3000",
10 | "postbuild": "cp -a public/. build && cp -r components build && echo 'synergyjs.org' > build/CNAME && rm -r ../docs && mv build ../docs",
11 | "serve": "cd build && npx serve -p 8000"
12 | },
13 | "keywords": [],
14 | "author": "",
15 | "license": "ISC",
16 | "devDependencies": {
17 | "express": "^4.18.2",
18 | "fs-extra": "^10.1.0",
19 | "globby": "^13.1.2",
20 | "highlight.js": "^11.7.0",
21 | "markdown-it": "^13.0.1",
22 | "nodemon": "^2.0.20",
23 | "puppeteer-core": "^15.5.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/website/pages/api.md:
--------------------------------------------------------------------------------
1 |
2 | API | Synergy JS
3 |
4 |
5 | # API
6 |
7 | ## Define
8 |
9 | Synergy's `define` function allows you to create a new HTML element. Once defined within an HTML document your custom element can be used on the page just like any other HTML tag. Your custom elements can be composed together, allowing you to build anything from a simple component to an entire website or application.
10 |
11 | The `define()` function registers a new Custom Element.
12 |
13 | ### Syntax
14 |
15 | ```js
16 | define(tagName, factory, template, styles)
17 | ```
18 |
19 | ### Parameters
20 |
21 | - `tagName` (required) [string] - Name for the new Custom Element. As per the Custom Element
22 | spec, an elements name must include a hyphen to differentiate from standard built-in elements.
23 |
24 | - `factory` (required) [function] - A factory function that will be called whenever a new instance of your Custom Element is created. It will be provided with one argument which is the Custom Element node itself. The factory function returns a Model (see below) or a Promise that resolves to a Model.
25 |
26 | - `template` (required) [HTMLTemplateElement | string] - The HTML for your view.
27 |
28 | - `styles` (optional) [string] - The CSS for your custom element. The CSS will be transformed to apply lightweight scoping before being added to the head of the document.
29 |
30 | ### Return value
31 |
32 | None (`undefined`)
33 |
34 | ## Model
35 |
36 | The factory function provided as the second argument to `define` must return a plain JavaScript object that represents the element _Model_.
37 |
38 | ```ts
39 | type Model = {
40 | /**
41 | * Provides the initial state to the component for its very first render.
42 | */
43 | state?: State
44 | /**
45 | * Invoked each time the custom element is appended into a
46 | * document-connected element
47 | */
48 | connectedCallback?(store: Store): void
49 | /**
50 | * Invoked each time the custom element is disconnected from the document
51 | */
52 | disconnectedCallback?(): void
53 | /**
54 | *
55 | */
56 | update?: {
57 | [actionName: string]: ActionHandler
58 | }
59 | /**
60 | * A custom wrapper around calls to getState, giving you the ability to define derived properties, for example
61 | */
62 | getState?(state: State): State
63 | /**
64 | * A debounced function that is called after every render cycle
65 | */
66 | subscribe?: {
67 | (state: State): void
68 | }
69 | /**
70 | *
71 | */
72 | middleware?: {
73 | [actionName: string]: Middleware | [Middleware]
74 | }
75 | /**
76 | * If this is omitted then Shadow DOM is not utilised and functionality is polyfilled.
77 | */
78 | shadow?: "open" | "closed"
79 | }
80 | ```
81 |
82 | ## High-level view
83 |
84 | The Synergy `define` function allows you to register a custom element on the page. Once defined, you can use your custom element like any other HTML tag.
85 |
86 | The `define` function takes up to four arguments:
87 |
88 | - `tagName` (required) [string] - Name for the new Custom Element. As per the Custom Element
89 | spec, an elements name must include a hyphen to differentiate from standard built-in elements.
90 |
91 | - `factory` (required) [function] - A factory function that will be called whenever a new instance of your Custom Element is created. It will be provided with one argument which is the Custom Element node itself. The factory function returns an Object (or a Promise that resolves to an Object) that defines the behaviour of the element.
92 |
93 | - `template` (required) [HTMLTemplateElement | string] - The HTML for your view.
94 |
95 | - `styles` (optional) [string] - The CSS for your custom element. The CSS will be transformed to apply lightweight scoping before being added to the head of the document.
96 |
97 | As you can see, the first, third, and fourth arguments are just strings. The third argument is standard HTML, and the fourth argument is standard CSS. One of the great things about Synergy is that it allows you to build UI using 100% standard, spec-compliant HTML, CSS, and JavaScript.
98 |
99 | In the next section we will look closer at our simple element example to understand more about how Synergys reactivity system works and what you can do with it.
100 |
--------------------------------------------------------------------------------
/website/pages/events.md:
--------------------------------------------------------------------------------
1 |
2 | Events | Synergy JS
3 |
4 |
5 | # Events
6 |
7 | Synergy allows you to map events to update functions of the same name.
8 |
9 | Model:
10 |
11 | ```js
12 | const factory = () => ({
13 | update: {
14 | sayHello: (state) => ({
15 | ...state,
16 | greeting: "Hello"
17 | }),
18 | })
19 | ```
20 |
21 | Template:
22 |
23 | ```html
24 | Say hello
25 | ```
26 |
27 | Every update function accepts the current state as its first argument, its return value will provide the next state for the custom element.
28 |
29 | ## Template scope
30 |
31 | Because each repeated block creates a new [_variable scope_](https://developer.mozilla.org/en-US/docs/Glossary/Scope), it is useful to be able to access those values within your handler. You can do this via the `scope` property of the second argument to your event handler.
32 |
33 | Model:
34 |
35 | ```js
36 | const factory = () => {
37 | return {
38 | state: {
39 | artists: [
40 | {
41 | name: "pablo picasso",
42 | tags: ["painter", "sculptor", "printmaker", "ceramicist"],
43 | },
44 | {
45 | name: "salvador dali",
46 | tags: ["painter", "sculptor", "photographer", "writer"],
47 | },
48 | ],
49 | },
50 | update: {
51 | select: (state, { scope }) => {
52 | const { artist, tag } = scope
53 |
54 | return {
55 | ...state,
56 | selected: {
57 | artist,
58 | tag,
59 | },
60 | }
61 | },
62 | },
63 | }
64 | }
65 | ```
66 |
67 | Template:
68 |
69 | ```html
70 |
71 | {{ artist.name }}
72 |
75 |
76 | ```
77 |
--------------------------------------------------------------------------------
/website/pages/forms.md:
--------------------------------------------------------------------------------
1 |
2 | Forms | Synergy JS
3 |
4 |
5 | # Forms
6 |
7 | Synergy makes working with form data a breeze by automagically binding named form controls to state properties of the same name.
8 |
9 | State:
10 |
11 | ```js
12 | {
13 | color: "#4287f5"
14 | }
15 | ```
16 |
17 | Template:
18 |
19 | ```html
20 |
21 | ```
22 |
23 | ## Select
24 |
25 | Attribute a name to your `` and the value
26 | of the bound property will reflect that of the
27 | currently selected ``
28 |
29 | State:
30 |
31 | ```js
32 | {
33 | pets: "hamster"
34 | }
35 | ```
36 |
37 | Template:
38 |
39 | ```html
40 | Choose a pet:
41 |
42 | --Please choose an option--
43 | Dog
44 | Cat
45 | Hamster
46 | Parrot
47 | Spider
48 | Goldfish
49 |
50 | ```
51 |
52 | The standard HTML `` element also supports
53 | the ability to select multiple options, using the
54 | **multiple** attribute:
55 |
56 | ```html
57 |
58 | ```
59 |
60 | A `` with `[multiple]` binds to an Array
61 | on your data:
62 |
63 | ```js
64 | {
65 | pets: ["hamster", "spider"]
66 | }
67 | ```
68 |
69 | ## Radio Buttons
70 |
71 | Add a name to each radio button to indicate which
72 | _group_ it belongs to.
73 |
74 | ```html
75 |
76 |
77 |
78 | ```
79 |
80 | As with ``, the value of the named
81 | property will reflect the value of the selected
82 | ` `.
83 |
84 | ```js
85 | {
86 | filter: "active"
87 | }
88 | ```
89 |
--------------------------------------------------------------------------------
/website/pages/getting-started.md:
--------------------------------------------------------------------------------
1 |
2 | Getting started | Synergy JS
3 |
4 |
5 | # Getting Started
6 |
7 | Synergy doesn't require any special toolchain, compiler, plugins etc. Its a tiny (~4k) package that gives you everything you need to start building directly in the browser.
8 |
9 | The quickest way to get started is to import the Synergy package directly from a CDN.
10 |
11 | ## Unpkg CDN
12 |
13 | ```html
14 |
17 | ```
18 |
19 | You can also install directly into your project using NPM.
20 |
21 | ## NPM
22 |
23 | ```bash
24 | $ npm i synergy
25 | ```
26 |
--------------------------------------------------------------------------------
/website/pages/hydration.md:
--------------------------------------------------------------------------------
1 |
2 | Hydration | Synergy JS
3 |
4 |
5 | # Hydration
6 |
7 | Synergy components serialise their data inside a script element each time that their viewmodel changes and merge any deserialised data back into their viewmodel during instantion. This allows you to capture the current state of one or more components in a page at any point in time and then reload that into a browser later to effectively resume from where you left off.
8 |
9 | Just remember that data serialisation can increase the size of your page significantly, so if you have a large number of components that need to be prerendered but _not_ rehydrated (e.g., components that have no event bindings and never need to update after the first render), then you should strip out both the data scripts and the component definitions for those particular components to achieve the best possible performance.
10 |
--------------------------------------------------------------------------------
/website/pages/index.md:
--------------------------------------------------------------------------------
1 |
2 | Synergy - A JavaScript library for crafting user interfaces
3 |
4 |
5 | ## What is Synergy?
6 |
7 | Synergy
8 | combines declarative data and event binding with functional state
9 | management and reactive updates to allow you to build all types of
10 | user interface for the web, no matter how simple or complex.
11 |
12 | Here's a simple example:
13 |
14 |
15 |
16 | ```html
17 |
18 |
19 |
34 | ```
35 |
36 | The above example demonstrates the three core features of Synergy:
37 |
38 | - **Declarative data and event binding:** Synergy
39 | provides a declarative template syntax that allows you to describe
40 | HTML output based on JavaScript state. The syntax is very simple
41 | and easy to learn.
42 | - **Functional state management:** Synergy allows you
43 | to describe changes to state as individual functions that are as
44 | easy to reason about as they are to test.
45 | - **Reactive updates:** Synergy efficiently batches
46 | updates to your HTML whenever your state changes
47 |
48 | ## Getting started
49 |
50 | If you're new to Synergy then the best place to start is the _Learn by Example_ section. It will introduce you to all of the features of Synergy by showing different examples that will help you to understand and learn quickly.
51 |
--------------------------------------------------------------------------------
/website/pages/my-counter.md:
--------------------------------------------------------------------------------
1 |
2 | Learn by example - My Counter | Synergy JS
3 |
4 |
5 | # my-counter
6 |
7 |
8 |
9 | A key feature of any user interface is the ability to update its state in response to different events. In Synergy, this process is defined by the object returned from your custom elements factory function.
10 |
11 | Lets take another look at the `my-counter` example from the home page:
12 |
13 | ```js
14 | const factory = () => ({
15 | state: { count: 0 },
16 | update: {
17 | increment: ({ count }) => ({
18 | count: count + 1,
19 | }),
20 | })
21 | ```
22 |
23 | In the example above, the returned object includes both the `state` and `update` properties.
24 |
25 | ## State
26 |
27 | `State` is an object that provides the _initial_ data for your custom element. Any properties defined here can be used directly in the template via text or attribute bindings, which is exactly how the value of `count` is included inside the template using text interpolation:
28 |
29 | ```html
30 | Count is: {{ count }}
31 | ```
32 |
33 | ## Update
34 |
35 | `Update` is a dictionary of named state update functions that can be referenced directly in the template as event handlers, as per the `:onclick="increment"` binding in the example above.
36 |
37 | Each state update function takes the _current_ state as its first argument and returns the _next_ state for the custom element.
38 |
--------------------------------------------------------------------------------
/website/pages/repeated-blocks.md:
--------------------------------------------------------------------------------
1 |
2 | Repeated Blocks | Synergy JS
3 |
4 |
5 | # Repeated Blocks
6 |
7 | Repeated blocks work with both Arrays and Objects.
8 |
9 | Repeat a block of HTML for each item in a collection using a the `:each` attribute.
10 |
11 | State:
12 |
13 | ```js
14 | {
15 | names: ["kate", "kevin", "randall"]
16 | }
17 | ```
18 |
19 | Template:
20 |
21 | ```html
22 |
23 | Hello {{ name }}
24 |
25 | ```
26 |
27 | ## Iteration Keys
28 |
29 | You can use parentheses to access the key as well as the value.
30 |
31 | Template:
32 |
33 | ```html
34 |
35 | todo {{ index }} of {{ todos.length }}
36 |
37 | ```
38 |
39 | ## Implicit scope
40 |
41 | Property access via the identifier is optional, you can also access directly like so...
42 |
43 | State:
44 |
45 | ```js
46 | {
47 | bars: [
48 | {
49 | x: 0,
50 | y: 0,
51 | width: 32,
52 | height: 16,
53 | fill: "hsl(100, 50%, 50%)",
54 | },
55 | {
56 | x: 0,
57 | y: 16,
58 | width: 64,
59 | height: 16,
60 | fill: "hsl(200, 50%, 50%)",
61 | },
62 | //...
63 | ]
64 | }
65 | ```
66 |
67 | Template:
68 |
69 | ```html
70 |
71 |
72 |
73 | ```
74 |
75 | ...when accessing properties in this way, Synergy will first check to see if the property is defined on the current item within the iteration, and will otherwise try the same property against the viewmodel itself.
76 |
77 | ## Multiple top-level nodes
78 |
79 | If you need more than one top-level element then you can wrap your repeated block in a template like so:
80 |
81 | State:
82 |
83 | ```js
84 | {
85 | cryptids: [{
86 | title: "Beast of Bodmin",
87 | description: "A large feline inhabiting Bodmin Moor."
88 | },
89 | {
90 | title: "Morgawr",
91 | description: "A sea serpent."
92 | }
93 | {
94 | title: "Owlman",
95 | description: "A giant owl-like creature."
96 | }
97 | ]
98 | }
99 | ```
100 |
101 | Template:
102 |
103 | ```html
104 |
105 |
106 | {{ title }}
107 | {{ description }}
108 |
109 |
110 | ```
111 |
112 | ...or in the case of SVG you can use `` instead of ``.
113 |
114 | ## Keyed Arrays
115 |
116 | Keys help Synergy identify which items in an collection of objects
117 | have changed.
118 |
119 | Using keys improves performance and
120 | avoids unexpected behaviour when re-rendering so it's always best to use them.
121 |
122 | List keys are specified using the `:key` attribute and should be a primitive value that is unique to that item within the collection.
123 |
124 | ```html
125 |
126 | Hello {{ person.name }}
127 |
128 | ```
129 |
--------------------------------------------------------------------------------
/website/pages/simple-clock.md:
--------------------------------------------------------------------------------
1 |
2 | Learn by example - Simple Clock | Synergy JS
3 |
4 |
5 | # simple-clock
6 |
7 |
8 |
9 | In this example we're going to create a custom element that displays the current time. We need a way to setup a scheduled event once the element is mounted to the DOM, and we will also need a way to programatically update state whenever that event fires to keep our time value synchronised with the real world.
10 |
11 | `connectedCallback` is a lifecycle event that fires whenever the element is connected to the DOM...
12 |
13 | ```js
14 | const factory = () => {
15 | return {
16 | update: {
17 | setTime: (state) => {
18 | return {
19 | ...state,
20 | time: new Date().toLocaleTimeString(),
21 | }
22 | },
23 | },
24 | connectedCallback: ({ dispatch }) => {
25 | setInterval(() => {
26 | dispatch({
27 | type: "setTime",
28 | })
29 | }, 100)
30 | },
31 | }
32 | }
33 | ```
34 |
35 | ```html
36 | Time: {{ time }}
37 | ```
38 |
39 | In order to _programatically_ update state we can use the `dispatch` function provided within the first argument to `connectedCallback`. The `dispatch` function accepts a single argument which is an object that must include a `type` property to identify the update function that we want to call.
40 |
41 | ## disconnectedCallback
42 |
43 | Of course, our element can be removed from the DOM at some later point, and we wouldn't want it to carry on scheduling re-renders in the background if there was nobody there to see it. So let's improve our `simple-clock` element to use the
44 | `disconnectedCallback` to clear our timer interval.
45 |
46 | ```js
47 | const factory = () => {
48 | let intervalID
49 |
50 | return {
51 | update: {
52 | setTime: (state, { payload }) => {
53 | return {
54 | ...state,
55 | time: payload,
56 | }
57 | },
58 | },
59 | connectedCallback: ({ dispatch }) => {
60 | intervalID = setInterval(() => {
61 | dispatch({
62 | type: "setTime",
63 | payload: new Date().toLocaleTimeString(),
64 | })
65 | }, 100)
66 | },
67 | disconnectedCallback: () => {
68 | clearInterval(intervalID)
69 | },
70 | }
71 | }
72 | ```
73 |
--------------------------------------------------------------------------------
/website/pages/styles.md:
--------------------------------------------------------------------------------
1 |
2 | Styles | Synergy JS
3 |
4 |
5 | # Styles
6 |
7 | ## Multiple classes with Array
8 |
9 | State:
10 |
11 | ```js
12 | {
13 | classes: ["w32", "h32", "rounded-full", "mx-auto"]
14 | }
15 | ```
16 |
17 | Template:
18 |
19 | ```html
20 |
21 | ```
22 |
23 | Output:
24 |
25 | ```html
26 |
27 | ```
28 |
29 | ## Conditional Classes with Object
30 |
31 | State:
32 |
33 | ```js
34 | {
35 | classes: {
36 | 'mx-auto': true,
37 | 'has-error': false
38 | }
39 | }
40 | ```
41 |
42 | Template:
43 |
44 | ```html
45 |
46 | ```
47 |
48 | Output:
49 |
50 | ```html
51 |
52 | ```
53 |
54 | ## Inline Styles
55 |
56 | State:
57 |
58 | ```js
59 | {
60 | primary: true,
61 | style: {
62 | display: "inline-block",
63 | borderRadius: "3px",
64 | background: this.primary ? "white" : "transparent",
65 | color: this.primary ? "black" : "white",
66 | border: "2px solid white",
67 | }
68 | }
69 | ```
70 |
71 | Template:
72 |
73 | ```html
74 |
75 | ```
76 |
77 | Output:
78 |
79 | ```html
80 |
89 | ```
90 |
--------------------------------------------------------------------------------
/website/pages/template-syntax.md:
--------------------------------------------------------------------------------
1 |
2 | Template Syntax | Synergy JS
3 |
4 |
5 | # Template syntax
6 |
7 | Synergy uses an HTML-based template syntax that allows you to
8 | declaratively add data and event bindings to your components HTML. All
9 | synergy templates are syntactically valid HTML that can be parsed by
10 | spec-compliant browsers and HTML parsers.
11 |
12 | ## Text
13 |
14 | Let's take a look at how text interpolation works with a simple example.
15 |
16 | State:
17 |
18 | ```js
19 | {
20 | name: "Kimberley"
21 | }
22 | ```
23 |
24 | Template:
25 |
26 | ```html
27 | Hello {{ name }}!
28 | ```
29 |
30 | Output:
31 |
32 | ```html
33 | Hello Kimberley!
34 | ```
35 |
36 | ## Attributes
37 |
38 | Attribute bindings are always prefixed with the colon mark (`:`).
39 |
40 | State:
41 |
42 | ```js
43 | {
44 | cx: ["pt-6", "space-y-4"],
45 | }
46 | ```
47 |
48 | Template:
49 |
50 | ```html
51 | ok
52 | ```
53 |
54 | Output:
55 |
56 | ```html
57 | ok
58 | ```
59 |
60 | ## Attribute interpolation
61 |
62 | When you need more than just a single state value you can use the mustache syntax for interpolation:
63 |
64 | State:
65 |
66 | ```js
67 | {
68 | textColor: 'gold',
69 | }
70 | ```
71 |
72 | Template:
73 |
74 | ```html
75 | ok
76 | ```
77 |
78 | Output:
79 |
80 | ```html
81 | ok
82 | ```
83 |
84 | ## Shorthand attributes
85 |
86 | When you bind an attribute to a property with the same name then you can use the shorthand notation:
87 |
88 | State:
89 |
90 | ```js
91 | {
92 | width: "100%",
93 | height: "100%",
94 | fill: "black"
95 | }
96 | ```
97 |
98 | Template:
99 |
100 | ```html
101 |
102 | ```
103 |
104 | Output:
105 |
106 | ```html
107 |
108 | ```
109 |
110 | ## Boolean attributes
111 |
112 | This is how we refer to any attribute bound to a boolean state property. It will be present on the bound element only when the value of the property is truthy.
113 |
114 | State:
115 |
116 | ```js
117 | {
118 | open: true
119 | closed: false
120 | }
121 | ```
122 |
123 | Template:
124 |
125 | ```html
126 |
127 | ```
128 |
129 | Output:
130 |
131 | ```html
132 |
133 | ```
134 |
135 | ## ARIA attributes
136 |
137 | One exception to Boolean Attributes are attributes prefixed with "aria-", these particular attributes will be set to the "true" of "false" string values as per their specification.
138 |
139 | State:
140 |
141 | ```js
142 | {
143 | title: "more information",
144 | expanded: false
145 | }
146 | ```
147 |
148 | Template:
149 |
150 | ```html
151 | {{ title }}
152 |
153 | ```
154 |
155 | Output:
156 |
157 | ```html
158 | {{ title }}
159 |
160 | ```
161 |
162 | ## Logical NOT (!)
163 |
164 | As per the examples above, you can prefix boolean properties with an exclamation mark to convert a truthy value to a falsy value, and vice versa.
165 |
166 | State:
167 |
168 | ```js
169 | {
170 | authenticated: true
171 | }
172 | ```
173 |
174 | Template:
175 |
176 | ```html
177 | Log in
178 | Log out
179 | ```
180 |
181 | Output:
182 |
183 | ```html
184 | Log in
185 |
186 | Log out
187 | ```
188 |
189 | ## Conditional rendering
190 |
191 | You can also conditionally render an element and its subtree using the `:if` binding with a boolean state property.
192 |
193 | State:
194 |
195 | ```js
196 | {
197 | authenticated: true
198 | }
199 | ```
200 |
201 | Template:
202 |
203 | ```html
204 | Log out
205 | Log in
206 | ```
207 |
208 | Output:
209 |
210 | ```html
211 | Log out
212 | ```
213 |
--------------------------------------------------------------------------------
/website/pages/todo-list.md:
--------------------------------------------------------------------------------
1 |
2 | Learn by example - Todo List | Synergy JS
3 |
4 |
5 | # todo-list
6 |
7 |
8 |
9 | In this example we will learn some more features of Synergy by looking at how to create a simple todo list. Let's take a look at the code and then we talk about each section and how it works...
10 |
11 | ```js
12 | const itemsLeft = ({ todos }) => {
13 | const n = todos.filter(({ completed }) => !completed).length
14 | return `${n} ${n === 1 ? "item" : "items"} left`
15 | }
16 |
17 | const factory = () => {
18 | return {
19 | state: {
20 | todos: [],
21 | },
22 | update: {
23 | addTodo: (state, { event: { key } }) => {
24 | if (key !== "Enter" || !state.newTodo?.length) return state
25 |
26 | return {
27 | ...state,
28 | newTodo: null,
29 | todos: state.todos.concat({
30 | title: state.newTodo,
31 | completed: false,
32 | }),
33 | }
34 | },
35 | removeTodo: (state, { scope: { todo } }) => {
36 | return {
37 | ...state,
38 | todos: state.todos.filter(({ title }) => title !== todo.title),
39 | }
40 | },
41 | },
42 | getState: (state) => {
43 | return {
44 | ...state,
45 | itemsLeft: itemsLeft(state),
46 | }
47 | },
48 | }
49 | }
50 | ```
51 |
52 | ```html
53 |
58 |
65 | {{ itemsLeft }}
66 | ```
67 |
68 | ## Two-way bindings (:name)
69 |
70 | Using the `:name` binding on a form input automatically creates a special two-way binding. The input will assume the value (if there is one) from a state property of the same name (in this case `newTodo`), and whenever the value of the input changes, state will automatically be updated to reflect that change.
71 |
72 | ```html
73 |
78 | ```
79 |
80 | We've already seen in previous examples how to add event bindings, and we're adding another one here to the input element so that it will invoke the `addTodo` update function whenever the `keyup` event fires.
81 |
82 | ## Context { event }
83 |
84 | We want to add a new todo item whenever the user presses the Enter key inside the input. The second argument to every state update function is the _context_ object which, if the update function was triggered by an event, includes an `event` key that points to the native Event object.
85 |
86 | ```js
87 | const factory = () => {
88 | return {
89 | // ...
90 | update: {
91 | addTodo: (state, { event: { key } }) => {
92 | if (key !== "Enter" || !state.newTodo?.length) return state
93 |
94 | return {
95 | ...state,
96 | newTodo: null,
97 | todos: state.todos.concat({
98 | title: state.newTodo,
99 | completed: false,
100 | }),
101 | }
102 | },
103 | // ...
104 | }
105 | // ...
106 | }
107 | ```
108 |
109 | In the example above, we use the Event object to check which key was pressed, and if it's _not_ the Enter key then we simply return the current state so that nothing changes. If it _was_ the Enter key, then our second condition checks that the `newTodo` input isn't empty before using its value to add the new todo to our list. When we add the new todo, we also set the value of `newTodo` to `null` so as to clear the input element ready for the next todo item to be added.
110 |
111 | ## Repeated blocks (:each)
112 |
113 | A block of HTML can be repeated for each item in an array by using the `:each` binding.
114 |
115 | ```html
116 |
117 |
118 | {{ todo.title }}
119 | [x]
120 |
121 | ```
122 |
123 | The `todo in todos` expression creates a new value called `todo` that refers to the current item in the iteration.
124 |
125 | ## Context { scope }
126 |
127 | When the button on one of our todo items is clicked, the `removeTodo` update function is invoked...
128 |
129 | ```js
130 | const factory = () => {
131 | return {
132 | // ...
133 | update: {
134 | removeTodo: (state, { scope: { todo } }) => {
135 | return {
136 | ...state,
137 | todos: state.todos.filter(({ title }) => title !== todo.title),
138 | }
139 | },
140 | // ...
141 | }
142 | // ...
143 | }
144 | ```
145 |
146 | As we just learned, the second argument to every state update function is the _context_ object. If the update function was triggered inside a repeated block, then this object includes a `scope` key that points to a copy of the scope from the event origin.
147 |
--------------------------------------------------------------------------------
/website/public/data.js:
--------------------------------------------------------------------------------
1 | export const navigation = [
2 | {
3 | title: "Getting Started",
4 | items: [
5 | {
6 | title: "Introduction",
7 | href: "/",
8 | },
9 | ],
10 | },
11 | {
12 | title: "Learn by Example",
13 | items: [
14 | {
15 | title: "my-counter",
16 | href: "/my-counter",
17 | },
18 | {
19 | title: "simple-clock",
20 | href: "/simple-clock",
21 | },
22 | {
23 | title: "todo-list",
24 | href: "/todo-list",
25 | },
26 | ],
27 | },
28 | {
29 | title: "Reference",
30 | items: [
31 | {
32 | title: "Template syntax",
33 | href: "/template-syntax",
34 | },
35 | {
36 | title: "Repeated blocks",
37 | href: "/repeated-blocks",
38 | },
39 | {
40 | title: "Events",
41 | href: "/events",
42 | },
43 | {
44 | title: "Styles",
45 | href: "/styles",
46 | },
47 | {
48 | title: "Forms",
49 | href: "/forms",
50 | },
51 | {
52 | title: "Slots",
53 | href: "/slots",
54 | },
55 | ],
56 | },
57 | ]
58 |
59 | function flatten(items) {
60 | return items.reduce((acc, item) => {
61 | if (item.items) {
62 | acc.push(...item.items.map((v) => ({ ...v, category: item.title })))
63 | }
64 | return acc
65 | }, [])
66 | }
67 |
68 | export const flatNav = flatten(navigation)
69 |
--------------------------------------------------------------------------------
/website/server.js:
--------------------------------------------------------------------------------
1 | import express from "express"
2 | import fs from "fs-extra"
3 | import hljs from "highlight.js"
4 | import MarkdownIt from "markdown-it"
5 | import { globby } from "globby"
6 |
7 | import { listCustomElements } from "./customElements.js"
8 | import { decapitate } from "./head.js"
9 | import { navigation } from "./data/nav.js"
10 | import { documentTemplate } from "./templates/document.js"
11 | import { navTemplate } from "./templates/nav.js"
12 |
13 | const componentsGlob = process.env.components || `components/**/*.js`
14 |
15 | let md = new MarkdownIt({
16 | html: true,
17 | highlight: function (str, language) {
18 | if (language && hljs.getLanguage(language)) {
19 | try {
20 | return (
21 | '' +
22 | hljs.highlight(str, { language, ignoreIllegals: true }).value +
23 | "
"
24 | )
25 | } catch (__) {}
26 | }
27 | return (
28 | '' + md.utils.escapeHtml(str) + "
"
29 | )
30 | },
31 | })
32 |
33 | ;(async () => {
34 | const mainNavigation = navTemplate(navigation)
35 | const app = express()
36 | const port = 3000
37 |
38 | app.use(express.static("public"))
39 |
40 | app.use("/components", express.static("components"))
41 |
42 | app.get("/docs", (_, res) => {
43 | const html = documentTemplate({
44 | mainContent: mainNavigation,
45 | })
46 |
47 | res.send(html)
48 | })
49 |
50 | app.use(async function (req, res, next) {
51 | if (req.originalUrl.includes(".")) return next()
52 |
53 | const route = req.originalUrl.split("?")[0]
54 | const fp = `./pages${route === "/" ? "/index" : route}.md`
55 | const markdown = await fs.readFile(fp, "utf8").catch((e) => {
56 | console.error(e)
57 | return null
58 | })
59 |
60 | if (markdown) {
61 | let { head, body } = decapitate(markdown)
62 |
63 | const customElements = listCustomElements(body)
64 |
65 | let mainContent = md.render(body)
66 |
67 | if (customElements) {
68 | let paths = await globby([componentsGlob])
69 |
70 | paths = paths.filter((p) => {
71 | let [dir, file] = p.split("/").slice(-2)
72 | return customElements.find(
73 | (name) => dir === name || file.split(".")[0] === name
74 | )
75 | })
76 |
77 | if (paths.length) {
78 | // append scripts to body
79 | paths.forEach((path) => {
80 | mainContent += ``
81 | })
82 | }
83 | }
84 |
85 | const html = documentTemplate({
86 | headContent: head,
87 | mainContent,
88 | mainNavigation,
89 | })
90 |
91 | res.send(html)
92 | } else {
93 | next()
94 | }
95 | })
96 |
97 | app.listen(port, () => {
98 | console.log(`Synergy docs running on port ${port}`)
99 | })
100 | })()
101 |
--------------------------------------------------------------------------------
/website/templates/document.js:
--------------------------------------------------------------------------------
1 | import { html } from "../utils.js"
2 |
3 | export const documentTemplate = ({
4 | title,
5 | headContent = "",
6 | mainContent = "",
7 | mainNavigation = "",
8 | }) => html`
9 |
10 |
11 |
12 |
13 |
14 |
15 | ${title && `${title} `} ${headContent}
16 |
23 |
177 |
178 |
179 |
180 |
189 | ${mainContent}
190 |
191 |
192 |
193 | `
194 |
--------------------------------------------------------------------------------
/website/templates/nav.js:
--------------------------------------------------------------------------------
1 | import { html } from "../utils.js"
2 |
3 | function list(items) {
4 | return html`
5 |
6 | ${items.map((item) => {
7 | const { title, items, href } = item
8 |
9 | if (title && items) return html`${title} ${list(items)} `
10 |
11 | if (title && href)
12 | return html`
13 | ${title}
14 | `
15 |
16 | return null
17 | })}
18 |
19 | `
20 | }
21 |
22 | export function navTemplate(items = []) {
23 | return html`
24 |
25 |
26 | docs
27 |
28 | ${list(items)}
29 |
30 | `
31 | }
32 |
--------------------------------------------------------------------------------
/website/utils.js:
--------------------------------------------------------------------------------
1 | function _(value) {
2 | if (typeof value === "undefined") return ""
3 | return String(Array.isArray(value) ? value.join("") : value)
4 | }
5 |
6 | function identity(template, ...args) {
7 | let str = ""
8 | for (let i = 0; i < args.length; i++) {
9 | str += template[i] + _(args[i])
10 | }
11 | return str + template[template.length - 1]
12 | }
13 |
14 | export const html = identity
15 | export const css = identity
16 | export const gql = identity
17 |
--------------------------------------------------------------------------------