initial commit (it all starts here...)
This commit is contained in:
commit
ac7da8cc6d
36 changed files with 4550 additions and 0 deletions
8
.editorconfig
Normal file
8
.editorconfig
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
11
.eslintrc
Normal file
11
.eslintrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "prettier"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
]
|
||||
}
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/dist
|
||||
public/
|
||||
node_modules/
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
lts/erbium
|
||||
373
LICENSE
Normal file
373
LICENSE
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
116
README.md
Normal file
116
README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# websnacks: Minimal Dependency Server-Side JSX for Static Sites
|
||||
|
||||
Develop fully static websites using typesafe JSX templates on the server without the complex build system and dependency management of server-side rendered React frameworks.
|
||||
|
||||
## Goals
|
||||
|
||||
- **No Client Runtime** Create clean, server-side web components leveraging the full expressiveness of Node.js and JSX without the added overhead and complexity of a runtime library like React or Angular.
|
||||
- **Simple and Performant** Straightforward, reliable, no-magic templating system that gives developers complete control over page generation and optimization.
|
||||
- **Minimal Dependencies** websnacks has only 2 optional dependencies: `node-watch` and `ws` for cross-platform live-reload in development.
|
||||
- **Familiar Semantics** Leverage existing JavaScript and React developer skills with JSX. Anyone with React experience should be able to quickly and easily pickup a websnacks site in just a matter of minutes.
|
||||
- **Typesafe** websnacks provides TypeScript definitions for fully typechecked templates. Catch errors during development instead of on your build server and have faster, more productive iteration cycles.
|
||||
- **Easy to Vendor and Maintain** websnack's codebase is written concisely and simply and has been designed to be easily vendored and maintained internally to ensure that your build process still works even years from now.
|
||||
|
||||
## Getting Started
|
||||
|
||||
websnacks is provided as very lightweight npm package and it is recommended that you add it to your project's dependencies via `npm i --save websnacks`, `yarn add websnacks`, or whatever other NPM-compatible package manager you're using.
|
||||
|
||||
The websnacks npm package provides a JSX-to-JS factory function `createElement`, a simple and XSS-safe HTML renderer, optional TypeScript typings for JSX elements and custom components, and a `websnacks` CLI binary.
|
||||
|
||||
The `websnacks` binary provides two commands:
|
||||
|
||||
- `websnacks build`, which renders the websnacks project in the current directory to a standalone static site, ready for hosting on your favorite CDN.
|
||||
- `websnacks dev`, which starts up a live-reloading development server that automatically watches your sources for changes.
|
||||
|
||||
### Project Structure
|
||||
|
||||
The `websnacks` binary assumes the following directory structure:
|
||||
|
||||
```
|
||||
<project-root>
|
||||
|-- pages/ # Each page must export a `page` var and generates a
|
||||
| |-- index.js|ts # corresponding html file with the same name and folder
|
||||
| |-- about.js|ts # nesting structure.
|
||||
| \-- blog/
|
||||
| \-- 2020-04-01.js|ts
|
||||
|-- static/ # Static assets in this folder are copied as-is to the
|
||||
| |-- images/ # output bundle unchanged.
|
||||
| | \-- logo.png
|
||||
| |-- styles.css
|
||||
| \-- robots.txt
|
||||
\-- websnacks.js|ts # Optional configuration file used to customize the
|
||||
# build process. See the examples folder for usage.
|
||||
```
|
||||
|
||||
### Defining Pages
|
||||
|
||||
websnacks Components are just JSX element factories and have the exact same interface as React functional components.
|
||||
|
||||
Page files are JS/TS files within the `pages/` directory that export a named `page` variable, which must be a websnacks component and must output an `<html>` tag as the root element.
|
||||
|
||||
```tsx
|
||||
import { createElement, Component } from "websnacks";
|
||||
|
||||
interface LayoutProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
const Layout: Component<LayoutProps> = ({ children, pageTitle }) => (
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>{pageTitle}</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
export const page: Component = () => (
|
||||
<Layout pageTitle="Hello Worldwide Web!">
|
||||
<h1 className="title">Hello Worldwide Web!</h1>
|
||||
</Layout>
|
||||
);
|
||||
```
|
||||
|
||||
## Integrations
|
||||
|
||||
### TypeScript
|
||||
|
||||
To use the websnacks binary to build or develop with TypeScript sources, add `ts-node` to your project's dependencies and use the `-r` flag to require ts-node, e.g. `websnacks -r ts-node/register dev`. This allows you to use TypeScript for page files and the websnacks configuration file.
|
||||
|
||||
### CSS-in-JS
|
||||
|
||||
websnacks doesn't directly support CSS-in-JS solutions, but provides hooks that can be used to generate CSS from those types of libraries. See `examples/personal-site` for an example project that uses typestyle.
|
||||
|
||||
## Why websnacks Instead of React?
|
||||
|
||||
React is a fantastic library for developing single-page applications (SPAs), but not all websites need the added complexity or runtime overhead of an SPA framework. Moreover, the myriad of development tools required to create server-side rendered, SEO-friendly static files from a React site can introduce considerable maintenance and upkeep costs to an otherwise dead-simple static site. Each dependency added to your project is also a potential supply-chain attack vector or weak link that can break your build pipeline (e.g. when a package is yanked).
|
||||
|
||||
websnacks aims to provide an excellent developer experience using the same tooling that React developers have come to expect and rely on, all while introducing as little incidental complexity as possible. Building a websnacks static site should be reliable, safe, and easy, requiring little to no maintenance and upkeep. If it builds today it should build two years from now.
|
||||
|
||||
## Limitations & Future Improvements
|
||||
|
||||
websnacks is deliberately limited in what it tries to achieve and does so to avoid unnecessary complexity in its design and implementation. That said, there are a few improvements that may be worth exploring in the future:
|
||||
|
||||
- **Client-Side JavaScript** Many static sites need at least basic client-side scripting, and although you can just use webpack/parcel/rollup to bundle your scripts out of band it would be nice to integrate some kind of bundling system into websnacks to handle most simple use cases. Needs a lot of careful thought and research to do right though.
|
||||
- **Hot Reloading** The dev server's live-reloading may be replaced with hot-reloading in the near future as long as it doesn't add too much additional complexity.
|
||||
- **Stricter JSX Typings** websnacks' current JSX TypeScript typings are based on Preact's JSX typings, which provide for basic type-checking of HTML attributes and generally allow any attribute on any HTML element. In the future, websnacks may adopt more strict JSX HTML element typings to prevent adding nonsense attributes to unsupported elements, string literal typings for enumerations, etc., but this will need to be balanced for forward compatibility with HTML spec changes as well.
|
||||
- **Image Resizing/srcset Support** Modern static websites increasingly have need for optimally-sized images for different media and screen resolutions, and some kind of declarative, automated image resizing solution might be a justified addition to websnacks as long as it doesn't add too much complexity.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Several excellent React server-side rendered (SSR) application frameworks exist with good community support and proven track records, and they are well worth evaluating for your static site project especially if you need lots of client-side scripting. The tradeoff with all of these frameworks, however, is a massive dependency tree spanning hundreds to even thousands of packages, and flexible yet often complex configuration and build systems that require non-trivial development and upkeep effort.
|
||||
|
||||
**NOTE:** Dependency counts include all direct and transient dependencies installed via `npm i` with a fresh package.json on MacOS with only the base package declared as a dependency (no plugins/extensions). Current as of 2020-05-25.
|
||||
|
||||
- **[react-static](https://github.com/react-static/react-static) (~1,232 Dependencies)** A well-designed, developer-friendly framework for generating static sites via React SSR. Very flexible with an intuitive and sane configuration system. Plugins add additional support for TypeScript and popular CSS-in-JS libraries.
|
||||
- **[next](https://github.com/zeit/next.js/) (~810 Dependencies)** A fully-featured, enterprise-quality React web application framework that includes a robust sever-side rendering system, TypeScript typings, and a client-side routing solution with efficient data loading and prefetching. Very impressive framework that is well-suited for interactive webapps and static sites that can make good use of client-side React.
|
||||
- **[gatsby](https://github.com/gatsbyjs/gatsby) (~1,838 Dependencies)** Data-centric, React SSR static-site generator that leverages GraphQL-like queries to fetch data for static page generation. A large community of plugins exist for various use cases which makes gatsby very flexible. Plugin quality tends to vary significantly, however, and there have been persistent performance and memory issues with gatsby's dev server and build pipeline on larger sites (>= 100k pages).
|
||||
5
bin/websnacks.js
Executable file
5
bin/websnacks.js
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const { main } = require("../dist/cli");
|
||||
|
||||
main();
|
||||
25
examples/personal-site/components/header.tsx
Normal file
25
examples/personal-site/components/header.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { stylesheet } from "typestyle";
|
||||
import { Component, createElement } from "websnacks";
|
||||
|
||||
const styles = stylesheet({
|
||||
header: {
|
||||
background: "#6c42bd",
|
||||
color: "#fff",
|
||||
padding: "32px",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 1px 8px -3px #000",
|
||||
},
|
||||
headline: {
|
||||
fontSize: "28px",
|
||||
},
|
||||
});
|
||||
|
||||
export interface HeaderProps {
|
||||
headline: string;
|
||||
}
|
||||
|
||||
export const Header: Component<HeaderProps> = ({ headline }) => (
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.headline}>{headline}</h1>
|
||||
</header>
|
||||
);
|
||||
68
examples/personal-site/components/layout.tsx
Normal file
68
examples/personal-site/components/layout.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { normalize } from "csstips";
|
||||
import { stylesheet } from "typestyle";
|
||||
import { Component, createElement } from "websnacks";
|
||||
|
||||
import { stylesheetPath } from "../config";
|
||||
import { Header } from "./header";
|
||||
import { Navbar } from "./navbar";
|
||||
|
||||
normalize();
|
||||
|
||||
const styles = stylesheet({
|
||||
html: {
|
||||
height: "100%",
|
||||
},
|
||||
wrapper: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
margin: 0,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
},
|
||||
mainBody: {
|
||||
padding: "16px",
|
||||
},
|
||||
navbar: {
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
zIndex: 9,
|
||||
},
|
||||
});
|
||||
|
||||
const SITE_TITLE = "Example Site";
|
||||
|
||||
export interface LayoutProps {
|
||||
headline?: string;
|
||||
}
|
||||
|
||||
export const Layout: Component<LayoutProps> = ({ children, headline }) => (
|
||||
<html className={styles.html} lang="en-US">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>
|
||||
{SITE_TITLE}
|
||||
{headline && ` | ${headline}`}
|
||||
</title>
|
||||
<meta name="description" content="" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<link rel="stylesheet" href={stylesheetPath} />
|
||||
</head>
|
||||
|
||||
<body className={styles.wrapper}>
|
||||
<div className={styles.navbar}>
|
||||
<Navbar />
|
||||
</div>
|
||||
|
||||
<main className={styles.main}>
|
||||
<Header headline={headline || SITE_TITLE} />
|
||||
|
||||
<div className={styles.mainBody}>{children}</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
43
examples/personal-site/components/navbar.tsx
Normal file
43
examples/personal-site/components/navbar.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { stylesheet } from "typestyle";
|
||||
import { Component, createElement } from "websnacks";
|
||||
|
||||
const styles = stylesheet({
|
||||
navbar: {
|
||||
minWidth: "140px",
|
||||
borderRight: "1px solid #ddd",
|
||||
background: "#fff",
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
borderBottom: "1px solid #333",
|
||||
padding: "6px",
|
||||
margin: "0 4px",
|
||||
fontSize: "18px",
|
||||
},
|
||||
linksList: {
|
||||
padding: "3px 16px 0",
|
||||
},
|
||||
linksListItem: {
|
||||
padding: "6px",
|
||||
},
|
||||
});
|
||||
|
||||
const links = [
|
||||
{ title: "Home", href: "/" },
|
||||
{ title: "Projects", href: "/projects" },
|
||||
];
|
||||
|
||||
export const Navbar: Component = () => (
|
||||
<nav className={styles.navbar}>
|
||||
<h2 className={styles.sectionTitle}>Navigation</h2>
|
||||
|
||||
<ol className={styles.linksList}>
|
||||
{links.map(({ title, href }) => (
|
||||
<li className={styles.linksListItem}>
|
||||
<a href={href}>{title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
1
examples/personal-site/config.ts
Normal file
1
examples/personal-site/config.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const stylesheetPath = "/styles.css";
|
||||
1263
examples/personal-site/package-lock.json
generated
Normal file
1263
examples/personal-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
13
examples/personal-site/package.json
Normal file
13
examples/personal-site/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "websnacks-example-personal-site",
|
||||
"scripts": {
|
||||
"build": "websnacks -r ts-node/register build",
|
||||
"dev": "websnacks -r ts-node/register dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"csstips": "^1.2.0",
|
||||
"ts-node": "^8.10.1",
|
||||
"typestyle": "^2.1.0",
|
||||
"websnacks": "../../"
|
||||
}
|
||||
}
|
||||
22
examples/personal-site/pages/index.tsx
Normal file
22
examples/personal-site/pages/index.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Component, createElement } from "websnacks";
|
||||
|
||||
import { Layout } from "../components/layout";
|
||||
|
||||
export const page: Component = () => (
|
||||
<Layout>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur
|
||||
dapibus condimentum mauris et egestas. Quisque orci nulla, consequat
|
||||
at erat laoreet, malesuada sodales nisi. Sed in lorem semper lorem
|
||||
placerat fermentum a id arcu. Curabitur non aliquam tellus, sed
|
||||
auctor lacus. Nunc sit amet lectus ultrices, sodales nisl sit amet,
|
||||
luctus nisl. Nunc mollis imperdiet quam, eget sollicitudin leo
|
||||
tincidunt vel. Duis felis dui, imperdiet aliquam bibendum sed,
|
||||
auctor et dolor. Vivamus odio ipsum, venenatis in felis sed, aliquam
|
||||
dictum turpis. Pellentesque pellentesque consequat neque, id
|
||||
imperdiet diam molestie nec. Nullam ut vestibulum est. Pellentesque
|
||||
orci urna, porta vel porta quis, semper ut enim. Donec sit amet urna
|
||||
arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
|
||||
</p>
|
||||
</Layout>
|
||||
);
|
||||
26
examples/personal-site/pages/projects.tsx
Normal file
26
examples/personal-site/pages/projects.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { stylesheet } from "typestyle";
|
||||
import { Component, createElement } from "websnacks";
|
||||
|
||||
import { Layout } from "../components/layout";
|
||||
|
||||
const styles = stylesheet({
|
||||
projectsGrid: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
width: "25%",
|
||||
},
|
||||
});
|
||||
|
||||
export const page: Component = () => (
|
||||
<Layout>
|
||||
<h1>Projects</h1>
|
||||
|
||||
<div className={styles.projectsGrid}>
|
||||
<div>Project 1</div>
|
||||
<div>Project 2</div>
|
||||
<div>Project 3</div>
|
||||
<div>Project 4</div>
|
||||
<div>Project 5</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
2
examples/personal-site/static/robots.txt
Normal file
2
examples/personal-site/static/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
17
examples/personal-site/tsconfig.json
Normal file
17
examples/personal-site/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "createElement",
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["components/**/*", "pages/**/*"]
|
||||
}
|
||||
26
examples/personal-site/websnacks.ts
Normal file
26
examples/personal-site/websnacks.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import { Config } from "websnacks";
|
||||
|
||||
import { stylesheetPath } from "./config";
|
||||
|
||||
const config: Config = {
|
||||
// Watch additional files and folders for changes when the dev server is
|
||||
// running.
|
||||
watch: ["components/", "config.ts"],
|
||||
// Hooks to execute after certain rendering events. Currently only
|
||||
// afterSiteRender is supported.
|
||||
hooks: {
|
||||
async afterSiteRender({ outDir }): Promise<void> {
|
||||
// NOTE: we dynamically import typestyle so that the global style
|
||||
// registry is properly updated once all pages are reloaded in
|
||||
// dev. We could also create a typestyle object in config.ts,
|
||||
// or even multiple objects to split up our styles into e.g. a
|
||||
// critical-path.css and noncrticial.css.
|
||||
const { getStyles } = await import("typestyle");
|
||||
const styles = getStyles();
|
||||
await fs.writeFile(path.join(outDir, stylesheetPath), styles);
|
||||
},
|
||||
},
|
||||
};
|
||||
export = config;
|
||||
16
index.d.ts
vendored
Normal file
16
index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export * from "./dist";
|
||||
|
||||
import { HTMLElement } from "./dist";
|
||||
import { IntrinsicElements as JsxIntrinsics } from "./dist/jsx";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = HTMLElement;
|
||||
type IntrinsicElements = JsxIntrinsics;
|
||||
}
|
||||
}
|
||||
1293
package-lock.json
generated
Normal file
1293
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
package.json
Normal file
43
package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "websnacks",
|
||||
"description": "Minimal dependency server-side JSX for static sites",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "M. George Hansen",
|
||||
"email": "mgeorge@technopolitica.com"
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "types.d.ts",
|
||||
"bin": "bin/websnacks.js",
|
||||
"files": [
|
||||
"/bin/websnacks.js",
|
||||
"/dist/**/*.js",
|
||||
"/dist/**/*.d.ts",
|
||||
"/dist/**/*.map",
|
||||
"/src/**/*.ts",
|
||||
"/index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "ts-node scripts/clean.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "~12",
|
||||
"@types/ws": "^7.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "^2.24.0",
|
||||
"@typescript-eslint/parser": "^2.24.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"ts-node": "^8.10.1",
|
||||
"typescript": "~3.8.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-watch": "^0.6.3",
|
||||
"ws": "^7.2.5"
|
||||
}
|
||||
}
|
||||
12
scripts/clean.ts
Normal file
12
scripts/clean.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
const DIST_DIR = path.join(ROOT_DIR, "dist");
|
||||
|
||||
fs.rmdirSync(DIST_DIR, { recursive: true });
|
||||
90
src/build.ts
Normal file
90
src/build.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import { Config, ConfigPaths } from "./config";
|
||||
import { renderPage } from "./render";
|
||||
import { purgeModuleAndDepsFromCache, walkDir } from "./utils";
|
||||
|
||||
const renderPagesToHtml = async ({
|
||||
pagesDir,
|
||||
outDir,
|
||||
}: ConfigPaths): Promise<void> => {
|
||||
const deferred = [];
|
||||
for await (const srcPath of walkDir(pagesDir)) {
|
||||
const ext = path.extname(srcPath);
|
||||
if (ext !== ".tsx") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure that we don't cache page modules when running in dev server.
|
||||
purgeModuleAndDepsFromCache(srcPath);
|
||||
const pageSrc = require(srcPath);
|
||||
if (!("page" in pageSrc)) {
|
||||
throw new Error(
|
||||
`page source at ${srcPath} does not export a "page" variable`
|
||||
);
|
||||
}
|
||||
let compiledHtml;
|
||||
try {
|
||||
compiledHtml = renderPage(pageSrc.page());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`failed to compile ${srcPath}: ${error.stack ?? error}`
|
||||
);
|
||||
}
|
||||
const relPath = path.relative(pagesDir, path.dirname(srcPath));
|
||||
let baseName = path.basename(srcPath, ".tsx");
|
||||
if (baseName === "index") {
|
||||
baseName = "";
|
||||
}
|
||||
const destPath = path.join(outDir, relPath, baseName, "index.html");
|
||||
deferred.push(
|
||||
(async () => {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.writeFile(destPath, compiledHtml);
|
||||
})()
|
||||
);
|
||||
}
|
||||
await Promise.all(deferred);
|
||||
};
|
||||
|
||||
const copyStaticAssets = async ({
|
||||
staticAssetsDir,
|
||||
outDir,
|
||||
}: ConfigPaths): Promise<void> => {
|
||||
try {
|
||||
await fs.access(staticAssetsDir);
|
||||
} catch (error) {
|
||||
// Static assets folder doesn't exist, so no-op.
|
||||
return;
|
||||
}
|
||||
|
||||
const deferred = [];
|
||||
for await (const assetPath of walkDir(staticAssetsDir)) {
|
||||
const relPath = path.relative(staticAssetsDir, assetPath);
|
||||
const destPath = path.join(outDir, relPath);
|
||||
deferred.push(
|
||||
(async () => {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.copyFile(assetPath, destPath);
|
||||
})()
|
||||
);
|
||||
}
|
||||
await Promise.all(deferred);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully render a websnacks site into a directory ready for serving by a static
|
||||
* host.
|
||||
*
|
||||
* @param config Configuration for the site.
|
||||
*/
|
||||
export const renderSite = async ({ paths, hooks }: Config): Promise<void> => {
|
||||
await Promise.all([renderPagesToHtml(paths), copyStaticAssets(paths)]);
|
||||
await hooks.afterSiteRender(paths);
|
||||
};
|
||||
45
src/cli/commands/build.ts
Normal file
45
src/cli/commands/build.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { renderSite } from "../../build";
|
||||
import { loadConfig } from "../../config";
|
||||
import { Command, UsageError } from "../types";
|
||||
|
||||
const helpText = `\
|
||||
Usage: websnacks build [ROOT_DIR]
|
||||
|
||||
Compile a site using websnacks JSX templates into a fully-functional,
|
||||
production-ready static site.
|
||||
|
||||
Args:
|
||||
ROOT_DIR Path to the websnacks project root directory.
|
||||
`;
|
||||
|
||||
interface BuildArgs {
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
const parseArgs = (args: string[]): BuildArgs => {
|
||||
if (args.length > 1) {
|
||||
throw new UsageError("too many arguments provided", helpText);
|
||||
}
|
||||
return {
|
||||
rootDir: args[0] || process.cwd(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build command used to build a websnacks site into a production-ready set of
|
||||
* static files.
|
||||
*/
|
||||
const buildCommand: Command = {
|
||||
execute: async (args: string[]): Promise<void> => {
|
||||
const { rootDir } = parseArgs(args);
|
||||
const config = await loadConfig(rootDir);
|
||||
await renderSite(config);
|
||||
},
|
||||
helpText,
|
||||
};
|
||||
export = buildCommand;
|
||||
268
src/cli/commands/dev.ts
Normal file
268
src/cli/commands/dev.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { existsSync, promises as fs, watch } from "fs";
|
||||
import * as http from "http";
|
||||
import * as path from "path";
|
||||
|
||||
import { renderSite } from "../../build";
|
||||
import { Config, loadConfig } from "../../config";
|
||||
import { Command, UsageError } from "../types";
|
||||
|
||||
const SERVER_PORT = 8080;
|
||||
|
||||
const injectLiveReloadScript = (htmlContents: string): string =>
|
||||
htmlContents.replace(
|
||||
"</html>",
|
||||
`
|
||||
<script>
|
||||
const ws = new WebSocket("ws://127.0.0.1:${SERVER_PORT}");
|
||||
ws.onmessage = function() {
|
||||
console.log('dev server requested reload, reloading...');
|
||||
location.reload();
|
||||
};
|
||||
</script>
|
||||
</html>
|
||||
`
|
||||
);
|
||||
|
||||
const guessMimeType = (ext: string): string => {
|
||||
let mimeType;
|
||||
switch (ext) {
|
||||
case ".apng":
|
||||
mimeType = "image/apng";
|
||||
break;
|
||||
case ".bmp":
|
||||
mimeType = "image/bmp";
|
||||
break;
|
||||
case ".css":
|
||||
mimeType = "text/css";
|
||||
break;
|
||||
case ".eot":
|
||||
mimeType = "application/vnd.ms-fontobject";
|
||||
break;
|
||||
case ".gif":
|
||||
mimeType = "image/gif";
|
||||
break;
|
||||
case ".htm":
|
||||
case ".html":
|
||||
mimeType = "text/html";
|
||||
break;
|
||||
case ".ico":
|
||||
mimeType = "image/vnd.microsoft.icon";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
mimeType = "text/javascript";
|
||||
break;
|
||||
case ".mp3":
|
||||
mimeType = "audio/mpeg";
|
||||
break;
|
||||
case ".mpeg":
|
||||
mimeType = "video/mpeg";
|
||||
break;
|
||||
case ".oga":
|
||||
mimeType = "audio/ogg";
|
||||
break;
|
||||
case ".ogv":
|
||||
mimeType = "video/ogg";
|
||||
break;
|
||||
case ".otf":
|
||||
mimeType = "font/otf";
|
||||
break;
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case ".svg":
|
||||
mimeType = "image/svg+xml";
|
||||
break;
|
||||
case ".txt":
|
||||
mimeType = "text/plain";
|
||||
break;
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
mimeType = "image/tiff";
|
||||
break;
|
||||
case ".ttf":
|
||||
mimeType = "font/ttf";
|
||||
break;
|
||||
case ".wav":
|
||||
mimeType = "audio/wav";
|
||||
break;
|
||||
case ".weba":
|
||||
mimeType = "audio/webm";
|
||||
break;
|
||||
case ".webm":
|
||||
mimeType = "video/webm";
|
||||
break;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
case ".woff":
|
||||
mimeType = "font/woff";
|
||||
break;
|
||||
case ".woff2":
|
||||
mimeType = "font/woff2";
|
||||
break;
|
||||
default:
|
||||
// Default to binary mimetype which most browsers will be able to
|
||||
// correctly interpret in the right context.
|
||||
mimeType = "application/octet-stream";
|
||||
}
|
||||
return mimeType;
|
||||
};
|
||||
|
||||
const serve = (publicDir: string): http.Server => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url == null) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let reqExt = path.extname(req.url);
|
||||
let reqPath = req.url;
|
||||
if (!reqExt) {
|
||||
reqPath = path.join(reqPath, "index.html");
|
||||
reqExt = ".html";
|
||||
}
|
||||
|
||||
let contents;
|
||||
try {
|
||||
contents = await fs.readFile(path.join(publicDir, reqPath));
|
||||
} catch (error) {
|
||||
console.error(`unable to load file ${reqPath}`);
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const mimeType = guessMimeType(reqExt);
|
||||
if (mimeType === "text/html") {
|
||||
contents = injectLiveReloadScript(contents.toString("utf8"));
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": mimeType,
|
||||
});
|
||||
res.end(contents);
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
const startWebSocketServer = async (
|
||||
server: http.Server
|
||||
): Promise<import("ws").Server | undefined> => {
|
||||
// Attempt to load the ws module, aborting if it isn't available.
|
||||
let ws;
|
||||
try {
|
||||
ws = await import("ws");
|
||||
} catch (error) {
|
||||
if (error.code !== "MODULE_NOT_FOUND") {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`'ws' module not found, live-reloading will be disabled`);
|
||||
return;
|
||||
}
|
||||
const wsServer = new ws.Server({ server });
|
||||
wsServer.on("connection", () => {
|
||||
console.log("connected to dev site");
|
||||
});
|
||||
return wsServer;
|
||||
};
|
||||
|
||||
const watchFolders = async (
|
||||
folders: string[],
|
||||
listener: (eventType: "update" | "remove", fileName: string) => void
|
||||
): Promise<void> => {
|
||||
// Try to load node-watch, falling back to fs watch if node-watch isn't
|
||||
// available.
|
||||
try {
|
||||
const { default: watch } = await import("node-watch");
|
||||
watch(folders, { recursive: true }, listener);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.code !== "MODULE_NOT_FOUND") {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`'node-watch' module not found, falling back to fs.watch (may ` +
|
||||
`result in file watch issues on some OSes)`
|
||||
);
|
||||
}
|
||||
// NOTE: fs.watch has significant cross-platform issues, including
|
||||
// triggering duplicate file events on some systems.
|
||||
for (const folder of folders) {
|
||||
watch(folder, { recursive: true }, (_, fileName) => {
|
||||
listener("update", fileName);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const helpText = `\
|
||||
Usage: websnacks dev [ROOT_DIR]
|
||||
|
||||
Start a live-reloading dev server for a websnacks project.
|
||||
|
||||
Args:
|
||||
ROOT_DIR Path to the websnacks project root directory.
|
||||
`;
|
||||
|
||||
interface DevArgs {
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
const parseArgs = (args: string[]): DevArgs | null => {
|
||||
if (args.length > 1) {
|
||||
throw new UsageError("too many arguments provided", helpText);
|
||||
}
|
||||
return {
|
||||
rootDir: args[0] || process.cwd(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Command to start up a live-reloading development server to allow for fast
|
||||
* local development of a websnacks site. The dev server aims to mimic a
|
||||
* production static hosting environment as closely as possible.
|
||||
*/
|
||||
const devCommand: Command = {
|
||||
async execute(args: string[]): Promise<void> {
|
||||
const parsedArgs = parseArgs(args);
|
||||
if (!parsedArgs) {
|
||||
return;
|
||||
}
|
||||
const { rootDir } = parsedArgs;
|
||||
const rebuild = async (): Promise<Config> => {
|
||||
const config = await loadConfig(rootDir);
|
||||
await renderSite(config);
|
||||
return config;
|
||||
};
|
||||
const config = await rebuild();
|
||||
const { outDir } = config.paths;
|
||||
const httpServer = serve(outDir);
|
||||
const wsServer = await startWebSocketServer(httpServer);
|
||||
httpServer.listen(SERVER_PORT, () => {
|
||||
console.log(`Listening at http://127.0.0.1:${SERVER_PORT}`);
|
||||
});
|
||||
const watchedFolders = config.watch.filter((filePath) =>
|
||||
existsSync(filePath)
|
||||
);
|
||||
watchFolders(watchedFolders, async (event, filePath) => {
|
||||
console.log(`${filePath}:${event} triggering rebuild...`);
|
||||
await rebuild();
|
||||
if (wsServer != null) {
|
||||
console.log(`rebuild finished, reloading browsers...`);
|
||||
for (const ws of wsServer.clients) {
|
||||
ws.send("reload");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
helpText,
|
||||
};
|
||||
export = devCommand;
|
||||
113
src/cli/index.ts
Normal file
113
src/cli/index.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Command, UsageError } from "./types";
|
||||
|
||||
const globalHelpText = `\
|
||||
Usage: websnacks [...globalOptions] <command>
|
||||
|
||||
Global Options:
|
||||
-r|--require <module> Module to require before executing the command. May
|
||||
be specified more than once to load multiple modules.
|
||||
|
||||
Commands:
|
||||
build Build a static site that uses websnacks templates.
|
||||
dev Start the live-reloading development server for a
|
||||
site.
|
||||
`;
|
||||
|
||||
interface Options {
|
||||
showHelp: boolean;
|
||||
require: string[];
|
||||
}
|
||||
|
||||
const parseArgs = (
|
||||
args: string[]
|
||||
): { options: Options; commandName?: string; commandArgs: string[] } => {
|
||||
const options: Options = {
|
||||
showHelp: false,
|
||||
require: [],
|
||||
};
|
||||
// Look ahead for the first argument that doesn't start with a "-" to
|
||||
// indicate the end of option parsing.
|
||||
while (args.length > 0 && args[0].indexOf("-") >= 0) {
|
||||
const opt = args.shift();
|
||||
switch (opt) {
|
||||
case "-h":
|
||||
case "--help":
|
||||
options.showHelp = true;
|
||||
break;
|
||||
case "-r":
|
||||
case "--require":
|
||||
const moduleName = args.shift();
|
||||
if (moduleName == null) {
|
||||
throw new UsageError(
|
||||
`-r requires a valid module name`,
|
||||
globalHelpText
|
||||
);
|
||||
}
|
||||
options.require.push(moduleName);
|
||||
break;
|
||||
default:
|
||||
throw new UsageError(`unknown option ${opt}`, globalHelpText);
|
||||
}
|
||||
}
|
||||
const commandName = args.shift();
|
||||
return { options, commandName, commandArgs: args };
|
||||
};
|
||||
|
||||
const _main = async (args: string[]): Promise<void> => {
|
||||
const { options, commandName, commandArgs } = parseArgs(args);
|
||||
if (options.showHelp) {
|
||||
console.log(`${globalHelpText}\n`);
|
||||
return;
|
||||
}
|
||||
if (commandName == null) {
|
||||
throw new UsageError(`must specify a valid command`, globalHelpText);
|
||||
}
|
||||
for (const moduleName of options.require) {
|
||||
await import(moduleName);
|
||||
}
|
||||
|
||||
let command: Command;
|
||||
switch (commandName) {
|
||||
case "build":
|
||||
command = await import("./commands/build");
|
||||
break;
|
||||
case "dev":
|
||||
command = await import("./commands/dev");
|
||||
break;
|
||||
default:
|
||||
throw new UsageError(
|
||||
`unknown command ${commandName}`,
|
||||
globalHelpText
|
||||
);
|
||||
}
|
||||
// NOTE: Should this just delegate to the command?
|
||||
for (const arg of commandArgs) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(`${command.helpText}\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await command.execute(commandArgs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Entrypoint of the CLI app.
|
||||
*/
|
||||
export const main = (): void => {
|
||||
_main(process.argv.slice(2)).catch((error) => {
|
||||
if (error instanceof UsageError) {
|
||||
console.error(`Error: ${error.message}\n`);
|
||||
console.log(`${error.helpText}\n`);
|
||||
} else {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.stack : JSON.stringify(error);
|
||||
console.error(`Unexpected error: ${errorMsg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
34
src/cli/types.ts
Normal file
34
src/cli/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CLI command representing an action that the CLI program supports.
|
||||
*/
|
||||
export interface Command {
|
||||
/**
|
||||
* Execute the command with the specified arguments.
|
||||
*
|
||||
* @param args List of CLI arguments to pass to the command.
|
||||
*/
|
||||
execute(args: string[]): Promise<void>;
|
||||
/**
|
||||
* Help text for this command.
|
||||
*/
|
||||
helpText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error that commands can issue to indicate incorrect usage along with help
|
||||
* text to guide the user to correct their mistake.
|
||||
*/
|
||||
export class UsageError extends Error {
|
||||
public readonly helpText: string;
|
||||
|
||||
public constructor(message: string, helpText: string) {
|
||||
super(message);
|
||||
|
||||
this.helpText = helpText;
|
||||
}
|
||||
}
|
||||
39
src/component.ts
Normal file
39
src/component.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An in-memory representation of a renderable HTML element.
|
||||
*/
|
||||
export interface HTMLElement {
|
||||
/**
|
||||
* Name of the tag that gets output upon rendering.
|
||||
*/
|
||||
tag: string;
|
||||
/**
|
||||
* Record of attribute names and values that should be output in the opening
|
||||
* tag.
|
||||
*/
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
/**
|
||||
* Child elements to render nested within this HTML element.
|
||||
*/
|
||||
children: Element[];
|
||||
}
|
||||
|
||||
/**
|
||||
* All valid types of elements that can be rendered to HTML.
|
||||
*/
|
||||
export type Element = HTMLElement | string | boolean | undefined | null;
|
||||
|
||||
/**
|
||||
* Custom HTMLElement factory that can be parameterized by props.
|
||||
*/
|
||||
export interface Component<P extends object = {}> {
|
||||
(
|
||||
props: P & {
|
||||
children?: Element[];
|
||||
}
|
||||
): HTMLElement;
|
||||
}
|
||||
85
src/config.ts
Normal file
85
src/config.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
|
||||
import { purgeModuleAndDepsFromCache } from "./utils";
|
||||
|
||||
/**
|
||||
* Paths used during configuration.
|
||||
*/
|
||||
export interface ConfigPaths {
|
||||
rootDir: string;
|
||||
outDir: string;
|
||||
pagesDir: string;
|
||||
staticAssetsDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks that allow user code to customize site rendering.
|
||||
*/
|
||||
export interface Hooks {
|
||||
/**
|
||||
* Hook that fires at the end of site rendering one all pages and assets are
|
||||
* fully rendered.
|
||||
*/
|
||||
afterSiteRender(context: ConfigPaths): Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-provided configuration options.
|
||||
*/
|
||||
export type UserConfig = {
|
||||
/** Hook implementations that allow customizing the rendering process. */
|
||||
hooks?: Partial<Hooks>;
|
||||
/** Additional folders and files to watch by the development server. */
|
||||
watch?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully-realized configuration for a websnacks site.
|
||||
*/
|
||||
export interface Config {
|
||||
paths: ConfigPaths;
|
||||
hooks: Hooks;
|
||||
watch: string[];
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Load configuration from a websnacks.ts/js file.
|
||||
*
|
||||
* @param rootDir Path to the directory where the websnacks.ts/js config file.
|
||||
*
|
||||
* @return Fully-realized configuration.
|
||||
*/
|
||||
export const loadConfig = async (rootDir: string): Promise<Config> => {
|
||||
const configPath = require.resolve(path.resolve(rootDir, "websnacks"));
|
||||
purgeModuleAndDepsFromCache(configPath);
|
||||
// TODO: validate user config.
|
||||
const userConfig = await import(configPath);
|
||||
const outDir = path.join(rootDir, "public");
|
||||
const pagesDir = path.join(rootDir, "pages");
|
||||
const staticAssetsDir = path.join(rootDir, "static");
|
||||
return {
|
||||
paths: {
|
||||
rootDir,
|
||||
outDir,
|
||||
pagesDir,
|
||||
staticAssetsDir,
|
||||
},
|
||||
hooks: {
|
||||
afterSiteRender: noop,
|
||||
...userConfig.hooks,
|
||||
},
|
||||
watch: [
|
||||
...userConfig.watch.map((p: string) => path.relative(rootDir, p)),
|
||||
path.relative(rootDir, configPath),
|
||||
pagesDir,
|
||||
staticAssetsDir,
|
||||
],
|
||||
};
|
||||
};
|
||||
54
src/create-element.ts
Normal file
54
src/create-element.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Element, HTMLElement } from "./component";
|
||||
import { HTMLAttributes } from "./jsx";
|
||||
|
||||
/**
|
||||
* Create an HTMLElement from a custom Component.
|
||||
*
|
||||
* @param comp Component responsible for constructing the HTMLElement.
|
||||
* @param props Properties passed to the Component that parameterize the
|
||||
* HTMLElement construction.
|
||||
* @param children Child elements that exist under this component.
|
||||
*
|
||||
* @return Fully-realized HTMLElement, ready for rendering.
|
||||
*/
|
||||
export function createElement<P extends object>(
|
||||
comp: Component<P>,
|
||||
props: P,
|
||||
...children: Element[]
|
||||
): HTMLElement;
|
||||
/**
|
||||
* Create an HTMLElement from a standard HTML5 tag.
|
||||
*
|
||||
* @param tag Lower-case name of the HTML5 tag this HTML element represents.
|
||||
* @param attrs Standard HTML5 attributes to add to the resulting tag when
|
||||
* rendered.
|
||||
* @param children Child elements that exist under this tag.
|
||||
*
|
||||
* @return Fully-realized HTMLElement, ready for rendering.
|
||||
*/
|
||||
export function createElement(
|
||||
tag: string,
|
||||
attrs: HTMLAttributes | null,
|
||||
...children: Element[]
|
||||
): HTMLElement;
|
||||
export function createElement(
|
||||
type: string | Component<any>,
|
||||
props: (HTMLAttributes & Record<string, any>) | null,
|
||||
...children: Element[]
|
||||
): HTMLElement {
|
||||
// Flatten the children array so we can accept arrays as children.
|
||||
const normalizedChildren = children.flat();
|
||||
if (type instanceof Function) {
|
||||
return type({ ...props, children: normalizedChildren });
|
||||
}
|
||||
|
||||
if (type !== type.toLowerCase()) {
|
||||
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
|
||||
}
|
||||
return { tag: type, attributes: props || {}, children: normalizedChildren };
|
||||
}
|
||||
8
src/index.ts
Normal file
8
src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export { HTMLElement, Component } from "./component";
|
||||
export { UserConfig as Config } from "./config";
|
||||
export { createElement } from "./create-element";
|
||||
273
src/jsx.ts
Normal file
273
src/jsx.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export interface RdfaAttributes {
|
||||
about?: string;
|
||||
datatype?: string;
|
||||
inlist?: any;
|
||||
prefix?: string;
|
||||
property?: string;
|
||||
resource?: string;
|
||||
typeof?: string;
|
||||
vocab?: string;
|
||||
}
|
||||
|
||||
export interface MicrodataAttributes {
|
||||
itemProp?: string;
|
||||
itemScope?: boolean;
|
||||
itemType?: string;
|
||||
itemID?: string;
|
||||
itemRef?: string;
|
||||
}
|
||||
|
||||
export interface HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
|
||||
accept?: string;
|
||||
acceptCharset?: string;
|
||||
accessKey?: string;
|
||||
action?: string;
|
||||
allowFullScreen?: boolean;
|
||||
allowTransparency?: boolean;
|
||||
alt?: string;
|
||||
as?: string;
|
||||
async?: boolean;
|
||||
autoComplete?: string;
|
||||
autoCorrect?: string;
|
||||
autoFocus?: boolean;
|
||||
autoPlay?: boolean;
|
||||
capture?: boolean;
|
||||
cellPadding?: number | string;
|
||||
cellSpacing?: number | string;
|
||||
charSet?: string;
|
||||
challenge?: string;
|
||||
checked?: boolean;
|
||||
class?: string;
|
||||
className?: string;
|
||||
cols?: number;
|
||||
colSpan?: number;
|
||||
content?: string;
|
||||
contentEditable?: boolean;
|
||||
contextMenu?: string;
|
||||
controls?: boolean;
|
||||
controlsList?: string;
|
||||
coords?: string;
|
||||
crossOrigin?: string;
|
||||
data?: string;
|
||||
dateTime?: string;
|
||||
default?: boolean;
|
||||
defer?: boolean;
|
||||
dir?: "auto" | "rtl" | "ltr";
|
||||
disabled?: boolean;
|
||||
disableRemotePlayback?: boolean;
|
||||
download?: any;
|
||||
draggable?: boolean;
|
||||
encType?: string;
|
||||
form?: string;
|
||||
formAction?: string;
|
||||
formEncType?: string;
|
||||
formMethod?: string;
|
||||
formNoValidate?: boolean;
|
||||
formTarget?: string;
|
||||
frameBorder?: number | string;
|
||||
headers?: string;
|
||||
height?: number | string;
|
||||
hidden?: boolean;
|
||||
high?: number;
|
||||
href?: string;
|
||||
hrefLang?: string;
|
||||
for?: string;
|
||||
htmlFor?: string;
|
||||
httpEquiv?: string;
|
||||
icon?: string;
|
||||
id?: string;
|
||||
inputMode?: string;
|
||||
integrity?: string;
|
||||
is?: string;
|
||||
keyParams?: string;
|
||||
keyType?: string;
|
||||
kind?: string;
|
||||
label?: string;
|
||||
lang?: string;
|
||||
list?: string;
|
||||
loop?: boolean;
|
||||
low?: number;
|
||||
manifest?: string;
|
||||
marginHeight?: number;
|
||||
marginWidth?: number;
|
||||
max?: number | string;
|
||||
maxLength?: number;
|
||||
media?: string;
|
||||
mediaGroup?: string;
|
||||
method?: string;
|
||||
min?: number | string;
|
||||
minLength?: number;
|
||||
multiple?: boolean;
|
||||
muted?: boolean;
|
||||
name?: string;
|
||||
nonce?: string;
|
||||
noValidate?: boolean;
|
||||
open?: boolean;
|
||||
optimum?: number;
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
playsInline?: boolean;
|
||||
poster?: string;
|
||||
preload?: string;
|
||||
radioGroup?: string;
|
||||
readOnly?: boolean;
|
||||
rel?: string;
|
||||
required?: boolean;
|
||||
role?: string;
|
||||
rows?: number;
|
||||
rowSpan?: number;
|
||||
sandbox?: string;
|
||||
scope?: string;
|
||||
scoped?: boolean;
|
||||
scrolling?: string;
|
||||
seamless?: boolean;
|
||||
selected?: boolean;
|
||||
shape?: string;
|
||||
size?: number;
|
||||
sizes?: string;
|
||||
slot?: string;
|
||||
span?: number;
|
||||
spellcheck?: boolean;
|
||||
src?: string;
|
||||
srcset?: string;
|
||||
srcDoc?: string;
|
||||
srcLang?: string;
|
||||
srcSet?: string;
|
||||
start?: number;
|
||||
step?: number | string;
|
||||
style?: string | { [key: string]: string | number };
|
||||
summary?: string;
|
||||
tabIndex?: number;
|
||||
target?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
useMap?: string;
|
||||
value?: string | string[] | number;
|
||||
volume?: string | number;
|
||||
width?: number | string;
|
||||
wmode?: string;
|
||||
wrap?: string;
|
||||
}
|
||||
|
||||
export interface IntrinsicElements {
|
||||
a: HTMLAttributes;
|
||||
abbr: HTMLAttributes;
|
||||
address: HTMLAttributes;
|
||||
area: HTMLAttributes;
|
||||
article: HTMLAttributes;
|
||||
aside: HTMLAttributes;
|
||||
audio: HTMLAttributes;
|
||||
b: HTMLAttributes;
|
||||
base: HTMLAttributes;
|
||||
bdi: HTMLAttributes;
|
||||
bdo: HTMLAttributes;
|
||||
big: HTMLAttributes;
|
||||
blockquote: HTMLAttributes;
|
||||
body: HTMLAttributes;
|
||||
br: HTMLAttributes;
|
||||
button: HTMLAttributes;
|
||||
canvas: HTMLAttributes;
|
||||
caption: HTMLAttributes;
|
||||
cite: HTMLAttributes;
|
||||
code: HTMLAttributes;
|
||||
col: HTMLAttributes;
|
||||
colgroup: HTMLAttributes;
|
||||
data: HTMLAttributes;
|
||||
datalist: HTMLAttributes;
|
||||
dd: HTMLAttributes;
|
||||
del: HTMLAttributes;
|
||||
details: HTMLAttributes;
|
||||
dfn: HTMLAttributes;
|
||||
dialog: HTMLAttributes;
|
||||
div: HTMLAttributes;
|
||||
dl: HTMLAttributes;
|
||||
dt: HTMLAttributes;
|
||||
em: HTMLAttributes;
|
||||
embed: HTMLAttributes;
|
||||
fieldset: HTMLAttributes;
|
||||
figcaption: HTMLAttributes;
|
||||
figure: HTMLAttributes;
|
||||
footer: HTMLAttributes;
|
||||
form: HTMLAttributes;
|
||||
h1: HTMLAttributes;
|
||||
h2: HTMLAttributes;
|
||||
h3: HTMLAttributes;
|
||||
h4: HTMLAttributes;
|
||||
h5: HTMLAttributes;
|
||||
h6: HTMLAttributes;
|
||||
head: HTMLAttributes;
|
||||
header: HTMLAttributes;
|
||||
hgroup: HTMLAttributes;
|
||||
hr: HTMLAttributes;
|
||||
html: HTMLAttributes;
|
||||
i: HTMLAttributes;
|
||||
iframe: HTMLAttributes;
|
||||
img: HTMLAttributes;
|
||||
input: HTMLAttributes;
|
||||
ins: HTMLAttributes;
|
||||
kbd: HTMLAttributes;
|
||||
keygen: HTMLAttributes;
|
||||
label: HTMLAttributes;
|
||||
legend: HTMLAttributes;
|
||||
li: HTMLAttributes;
|
||||
link: HTMLAttributes;
|
||||
main: HTMLAttributes;
|
||||
map: HTMLAttributes;
|
||||
mark: HTMLAttributes;
|
||||
marquee: HTMLAttributes;
|
||||
menu: HTMLAttributes;
|
||||
menuitem: HTMLAttributes;
|
||||
meta: HTMLAttributes;
|
||||
meter: HTMLAttributes;
|
||||
nav: HTMLAttributes;
|
||||
noscript: HTMLAttributes;
|
||||
object: HTMLAttributes;
|
||||
ol: HTMLAttributes;
|
||||
optgroup: HTMLAttributes;
|
||||
option: HTMLAttributes;
|
||||
output: HTMLAttributes;
|
||||
p: HTMLAttributes;
|
||||
param: HTMLAttributes;
|
||||
picture: HTMLAttributes;
|
||||
pre: HTMLAttributes;
|
||||
progress: HTMLAttributes;
|
||||
q: HTMLAttributes;
|
||||
rp: HTMLAttributes;
|
||||
rt: HTMLAttributes;
|
||||
ruby: HTMLAttributes;
|
||||
s: HTMLAttributes;
|
||||
samp: HTMLAttributes;
|
||||
script: HTMLAttributes;
|
||||
section: HTMLAttributes;
|
||||
select: HTMLAttributes;
|
||||
slot: HTMLAttributes;
|
||||
small: HTMLAttributes;
|
||||
source: HTMLAttributes;
|
||||
span: HTMLAttributes;
|
||||
strong: HTMLAttributes;
|
||||
style: HTMLAttributes;
|
||||
sub: HTMLAttributes;
|
||||
summary: HTMLAttributes;
|
||||
sup: HTMLAttributes;
|
||||
table: HTMLAttributes;
|
||||
tbody: HTMLAttributes;
|
||||
td: HTMLAttributes;
|
||||
textarea: HTMLAttributes;
|
||||
tfoot: HTMLAttributes;
|
||||
th: HTMLAttributes;
|
||||
thead: HTMLAttributes;
|
||||
time: HTMLAttributes;
|
||||
title: HTMLAttributes;
|
||||
tr: HTMLAttributes;
|
||||
track: HTMLAttributes;
|
||||
u: HTMLAttributes;
|
||||
ul: HTMLAttributes;
|
||||
var: HTMLAttributes;
|
||||
video: HTMLAttributes;
|
||||
wbr: HTMLAttributes;
|
||||
}
|
||||
85
src/render.ts
Normal file
85
src/render.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Element, HTMLElement } from "./component";
|
||||
|
||||
const HTML_ESCAPES: { [char: string]: string } = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
};
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
|
||||
|
||||
const escapeAttr = (text: string): string => text.replace(/"/g, """);
|
||||
|
||||
const renderElement = (elem: Element): string => {
|
||||
// Ignore null and true/false to support nicer JSX conditional syntax with
|
||||
// &&, ||, !! operators.
|
||||
if (elem == null || typeof elem === "boolean") {
|
||||
return "";
|
||||
}
|
||||
if (typeof elem === "string") {
|
||||
return escapeHtml(elem);
|
||||
}
|
||||
|
||||
let output = "";
|
||||
output += startTag(elem);
|
||||
for (const child of elem.children) {
|
||||
output += renderElement(child);
|
||||
}
|
||||
output += endTag(elem);
|
||||
return output;
|
||||
};
|
||||
|
||||
const startTag = (elem: HTMLElement): string => {
|
||||
let output = `<${escapeHtml(elem.tag)}`;
|
||||
|
||||
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
|
||||
// Handle boolean attributes with a false value by not outputting the
|
||||
// attribute at all.
|
||||
if (attrValue === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
|
||||
if (normalizedAttrName === "classname") {
|
||||
normalizedAttrName = "class";
|
||||
}
|
||||
if (attrValue === true) {
|
||||
output += ` ${normalizedAttrName}=""`;
|
||||
} else {
|
||||
output += ` ${normalizedAttrName}="${escapeAttr(
|
||||
attrValue.toString()
|
||||
)}"`;
|
||||
}
|
||||
}
|
||||
|
||||
output += ">";
|
||||
return output;
|
||||
};
|
||||
|
||||
const endTag = (elem: HTMLElement): string => `</${escapeHtml(elem.tag)}>`;
|
||||
|
||||
/**
|
||||
* Render a complete HTML page from an HTMLElement. Note that the root element
|
||||
* must represent a valid HTML tag.
|
||||
*
|
||||
* @param rootElem HTML element representing the root of the document.
|
||||
*
|
||||
* @return Fully rendered HTML document as a string.
|
||||
*/
|
||||
export const renderPage = (rootElem: HTMLElement): string => {
|
||||
if (rootElem.tag.toLowerCase() !== "html") {
|
||||
throw new Error(
|
||||
`attempted to render page with non-HTML root element ${rootElem.tag}`
|
||||
);
|
||||
}
|
||||
|
||||
let output = "<!DOCTYPE html>";
|
||||
output += renderElement(rootElem);
|
||||
return output;
|
||||
};
|
||||
50
src/utils.ts
Normal file
50
src/utils.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Recursively walk a directory, returning the files it finds.
|
||||
*
|
||||
* @param dirPath Path to the directory to walk.
|
||||
*
|
||||
* @return Generator that yields the files found while walking the directory.
|
||||
*/
|
||||
export const walkDir = async function* (
|
||||
dirPath: string
|
||||
): AsyncGenerator<string> {
|
||||
const dirEnts = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const dirEnt of dirEnts) {
|
||||
if (dirEnt.isDirectory()) {
|
||||
yield* walkDir(path.join(dirPath, dirEnt.name));
|
||||
}
|
||||
if (dirEnt.isFile()) {
|
||||
yield path.join(dirPath, dirEnt.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Purge cached versions of a node module and all of its dependencies from the
|
||||
* global require cache, ensuring that future imports reload the module from
|
||||
* disk.
|
||||
*
|
||||
* @param modName Name of the module to purge from the require cache.
|
||||
*/
|
||||
export const purgeModuleAndDepsFromCache = (modName: string): void => {
|
||||
var modPath = require.resolve(modName);
|
||||
if (modPath == null) {
|
||||
return;
|
||||
}
|
||||
const mod = require.cache[modPath];
|
||||
if (mod == null) {
|
||||
return;
|
||||
}
|
||||
for (const child of mod.children) {
|
||||
purgeModuleAndDepsFromCache(child.id);
|
||||
}
|
||||
delete require.cache[modPath];
|
||||
};
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019"],
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue