Start new HTML export logic
continuous-integration/drone/push Build is passing Details

master
Garrett Mills 4 years ago
parent 97d3ef6cae
commit 54d07754ac
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246

@ -0,0 +1,86 @@
/*!
* Start Bootstrap - Simple Sidebar (https://startbootstrap.com/templates/simple-sidebar)
* Copyright 2013-2020 Start Bootstrap
* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-simple-sidebar/blob/master/LICENSE)
*/
#wrapper {
overflow-x: hidden;
}
#sidebar-wrapper {
min-height: 100vh;
margin-left: -15rem;
-webkit-transition: margin .25s ease-out;
-moz-transition: margin .25s ease-out;
-o-transition: margin .25s ease-out;
transition: margin .25s ease-out;
}
#sidebar-wrapper .sidebar-heading {
padding: 0.875rem 1.25rem;
font-size: 1.2rem;
}
#sidebar-wrapper .list-group {
width: 15rem;
}
#page-content-wrapper {
min-width: 100vw;
}
#wrapper.toggled #sidebar-wrapper {
margin-left: 0;
}
@media (min-width: 768px) {
#sidebar-wrapper {
margin-left: 0;
}
#page-content-wrapper {
min-width: 0;
width: 100%;
}
#wrapper.toggled #sidebar-wrapper {
margin-left: -15rem;
}
}
.code-ref {
margin-top: 20px;
margin-bottom: 20px;
height: 600px;
}
.file-ref {
border: 1px solid darkgray;
margin: 20px 0;
border-radius: 5px;
padding: 10px;
display: flex;
flex-wrap: wrap;
}
.file-ref .file {
display: flex;
border: 1px solid lightgray;
padding: 10px;
border-radius: 5px;
margin-right: 10px;
}
.file-ref .file .file-name {
margin-right: 15px;
align-items: center;
display: flex;
}
.file-ref .file .dl-link {
border: 2px solid #007bff;
padding: 0 7px;
border-radius: 4px;
text-decoration: none !important;
}

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ PAGE_TITLE }} | {{ GROUP_TITLE }}</title>
<!-- Bootstrap core CSS -->
<link href="vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<!-- Custom styles for this template -->
<link href="css/simple-sidebar.css" rel="stylesheet">
</head>
<body>
<div class="d-flex" id="wrapper">
<!-- Sidebar -->
<div class="bg-light border-right" id="sidebar-wrapper">
<div class="sidebar-heading">{{ GROUP_TITLE }}</div>
<div class="list-group list-group-flush" id="sidebar-container"></div>
</div>
<!-- /#sidebar-wrapper -->
<!-- Page Content -->
<div id="page-content-wrapper">
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<button class="btn btn-primary" id="menu-toggle">Menu</button>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto mt-2 mt-lg-0">
<!-- <li class="nav-item active">-->
<!-- <a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>-->
<!-- </li>-->
<!-- <li class="nav-item">-->
<!-- <a class="nav-link" href="#">Link</a>-->
<!-- </li>-->
<!-- <li class="nav-item dropdown">-->
<!-- <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">-->
<!-- Dropdown-->
<!-- </a>-->
<!-- <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">-->
<!-- <a class="dropdown-item" href="#">Action</a>-->
<!-- <a class="dropdown-item" href="#">Another action</a>-->
<!-- <div class="dropdown-divider"></div>-->
<!-- <a class="dropdown-item" href="#">Something else here</a>-->
<!-- </div>-->
<!-- </li>-->
</ul>
</div>
</nav>
<div class="container-fluid" style="margin-bottom: 50px;">
<h1 class="mt-4">{{ PAGE_TITLE }}</h1>
{{ PAGE_CONTENT }}
</div>
</div>
<!-- /#page-content-wrapper -->
</div>
<!-- /#wrapper -->
<!-- Bootstrap core JavaScript -->
<script src="vendor/jquery/jquery.min.js"></script>
<script src="vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="vendor/monaco/index.js" type="module"></script>
<!-- Menu Toggle Script -->
<script>
$("#menu-toggle").click(function(e) {
e.preventDefault();
$("#wrapper").toggleClass("toggled");
});
const manifest = {{ MANIFEST }};
manifest.sidebar.forEach(item => {
const elem = $('<a href="' + item.link + '" class="list-group-item list-group-item-action bg-light"' + (item.level ? ' style="padding-left: ' + (20 * (item.level + 1)) + 'px;"' : '') + '>' + item.title + '</a>')
$('#sidebar-container').append(elem)
})
</script>
</body>
</html>

@ -0,0 +1,34 @@
{
"sidebar": [
{
"title": "Dashboard",
"level": 0,
"link": "page-Dashboard.html"
},
{
"title": "Shortcuts",
"level": 1,
"link": "page-Shortcuts.html"
},
{
"title": "Overview",
"level": 0,
"link": "page-Overview.html"
},
{
"title": "Events",
"level": 1,
"link": "page-Events.html"
},
{
"title": "Profile",
"level": 1,
"link": "page-Profile.html"
},
{
"title": "Status",
"level": 2,
"link": "page-Status.html"
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,325 @@
/*!
* Bootstrap Reboot v4.5.0 (https://getbootstrap.com/)
* Copyright 2011-2020 The Bootstrap Authors
* Copyright 2011-2020 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
display: block;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
[tabindex="-1"]:focus:not(:focus-visible) {
outline: 0 !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: .5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
a {
color: #007bff;
text-decoration: none;
background-color: transparent;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]) {
color: inherit;
text-decoration: none;
}
a:not([href]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
-ms-overflow-style: scrollbar;
}
figure {
margin: 0 0 1rem;
}
img {
vertical-align: middle;
border-style: none;
}
svg {
overflow: hidden;
vertical-align: middle;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #6c757d;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
button {
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
[role="button"] {
cursor: pointer;
}
select {
word-wrap: normal;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button:not(:disabled),
[type="button"]:not(:disabled),
[type="reset"]:not(:disabled),
[type="submit"]:not(:disabled) {
cursor: pointer;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: .5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
cursor: pointer;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v4.5.0 (https://getbootstrap.com/)
* Copyright 2011-2020 The Bootstrap Authors
* Copyright 2011-2020 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]){color:inherit;text-decoration:none}a:not([href]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,158 @@
const { Controller } = require('libflitter')
const ncp = require('ncp').ncp
const fs = require('fs').promises
const path = require('path')
class ExportController extends Controller {
static get services() {
return [...super.services, 'models', 'utility']
}
async get_export_list(req, res, next) {
const Export = this.models.get('api:Export')
const exports = await Export.find({
user_id: req.user.id,
})
return res.api(exports)
}
async export_subtree(req, res, next) {
const format = req.form.format
const page = req.form.page
if ( format === 'html' ) {
const generated_export = await this.export_subtree_as_html(page, req.user)
}
}
async export_subtree_as_html(page, user) {
const flat_tree = []
const add_to_tree = async (page, level = 0) => {
if ( await page.is_accessible_by(user, 'view') ) {
flat_tree.push({
level,
page,
file_name: `${page.Name.replace(/\s/g, '-')}-${page.UUID}.html`,
})
const children = await page.childPages
for ( const child of children ) {
await add_to_tree(child, level + 1)
}
}
}
await add_to_tree(page)
const manifest = {
sidebar: [],
}
for ( const item of flat_tree ) {
manifest.sidebar.push({
title: item.page.Name,
level: item.level,
link: item.file_name,
})
}
// Copy the template over
const work_dir = await this.scratch_dir()
await this.copy_template(this.utility.path('app', 'assets', 'export', 'html'), work_dir)
const html_template = await fs.readFile(path.resolve(work_dir, 'index.html'), 'utf-8')
for ( const item of flat_tree ) {
let item_template = html_template
item_template = item_template.replace(/{{\s?MANIFEST\s?}}/g, JSON.stringify(manifest))
item_template = item_template.replace(/{{\s?GROUP_TITLE\s?}}/g, page.Name)
item_template = item_template.replace(/{{\s?PAGE_TITLE\s?}}/g, item.page.Name)
item_template = item_template.replace(/{{\s?PAGE_CONTENT\s?}}/g, await this.page_as_html(item.page, work_dir))
await fs.writeFile(path.resolve(work_dir, item.file_name), item_template)
}
return work_dir
}
async copy_template(from, to) {
return new Promise((res, rej) => {
ncp(from, to, err => {
if ( err ) rej(err)
else res()
})
})
}
async scratch_dir() {
const tmp = require('tmp')
return new Promise((res, rej) => {
tmp.dir((err, path) => {
if ( err ) rej(err)
else res(path)
})
})
}
async page_as_html(page, work_dir) {
const Codium = this.models.get('api:Codium')
const FileGroup = this.models.get('api:FileGroup')
const File = this.models.get('upload::File')
let html = ''
const nodes = await page.nodes
for ( const node of nodes ) {
// ATM, there are 4 node types: norm, database_ref, files_ref, and code_ref
if ( node.Value.Mode === 'norm' ) {
html += node.Value.Value
} else if ( node.Type === 'code_ref' ) {
const code = await Codium.findOne({ UUID: node.Value.Value })
if ( code ) {
const snip_file = `code-snippet-${code.UUID}.txt`
await fs.writeFile(path.resolve(work_dir, snip_file), code.code)
html += `
<div class="code-ref">
<wc-monaco-editor id="${code.UUID}" language="${code.Language}" src="${snip_file}"></wc-monaco-editor>
</div>
`
}
} else if ( node.Type === 'file_ref' ) {
const file_group = await FileGroup.findOne({ UUID: node.Value.Value })
if ( file_group ) {
const file_htmls = []
for ( const file_id of file_group.FileIds ) {
const file = await File.findById(file_id)
if ( file ) {
const store_path = file.provider().filepath(file.store_id)
await this.copy_template(store_path, path.resolve(work_dir, `file-${file.upload_name}`))
file_htmls.push(`
<div class="file">
<div class="file-name">${file.original_name}</div>
<a href="file-${file.upload_name}" class="dl-link" target="_blank"></a>
</div>
`)
}
}
html += `
<div class="file-ref">
${file_htmls.join('\n')}
</div>`
}
}
}
return html
}
}
module.exports = exports = ExportController

@ -0,0 +1,17 @@
const { Model } = require('flitter-orm')
class ExportModel extends Model {
static get schema() {
return {
user_id: String,
create_date: { type: Date, default: () => new Date },
format: String, // 'html' | 'pdf' | 'markdown' | 'json'
subtree: { type: Boolean, default: true },
file_id: String,
PageId: String,
Active: { type: Boolean, default: true },
}
}
}
module.exports = exports = ExportModel

@ -0,0 +1,52 @@
/*
* API v1 Routes
* -------------------------------------------------------------
* Description here
*/
const index = {
prefix: '/api/v1/exports',
middleware: [
'auth:UserOnly',
],
get: {
'/': ['controller::api:v1:Export.get_export_list'],
// '/page/:PageId/info': [
// ['middleware::api:RequiredFields', { form: 'sharing.page' }],
// ['middleware::api:PageRoute', {level: 'manage'}],
// 'controller::api:v1:Sharing.page_info',
// ],
// '/page/:PageId/link/:level': [
// ['middleware::api:RequiredFields', { form: 'sharing.page_link'}],
// ['middleware::api:PageRoute', {level: 'manage'}],
// 'controller::api:v1:Sharing.get_link',
// ],
},
post: {
'/subtree': [
['middleware::api:RequiredFields', { form: 'exports.subtree' }],
['middleware::api:PageRoute', {level: 'view'}],
'controller::api:v1:Export.export_subtree',
],
// // Share a page with the specified user.
// '/page/:PageId/share': [
// ['middleware::api:RequiredFields', { form: 'sharing.page_level' }],
// ['middleware::api:PageRoute', {level: 'manage'}],
// 'middleware::api:UserRoute',
// 'controller::api:v1:Sharing.share_page',
// ],
//
// // Unshare a page with the specified user.
// '/page/:PageId/revoke': [
// ['middleware::api:RequiredFields', { form: 'sharing.page_user' }],
// ['middleware::api:PageRoute', {level: 'manage'}],
// 'middleware::api:UserRoute',
// 'controller::api:v1:Sharing.revoke_page',
// ],
},
}
module.exports = exports = index

@ -0,0 +1,15 @@
module.exports = exports = {
subtree: {
fields: {
PageId: {
required: true,
coerce: String,
},
format: {
required: true,
coerce: String,
in_set: ['html', 'pdf', 'markdown', 'json'],
}
}
}
}

@ -25,6 +25,8 @@
"flitter-orm": "^0.4.0",
"flitter-upload": "^0.8.1",
"jsonwebtoken": "^8.5.1",
"libflitter": "^0.54.1"
"libflitter": "^0.54.1",
"ncp": "^2.0.0",
"tmp": "^0.2.1"
}
}

@ -4121,6 +4121,13 @@ through@~2.3.8:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
tmp@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==
dependencies:
rimraf "^3.0.0"
to-fast-properties@^1.0.0, to-fast-properties@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"

Loading…
Cancel
Save