oss
Co-authored-by: Anmol Sethi <hi@nhooyr.io>
This commit is contained in:
commit
524c089a74
594 changed files with 176172 additions and 0 deletions
2
.github/issue_request_template.md
vendored
Normal file
2
.github/issue_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<!-- Please title the issue with a scope prefix like cli: improve performance. -->
|
||||||
|
<!-- Please add screenshots or screencasts for ui/autolayout issues. -->
|
||||||
2
.github/pull_request_template.md
vendored
Normal file
2
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<!-- Please title the PR with a scope prefix like cli: performance improvements. -->
|
||||||
|
<!-- Please add screenshots or screencasts for ui/autolayout changes. -->
|
||||||
97
.github/workflows/ci.yml
vendored
Normal file
97
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
name: ci
|
||||||
|
on: [push]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
assert-linear:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- run: ./make.sh assert-linear
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
fmt:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version-file: ./go.mod
|
||||||
|
cache: true
|
||||||
|
- run: ./make.sh fmt
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version-file: ./go.mod
|
||||||
|
cache: true
|
||||||
|
- run: ./make.sh lint
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version-file: ./go.mod
|
||||||
|
cache: true
|
||||||
|
- run: ./make.sh build
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version-file: ./go.mod
|
||||||
|
cache: true
|
||||||
|
- run: ./make.sh test
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: d2chaos-test
|
||||||
|
path: ./d2chaos/out
|
||||||
|
race:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version-file: ./go.mod
|
||||||
|
cache: true
|
||||||
|
- run: ./make.sh race
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: d2chaos-race
|
||||||
|
path: ./d2chaos/out
|
||||||
26
.github/workflows/daily.yml
vendored
Normal file
26
.github/workflows/daily.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: daily
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
# daily at 00:42 to avoid hourly loads in GitHub actions
|
||||||
|
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||||
|
- cron: '42 0 * * *'
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
all:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version-file: ./go.mod
|
||||||
|
cache: true
|
||||||
|
- run: CI_ALL=1 ./make.sh
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.make-log
|
||||||
|
.changed-files
|
||||||
|
.make-log.txt
|
||||||
|
*.got.json
|
||||||
|
*.got.svg
|
||||||
|
e2e_report.html
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "ci/sub"]
|
||||||
|
path = ci/sub
|
||||||
|
url = https://github.com/terrastruct/ci
|
||||||
375
LICENSE.txt
Normal file
375
LICENSE.txt
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
Copyright 2022 Terrastruct Inc.
|
||||||
|
|
||||||
|
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.
|
||||||
26
Makefile
Normal file
26
Makefile
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.POSIX:
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
|
all: fmt lint build test
|
||||||
|
ifdef CI
|
||||||
|
all: assert-linear
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt:
|
||||||
|
prefix "$@" ./ci/sub/fmt/make.sh
|
||||||
|
.PHONY: lint
|
||||||
|
lint:
|
||||||
|
prefix "$@" go vet --composites=false ./...
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
prefix "$@" go build ./...
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
prefix "$@" ./ci/test.sh
|
||||||
|
.PHONY: race
|
||||||
|
race:
|
||||||
|
prefix "$@" ./ci/test.sh --race ./...
|
||||||
|
.PHONY: assert-linear
|
||||||
|
assert-linear:
|
||||||
|
prefix "$@" ./ci/sub/assert_linear.sh
|
||||||
201
README.md
Normal file
201
README.md
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
<div align="center">
|
||||||
|
<h1>
|
||||||
|
<img src="./docs/assets/logo.svg" alt="D2" />
|
||||||
|
</h1>
|
||||||
|
<p>A modern DSL that turns text into diagrams.</p>
|
||||||
|
|
||||||
|
Language docs: [https://d2-lang.com](https://d2-lang.com)
|
||||||
|
|
||||||
|
[](https://github.com/terrastruct/d2/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/terrastruct/d2/releases)
|
||||||
|
[](https://discord.gg/h9VFkAKTsT)
|
||||||
|

|
||||||
|
[](./LICENSE.txt)
|
||||||
|
[](https://pkg.go.dev/oss.terrastruct.com/d2)
|
||||||
|
|
||||||
|
<img src="./docs/assets/cli.gif" alt="D2 CLI" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Table of Contents
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
- [Quickstart (CLI)](#quickstart-cli)
|
||||||
|
* [MacOS](#macos)
|
||||||
|
* [Linux/Windows](#linuxwindows)
|
||||||
|
- [Quickstart (library)](#quickstart-library)
|
||||||
|
- [Themes](#themes)
|
||||||
|
- [Fonts](#fonts)
|
||||||
|
- [Export file types](#export-file-types)
|
||||||
|
- [Language tooling](#language-tooling)
|
||||||
|
- [Layout engine](#layout-engine)
|
||||||
|
- [Comparison](#comparison)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [License](#license)
|
||||||
|
- [Dependencies](#dependencies)
|
||||||
|
- [Related](#related)
|
||||||
|
* [VSCode extension](#vscode-extension)
|
||||||
|
* [Vim extension](#vim-extension)
|
||||||
|
* [Misc](#misc)
|
||||||
|
|
||||||
|
<!-- tocstop -->
|
||||||
|
|
||||||
|
## Quickstart (CLI)
|
||||||
|
The most convenient way to use D2 is to just run it as a CLI executable to
|
||||||
|
produce SVGs from `.d2` files.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install oss.terrastruct.com/d2
|
||||||
|
|
||||||
|
echo 'x -> y -> z' > in.d2
|
||||||
|
d2 --watch in.d2 out.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser window will open with `out.svg` and live-reload on changes to `in.d2`.
|
||||||
|
|
||||||
|
### MacOS
|
||||||
|
|
||||||
|
Homebrew package coming soon.
|
||||||
|
|
||||||
|
### Linux/Windows
|
||||||
|
|
||||||
|
We have precompiled binaries on the [releases](https://github.com/terrastruct/d2/releases)
|
||||||
|
page. D2 will be added to OS-respective package managers soon.
|
||||||
|
|
||||||
|
|
||||||
|
## Quickstart (library)
|
||||||
|
In addition to being a runnable CLI tool, D2 can also be used to produce diagrams from
|
||||||
|
Go programs.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"github.com/terrastruct/d2/d2compiler"
|
||||||
|
"github.com/terrastruct/d2/d2exporter"
|
||||||
|
"github.com/terrastruct/d2/d2layouts/d2dagrelayout"
|
||||||
|
"github.com/terrastruct/d2/d2renderers/textmeasure"
|
||||||
|
"github.com/terrastruct/d2/d2themes/d2themescatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
graph, err := d2compiler.Compile("", strings.NewReader("x -> y"), &d2compiler.CompileOptions{ UTF16: true })
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
err = graph.SetDimensions(nil, ruler)
|
||||||
|
err = d2dagrelayout.Layout(ctx, graph)
|
||||||
|
diagram, err := d2exporter.Export(ctx, graph, d2themescatalog.NeutralDefault)
|
||||||
|
ioutil.WriteFile(filepath.Join("out.svg"), d2svg.Render(*diagram), 0600)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
D2 is built to be hackable -- the language has an API built on top of it to make edits
|
||||||
|
programmatically.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"github.com/terrastruct/d2/d2oracle"
|
||||||
|
"github.com/terrastruct/d2/d2format"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ...modifying the diagram `x -> y` from above
|
||||||
|
// Create a shape with the ID, "meow"
|
||||||
|
graph, err = d2oracle.Create(graph "meow")
|
||||||
|
// Style the shape green
|
||||||
|
graph, err = d2oracle.Set(graph "meow.style.fill", "green")
|
||||||
|
// Create a shape with the ID, "cat"
|
||||||
|
graph, err = d2oracle.Create(graph "cat")
|
||||||
|
// Move the shape "meow" inside the container "cat"
|
||||||
|
graph, err = d2oracle.Move(graph "meow", "cat.meow")
|
||||||
|
// Prints formatted D2 code
|
||||||
|
println(d2format.Format(graph.AST))
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes it easy to build functionality on top of D2. Terrastruct uses the above API to
|
||||||
|
implement editing of D2 from mouse actions in a visual interface.
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
D2 includes a variety of official themes to style your diagrams beautifully right out of
|
||||||
|
the box. See [./d2themes](./d2themes) to browse the available themes and make or
|
||||||
|
contribute your own creation.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
D2 ships with "Source Sans Pro" as the font in renders. If you wish to use a different
|
||||||
|
one, please see [./d2renderers/d2fonts](./d2renderers/d2fonts).
|
||||||
|
|
||||||
|
## Export file types
|
||||||
|
|
||||||
|
D2 currently supports SVG exports. More coming soon.
|
||||||
|
|
||||||
|
## Language tooling
|
||||||
|
|
||||||
|
D2 is designed with language tooling in mind. D2's parser can parse multiple errors from a
|
||||||
|
broken program, has an autoformatter, syntax highlighting, and we have plans for LSP's and
|
||||||
|
more. Good language tooling is necessary for creating and maintaining large diagrams.
|
||||||
|
|
||||||
|
The extensions for VSCode and Vim can be found in the [Related](#related) section.
|
||||||
|
|
||||||
|
## Layout engine
|
||||||
|
|
||||||
|
D2 currently uses the open-source library [dagre](https://github.com/dagrejs/dagre) as its
|
||||||
|
default layout engine. D2 includes a wrapper around dagre to work around one of its
|
||||||
|
biggest limitations -- the inability to make container-to-container edges.
|
||||||
|
|
||||||
|
Dagre was chosen due to its popularity in other tools, but D2 intends to integrate with a
|
||||||
|
variety of layout engines, e.g. `dot`, as well as single-purpose layout types like
|
||||||
|
sequence diagrams. You can choose whichever layout engine you like and works best for the
|
||||||
|
diagram you're making.
|
||||||
|
|
||||||
|
Terrastruct has created a proprietary layout engine called
|
||||||
|
[TALA](https://terrastruct.com/tala). It has been designed specifically for software
|
||||||
|
architecture diagrams, though it's good for other domains too. TALA has many advantages
|
||||||
|
over other layout engines, the biggest being that it isn't constrained to hierarchies, or
|
||||||
|
any single type like "radial" or "tree" (as almost all layout engines are). For more
|
||||||
|
information and to download & try TALA, see
|
||||||
|
[https://github.com/terrastruct/TALA](https://github.com/terrastruct/TALA).
|
||||||
|
|
||||||
|
## Comparison
|
||||||
|
|
||||||
|
For a comparison against other popular text-to-diagram tools, see
|
||||||
|
[https://text-to-diagram.com](https://text-to-diagram.com).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! See [./docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright © 2022 Terrastruct, Inc. Open-source licensed under the Mozilla Public License
|
||||||
|
2.0.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
D2 is light on third-party dependencies in the source code. Note that these are bundled
|
||||||
|
with D2, you do not have to separately install anything.
|
||||||
|
|
||||||
|
| Dependency | What it does |
|
||||||
|
| ----------- | ----------- |
|
||||||
|
| [slog](https://cdr.dev/slog) | logging (deprecating it is a TODO) |
|
||||||
|
| [goldmark](https://github.com/yuin/goldmark), [goquery](https://github.com/PuerkitoBio/goquery) | Markdown rendering |
|
||||||
|
| [chroma](https://github.com/alecthomas/chroma) | syntax highlighting code snippets |
|
||||||
|
| [pflag](https://github.com/spf13/pflag), [fsnotify](https://github.com/fsnotify/fsnotify), [websocket](https://nhooyr.io/websocket) | CLI functions |
|
||||||
|
| [v8go](https://rogchap.com/v8go) | Run Javascript (e.g. Dagre layout engine) |
|
||||||
|
| [gonum](https://gonum.org/v1/plot) | Bezier curve stuff (rendering) |
|
||||||
|
|
||||||
|
The rest are helpers we've open-sourced. E.g. [diff](https://oss.terrastruct.com/diff) for
|
||||||
|
our testing framework.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
### VSCode extension
|
||||||
|
|
||||||
|
[https://github.com/terrastruct/d2-vscode](https://github.com/terrastruct/d2-vscode)
|
||||||
|
|
||||||
|
### Vim extension
|
||||||
|
|
||||||
|
[https://github.com/terrastruct/d2-vim](https://github.com/terrastruct/d2-vim)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- [https://github.com/terrastruct/d2-docs](https://github.com/terrastruct/d2-docs)
|
||||||
|
- [https://github.com/terrastruct/text-to-diagram-com](https://github.com/terrastruct/text-to-diagram-com)
|
||||||
9
c.go
Normal file
9
c.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package d2
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dagreLayout = d2dagrelayout.Layout
|
||||||
|
}
|
||||||
3
ci/sub/README.md
Normal file
3
ci/sub/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# ci
|
||||||
|
|
||||||
|
Terrastruct's CI scripts.
|
||||||
24
ci/sub/assert_linear.sh
Executable file
24
ci/sub/assert_linear.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
. "$(dirname "$0")/lib.sh"
|
||||||
|
cd -- "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# assert_linear.sh ensures that the current commit does not contain any PR merge commits
|
||||||
|
# compared to master as if it does, then that means our diffing mechanisms will be
|
||||||
|
# incorrect. We want all changes compared to master to be checked, not all changed
|
||||||
|
# relative to the previous PR into this branch.
|
||||||
|
|
||||||
|
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then
|
||||||
|
git fetch --unshallow origin master
|
||||||
|
fi
|
||||||
|
|
||||||
|
merge_base="$(git merge-base HEAD origin/master)"
|
||||||
|
merges="$(git --no-pager log --merges --grep="Merge pull request" --grep="\[ci-base\]" --format=%h "$merge_base"..HEAD)"
|
||||||
|
|
||||||
|
if [ -n "$merges" ]; then
|
||||||
|
echoerr <<EOF
|
||||||
|
Found merge pull request commit(s) in PR: $(_echo "$merges" | tr '\n' ' ')
|
||||||
|
Each pull request must be merged separately for CI to run correctly.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
11
ci/sub/bin/echop
Executable file
11
ci/sub/bin/echop
Executable file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
. "$(dirname "$0")/../lib.sh"
|
||||||
|
|
||||||
|
prefix="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [ -z "${COLOR:-}" ]; then
|
||||||
|
COLOR="$(get_rand_color "$prefix")"
|
||||||
|
fi
|
||||||
|
printf '%s: %s\n' "$(setaf "$COLOR" "$prefix")" "$*"
|
||||||
38
ci/sub/bin/prefix
Executable file
38
ci/sub/bin/prefix
Executable file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
. "$(dirname "$0")/../lib.sh"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
prefix="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
COLOR="$(get_rand_color "$prefix")"
|
||||||
|
prefix="$(setaf "$COLOR" "$prefix")"
|
||||||
|
_echo "$prefix^:" "$*"
|
||||||
|
|
||||||
|
# We need to make sure we exit with a non zero exit if the command fails.
|
||||||
|
# /bin/sh does not support -o pipefail unfortunately.
|
||||||
|
fifo="$(mktemp -d)/fifo"
|
||||||
|
mkfifo "$fifo"
|
||||||
|
# We add the prefix to all lines and remove any lines about recursive make.
|
||||||
|
# We cannot silence these with -s which is unfortunate.
|
||||||
|
sed -e "s#^#$prefix: #" -e "/make\[.\]: warning: -j/d" "$fifo" &
|
||||||
|
|
||||||
|
exit_trap() {
|
||||||
|
code="$?"
|
||||||
|
end="$(awk 'BEGIN{srand(); print srand()}')"
|
||||||
|
dur="$((end - start))"
|
||||||
|
|
||||||
|
if [ "$code" -eq 0 ]; then
|
||||||
|
_echo "$prefix\$:" "$(setaf 2 success)" "($(echo_dur $dur))"
|
||||||
|
else
|
||||||
|
_echo "$prefix\$:" "$(setaf 1 failure)" "($(echo_dur $dur))"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap exit_trap EXIT
|
||||||
|
|
||||||
|
start="$(awk 'BEGIN{srand(); print srand()}')"
|
||||||
|
"$@" >"$fifo" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
10
ci/sub/bin/xargsd
Executable file
10
ci/sub/bin/xargsd
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
. "$(dirname "$0")/../lib.sh"
|
||||||
|
|
||||||
|
set_changed_files
|
||||||
|
|
||||||
|
pattern="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
< "$CHANGED_FILES" grep "$pattern" | hide xargs ${CI:+-r} -t -P16 "-n${XARGS_N:-256}" -- "$@"
|
||||||
49
ci/sub/fmt/Makefile
Normal file
49
ci/sub/fmt/Makefile
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
.POSIX:
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
|
all:
|
||||||
|
ifdef CI
|
||||||
|
git -c color.ui=always diff --exit-code
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifdef CI_FMT_GO
|
||||||
|
all: go
|
||||||
|
endif
|
||||||
|
.PHONY: go
|
||||||
|
go: gofmt goimports
|
||||||
|
.PHONY: gofmt
|
||||||
|
gofmt:
|
||||||
|
prefix "$@" xargsd '\.go$$' gofmt -s -w
|
||||||
|
.PHONY: goimports
|
||||||
|
goimports: gofmt
|
||||||
|
prefix "$@" xargsd '\.go$$' go run golang.org/x/tools/cmd/goimports@v0.1.12 \
|
||||||
|
-w -local="$$CI_GOIMPORTS_LOCAL"
|
||||||
|
|
||||||
|
ifdef CI_FMT_PRETTIER
|
||||||
|
all: prettier
|
||||||
|
endif
|
||||||
|
.PHONY: prettier
|
||||||
|
prettier:
|
||||||
|
prefix "$@" xargsd '\.\(js\|jsx\|ts\|tsx\|scss\|css\|html\)$$' \
|
||||||
|
npx prettier@2.7.1 --print-width=90 --write
|
||||||
|
|
||||||
|
ifdef CI_FMT_MARKDOWN
|
||||||
|
all: markdown-toc
|
||||||
|
endif
|
||||||
|
.PHONY: markdown-toc
|
||||||
|
markdown-toc:
|
||||||
|
XARGS_N=1 prefix "$@" xargsd '\.md$$' npx markdown-toc@1.2.0 -i
|
||||||
|
|
||||||
|
ifdef CI_FMT_GO_MODULE
|
||||||
|
all: gomodtidy
|
||||||
|
endif
|
||||||
|
.PHONY: gomodtidy
|
||||||
|
gomodtidy:
|
||||||
|
prefix "$@" go mod tidy
|
||||||
|
|
||||||
|
ifdef CI_FMT_NODE_MODULE
|
||||||
|
all: yarn
|
||||||
|
endif
|
||||||
|
.PHONY: yarn
|
||||||
|
yarn:
|
||||||
|
prefix "$@" yarn $${CI:+--immutable} $${CI:+--immutable-cache}
|
||||||
32
ci/sub/fmt/make.sh
Executable file
32
ci/sub/fmt/make.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
. "$(dirname "$0")/../lib.sh"
|
||||||
|
PATH="$(cd -- "$(dirname "$0")" && pwd)/../bin:$PATH"
|
||||||
|
|
||||||
|
set_changed_files
|
||||||
|
gomod_path="$(search_up go.mod || true)"
|
||||||
|
if [ "$gomod_path" ]; then
|
||||||
|
export CI_FMT_GO_MODULE=1
|
||||||
|
module_name="$(cat "$gomod_path" | head -n1 | cut -d' ' -f2 )"
|
||||||
|
if [ "${CI_GOIMPORTS_LOCAL:-}" ]; then
|
||||||
|
export CI_GOIMPORTS_LOCAL="$CI_GOIMPORTS_LOCAL,$module_name"
|
||||||
|
else
|
||||||
|
export CI_GOIMPORTS_LOCAL="$module_name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if search_up package.json > /dev/null; then
|
||||||
|
export CI_FMT_NODE_MODULE=1
|
||||||
|
fi
|
||||||
|
if < "$CHANGED_FILES" grep -qm1 '\.go$'; then
|
||||||
|
export CI_FMT_GO=1
|
||||||
|
fi
|
||||||
|
if < "$CHANGED_FILES" grep -qm1 '\.md$'; then
|
||||||
|
if [ -z "${CI:-}" ]; then
|
||||||
|
# Only locally for now.
|
||||||
|
export CI_FMT_MARKDOWN=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if < "$CHANGED_FILES" grep -qm1 '\.\(js\|jsx\|ts\|tsx\|scss\|css\|html\)$'; then
|
||||||
|
export CI_FMT_PRETTIER=1
|
||||||
|
fi
|
||||||
|
_make -f "$(dirname "$0")/Makefile" "$@"
|
||||||
340
ci/sub/lib.sh
Normal file
340
ci/sub/lib.sh
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ "${CI_DEBUG:-}" ]; then
|
||||||
|
set -x
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ***
|
||||||
|
# logging
|
||||||
|
# ***
|
||||||
|
|
||||||
|
_echo() {
|
||||||
|
printf '%s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
setaf() {
|
||||||
|
if [ -z "${TERM:-}" ]; then
|
||||||
|
export TERM=xterm-256color
|
||||||
|
fi
|
||||||
|
tput setaf "$1"
|
||||||
|
shift
|
||||||
|
printf '%s' "$*"
|
||||||
|
tput sgr0
|
||||||
|
}
|
||||||
|
|
||||||
|
echoerr() {
|
||||||
|
printf '%s ' "$(setaf 1 err:)" >&2
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
printf '%s\n' "$*" >&2
|
||||||
|
else
|
||||||
|
cat >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
sh_c() {
|
||||||
|
printf '%s %s\n' "$(setaf 3 exec:)" "$*"
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_rand_color() {
|
||||||
|
# 1-6 are regular and 9-14 are bright.
|
||||||
|
# 1,2 and 9,10 are red and green but we use those for success and failure.
|
||||||
|
pick "$*" 3 4 5 6 11 12 13 14
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
out="$(mktemp)"
|
||||||
|
set +e
|
||||||
|
"$@" >"$out" 2>&1
|
||||||
|
code="$?"
|
||||||
|
set -e
|
||||||
|
if [ "$code" -eq 0 -a -z "${CI_DEBUG:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
cat "$out" >&2
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_dur() {
|
||||||
|
local dur=$1
|
||||||
|
local h=$((dur/60/60))
|
||||||
|
local m=$((dur/60%60))
|
||||||
|
local s=$((dur%60))
|
||||||
|
printf '%dh%dm%ds' "$h" "$m" "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
sponge() {
|
||||||
|
dst="$1"
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
cat > "$tmp"
|
||||||
|
cat "$tmp" > "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
stripansi() {
|
||||||
|
# First regex gets rid of standard xterm escape sequences for controlling
|
||||||
|
# visual attributes.
|
||||||
|
# The second regex I'm not 100% sure, the reference says it selects the US
|
||||||
|
# encoding but I'm not sure why that's necessary or why it always occurs
|
||||||
|
# in tput sgr0 before the standard escape sequence.
|
||||||
|
# See tput sgr0 | xxd
|
||||||
|
sed -e $'s/\x1b\[[0-9;]*m//g' -e $'s/\x1b(.//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
runtty() {
|
||||||
|
case "$(uname)" in
|
||||||
|
Darwin)
|
||||||
|
script -q /dev/null "$@"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
script -eqc "$*"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echoerr "runtty: unsupported OS $(uname)"
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ***
|
||||||
|
# rand
|
||||||
|
# ***
|
||||||
|
|
||||||
|
rand() {(
|
||||||
|
seed="$1"
|
||||||
|
range="$2"
|
||||||
|
|
||||||
|
seed_file="$(mktemp)"
|
||||||
|
_echo "$seed" | md5sum > "$seed_file"
|
||||||
|
shuf -i "$range" -n 1 --random-source="$seed_file"
|
||||||
|
)}
|
||||||
|
|
||||||
|
pick() {(
|
||||||
|
seed="$1"
|
||||||
|
shift
|
||||||
|
i="$(rand "$seed" "1-$#")"
|
||||||
|
eval "_echo \$$i"
|
||||||
|
)}
|
||||||
|
|
||||||
|
# ***
|
||||||
|
# git
|
||||||
|
# ***
|
||||||
|
|
||||||
|
set_git_base() {
|
||||||
|
if [ -n "${GIT_BASE_DONE:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${CI_ALL:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git show --no-patch --format=%s%n%b | grep -qiF '\[ci-all\]'; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then
|
||||||
|
git fetch --unshallow origin master
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unfortunately --grep searches the whole commit message but we just want the header
|
||||||
|
# searched. Should fix by using grep directly later.
|
||||||
|
export GIT_BASE="$(git log --merges --grep="Merge pull request" --grep="\[ci-base\]" --format=%h HEAD~1 | head -n1)"
|
||||||
|
export GIT_BASE_DONE=1
|
||||||
|
if [ -n "$GIT_BASE" ]; then
|
||||||
|
echop make "GIT_BASE=$GIT_BASE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_changed() {
|
||||||
|
set_git_base
|
||||||
|
if [ -z "${GIT_BASE:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
! git diff --quiet "$GIT_BASE" -- "$@" ||
|
||||||
|
[ -n "$(git ls-files --other --exclude-standard -- "$@")" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
set_changed_files() {
|
||||||
|
set_git_base
|
||||||
|
|
||||||
|
filter_exists() {
|
||||||
|
while read -r p; do
|
||||||
|
if [ -e "$p" ]; then
|
||||||
|
printf '%s\n' "$p"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "${CHANGED_FILES:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGED_FILES=./.changed-files
|
||||||
|
git ls-files --other --exclude-standard > "$CHANGED_FILES"
|
||||||
|
if [ -n "${GIT_BASE:-}" ]; then
|
||||||
|
git diff --relative --name-only "$GIT_BASE" | filter_exists >> "$CHANGED_FILES"
|
||||||
|
else
|
||||||
|
git ls-files >> "$CHANGED_FILES"
|
||||||
|
fi
|
||||||
|
export CHANGED_FILES
|
||||||
|
}
|
||||||
|
|
||||||
|
# ***
|
||||||
|
# make
|
||||||
|
# ***
|
||||||
|
|
||||||
|
_make() {
|
||||||
|
if [ "${CI:-}" ]; then
|
||||||
|
if ! is_changed .; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ "${GITHUB_TOKEN:-}" ]; then
|
||||||
|
git config --global credential.helper store
|
||||||
|
cat > ~/.git-credentials <<EOF
|
||||||
|
https://cyborg-ts:$GITHUB_TOKEN@github.com
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
git submodule update --init --recursive
|
||||||
|
fi
|
||||||
|
if [ -z "${MAKE_LOG:-}" ]; then
|
||||||
|
CI_MAKE_ROOT=1
|
||||||
|
export MAKE_LOG="./.make-log"
|
||||||
|
set +e
|
||||||
|
# runtty is necessary to allow make to write its output unbuffered. Otherwise the
|
||||||
|
# output is printed in surges as the write buffer is exceeded rather than a continous
|
||||||
|
# stream. Remove the runtty prefix to experience the laggy behaviour without it.
|
||||||
|
runtty make -sj8 "$@" \
|
||||||
|
| tee /dev/stderr "$MAKE_LOG" \
|
||||||
|
| stripansi > "$MAKE_LOG.txt"
|
||||||
|
else
|
||||||
|
CI_MAKE_ROOT=0
|
||||||
|
set +e
|
||||||
|
make -sj8 "$@" 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
code="$?"
|
||||||
|
set -e
|
||||||
|
if [ "$code" -ne 0 ]; then
|
||||||
|
notify "$code"
|
||||||
|
return "$code"
|
||||||
|
fi
|
||||||
|
# make doesn't return a nonsuccess exit code on recipe failures.
|
||||||
|
if <"$MAKE_LOG" grep -q 'make.* \*\*\* .* Error'; then
|
||||||
|
notify 1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "${CI:-}" ]; then
|
||||||
|
# Make sure nothing has changed
|
||||||
|
if ! git -c color.ui=always diff --exit-code; then
|
||||||
|
notify 1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
notify 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ***
|
||||||
|
# misc
|
||||||
|
# ***
|
||||||
|
|
||||||
|
search_up() {(
|
||||||
|
file="$1"
|
||||||
|
git_root="$(git rev-parse --show-toplevel)"
|
||||||
|
while true; do
|
||||||
|
if [ -e "$file" ]; then
|
||||||
|
_echo "$file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ "$PWD" = "$git_root" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
cd ..
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
)}
|
||||||
|
|
||||||
|
# ***
|
||||||
|
# integrations
|
||||||
|
# ***
|
||||||
|
|
||||||
|
aws() {
|
||||||
|
# Without the redirection aws's cli will write directly to /dev/tty bypassing prefix.
|
||||||
|
command aws "$@" > /dev/stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
if [ "$CI_MAKE_ROOT" -eq 0 -o -z "${CI:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -z "${SLACK_WEBHOOK_URL:-}" -a -z "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||||
|
# Not all repos need CI failure notifications.
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${GITHUB_RUN_ID:-}" ]; then
|
||||||
|
# For testing.
|
||||||
|
GITHUB_WORKFLOW=ci
|
||||||
|
GITHUB_JOB=fmt
|
||||||
|
GITHUB_REPOSITORY=terrastruct/src
|
||||||
|
GITHUB_RUN_ID=3086720699
|
||||||
|
GITHUB_JOB=all
|
||||||
|
elif [ "$GITHUB_REF_PROTECTED" != true ]; then
|
||||||
|
# We only want to notify on protected branch failures.
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
code="$1"
|
||||||
|
if [ "$code" -eq 0 ]; then
|
||||||
|
status=success
|
||||||
|
emoji=🟢
|
||||||
|
else
|
||||||
|
status='failure'
|
||||||
|
emoji=🛑
|
||||||
|
if [ "${SLACK_WEBHOOK_URL:-}" ]; then
|
||||||
|
status="$status <!here>"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITHUB_JOB_URL="$(curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?per_page=100" | \
|
||||||
|
jq -r ".jobs[] | select( .name == \"$GITHUB_JOB\") | .html_url")"
|
||||||
|
if [ -z "$GITHUB_JOB_URL" ]; then
|
||||||
|
status="failed to query github job URL <!here>"
|
||||||
|
emoji=🛑
|
||||||
|
GITHUB_JOB_URL="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
commit_sha="$(git rev-parse --short HEAD)"
|
||||||
|
commit_title="$(git show --no-patch '--format=%s')"
|
||||||
|
|
||||||
|
# We need to escape any & < > in $commit_title.
|
||||||
|
# See https://api.slack.com/reference/surfaces/formatting#escaping
|
||||||
|
commit_title="$(_echo "$commit_title" | sed -e 's/&/\&/g' )"
|
||||||
|
commit_title="$(_echo "$commit_title" | sed -e 's/</\</g' )"
|
||||||
|
commit_title="$(_echo "$commit_title" | sed -e 's/>/\>/g' )"
|
||||||
|
|
||||||
|
# Three differences.
|
||||||
|
# 1. @here doesn't work in discord code blocks but do in slack.
|
||||||
|
# 2. URLs don't work in discord code blocks but do in slack.
|
||||||
|
# 3. content vs text for the request JSON payload.
|
||||||
|
# 4. Discord handles spacing in and around code blocks really weirdly. If $GITHUB_JOB_URL
|
||||||
|
# has a newline between it and the end of the code block, it's rendered as a separate
|
||||||
|
# paragraph instead of just below the code block.
|
||||||
|
if [ "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||||
|
msg="---"
|
||||||
|
if [ "$code" -ne 0 ]; then
|
||||||
|
msg="$msg @here"
|
||||||
|
fi
|
||||||
|
msg="$msg\`\`\`
|
||||||
|
$emoji $commit_sha - $commit_title | $GITHUB_WORKFLOW/$GITHUB_JOB: $status
|
||||||
|
\`\`\`$GITHUB_JOB_URL"
|
||||||
|
json="{\"content\":$(printf %s "$msg" | jq -sR .)}"
|
||||||
|
url="$DISCORD_WEBHOOK_URL"
|
||||||
|
elif [ "${SLACK_WEBHOOK_URL:-}" ]; then
|
||||||
|
msg="\`\`\`
|
||||||
|
$emoji $commit_sha - $commit_title | $GITHUB_WORKFLOW/$GITHUB_JOB: $status
|
||||||
|
$GITHUB_JOB_URL
|
||||||
|
\`\`\`"
|
||||||
|
json="{\"text\":$(printf %s "$msg" | jq -sR .)}"
|
||||||
|
url="$SLACK_WEBHOOK_URL"
|
||||||
|
fi
|
||||||
|
sh_c curl -fsSL -X POST -H 'Content-type: application/json' --data "$json" "$url" > /dev/null
|
||||||
|
}
|
||||||
12
ci/test.sh
Executable file
12
ci/test.sh
Executable file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [ "$*" = "" ]; then
|
||||||
|
set ./...
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${CI:-}" ]; then
|
||||||
|
export FORCE_COLOR=1
|
||||||
|
fi
|
||||||
|
go test --timeout=30m "$@"
|
||||||
118
cmd/d2/help.go
Normal file
118
cmd/d2/help.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2plugin"
|
||||||
|
"oss.terrastruct.com/d2/lib/xmain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func help(ms *xmain.State) {
|
||||||
|
fmt.Fprintf(ms.Stdout, `Usage:
|
||||||
|
%s [--watch=false] [--theme=0] file.d2 [file.svg]
|
||||||
|
|
||||||
|
%[1]s compiles and renders file.d2 to file.svg
|
||||||
|
Use - to have d2 read from stdin or write to stdout.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
%[1]s layout - Lists available layout engine options with short help
|
||||||
|
%[1]s layout [layout name] - Display long help for a particular layout engine
|
||||||
|
|
||||||
|
See more docs at https://oss.terrastruct.com/d2
|
||||||
|
`, ms.Name, ms.FlagHelp())
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutHelp(ctx context.Context, ms *xmain.State) error {
|
||||||
|
if len(ms.FlagSet.Args()) == 1 {
|
||||||
|
return shortLayoutHelp(ctx, ms)
|
||||||
|
} else if len(ms.FlagSet.Args()) == 2 {
|
||||||
|
return longLayoutHelp(ctx, ms)
|
||||||
|
} else {
|
||||||
|
return xmain.UsageErrorf("too many arguments passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortLayoutHelp(ctx context.Context, ms *xmain.State) error {
|
||||||
|
var pluginLines []string
|
||||||
|
plugins, err := d2plugin.ListPlugins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range plugins {
|
||||||
|
pluginLines = append(pluginLines, p.Name+" - "+p.ShortHelp)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(ms.Stdout, `Available layout engines found:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
To use a particular layout engine, set the environment variable D2_LAYOUT=[layout name].
|
||||||
|
|
||||||
|
Example:
|
||||||
|
D2_LAYOUT=dagre d2 in.d2 out.svg
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
%s layout [layout name] - Display long help for a particular layout engine
|
||||||
|
|
||||||
|
See more docs at https://oss.terrastruct.com/d2
|
||||||
|
`, strings.Join(pluginLines, "\n"), ms.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func longLayoutHelp(ctx context.Context, ms *xmain.State) error {
|
||||||
|
layout := ms.FlagSet.Arg(1)
|
||||||
|
plugin, path, err := d2plugin.FindPlugin(ctx, layout)
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
return layoutNotFound(ctx, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginLocation := "bundled"
|
||||||
|
if path != "" {
|
||||||
|
pluginLocation = fmt.Sprintf("executable plugin at %s", humanPath(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginInfo, err := plugin.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(ms.Stdout, `%s (%s):
|
||||||
|
|
||||||
|
%s
|
||||||
|
`, pluginInfo.Name, pluginLocation, pluginInfo.LongHelp)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutNotFound(ctx context.Context, layout string) error {
|
||||||
|
var names []string
|
||||||
|
plugins, err := d2plugin.ListPlugins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range plugins {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return xmain.UsageErrorf(`D2_LAYOUT "%s" is not bundled and could not be found in your $PATH.
|
||||||
|
The available options are: %s. For details on each option, run "d2 layout".
|
||||||
|
|
||||||
|
For more information on setup, please visit https://github.com/terrastruct/d2.`,
|
||||||
|
layout, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanPath(fp string) string {
|
||||||
|
if strings.HasPrefix(fp, os.Getenv("HOME")) {
|
||||||
|
return filepath.Join("~", strings.TrimPrefix(fp, os.Getenv("HOME")))
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
188
cmd/d2/main.go
Normal file
188
cmd/d2/main.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2"
|
||||||
|
"oss.terrastruct.com/d2/cmd/version"
|
||||||
|
"oss.terrastruct.com/d2/d2plugin"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2svg"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/textmeasure"
|
||||||
|
"oss.terrastruct.com/d2/d2themes"
|
||||||
|
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
|
||||||
|
"oss.terrastruct.com/d2/lib/xmain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
xmain.Main(run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, ms *xmain.State) (err error) {
|
||||||
|
// :(
|
||||||
|
ctx = xmain.DiscardSlog(ctx)
|
||||||
|
|
||||||
|
watchFlag := ms.FlagSet.BoolP("watch", "w", false, "watch for changes to input and live reload. Use $PORT and $HOST to specify the listening address.\n$D2_PORT and $D2_HOST are also accepted and take priority. Default is localhost:0")
|
||||||
|
themeFlag := ms.FlagSet.Int64P("theme", "t", 0, "set the diagram theme. For a list of available options, see https://oss.terrastruct.com/d2")
|
||||||
|
bundleFlag := ms.FlagSet.BoolP("bundle", "b", true, "bundle all assets and layers into the output svg")
|
||||||
|
versionFlag := ms.FlagSet.BoolP("version", "v", false, "get the version and check for updates")
|
||||||
|
debugFlag := ms.FlagSet.BoolP("debug", "d", false, "print debug logs")
|
||||||
|
err = ms.FlagSet.Parse(ms.Args)
|
||||||
|
|
||||||
|
if !errors.Is(err, pflag.ErrHelp) && err != nil {
|
||||||
|
return fmt.Errorf("failed to parse flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ms.FlagSet.Args()) > 0 {
|
||||||
|
switch ms.FlagSet.Arg(0) {
|
||||||
|
case "layout":
|
||||||
|
return layoutHelp(ctx, ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, pflag.ErrHelp) {
|
||||||
|
help(ms)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if *debugFlag {
|
||||||
|
ms.Env.Setenv("DEBUG", "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
var inputPath string
|
||||||
|
var outputPath string
|
||||||
|
|
||||||
|
if len(ms.FlagSet.Args()) == 0 {
|
||||||
|
if versionFlag != nil && *versionFlag {
|
||||||
|
version.CheckVersion(ctx, ms.Log)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
help(ms)
|
||||||
|
return nil
|
||||||
|
} else if len(ms.FlagSet.Args()) >= 3 {
|
||||||
|
return xmain.UsageErrorf("too many arguments passed")
|
||||||
|
}
|
||||||
|
if len(ms.FlagSet.Args()) >= 1 {
|
||||||
|
if ms.FlagSet.Arg(0) == "version" {
|
||||||
|
version.CheckVersion(ctx, ms.Log)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inputPath = ms.FlagSet.Arg(0)
|
||||||
|
}
|
||||||
|
if len(ms.FlagSet.Args()) >= 2 {
|
||||||
|
outputPath = ms.FlagSet.Arg(1)
|
||||||
|
} else {
|
||||||
|
if inputPath == "-" {
|
||||||
|
outputPath = "-"
|
||||||
|
} else {
|
||||||
|
outputPath = renameExt(inputPath, ".svg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match := d2themescatalog.Find(*themeFlag)
|
||||||
|
if match == (d2themes.Theme{}) {
|
||||||
|
return xmain.UsageErrorf("-t[heme] could not be found. The available options are:\n%s\nYou provided: %d", d2themescatalog.CLIString(), *themeFlag)
|
||||||
|
}
|
||||||
|
ms.Env.Setenv("D2_THEME", fmt.Sprintf("%d", *themeFlag))
|
||||||
|
|
||||||
|
envD2Layout := ms.Env.Getenv("D2_LAYOUT")
|
||||||
|
if envD2Layout == "" {
|
||||||
|
envD2Layout = "dagre"
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin, path, err := d2plugin.FindPlugin(ctx, envD2Layout)
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
return layoutNotFound(ctx, envD2Layout)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginLocation := "bundled"
|
||||||
|
if path != "" {
|
||||||
|
pluginLocation = fmt.Sprintf("executable plugin at %s", humanPath(path))
|
||||||
|
}
|
||||||
|
ms.Log.Debug.Printf("using layout plugin %s (%s)", envD2Layout, pluginLocation)
|
||||||
|
|
||||||
|
if *watchFlag {
|
||||||
|
if inputPath == "-" {
|
||||||
|
return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin")
|
||||||
|
}
|
||||||
|
ms.Env.Setenv("LOG_TIMESTAMPS", "1")
|
||||||
|
w, err := newWatcher(ctx, ms, plugin, inputPath, outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Minute*2)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if *bundleFlag {
|
||||||
|
_ = 343
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = compile(ctx, ms, plugin, inputPath, outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ms.Log.Success.Printf("successfully compiled %v to %v", inputPath, outputPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, inputPath, outputPath string) ([]byte, error) {
|
||||||
|
input, err := ms.ReadPath(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
themeID, _ := strconv.ParseInt(ms.Env.Getenv("D2_THEME"), 10, 64)
|
||||||
|
d, err := d2.Compile(ctx, string(input), &d2.CompileOptions{
|
||||||
|
Layout: plugin.Layout,
|
||||||
|
Ruler: ruler,
|
||||||
|
ThemeID: themeID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
svg, err := d2svg.Render(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
svg, err = plugin.PostProcess(ctx, svg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ms.WritePath(outputPath, svg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return svg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newExt must include leading .
|
||||||
|
func renameExt(fp string, newExt string) string {
|
||||||
|
ext := filepath.Ext(fp)
|
||||||
|
if ext == "" {
|
||||||
|
return fp + newExt
|
||||||
|
} else {
|
||||||
|
return strings.TrimSuffix(fp, ext) + newExt
|
||||||
|
}
|
||||||
|
}
|
||||||
12
cmd/d2/main_test.go
Normal file
12
cmd/d2/main_test.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TODO: Use virtual file system with fs.FS in xmain.State when fs.FS supports writes.
|
||||||
|
// See https://github.com/golang/go/issues/45757. Or modify.
|
||||||
|
// We would need to abstract out fsnotify as well to work with the virtual test file system.
|
||||||
|
// See also https://pkg.go.dev/testing/fstest
|
||||||
|
// For now cleaning up temp directories after running tests is enough.
|
||||||
|
func TestRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
}
|
||||||
9
cmd/d2/static/watch.css
Normal file
9
cmd/d2/static/watch.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#d2c-err {
|
||||||
|
/* This style was copied from Chrome's svg parser error style. */
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border: 2px solid #c77;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px;
|
||||||
|
background-color: #fdd;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
53
cmd/d2/static/watch.js
Normal file
53
cmd/d2/static/watch.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"use strict";
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
init(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
function init(reconnectDelay) {
|
||||||
|
const devMode = document.body.dataset.d2DevMode === "true";
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`ws://${window.location.host}${window.location.pathname}watch`
|
||||||
|
);
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
console.info("watch websocket opened");
|
||||||
|
};
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (devMode) {
|
||||||
|
console.debug("watch websocket received data", ev);
|
||||||
|
} else {
|
||||||
|
console.debug("watch websocket received data");
|
||||||
|
}
|
||||||
|
const d2ErrDiv = window.document.querySelector("#d2-err");
|
||||||
|
if (msg.err) {
|
||||||
|
d2ErrDiv.innerText = msg.err;
|
||||||
|
d2ErrDiv.style.display = "block";
|
||||||
|
d2ErrDiv.scrollIntoView();
|
||||||
|
} else {
|
||||||
|
const d2SVG = window.document.querySelector("#d2-svg");
|
||||||
|
// We could turn d2SVG into an actual SVG element and use outerHTML to fully replace it
|
||||||
|
// with the result from the renderer but unfortunately that overwrites the #d2-svg ID.
|
||||||
|
// Even if you add another line to set it afterwards. The parsing/interpretation of outerHTML must be async.
|
||||||
|
//
|
||||||
|
// There's no way around that short of parsing out the top level svg tag in the msg and
|
||||||
|
// setting innerHTML to only the actual svg innards. However then you also need to parse
|
||||||
|
// out the width, height and viewbox out of the top level SVG tag and update those manually.
|
||||||
|
d2SVG.innerHTML = msg.svg;
|
||||||
|
d2ErrDiv.style.display = "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = (ev) => {
|
||||||
|
console.error("watch websocket connection error", ev);
|
||||||
|
};
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
console.error(`watch websocket closed with code`, ev.code, `and reason`, ev.reason);
|
||||||
|
console.info(`reconnecting in ${reconnectDelay / 1000} seconds`);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (reconnectDelay < 16000) {
|
||||||
|
reconnectDelay *= 2;
|
||||||
|
}
|
||||||
|
init(reconnectDelay);
|
||||||
|
}, reconnectDelay);
|
||||||
|
};
|
||||||
|
}
|
||||||
534
cmd/d2/watch.go
Normal file
534
cmd/d2/watch.go
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
"nhooyr.io/websocket/wsjson"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2plugin"
|
||||||
|
"oss.terrastruct.com/d2/lib/xbrowser"
|
||||||
|
"oss.terrastruct.com/d2/lib/xhttp"
|
||||||
|
"oss.terrastruct.com/d2/lib/xmain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enabled with the build tag "dev".
|
||||||
|
// See watch_dev.go
|
||||||
|
// Controls whether the embedded staticFS is used or if files are served directly from the
|
||||||
|
// file system. Useful for quick iteration in development.
|
||||||
|
var devMode = false
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
type watcher struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
devMode bool
|
||||||
|
|
||||||
|
ms *xmain.State
|
||||||
|
layoutPlugin d2plugin.Plugin
|
||||||
|
inputPath string
|
||||||
|
outputPath string
|
||||||
|
|
||||||
|
compileCh chan struct{}
|
||||||
|
|
||||||
|
fw *fsnotify.Watcher
|
||||||
|
l net.Listener
|
||||||
|
staticFileServer http.Handler
|
||||||
|
|
||||||
|
wsclientsMu sync.Mutex
|
||||||
|
closing bool
|
||||||
|
wsclientsWG sync.WaitGroup
|
||||||
|
wsclients map[*wsclient]struct{}
|
||||||
|
|
||||||
|
errMu sync.Mutex
|
||||||
|
err error
|
||||||
|
|
||||||
|
resMu sync.Mutex
|
||||||
|
res *compileResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type compileResult struct {
|
||||||
|
Err string `json:"err"`
|
||||||
|
SVG string `json:"svg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWatcher(ctx context.Context, ms *xmain.State, layoutPlugin d2plugin.Plugin, inputPath, outputPath string) (*watcher, error) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
w := &watcher{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
devMode: devMode,
|
||||||
|
|
||||||
|
ms: ms,
|
||||||
|
layoutPlugin: layoutPlugin,
|
||||||
|
inputPath: inputPath,
|
||||||
|
outputPath: outputPath,
|
||||||
|
|
||||||
|
compileCh: make(chan struct{}, 1),
|
||||||
|
wsclients: make(map[*wsclient]struct{}),
|
||||||
|
}
|
||||||
|
err := w.init()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) init() error {
|
||||||
|
fw, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.fw = fw
|
||||||
|
err = w.initStaticFileServer()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) initStaticFileServer() error {
|
||||||
|
// Serve files directly in dev mode for fast iteration.
|
||||||
|
if w.devMode {
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("d2: runtime failed to provide path of watch.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
staticFilesDir := filepath.Join(filepath.Dir(file), "./static")
|
||||||
|
w.staticFileServer = http.FileServer(http.Dir(staticFilesDir))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sfs, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.staticFileServer = http.FileServer(http.FS(sfs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) run() error {
|
||||||
|
defer w.close()
|
||||||
|
|
||||||
|
w.goFunc(w.watchLoop)
|
||||||
|
w.goFunc(w.compileLoop)
|
||||||
|
|
||||||
|
err := w.goServe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.wg.Wait()
|
||||||
|
w.close()
|
||||||
|
return w.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) close() {
|
||||||
|
w.wsclientsMu.Lock()
|
||||||
|
if w.closing {
|
||||||
|
w.wsclientsMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.closing = true
|
||||||
|
w.wsclientsMu.Unlock()
|
||||||
|
|
||||||
|
w.cancel()
|
||||||
|
if w.fw != nil {
|
||||||
|
err := w.fw.Close()
|
||||||
|
w.setErr(err)
|
||||||
|
}
|
||||||
|
if w.l != nil {
|
||||||
|
err := w.l.Close()
|
||||||
|
w.setErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.wsclientsWG.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) setErr(err error) {
|
||||||
|
w.errMu.Lock()
|
||||||
|
if w.err == nil {
|
||||||
|
w.err = err
|
||||||
|
}
|
||||||
|
w.errMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) goFunc(fn func(context.Context) error) {
|
||||||
|
w.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer w.wg.Done()
|
||||||
|
defer w.cancel()
|
||||||
|
|
||||||
|
err := fn(w.ctx)
|
||||||
|
w.setErr(err)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* IMPORTANT
|
||||||
|
*
|
||||||
|
* Do not touch watchLoop or ensureAddWatch without consulting @nhooyr
|
||||||
|
* fsnotify and file system watching APIs in general are notoriously hard
|
||||||
|
* to use correctly.
|
||||||
|
*
|
||||||
|
* This issue is a good summary though it too contains confusion and misunderstandings:
|
||||||
|
* https://github.com/fsnotify/fsnotify/issues/372
|
||||||
|
*
|
||||||
|
* The code was thoroughly considered and experimentally vetted.
|
||||||
|
*
|
||||||
|
* TODO: Abstract out file system and fsnotify to test this with 100% coverage. See comment in main_test.go
|
||||||
|
*/
|
||||||
|
func (w *watcher) watchLoop(ctx context.Context) error {
|
||||||
|
lastModified, err := w.ensureAddWatch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.ms.Log.Info.Printf("compiling %v...", w.inputPath)
|
||||||
|
w.requestCompile()
|
||||||
|
|
||||||
|
eatBurstTimer := time.NewTimer(0)
|
||||||
|
<-eatBurstTimer.C
|
||||||
|
pollTicker := time.NewTicker(time.Second * 10)
|
||||||
|
defer pollTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pollTicker.C:
|
||||||
|
// In case we missed an event indicating the path is unwatchable and we won't be
|
||||||
|
// getting any more events.
|
||||||
|
// File notification APIs are notoriously unreliable. I've personally experienced
|
||||||
|
// many quirks and so feel this check is justified even if excessive.
|
||||||
|
mt, err := w.ensureAddWatch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !mt.Equal(lastModified) {
|
||||||
|
// We missed changes.
|
||||||
|
lastModified = mt
|
||||||
|
w.requestCompile()
|
||||||
|
}
|
||||||
|
case ev, ok := <-w.fw.Events:
|
||||||
|
if !ok {
|
||||||
|
return errors.New("fsnotify watcher closed")
|
||||||
|
}
|
||||||
|
w.ms.Log.Debug.Printf("received file system event %v", ev)
|
||||||
|
mt, err := w.ensureAddWatch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ev.Op == fsnotify.Chmod {
|
||||||
|
if mt.Equal(lastModified) {
|
||||||
|
// Benign Chmod.
|
||||||
|
// See https://github.com/fsnotify/fsnotify/issues/15
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// We missed changes.
|
||||||
|
lastModified = mt
|
||||||
|
}
|
||||||
|
// The purpose of eatBurstTimer is to wait at least 32 milliseconds after a sequence of
|
||||||
|
// events to ensure that whomever is editing the file is now done.
|
||||||
|
//
|
||||||
|
// For example, On macOS editing with neovim, every write I see a chmod immediately
|
||||||
|
// followed by a write followed by another chmod. We don't want the three events to
|
||||||
|
// be treated as two or three compilations, we want them to be batched into one.
|
||||||
|
//
|
||||||
|
// Another example would be a very large file where one logical edit becomes write
|
||||||
|
// events. We wouldn't want to try to compile an incomplete file and then report a
|
||||||
|
// misleading error.
|
||||||
|
eatBurstTimer.Reset(time.Millisecond * 32)
|
||||||
|
case <-eatBurstTimer.C:
|
||||||
|
w.ms.Log.Info.Printf("detected change in %v: recompiling...", w.inputPath)
|
||||||
|
w.requestCompile()
|
||||||
|
case err, ok := <-w.fw.Errors:
|
||||||
|
if !ok {
|
||||||
|
return errors.New("fsnotify watcher closed")
|
||||||
|
}
|
||||||
|
w.ms.Log.Error.Printf("fsnotify error: %v", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) requestCompile() {
|
||||||
|
select {
|
||||||
|
case w.compileCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) ensureAddWatch(ctx context.Context) (time.Time, error) {
|
||||||
|
interval := time.Second
|
||||||
|
tc := time.NewTimer(0)
|
||||||
|
<-tc.C
|
||||||
|
for {
|
||||||
|
mt, err := w.addWatch(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return mt, nil
|
||||||
|
}
|
||||||
|
w.ms.Log.Error.Printf("failed to watch inputPath %q: %v (retrying in %v)", w.inputPath, err, interval)
|
||||||
|
|
||||||
|
tc.Reset(interval)
|
||||||
|
select {
|
||||||
|
case <-tc.C:
|
||||||
|
if interval < time.Second*16 {
|
||||||
|
interval *= 2
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return time.Time{}, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) addWatch(ctx context.Context) (time.Time, error) {
|
||||||
|
err := w.fw.Add(w.inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
var d os.FileInfo
|
||||||
|
d, err = os.Stat(w.inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return d.ModTime(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) compileLoop(ctx context.Context) error {
|
||||||
|
firstCompile := true
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-w.compileCh:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
recompiledPrefix := ""
|
||||||
|
if !firstCompile {
|
||||||
|
recompiledPrefix = "re"
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := compile(ctx, w.ms, w.layoutPlugin, w.inputPath, w.outputPath)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to %scompile: %w", recompiledPrefix, err)
|
||||||
|
w.ms.Log.Error.Print(err)
|
||||||
|
w.broadcast(&compileResult{
|
||||||
|
Err: err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
w.ms.Log.Success.Printf("successfully %scompiled %v to %v", recompiledPrefix, w.inputPath, w.outputPath)
|
||||||
|
w.broadcast(&compileResult{
|
||||||
|
SVG: string(b),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstCompile {
|
||||||
|
firstCompile = false
|
||||||
|
url := fmt.Sprintf("http://%s", w.l.Addr())
|
||||||
|
err = xbrowser.OpenURL(ctx, w.ms.Env, url)
|
||||||
|
if err != nil {
|
||||||
|
w.ms.Log.Warn.Printf("failed to open browser to %v: %v", url, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) listen() error {
|
||||||
|
host := "localhost"
|
||||||
|
port := "0"
|
||||||
|
hostEnv := w.ms.Env.Getenv("HOST")
|
||||||
|
if hostEnv != "" {
|
||||||
|
host = hostEnv
|
||||||
|
}
|
||||||
|
portEnv := w.ms.Env.Getenv("PORT")
|
||||||
|
if portEnv != "" {
|
||||||
|
port = portEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", net.JoinHostPort(host, port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.l = l
|
||||||
|
w.ms.Log.Success.Printf("listening on http://%v", w.l.Addr())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) goServe() error {
|
||||||
|
m := http.NewServeMux()
|
||||||
|
// TODO: Add cmdlog logging and error reporting middleware
|
||||||
|
// TODO: Add standard debug/profiling routes
|
||||||
|
m.HandleFunc("/", w.handleRoot)
|
||||||
|
m.Handle("/static/", http.StripPrefix("/static", w.staticFileServer))
|
||||||
|
m.Handle("/watch", xhttp.HandlerFuncAdapter{w.ms.Log, w.handleWatch})
|
||||||
|
|
||||||
|
s := xhttp.NewServer(w.ms.Log.Warn, xhttp.Log(w.ms.Log, m))
|
||||||
|
w.goFunc(func(ctx context.Context) error {
|
||||||
|
return xhttp.Serve(ctx, time.Second*30, s, w.l)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) getRes() *compileResult {
|
||||||
|
w.resMu.Lock()
|
||||||
|
defer w.resMu.Unlock()
|
||||||
|
return w.res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) handleRoot(hw http.ResponseWriter, r *http.Request) {
|
||||||
|
hw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(hw, `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>%s</title>
|
||||||
|
<script src="./static/watch.js"></script>
|
||||||
|
<link rel="stylesheet" href="./static/watch.css">
|
||||||
|
</head>
|
||||||
|
<body data-d2-dev-mode=%t>
|
||||||
|
<div id="d2-err" style="display: none"></div>
|
||||||
|
<div id="d2-svg"></div>
|
||||||
|
</body>
|
||||||
|
</html>`, w.outputPath, w.devMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) handleWatch(hw http.ResponseWriter, r *http.Request) error {
|
||||||
|
w.wsclientsMu.Lock()
|
||||||
|
if w.closing {
|
||||||
|
w.wsclientsMu.Unlock()
|
||||||
|
return xhttp.Errorf(http.StatusServiceUnavailable, "server shutting down...", "server shutting down...")
|
||||||
|
}
|
||||||
|
// We must register ourselves before we even upgrade the connection to ensure that
|
||||||
|
// w.close() will wait for us. If we instead registered afterwards, then there is a
|
||||||
|
// brief period between the hijack and the registration where close may return without
|
||||||
|
// waiting for us to finish.
|
||||||
|
w.wsclientsWG.Add(1)
|
||||||
|
w.wsclientsMu.Unlock()
|
||||||
|
|
||||||
|
c, err := websocket.Accept(hw, r, &websocket.AcceptOptions{
|
||||||
|
CompressionMode: websocket.CompressionDisabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
w.wsclientsWG.Done()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer w.wsclientsWG.Done()
|
||||||
|
defer c.Close(websocket.StatusInternalError, "the sky is falling")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(w.ctx, time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cl := &wsclient{
|
||||||
|
w: w,
|
||||||
|
resultsCh: make(chan struct{}, 1),
|
||||||
|
c: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.wsclientsMu.Lock()
|
||||||
|
w.wsclients[cl] = struct{}{}
|
||||||
|
w.wsclientsMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
w.wsclientsMu.Lock()
|
||||||
|
delete(w.wsclients, cl)
|
||||||
|
w.wsclientsMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx = cl.c.CloseRead(ctx)
|
||||||
|
go wsHeartbeat(ctx, cl.c)
|
||||||
|
_ = cl.writeLoop(ctx)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsclient struct {
|
||||||
|
w *watcher
|
||||||
|
resultsCh chan struct{}
|
||||||
|
c *websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *wsclient) writeLoop(ctx context.Context) error {
|
||||||
|
for {
|
||||||
|
res := cl.w.getRes()
|
||||||
|
if res != nil {
|
||||||
|
err := cl.write(ctx, res)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cl.resultsCh:
|
||||||
|
case <-ctx.Done():
|
||||||
|
cl.c.Close(websocket.StatusGoingAway, "server shutting down...")
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *wsclient) write(ctx context.Context, res *compileResult) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return wsjson.Write(ctx, cl.c, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) broadcast(res *compileResult) {
|
||||||
|
w.resMu.Lock()
|
||||||
|
w.res = res
|
||||||
|
w.resMu.Unlock()
|
||||||
|
|
||||||
|
w.wsclientsMu.Lock()
|
||||||
|
defer w.wsclientsMu.Unlock()
|
||||||
|
clientsSuffix := ""
|
||||||
|
if len(w.wsclients) != 1 {
|
||||||
|
clientsSuffix = "s"
|
||||||
|
}
|
||||||
|
w.ms.Log.Info.Printf("broadcasting update to %d client%s", len(w.wsclients), clientsSuffix)
|
||||||
|
for cl := range w.wsclients {
|
||||||
|
select {
|
||||||
|
case cl.resultsCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wsHeartbeat(ctx context.Context, c *websocket.Conn) {
|
||||||
|
defer c.Close(websocket.StatusInternalError, "the sky is falling")
|
||||||
|
|
||||||
|
t := time.NewTimer(0)
|
||||||
|
<-t.C
|
||||||
|
for {
|
||||||
|
err := c.Ping(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Reset(time.Second * 30)
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
cmd/d2/watch_dev.go
Normal file
8
cmd/d2/watch_dev.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
//go:build dev
|
||||||
|
// +build dev
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
devMode = true
|
||||||
|
}
|
||||||
12
cmd/d2plugin-dagre/main.go
Normal file
12
cmd/d2plugin-dagre/main.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build cgo && !nodagre
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"oss.terrastruct.com/d2/d2plugin"
|
||||||
|
"oss.terrastruct.com/d2/lib/xmain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
xmain.Main(d2plugin.Serve(d2plugin.DagrePlugin))
|
||||||
|
}
|
||||||
38
cmd/version/version.go
Normal file
38
cmd/version/version.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/go-github/github"
|
||||||
|
"oss.terrastruct.com/cmdlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-built binaries will have version set during build time.
|
||||||
|
var Version = "master (built from source)"
|
||||||
|
|
||||||
|
func CheckVersion(ctx context.Context, logger *cmdlog.Logger) {
|
||||||
|
logger.Info.Printf("D2 version: %s\n", Version)
|
||||||
|
|
||||||
|
if Version == "master (built from source)" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info.Printf("Checking for updates...")
|
||||||
|
latest, err := getLatestVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug.Printf("Error reaching Github for latest version: %s", err.Error())
|
||||||
|
} else if Version != "master" && Version != latest {
|
||||||
|
logger.Info.Printf("A new version of D2 is available: %s -> %s", Version, latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLatestVersion(ctx context.Context) (string, error) {
|
||||||
|
client := github.NewClient(nil)
|
||||||
|
rep, _, err := client.Repositories.GetLatestRelease(ctx, "terrastruct", "d2")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return *rep.TagName, nil
|
||||||
|
}
|
||||||
58
d2.go
Normal file
58
d2.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package d2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2compiler"
|
||||||
|
"oss.terrastruct.com/d2/d2exporter"
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/textmeasure"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CompileOptions struct {
|
||||||
|
UTF16 bool
|
||||||
|
MeasuredTexts []*d2target.MText
|
||||||
|
Ruler *textmeasure.Ruler
|
||||||
|
Layout func(context.Context, *d2graph.Graph) error
|
||||||
|
|
||||||
|
ThemeID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target.Diagram, error) {
|
||||||
|
if opts == nil {
|
||||||
|
opts = &CompileOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := d2compiler.Compile("", strings.NewReader(input), &d2compiler.CompileOptions{
|
||||||
|
UTF16: opts.UTF16,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = g.SetDimensions(opts.MeasuredTexts, opts.Ruler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Layout != nil {
|
||||||
|
err = opts.Layout(ctx, g)
|
||||||
|
} else if os.Getenv("D2_LAYOUT") == "dagre" && dagreLayout != nil {
|
||||||
|
err = dagreLayout(ctx, g)
|
||||||
|
} else {
|
||||||
|
err = errors.New("no available layout")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
diagram, err := d2exporter.Export(ctx, g, opts.ThemeID)
|
||||||
|
return diagram, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// See c.go
|
||||||
|
var dagreLayout func(context.Context, *d2graph.Graph) error
|
||||||
972
d2ast/d2ast.go
Normal file
972
d2ast/d2ast.go
Normal file
|
|
@ -0,0 +1,972 @@
|
||||||
|
// d2ast implements the d2 language's abstract syntax tree.
|
||||||
|
//
|
||||||
|
// https://github.com/terrastruct/d2-vscode
|
||||||
|
// https://terrastruct.com/docs/d2/tour/intro/
|
||||||
|
//
|
||||||
|
// Special characters to think about in parser:
|
||||||
|
// #
|
||||||
|
// """
|
||||||
|
// ;
|
||||||
|
// []
|
||||||
|
// {}
|
||||||
|
// |
|
||||||
|
// $
|
||||||
|
// '
|
||||||
|
// "
|
||||||
|
// \
|
||||||
|
// :
|
||||||
|
// .
|
||||||
|
// --
|
||||||
|
// <>
|
||||||
|
// *
|
||||||
|
// &
|
||||||
|
// ()
|
||||||
|
package d2ast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/xdefer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is the base interface implemented by all d2 AST nodes.
|
||||||
|
// TODO: add error node for autofmt of incomplete AST
|
||||||
|
type Node interface {
|
||||||
|
node()
|
||||||
|
|
||||||
|
// Type returns the user friendly name of the node.
|
||||||
|
Type() string
|
||||||
|
|
||||||
|
// GetRange returns the range a node occupies in its file.
|
||||||
|
GetRange() Range
|
||||||
|
|
||||||
|
// TODO: add Children() for walking AST
|
||||||
|
// Children() []Node
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Node = &Comment{}
|
||||||
|
var _ Node = &BlockComment{}
|
||||||
|
|
||||||
|
var _ Node = &Null{}
|
||||||
|
var _ Node = &Boolean{}
|
||||||
|
var _ Node = &Number{}
|
||||||
|
var _ Node = &UnquotedString{}
|
||||||
|
var _ Node = &DoubleQuotedString{}
|
||||||
|
var _ Node = &SingleQuotedString{}
|
||||||
|
var _ Node = &BlockString{}
|
||||||
|
var _ Node = &Substitution{}
|
||||||
|
|
||||||
|
var _ Node = &Array{}
|
||||||
|
var _ Node = &Map{}
|
||||||
|
var _ Node = &Key{}
|
||||||
|
var _ Node = &KeyPath{}
|
||||||
|
var _ Node = &Edge{}
|
||||||
|
var _ Node = &EdgeIndex{}
|
||||||
|
|
||||||
|
// Range represents a range between Start and End in Path.
|
||||||
|
// It's also used in the d2parser package to represent the range of an error.
|
||||||
|
//
|
||||||
|
// note: See docs on Position.
|
||||||
|
//
|
||||||
|
// It has a custom JSON string encoding with encoding.TextMarshaler and
|
||||||
|
// encoding.TextUnmarshaler to keep it compact as the JSON struct encoding is too verbose,
|
||||||
|
// especially with json.MarshalIndent.
|
||||||
|
//
|
||||||
|
// It looks like path,start-end
|
||||||
|
type Range struct {
|
||||||
|
Path string
|
||||||
|
Start Position
|
||||||
|
End Position
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ fmt.Stringer = Range{}
|
||||||
|
var _ encoding.TextMarshaler = Range{}
|
||||||
|
var _ encoding.TextUnmarshaler = &Range{}
|
||||||
|
|
||||||
|
func MakeRange(s string) Range {
|
||||||
|
var r Range
|
||||||
|
_ = r.UnmarshalText([]byte(s))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a string representation of the range including only the path and start.
|
||||||
|
//
|
||||||
|
// If path is empty, it will be omitted.
|
||||||
|
//
|
||||||
|
// The format is path:start
|
||||||
|
func (r Range) String() string {
|
||||||
|
var s strings.Builder
|
||||||
|
if r.Path != "" {
|
||||||
|
s.WriteString(r.Path)
|
||||||
|
s.WriteByte(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
s.WriteString(r.Start.String())
|
||||||
|
return s.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OneLine returns true if the Range starts and ends on the same line.
|
||||||
|
func (r Range) OneLine() bool {
|
||||||
|
return r.Start.Line == r.End.Line
|
||||||
|
}
|
||||||
|
|
||||||
|
// See docs on Range.
|
||||||
|
func (r Range) MarshalText() ([]byte, error) {
|
||||||
|
start, _ := r.Start.MarshalText()
|
||||||
|
end, _ := r.End.MarshalText()
|
||||||
|
return []byte(fmt.Sprintf("%s,%s-%s", r.Path, start, end)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// See docs on Range.
|
||||||
|
func (r *Range) UnmarshalText(b []byte) (err error) {
|
||||||
|
defer xdefer.Errorf(&err, "failed to unmarshal Range from %q", b)
|
||||||
|
|
||||||
|
i := bytes.LastIndexByte(b, '-')
|
||||||
|
if i == -1 {
|
||||||
|
return errors.New("missing End field")
|
||||||
|
}
|
||||||
|
end := b[i+1:]
|
||||||
|
b = b[:i]
|
||||||
|
|
||||||
|
i = bytes.LastIndexByte(b, ',')
|
||||||
|
if i == -1 {
|
||||||
|
return errors.New("missing Start field")
|
||||||
|
}
|
||||||
|
start := b[i+1:]
|
||||||
|
b = b[:i]
|
||||||
|
|
||||||
|
r.Path = string(b)
|
||||||
|
err = r.Start.UnmarshalText(start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.End.UnmarshalText(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position represents a line:column and byte position in a file.
|
||||||
|
//
|
||||||
|
// note: Line and Column are zero indexed.
|
||||||
|
// note: Column and Byte are UTF-8 byte indexes unless byUTF16 was passed to Position.Advance in
|
||||||
|
// which they are UTF-16 code unit indexes.
|
||||||
|
// If intended for Javascript consumption like in the browser or via LSP, byUTF16 is
|
||||||
|
// set to true.
|
||||||
|
type Position struct {
|
||||||
|
Line int
|
||||||
|
Column int
|
||||||
|
Byte int
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ fmt.Stringer = Position{}
|
||||||
|
var _ encoding.TextMarshaler = Position{}
|
||||||
|
var _ encoding.TextUnmarshaler = &Position{}
|
||||||
|
|
||||||
|
// String returns a line:column representation of the position suitable for error messages.
|
||||||
|
//
|
||||||
|
// note: Should not normally be used directly, see Range.String()
|
||||||
|
func (p Position) String() string {
|
||||||
|
return fmt.Sprintf("%d:%d", p.Line+1, p.Column+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// See docs on Range.
|
||||||
|
func (p Position) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(fmt.Sprintf("%d:%d:%d", p.Line, p.Column, p.Byte)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// See docs on Range.
|
||||||
|
func (p *Position) UnmarshalText(b []byte) (err error) {
|
||||||
|
defer xdefer.Errorf(&err, "failed to unmarshal Position from %q", b)
|
||||||
|
|
||||||
|
fields := bytes.Split(b, []byte{':'})
|
||||||
|
if len(fields) != 3 {
|
||||||
|
return errors.New("expected three fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Line, err = strconv.Atoi(string(fields[0]))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Column, err = strconv.Atoi(string(fields[1]))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Byte, err = strconv.Atoi(string(fields[2]))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// From copies src into p. It's used in the d2parser package to set a node's Range.End to
|
||||||
|
// the parser's current pos on all return paths with defer.
|
||||||
|
func (p *Position) From(src *Position) {
|
||||||
|
*p = *src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance advances p's Line, Column and Byte by r and returns
|
||||||
|
// the new Position.
|
||||||
|
// Set byUTF16 to advance the position as though r represents
|
||||||
|
// a UTF-16 codepoint.
|
||||||
|
func (p Position) Advance(r rune, byUTF16 bool) Position {
|
||||||
|
size := utf8.RuneLen(r)
|
||||||
|
if byUTF16 {
|
||||||
|
size = 1
|
||||||
|
r1, r2 := utf16.EncodeRune(r)
|
||||||
|
if r1 != '\uFFFD' && r2 != '\uFFFD' {
|
||||||
|
size = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == '\n' {
|
||||||
|
p.Line++
|
||||||
|
p.Column = 0
|
||||||
|
} else {
|
||||||
|
p.Column += size
|
||||||
|
}
|
||||||
|
p.Byte += size
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Position) Subtract(r rune, byUTF16 bool) Position {
|
||||||
|
size := utf8.RuneLen(r)
|
||||||
|
if byUTF16 {
|
||||||
|
size = 1
|
||||||
|
r1, r2 := utf16.EncodeRune(r)
|
||||||
|
if r1 != '\uFFFD' && r2 != '\uFFFD' {
|
||||||
|
size = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == '\n' {
|
||||||
|
panic("d2ast: cannot subtract newline from Position")
|
||||||
|
} else {
|
||||||
|
p.Column -= size
|
||||||
|
}
|
||||||
|
p.Byte -= size
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Position) SubtractString(s string, byUTF16 bool) Position {
|
||||||
|
for _, r := range s {
|
||||||
|
p = p.Subtract(r, byUTF16)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapNode is implemented by nodes that may be children of Maps.
|
||||||
|
type MapNode interface {
|
||||||
|
Node
|
||||||
|
mapNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ MapNode = &Comment{}
|
||||||
|
var _ MapNode = &BlockComment{}
|
||||||
|
var _ MapNode = &Key{}
|
||||||
|
var _ MapNode = &Substitution{}
|
||||||
|
|
||||||
|
// ArrayNode is implemented by nodes that may be children of Arrays.
|
||||||
|
type ArrayNode interface {
|
||||||
|
Node
|
||||||
|
arrayNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// See Value for the rest.
|
||||||
|
var _ ArrayNode = &Comment{}
|
||||||
|
var _ ArrayNode = &BlockComment{}
|
||||||
|
var _ ArrayNode = &Substitution{}
|
||||||
|
|
||||||
|
// Value is implemented by nodes that may be values of a key.
|
||||||
|
type Value interface {
|
||||||
|
ArrayNode
|
||||||
|
value()
|
||||||
|
}
|
||||||
|
|
||||||
|
// See Scalar for rest.
|
||||||
|
var _ Value = &Array{}
|
||||||
|
var _ Value = &Map{}
|
||||||
|
|
||||||
|
// Scalar is implemented by nodes that represent scalar values.
|
||||||
|
type Scalar interface {
|
||||||
|
Value
|
||||||
|
scalar()
|
||||||
|
ScalarString() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// See String for rest.
|
||||||
|
var _ Scalar = &Null{}
|
||||||
|
var _ Scalar = &Boolean{}
|
||||||
|
var _ Scalar = &Number{}
|
||||||
|
|
||||||
|
// String is implemented by nodes that represent strings.
|
||||||
|
type String interface {
|
||||||
|
Scalar
|
||||||
|
SetString(string)
|
||||||
|
Copy() String
|
||||||
|
_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ String = &UnquotedString{}
|
||||||
|
var _ String = &SingleQuotedString{}
|
||||||
|
var _ String = &DoubleQuotedString{}
|
||||||
|
var _ String = &BlockString{}
|
||||||
|
|
||||||
|
func (c *Comment) node() {}
|
||||||
|
func (c *BlockComment) node() {}
|
||||||
|
func (n *Null) node() {}
|
||||||
|
func (b *Boolean) node() {}
|
||||||
|
func (n *Number) node() {}
|
||||||
|
func (s *UnquotedString) node() {}
|
||||||
|
func (s *DoubleQuotedString) node() {}
|
||||||
|
func (s *SingleQuotedString) node() {}
|
||||||
|
func (s *BlockString) node() {}
|
||||||
|
func (s *Substitution) node() {}
|
||||||
|
func (a *Array) node() {}
|
||||||
|
func (m *Map) node() {}
|
||||||
|
func (k *Key) node() {}
|
||||||
|
func (k *KeyPath) node() {}
|
||||||
|
func (e *Edge) node() {}
|
||||||
|
func (i *EdgeIndex) node() {}
|
||||||
|
|
||||||
|
func (c *Comment) Type() string { return "comment" }
|
||||||
|
func (c *BlockComment) Type() string { return "block comment" }
|
||||||
|
func (n *Null) Type() string { return "null" }
|
||||||
|
func (b *Boolean) Type() string { return "boolean" }
|
||||||
|
func (n *Number) Type() string { return "number" }
|
||||||
|
func (s *UnquotedString) Type() string { return "unquoted string" }
|
||||||
|
func (s *DoubleQuotedString) Type() string { return "double quoted string" }
|
||||||
|
func (s *SingleQuotedString) Type() string { return "single quoted string" }
|
||||||
|
func (s *BlockString) Type() string { return s.Tag + " block string" }
|
||||||
|
func (s *Substitution) Type() string { return "substitution" }
|
||||||
|
func (a *Array) Type() string { return "array" }
|
||||||
|
func (m *Map) Type() string { return "map" }
|
||||||
|
func (k *Key) Type() string { return "map key" }
|
||||||
|
func (k *KeyPath) Type() string { return "key path" }
|
||||||
|
func (e *Edge) Type() string { return "edge" }
|
||||||
|
func (i *EdgeIndex) Type() string { return "edge index" }
|
||||||
|
|
||||||
|
func (c *Comment) GetRange() Range { return c.Range }
|
||||||
|
func (c *BlockComment) GetRange() Range { return c.Range }
|
||||||
|
func (n *Null) GetRange() Range { return n.Range }
|
||||||
|
func (b *Boolean) GetRange() Range { return b.Range }
|
||||||
|
func (n *Number) GetRange() Range { return n.Range }
|
||||||
|
func (s *UnquotedString) GetRange() Range { return s.Range }
|
||||||
|
func (s *DoubleQuotedString) GetRange() Range { return s.Range }
|
||||||
|
func (s *SingleQuotedString) GetRange() Range { return s.Range }
|
||||||
|
func (s *BlockString) GetRange() Range { return s.Range }
|
||||||
|
func (s *Substitution) GetRange() Range { return s.Range }
|
||||||
|
func (a *Array) GetRange() Range { return a.Range }
|
||||||
|
func (m *Map) GetRange() Range { return m.Range }
|
||||||
|
func (k *Key) GetRange() Range { return k.Range }
|
||||||
|
func (k *KeyPath) GetRange() Range { return k.Range }
|
||||||
|
func (e *Edge) GetRange() Range { return e.Range }
|
||||||
|
func (i *EdgeIndex) GetRange() Range { return i.Range }
|
||||||
|
|
||||||
|
func (c *Comment) mapNode() {}
|
||||||
|
func (c *BlockComment) mapNode() {}
|
||||||
|
func (k *Key) mapNode() {}
|
||||||
|
func (s *Substitution) mapNode() {}
|
||||||
|
|
||||||
|
func (c *Comment) arrayNode() {}
|
||||||
|
func (c *BlockComment) arrayNode() {}
|
||||||
|
func (n *Null) arrayNode() {}
|
||||||
|
func (b *Boolean) arrayNode() {}
|
||||||
|
func (n *Number) arrayNode() {}
|
||||||
|
func (s *UnquotedString) arrayNode() {}
|
||||||
|
func (s *DoubleQuotedString) arrayNode() {}
|
||||||
|
func (s *SingleQuotedString) arrayNode() {}
|
||||||
|
func (s *BlockString) arrayNode() {}
|
||||||
|
func (s *Substitution) arrayNode() {}
|
||||||
|
func (a *Array) arrayNode() {}
|
||||||
|
func (m *Map) arrayNode() {}
|
||||||
|
|
||||||
|
func (n *Null) value() {}
|
||||||
|
func (b *Boolean) value() {}
|
||||||
|
func (n *Number) value() {}
|
||||||
|
func (s *UnquotedString) value() {}
|
||||||
|
func (s *DoubleQuotedString) value() {}
|
||||||
|
func (s *SingleQuotedString) value() {}
|
||||||
|
func (s *BlockString) value() {}
|
||||||
|
func (a *Array) value() {}
|
||||||
|
func (m *Map) value() {}
|
||||||
|
|
||||||
|
func (n *Null) scalar() {}
|
||||||
|
func (b *Boolean) scalar() {}
|
||||||
|
func (n *Number) scalar() {}
|
||||||
|
func (s *UnquotedString) scalar() {}
|
||||||
|
func (s *DoubleQuotedString) scalar() {}
|
||||||
|
func (s *SingleQuotedString) scalar() {}
|
||||||
|
func (s *BlockString) scalar() {}
|
||||||
|
|
||||||
|
// TODO: mistake, move into parse.go
|
||||||
|
func (n *Null) ScalarString() string { return n.Type() }
|
||||||
|
func (b *Boolean) ScalarString() string { return strconv.FormatBool(b.Value) }
|
||||||
|
func (n *Number) ScalarString() string { return n.Raw }
|
||||||
|
func (s *UnquotedString) ScalarString() string {
|
||||||
|
if len(s.Value) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s.Value[0].String == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s.Value[0].String
|
||||||
|
}
|
||||||
|
func (s *DoubleQuotedString) ScalarString() string {
|
||||||
|
if len(s.Value) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s.Value[0].String == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s.Value[0].String
|
||||||
|
}
|
||||||
|
func (s *SingleQuotedString) ScalarString() string { return s.Value }
|
||||||
|
func (s *BlockString) ScalarString() string { return s.Value }
|
||||||
|
|
||||||
|
func (s *UnquotedString) SetString(s2 string) { s.Value = []InterpolationBox{{String: &s2}} }
|
||||||
|
func (s *DoubleQuotedString) SetString(s2 string) { s.Value = []InterpolationBox{{String: &s2}} }
|
||||||
|
func (s *SingleQuotedString) SetString(s2 string) { s.Raw = ""; s.Value = s2 }
|
||||||
|
func (s *BlockString) SetString(s2 string) { s.Value = s2 }
|
||||||
|
|
||||||
|
func (s *UnquotedString) Copy() String { tmp := *s; return &tmp }
|
||||||
|
func (s *DoubleQuotedString) Copy() String { tmp := *s; return &tmp }
|
||||||
|
func (s *SingleQuotedString) Copy() String { tmp := *s; return &tmp }
|
||||||
|
func (s *BlockString) Copy() String { tmp := *s; return &tmp }
|
||||||
|
|
||||||
|
func (s *UnquotedString) _string() {}
|
||||||
|
func (s *DoubleQuotedString) _string() {}
|
||||||
|
func (s *SingleQuotedString) _string() {}
|
||||||
|
func (s *BlockString) _string() {}
|
||||||
|
|
||||||
|
type Comment struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockComment struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Null struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Boolean struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Value bool `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Number struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Value *big.Rat `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnquotedString struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Value []InterpolationBox `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FlatUnquotedString(s string) *UnquotedString {
|
||||||
|
return &UnquotedString{
|
||||||
|
Value: []InterpolationBox{{String: &s}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DoubleQuotedString struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Value []InterpolationBox `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FlatDoubleQuotedString(s string) *DoubleQuotedString {
|
||||||
|
return &DoubleQuotedString{
|
||||||
|
Value: []InterpolationBox{{String: &s}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SingleQuotedString struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockString struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
|
||||||
|
// Quote contains the pipe delimiter for the block string.
|
||||||
|
// e.g. if 5 pipes were used to begin a block string, then Quote == "||||".
|
||||||
|
// The tag is not included.
|
||||||
|
Quote string `json:"quote"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Array struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Nodes []ArrayNodeBox `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Map struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Nodes []MapNodeBox `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Map) InsertAfter(cursor, n MapNode) {
|
||||||
|
afterIndex := len(m.Nodes) - 1
|
||||||
|
|
||||||
|
for i, n := range m.Nodes {
|
||||||
|
if n.Unbox() == cursor {
|
||||||
|
afterIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a := make([]MapNodeBox, 0, len(m.Nodes))
|
||||||
|
a = append(a, m.Nodes[:afterIndex+1]...)
|
||||||
|
a = append(a, MakeMapNodeBox(n))
|
||||||
|
a = append(a, m.Nodes[afterIndex+1:]...)
|
||||||
|
m.Nodes = a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Map) InsertBefore(cursor, n MapNode) {
|
||||||
|
beforeIndex := len(m.Nodes)
|
||||||
|
|
||||||
|
for i, n := range m.Nodes {
|
||||||
|
if n.Unbox() == cursor {
|
||||||
|
beforeIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a := make([]MapNodeBox, 0, len(m.Nodes))
|
||||||
|
a = append(a, m.Nodes[:beforeIndex]...)
|
||||||
|
a = append(a, MakeMapNodeBox(n))
|
||||||
|
a = append(a, m.Nodes[beforeIndex:]...)
|
||||||
|
m.Nodes = a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Map) IsFileMap() bool {
|
||||||
|
return m.Range.Start.Line == 0 && m.Range.Start.Column == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: require @ on import values for readability
|
||||||
|
type Key struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
|
||||||
|
// Indicates this MapKey is an override selector.
|
||||||
|
Ampersand bool `json:"ampersand,omitempty"`
|
||||||
|
|
||||||
|
// At least one of Key and Edges will be set but all four can also be set.
|
||||||
|
// The following are all valid MapKeys:
|
||||||
|
// Key:
|
||||||
|
// x
|
||||||
|
// Edges:
|
||||||
|
// x -> y
|
||||||
|
// Edges and EdgeIndex:
|
||||||
|
// (x -> y)[*]
|
||||||
|
// Edges and EdgeKey:
|
||||||
|
// (x -> y).label
|
||||||
|
// Key and Edges:
|
||||||
|
// container.(x -> y)
|
||||||
|
// Key, Edges and EdgeKey:
|
||||||
|
// container.(x -> y -> z).label
|
||||||
|
// Key, Edges, EdgeIndex EdgeKey:
|
||||||
|
// container.(x -> y -> z)[4].label
|
||||||
|
Key *KeyPath `json:"key,omitempty"`
|
||||||
|
Edges []*Edge `json:"edges,omitempty"`
|
||||||
|
EdgeIndex *EdgeIndex `json:"edge_index,omitempty"`
|
||||||
|
EdgeKey *KeyPath `json:"edge_key,omitempty"`
|
||||||
|
|
||||||
|
Primary ScalarBox `json:"primary,omitempty"`
|
||||||
|
Value ValueBox `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO there's more stuff to compare
|
||||||
|
func (mk1 *Key) Equals(mk2 *Key) bool {
|
||||||
|
if (mk1.Key == nil) != (mk2.Key == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (mk1.EdgeIndex == nil) != (mk2.EdgeIndex == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (mk1.EdgeKey == nil) != (mk2.EdgeKey == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(mk1.Edges) != len(mk2.Edges) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (mk1.Value.Map == nil) != (mk2.Value.Map == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (mk1.Value.Unbox() == nil) != (mk2.Value.Unbox() == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk1.Key != nil {
|
||||||
|
if len(mk1.Key.Path) != len(mk2.Key.Path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, id := range mk1.Key.Path {
|
||||||
|
if id.Unbox().ScalarString() != mk2.Key.Path[i].Unbox().ScalarString() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk1.Value.Map != nil {
|
||||||
|
if len(mk1.Value.Map.Nodes) != len(mk2.Value.Map.Nodes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range mk1.Value.Map.Nodes {
|
||||||
|
if !mk1.Value.Map.Nodes[i].MapKey.Equals(mk2.Value.Map.Nodes[i].MapKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk1.Value.Unbox() != nil {
|
||||||
|
if mk1.Value.ScalarBox().Unbox().ScalarString() != mk2.Value.ScalarBox().Unbox().ScalarString() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mk *Key) SetScalar(scalar ScalarBox) {
|
||||||
|
if mk.Value.Unbox() != nil && mk.Value.ScalarBox().Unbox() == nil {
|
||||||
|
mk.Primary = scalar
|
||||||
|
} else {
|
||||||
|
mk.Value = MakeValueBox(scalar.Unbox())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPath struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Path []*StringBox `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Edge struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
|
||||||
|
Src *KeyPath `json:"src"`
|
||||||
|
// empty, < or *
|
||||||
|
SrcArrow string `json:"src_arrow"`
|
||||||
|
|
||||||
|
Dst *KeyPath `json:"dst"`
|
||||||
|
// empty, > or *
|
||||||
|
DstArrow string `json:"dst_arrow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EdgeIndex struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
|
||||||
|
// [n] or [*]
|
||||||
|
Int *int `json:"int"`
|
||||||
|
Glob bool `json:"glob"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Substitution struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
|
||||||
|
Spread bool `json:"spread"`
|
||||||
|
Path []*StringBox `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapNodeBox is used to box MapNode for JSON persistence.
|
||||||
|
type MapNodeBox struct {
|
||||||
|
Comment *Comment `json:"comment,omitempty"`
|
||||||
|
BlockComment *BlockComment `json:"block_comment,omitempty"`
|
||||||
|
Substitution *Substitution `json:"substitution,omitempty"`
|
||||||
|
MapKey *Key `json:"map_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func MakeMapNodeBox(n MapNode) MapNodeBox {
|
||||||
|
var box MapNodeBox
|
||||||
|
switch n := n.(type) {
|
||||||
|
case *Comment:
|
||||||
|
box.Comment = n
|
||||||
|
case *BlockComment:
|
||||||
|
box.BlockComment = n
|
||||||
|
case *Substitution:
|
||||||
|
box.Substitution = n
|
||||||
|
case *Key:
|
||||||
|
box.MapKey = n
|
||||||
|
}
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mb MapNodeBox) Unbox() MapNode {
|
||||||
|
switch {
|
||||||
|
case mb.Comment != nil:
|
||||||
|
return mb.Comment
|
||||||
|
case mb.BlockComment != nil:
|
||||||
|
return mb.BlockComment
|
||||||
|
case mb.Substitution != nil:
|
||||||
|
return mb.Substitution
|
||||||
|
case mb.MapKey != nil:
|
||||||
|
return mb.MapKey
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrayNodeBox is used to box ArrayNode for JSON persistence.
|
||||||
|
type ArrayNodeBox struct {
|
||||||
|
Comment *Comment `json:"comment,omitempty"`
|
||||||
|
BlockComment *BlockComment `json:"block_comment,omitempty"`
|
||||||
|
Substitution *Substitution `json:"substitution,omitempty"`
|
||||||
|
Null *Null `json:"null,omitempty"`
|
||||||
|
Boolean *Boolean `json:"boolean,omitempty"`
|
||||||
|
Number *Number `json:"number,omitempty"`
|
||||||
|
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
|
||||||
|
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
|
||||||
|
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
|
||||||
|
BlockString *BlockString `json:"block_string,omitempty"`
|
||||||
|
Array *Array `json:"array,omitempty"`
|
||||||
|
Map *Map `json:"map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ab ArrayNodeBox) Unbox() ArrayNode {
|
||||||
|
switch {
|
||||||
|
case ab.Comment != nil:
|
||||||
|
return ab.Comment
|
||||||
|
case ab.BlockComment != nil:
|
||||||
|
return ab.BlockComment
|
||||||
|
case ab.Substitution != nil:
|
||||||
|
return ab.Substitution
|
||||||
|
case ab.Null != nil:
|
||||||
|
return ab.Null
|
||||||
|
case ab.Boolean != nil:
|
||||||
|
return ab.Boolean
|
||||||
|
case ab.Number != nil:
|
||||||
|
return ab.Number
|
||||||
|
case ab.UnquotedString != nil:
|
||||||
|
return ab.UnquotedString
|
||||||
|
case ab.DoubleQuotedString != nil:
|
||||||
|
return ab.DoubleQuotedString
|
||||||
|
case ab.SingleQuotedString != nil:
|
||||||
|
return ab.SingleQuotedString
|
||||||
|
case ab.BlockString != nil:
|
||||||
|
return ab.BlockString
|
||||||
|
case ab.Array != nil:
|
||||||
|
return ab.Array
|
||||||
|
case ab.Map != nil:
|
||||||
|
return ab.Map
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueBox is used to box Value for JSON persistence.
|
||||||
|
type ValueBox struct {
|
||||||
|
Null *Null `json:"null,omitempty"`
|
||||||
|
Boolean *Boolean `json:"boolean,omitempty"`
|
||||||
|
Number *Number `json:"number,omitempty"`
|
||||||
|
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
|
||||||
|
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
|
||||||
|
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
|
||||||
|
BlockString *BlockString `json:"block_string,omitempty"`
|
||||||
|
Array *Array `json:"array,omitempty"`
|
||||||
|
Map *Map `json:"map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vb ValueBox) Unbox() Value {
|
||||||
|
switch {
|
||||||
|
case vb.Null != nil:
|
||||||
|
return vb.Null
|
||||||
|
case vb.Boolean != nil:
|
||||||
|
return vb.Boolean
|
||||||
|
case vb.Number != nil:
|
||||||
|
return vb.Number
|
||||||
|
case vb.UnquotedString != nil:
|
||||||
|
return vb.UnquotedString
|
||||||
|
case vb.DoubleQuotedString != nil:
|
||||||
|
return vb.DoubleQuotedString
|
||||||
|
case vb.SingleQuotedString != nil:
|
||||||
|
return vb.SingleQuotedString
|
||||||
|
case vb.BlockString != nil:
|
||||||
|
return vb.BlockString
|
||||||
|
case vb.Array != nil:
|
||||||
|
return vb.Array
|
||||||
|
case vb.Map != nil:
|
||||||
|
return vb.Map
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func MakeValueBox(v Value) ValueBox {
|
||||||
|
var vb ValueBox
|
||||||
|
switch v := v.(type) {
|
||||||
|
case *Null:
|
||||||
|
vb.Null = v
|
||||||
|
case *Boolean:
|
||||||
|
vb.Boolean = v
|
||||||
|
case *Number:
|
||||||
|
vb.Number = v
|
||||||
|
case *UnquotedString:
|
||||||
|
vb.UnquotedString = v
|
||||||
|
case *DoubleQuotedString:
|
||||||
|
vb.DoubleQuotedString = v
|
||||||
|
case *SingleQuotedString:
|
||||||
|
vb.SingleQuotedString = v
|
||||||
|
case *BlockString:
|
||||||
|
vb.BlockString = v
|
||||||
|
case *Array:
|
||||||
|
vb.Array = v
|
||||||
|
case *Map:
|
||||||
|
vb.Map = v
|
||||||
|
}
|
||||||
|
return vb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vb ValueBox) ScalarBox() ScalarBox {
|
||||||
|
var sb ScalarBox
|
||||||
|
sb.Null = vb.Null
|
||||||
|
sb.Boolean = vb.Boolean
|
||||||
|
sb.Number = vb.Number
|
||||||
|
sb.UnquotedString = vb.UnquotedString
|
||||||
|
sb.DoubleQuotedString = vb.DoubleQuotedString
|
||||||
|
sb.SingleQuotedString = vb.SingleQuotedString
|
||||||
|
sb.BlockString = vb.BlockString
|
||||||
|
return sb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vb ValueBox) StringBox() *StringBox {
|
||||||
|
var sb StringBox
|
||||||
|
sb.UnquotedString = vb.UnquotedString
|
||||||
|
sb.DoubleQuotedString = vb.DoubleQuotedString
|
||||||
|
sb.SingleQuotedString = vb.SingleQuotedString
|
||||||
|
sb.BlockString = vb.BlockString
|
||||||
|
return &sb
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScalarBox is used to box Scalar for JSON persistence.
|
||||||
|
// TODO: implement ScalarString()
|
||||||
|
type ScalarBox struct {
|
||||||
|
Null *Null `json:"null,omitempty"`
|
||||||
|
Boolean *Boolean `json:"boolean,omitempty"`
|
||||||
|
Number *Number `json:"number,omitempty"`
|
||||||
|
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
|
||||||
|
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
|
||||||
|
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
|
||||||
|
BlockString *BlockString `json:"block_string,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sb ScalarBox) Unbox() Scalar {
|
||||||
|
switch {
|
||||||
|
case sb.Null != nil:
|
||||||
|
return sb.Null
|
||||||
|
case sb.Boolean != nil:
|
||||||
|
return sb.Boolean
|
||||||
|
case sb.Number != nil:
|
||||||
|
return sb.Number
|
||||||
|
case sb.UnquotedString != nil:
|
||||||
|
return sb.UnquotedString
|
||||||
|
case sb.DoubleQuotedString != nil:
|
||||||
|
return sb.DoubleQuotedString
|
||||||
|
case sb.SingleQuotedString != nil:
|
||||||
|
return sb.SingleQuotedString
|
||||||
|
case sb.BlockString != nil:
|
||||||
|
return sb.BlockString
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringBox is used to box String for JSON persistence.
|
||||||
|
type StringBox struct {
|
||||||
|
UnquotedString *UnquotedString `json:"unquoted_string,omitempty"`
|
||||||
|
DoubleQuotedString *DoubleQuotedString `json:"double_quoted_string,omitempty"`
|
||||||
|
SingleQuotedString *SingleQuotedString `json:"single_quoted_string,omitempty"`
|
||||||
|
BlockString *BlockString `json:"block_string,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sb *StringBox) Unbox() String {
|
||||||
|
switch {
|
||||||
|
case sb.UnquotedString != nil:
|
||||||
|
return sb.UnquotedString
|
||||||
|
case sb.DoubleQuotedString != nil:
|
||||||
|
return sb.DoubleQuotedString
|
||||||
|
case sb.SingleQuotedString != nil:
|
||||||
|
return sb.SingleQuotedString
|
||||||
|
case sb.BlockString != nil:
|
||||||
|
return sb.BlockString
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpolationBox is used to select between strings and substitutions in unquoted and
|
||||||
|
// double quoted strings. There is no corresponding interface to avoid unnecessary
|
||||||
|
// abstraction.
|
||||||
|
type InterpolationBox struct {
|
||||||
|
String *string `json:"string,omitempty"`
|
||||||
|
StringRaw *string `json:"raw_string,omitempty"`
|
||||||
|
Substitution *Substitution `json:"substitution,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// & is only special if it begins a key.
|
||||||
|
// - is only special if followed by another - in a key.
|
||||||
|
// ' " and | are only special if they begin an unquoted key or value.
|
||||||
|
var UnquotedKeySpecials = string([]rune{'#', ';', '\n', '\\', '{', '}', '[', ']', '\'', '"', '|', ':', '.', '-', '<', '>', '*', '&', '(', ')'})
|
||||||
|
var UnquotedValueSpecials = string([]rune{'#', ';', '\n', '\\', '{', '}', '[', ']', '\'', '"', '|', '$'})
|
||||||
|
|
||||||
|
// RawString returns s in a AST String node that can format s in the most aesthetically
|
||||||
|
// pleasing way.
|
||||||
|
// TODO: Return StringBox
|
||||||
|
func RawString(s string, inKey bool) String {
|
||||||
|
if s == "" {
|
||||||
|
return FlatDoubleQuotedString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if inKey {
|
||||||
|
for i, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '-':
|
||||||
|
if i+1 < len(s) && s[i+1] != '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case '&':
|
||||||
|
if i > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(UnquotedKeySpecials, r) {
|
||||||
|
if !strings.ContainsRune(s, '"') {
|
||||||
|
return FlatDoubleQuotedString(s)
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(s, '\n') {
|
||||||
|
return FlatDoubleQuotedString(s)
|
||||||
|
}
|
||||||
|
return &SingleQuotedString{Value: s}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if s == "null" || strings.ContainsAny(s, UnquotedValueSpecials) {
|
||||||
|
if !strings.ContainsRune(s, '"') && !strings.ContainsRune(s, '$') {
|
||||||
|
return FlatDoubleQuotedString(s)
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(s, '\n') {
|
||||||
|
return FlatDoubleQuotedString(s)
|
||||||
|
}
|
||||||
|
return &SingleQuotedString{Value: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSurroundingWhitespace(s) {
|
||||||
|
return FlatDoubleQuotedString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FlatUnquotedString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSurroundingWhitespace(s string) bool {
|
||||||
|
r, _ := utf8.DecodeRuneInString(s)
|
||||||
|
r2, _ := utf8.DecodeLastRuneInString(s)
|
||||||
|
return unicode.IsSpace(r) || unicode.IsSpace(r2)
|
||||||
|
}
|
||||||
813
d2ast/d2ast_test.go
Normal file
813
d2ast/d2ast_test.go
Normal file
|
|
@ -0,0 +1,813 @@
|
||||||
|
package d2ast_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
|
math_rand "math/rand"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/xrand"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/diff"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
"oss.terrastruct.com/d2/d2format"
|
||||||
|
"oss.terrastruct.com/d2/d2parser"
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("String", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
r d2ast.Range
|
||||||
|
exp string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "one_byte",
|
||||||
|
r: d2ast.Range{
|
||||||
|
Path: "/src/example.go",
|
||||||
|
Start: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 5,
|
||||||
|
Byte: 100,
|
||||||
|
},
|
||||||
|
End: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 6,
|
||||||
|
Byte: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exp: "/src/example.go:11:6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more_than_one_byte",
|
||||||
|
r: d2ast.Range{
|
||||||
|
Path: "/src/example.go",
|
||||||
|
Start: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 5,
|
||||||
|
Byte: 100,
|
||||||
|
},
|
||||||
|
End: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 7,
|
||||||
|
Byte: 101,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exp: "/src/example.go:11:6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_path",
|
||||||
|
r: d2ast.Range{
|
||||||
|
Start: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 5,
|
||||||
|
Byte: 100,
|
||||||
|
},
|
||||||
|
End: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 7,
|
||||||
|
Byte: 101,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exp: "11:6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start_equal_end",
|
||||||
|
r: d2ast.Range{
|
||||||
|
Start: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 5,
|
||||||
|
Byte: 100,
|
||||||
|
},
|
||||||
|
End: d2ast.Position{
|
||||||
|
Line: 10,
|
||||||
|
Column: 5,
|
||||||
|
Byte: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exp: "11:6",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if tc.exp != tc.r.String() {
|
||||||
|
t.Fatalf("expected %q but got %q", tc.exp, tc.r.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UnmarshalText", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
|
||||||
|
exp d2ast.Range
|
||||||
|
|
||||||
|
errmsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
in: `"json_test.d2,1:1:0-5:1:50"`,
|
||||||
|
exp: d2ast.Range{Path: "json_test.d2", Start: d2ast.Position{Line: 1, Column: 1, Byte: 0}, End: d2ast.Position{Line: 5, Column: 1, Byte: 50}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "err1",
|
||||||
|
in: `"json_test.d2-5:1:50"`,
|
||||||
|
errmsg: "missing Start field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "err2",
|
||||||
|
in: `"json_test.d2"`,
|
||||||
|
errmsg: "missing End field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "err3",
|
||||||
|
in: `"json_test.d2,1:1:0-5:150"`,
|
||||||
|
errmsg: "expected three fields",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "err4",
|
||||||
|
in: `"json_test.d2,1:10-5:1:50"`,
|
||||||
|
errmsg: "expected three fields",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "err5",
|
||||||
|
in: `"json_test.d2,a:1:0-5:1:50"`,
|
||||||
|
errmsg: `strconv.Atoi: parsing "a": invalid syntax`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "err6",
|
||||||
|
in: `"json_test.d2,1:c:0-5:1:50"`,
|
||||||
|
errmsg: `strconv.Atoi: parsing "c": invalid syntax`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var r d2ast.Range
|
||||||
|
err := json.Unmarshal([]byte(tc.in), &r)
|
||||||
|
|
||||||
|
if tc.errmsg != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error: %#v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.errmsg) {
|
||||||
|
t.Fatalf("error message does not contain %q: %q", tc.errmsg, err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tc.exp, r) {
|
||||||
|
t.Fatalf("expected %#v but got %#v", tc.exp, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Advance", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("UTF-8", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var p d2ast.Position
|
||||||
|
p = p.Advance('a', false)
|
||||||
|
diff.AssertJSONEq(t, `"0:1:1"`, p)
|
||||||
|
p = p.Advance('\n', false)
|
||||||
|
diff.AssertJSONEq(t, `"1:0:2"`, p)
|
||||||
|
p = p.Advance('è', false)
|
||||||
|
diff.AssertJSONEq(t, `"1:2:4"`, p)
|
||||||
|
p = p.Advance('𐀀', false)
|
||||||
|
diff.AssertJSONEq(t, `"1:6:8"`, p)
|
||||||
|
|
||||||
|
p = p.Subtract('𐀀', false)
|
||||||
|
diff.AssertJSONEq(t, `"1:2:4"`, p)
|
||||||
|
p = p.Subtract('è', false)
|
||||||
|
diff.AssertJSONEq(t, `"1:0:2"`, p)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UTF-16", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var p d2ast.Position
|
||||||
|
p = p.Advance('a', true)
|
||||||
|
diff.AssertJSONEq(t, `"0:1:1"`, p)
|
||||||
|
p = p.Advance('\n', true)
|
||||||
|
diff.AssertJSONEq(t, `"1:0:2"`, p)
|
||||||
|
p = p.Advance('è', true)
|
||||||
|
diff.AssertJSONEq(t, `"1:1:3"`, p)
|
||||||
|
p = p.Advance('𐀀', true)
|
||||||
|
diff.AssertJSONEq(t, `"1:3:5"`, p)
|
||||||
|
|
||||||
|
p = p.Subtract('𐀀', true)
|
||||||
|
diff.AssertJSONEq(t, `"1:1:3"`, p)
|
||||||
|
p = p.Subtract('è', true)
|
||||||
|
diff.AssertJSONEq(t, `"1:0:2"`, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := &d2ast.Map{
|
||||||
|
Range: d2ast.Range{Path: "json_test.d2", Start: d2ast.Position{Line: 0, Column: 0, Byte: 0}, End: d2ast.Position{Line: 5, Column: 1, Byte: 50}},
|
||||||
|
|
||||||
|
Nodes: []d2ast.MapNodeBox{
|
||||||
|
{
|
||||||
|
Comment: &d2ast.Comment{
|
||||||
|
Value: `America was discovered by Amerigo Vespucci and was named after him, until
|
||||||
|
people got tired of living in a place called "Vespuccia" and changed its
|
||||||
|
name to "America".
|
||||||
|
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BlockComment: &d2ast.BlockComment{
|
||||||
|
Value: `America was discovered by Amerigo Vespucci and was named after him, until
|
||||||
|
people got tired of living in a place called "Vespuccia" and changed its
|
||||||
|
name to "America".
|
||||||
|
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Substitution: &d2ast.Substitution{
|
||||||
|
Spread: true,
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
BlockString: &d2ast.BlockString{
|
||||||
|
Quote: "|",
|
||||||
|
Tag: "text",
|
||||||
|
Value: `America was discovered by Amerigo Vespucci and was named after him, until
|
||||||
|
people got tired of living in a place called "Vespuccia" and changed its
|
||||||
|
name to "America".
|
||||||
|
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MapKey: &d2ast.Key{
|
||||||
|
Ampersand: true,
|
||||||
|
|
||||||
|
Key: &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "before edges",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Edges: []*d2ast.Edge{
|
||||||
|
{
|
||||||
|
Src: &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SrcArrow: "*",
|
||||||
|
|
||||||
|
Dst: &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "dst",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DstArrow: ">",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Src: &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "dst",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Dst: &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "dst2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
EdgeIndex: &d2ast.EdgeIndex{
|
||||||
|
Glob: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
EdgeKey: &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "after edges",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: d2ast.ScalarBox{
|
||||||
|
Null: &d2ast.Null{},
|
||||||
|
},
|
||||||
|
|
||||||
|
Value: d2ast.ValueBox{
|
||||||
|
Array: &d2ast.Array{
|
||||||
|
Nodes: []d2ast.ArrayNodeBox{
|
||||||
|
{
|
||||||
|
Boolean: &d2ast.Boolean{
|
||||||
|
Value: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Number: &d2ast.Number{
|
||||||
|
Raw: "0xFF",
|
||||||
|
Value: big.NewRat(15, 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UnquotedString: &d2ast.UnquotedString{
|
||||||
|
Value: []d2ast.InterpolationBox{
|
||||||
|
{
|
||||||
|
String: go2.Pointer("no quotes needed"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UnquotedString: &d2ast.UnquotedString{
|
||||||
|
Value: []d2ast.InterpolationBox{
|
||||||
|
{
|
||||||
|
Substitution: &d2ast.Substitution{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DoubleQuotedString: &d2ast.DoubleQuotedString{
|
||||||
|
Value: []d2ast.InterpolationBox{
|
||||||
|
{
|
||||||
|
String: go2.Pointer("no quotes needed"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SingleQuotedString: &d2ast.SingleQuotedString{
|
||||||
|
Value: "rawr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BlockString: &d2ast.BlockString{
|
||||||
|
Quote: "|",
|
||||||
|
Tag: "text",
|
||||||
|
Value: `America was discovered by Amerigo Vespucci and was named after him, until
|
||||||
|
people got tired of living in a place called "Vespuccia" and changed its
|
||||||
|
name to "America".
|
||||||
|
-- Mike Harding, "The Armchair Anarchist's Almanac"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.AssertJSONEq(t, `{
|
||||||
|
"range": "json_test.d2,0:0:0-5:1:50",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"comment": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"value": "America was discovered by Amerigo Vespucci and was named after him, until\npeople got tired of living in a place called \"Vespuccia\" and changed its\nname to \"America\".\n\t\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_comment": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"value": "America was discovered by Amerigo Vespucci and was named after him, until\npeople got tired of living in a place called \"Vespuccia\" and changed its\nname to \"America\".\n\t\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"substitution": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"spread": true,
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"block_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"quote": "|",
|
||||||
|
"tag": "text",
|
||||||
|
"value": "America was discovered by Amerigo Vespucci and was named after him, until\n\tpeople got tired of living in a place called \"Vespuccia\" and changed its\n\tname to \"America\".\n\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"map_key": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"ampersand": true,
|
||||||
|
"key": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "before edges"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"src": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"src_arrow": "*",
|
||||||
|
"dst": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "dst"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dst_arrow": ">"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"src": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "dst"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"src_arrow": "",
|
||||||
|
"dst": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "dst2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dst_arrow": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edge_index": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"int": null,
|
||||||
|
"glob": true
|
||||||
|
},
|
||||||
|
"edge_key": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "after edges"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"primary": {
|
||||||
|
"null": {
|
||||||
|
"range": ",0:0:0-0:0:0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"array": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"boolean": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "0xFF",
|
||||||
|
"value": "15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unquoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"string": "no quotes needed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unquoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"substitution": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"spread": false,
|
||||||
|
"path": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"double_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"string": "no quotes needed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"single_quoted_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"raw": "",
|
||||||
|
"value": "rawr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"block_string": {
|
||||||
|
"range": ",0:0:0-0:0:0",
|
||||||
|
"quote": "|",
|
||||||
|
"tag": "text",
|
||||||
|
"value": "America was discovered by Amerigo Vespucci and was named after him, until\n\t\t\tpeople got tired of living in a place called \"Vespuccia\" and changed its\n\t\t\tname to \"America\".\n\t\t\t-- Mike Harding, \"The Armchair Anarchist's Almanac\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRawStringKey(t *testing.T, key string) {
|
||||||
|
ast := d2ast.RawString(key, true)
|
||||||
|
enc := d2format.Format(ast)
|
||||||
|
k, err := d2parser.ParseKey(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(k.Path) != 1 {
|
||||||
|
t.Fatalf("unexpected key length: %#v", k.Path)
|
||||||
|
}
|
||||||
|
err = diff.Runes(key, k.Path[0].Unbox().ScalarString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRawStringValue(t *testing.T, value string) {
|
||||||
|
ast := d2ast.RawString(value, false)
|
||||||
|
enc := d2format.Format(ast)
|
||||||
|
v, err := d2parser.ParseValue(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ps, ok := v.(d2ast.Scalar)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected value type: %#v", v)
|
||||||
|
}
|
||||||
|
err = diff.Runes(value, ps.ScalarString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("chaos", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("pinned", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
pinnedTestCases := []struct {
|
||||||
|
name string
|
||||||
|
str string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "1",
|
||||||
|
str: "\U000b64cd\U0008b732\U0009632c\U000983f8\U000f42d4\U000c4749\U00041723\uf584蝉\U00100cd5\U0003325d\U0003e4d2\U0007ff0e\U000e03d8\U000b0431\U00042053\U0001b3ea𠒹\U0006d9cf\U000c5b1c\U00019a3c\U000f3c3d\U0004acedଶ\U0009da18\U0001a0bb\U000b6bfd\U00015ebd\U00088c5a녈\U00078277\U000eaa58\U0009266b\U000d85ae\U000d6ce8譊𣱡\U0008ac84\U000a722f\U000d3d35\U00072581\U000c3423\U000a1753\U00082014\U0001bde6\U0010bf47炏\U000423fa\U0007df70\U00088aaf\U00074e5e\U000ee80b\U000e3d53\U0003f542\U0001ad9f\U00031408\U000cce7e\U00082172\u202f",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2",
|
||||||
|
str: "'\"Tc\U000d148d\U000dd61a\U0007cf68OO\U000b87a9\U000c073a\U000e7828n\U00068a9fc\U0004fbf5\x041\\'''",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "3",
|
||||||
|
str: "\r\U00057d53\x01'\U00042e5a\U0007be73T\U000fb916\x01\U000e0e4afL]\U000474d1\x15\U00083bc0\fbT\ue09bs{vP\U000b3d33\x0f\U0007ad13\x10\U00098b38\x1d\U000cf9da\n ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range pinnedTestCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("key", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
testRawStringKey(t, tc.str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("value", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
testRawStringValue(t, tc.str)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
i := i
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := xrand.String(math_rand.Intn(99), nil)
|
||||||
|
t.Logf("testing: %q", s)
|
||||||
|
|
||||||
|
t.Run("key", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
testRawStringKey(t, s)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("value", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
testRawStringValue(t, s)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
str string
|
||||||
|
exp string
|
||||||
|
inKey bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
str: ``,
|
||||||
|
exp: `""`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null",
|
||||||
|
str: `null`,
|
||||||
|
exp: `"null"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
str: `wearisome_condition_of_humanity`,
|
||||||
|
exp: `wearisome_condition_of_humanity`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_double",
|
||||||
|
str: `'#;#;#'`,
|
||||||
|
exp: `"'#;#;#'"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_single_quote",
|
||||||
|
str: `"cambridge"`,
|
||||||
|
exp: `'"cambridge"'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_single_dollar",
|
||||||
|
str: `$bingo`,
|
||||||
|
exp: `'$bingo'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not_key_specials",
|
||||||
|
str: `------`,
|
||||||
|
exp: `------`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key_specials_double",
|
||||||
|
str: `-----`,
|
||||||
|
exp: `"-----"`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key_specials_single",
|
||||||
|
str: `"cambridge"`,
|
||||||
|
exp: `'"cambridge"'`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key_specials_unquoted",
|
||||||
|
str: `square-2`,
|
||||||
|
exp: `square-2`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiline",
|
||||||
|
str: `||||yes
|
||||||
|
yes
|
||||||
|
yes
|
||||||
|
yes
|
||||||
|
||||`,
|
||||||
|
exp: `"||||yes\nyes\nyes\nyes\n||||"`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading_whitespace",
|
||||||
|
str: ` yoho_park `,
|
||||||
|
exp: `" yoho_park "`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading_whitespace_newlines",
|
||||||
|
str: ` yoho
|
||||||
|
_park `,
|
||||||
|
exp: `" yoho\n_park "`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading_space_double_quotes_and_newlines",
|
||||||
|
str: ` "yoho"
|
||||||
|
_park `,
|
||||||
|
exp: `" \"yoho\"\n_park "`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ast := d2ast.RawString(tc.str, tc.inKey)
|
||||||
|
diff.AssertStringEq(t, tc.exp, d2format.Format(ast))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
15
d2ast/error.go
Normal file
15
d2ast/error.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package d2ast
|
||||||
|
|
||||||
|
// TODO: Right now this is here to be available in both the Parser and Compiler but
|
||||||
|
// eventually we should make this a real part of the AST so that autofmt works on
|
||||||
|
// files with parse errors and semantically it makes more sense.
|
||||||
|
// Compile would continue to maintain a separate set of errors and then we'd do a
|
||||||
|
// merge & sort to get the final list of errors for user display.
|
||||||
|
type Error struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Message string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Error) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
259
d2chaos/d2chaos.go
Normal file
259
d2chaos/d2chaos.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package d2chaos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
mathrand "math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
"oss.terrastruct.com/d2/d2format"
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2oracle"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenDSL(maxi int) (_ string, err error) {
|
||||||
|
gs := &dslGenState{
|
||||||
|
rand: mathrand.New(mathrand.NewSource(time.Now().UnixNano())),
|
||||||
|
g: d2graph.NewGraph(&d2ast.Map{}),
|
||||||
|
nodeShapes: make(map[string]string),
|
||||||
|
}
|
||||||
|
err = gs.gen(maxi)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return d2format.Format(gs.g.AST), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dslGenState struct {
|
||||||
|
rand *mathrand.Rand
|
||||||
|
g *d2graph.Graph
|
||||||
|
|
||||||
|
nodesArr []string
|
||||||
|
nodeShapes map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) gen(maxi int) error {
|
||||||
|
maxi = gs.rand.Intn(maxi) + 1
|
||||||
|
|
||||||
|
for i := 0; i < maxi; i++ {
|
||||||
|
switch gs.roll(25, 75) {
|
||||||
|
case 0:
|
||||||
|
// 25% chance of creating a new node.
|
||||||
|
err := gs.node()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
// 75% chance of connecting two random nodes with a random label.
|
||||||
|
err := gs.edge()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) genNode(containerID string) (string, error) {
|
||||||
|
nodeID := gs.randStr(32, true)
|
||||||
|
if containerID != "" {
|
||||||
|
nodeID = containerID + "." + nodeID
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
gs.g, nodeID, err = d2oracle.Create(gs.g, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gs.nodesArr = append(gs.nodesArr, nodeID)
|
||||||
|
gs.nodeShapes[nodeID] = "square"
|
||||||
|
return nodeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) node() error {
|
||||||
|
containerID := ""
|
||||||
|
var err error
|
||||||
|
if gs.roll(25, 75) == 1 {
|
||||||
|
// 75% chance of creating this as a child under a container.
|
||||||
|
containerID, err = gs.randContainer()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeID, err := gs.genNode(containerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if gs.roll(25, 75) == 0 {
|
||||||
|
// 25% chance of adding a label.
|
||||||
|
gs.g, err = d2oracle.Set(gs.g, nodeID, nil, go2.Pointer(gs.randStr(256, false)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gs.roll(25, 75) == 1 {
|
||||||
|
// 75% chance of adding a shape.
|
||||||
|
randShape := gs.randShape()
|
||||||
|
gs.g, err = d2oracle.Set(gs.g, nodeID+".shape", nil, go2.Pointer(randShape))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gs.nodeShapes[nodeID] = randShape
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) edge() error {
|
||||||
|
var src string
|
||||||
|
var dst string
|
||||||
|
var err error
|
||||||
|
for {
|
||||||
|
src, err = gs.randNode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst, err = gs.randNode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if src != dst {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = gs.node()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srcArrow := "-"
|
||||||
|
if gs.randBool() {
|
||||||
|
srcArrow = "<"
|
||||||
|
}
|
||||||
|
dstArrow := "-"
|
||||||
|
if gs.randBool() {
|
||||||
|
dstArrow = ">"
|
||||||
|
if srcArrow == "<" {
|
||||||
|
dstArrow = "->"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%s %s%s %s", src, srcArrow, dstArrow, dst)
|
||||||
|
gs.g, key, err = d2oracle.Create(gs.g, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if gs.randBool() {
|
||||||
|
gs.g, err = d2oracle.Set(gs.g, key, nil, go2.Pointer(gs.randStr(128, false)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) randContainer() (string, error) {
|
||||||
|
containers := go2.Filter(gs.nodesArr, func(x string) bool {
|
||||||
|
shape := gs.nodeShapes[x]
|
||||||
|
return shape != "image" &&
|
||||||
|
shape != "code" &&
|
||||||
|
shape != "sql_table" &&
|
||||||
|
shape != "text" &&
|
||||||
|
shape != "class"
|
||||||
|
})
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return containers[gs.rand.Intn(len(containers))], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) randNode() (string, error) {
|
||||||
|
if len(gs.nodesArr) == 0 {
|
||||||
|
return gs.genNode("")
|
||||||
|
}
|
||||||
|
return gs.nodesArr[gs.rand.Intn(len(gs.nodesArr))], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) randBool() bool {
|
||||||
|
return gs.rand.Intn(2) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO go back to using xrand.String, currently some incompatibility with
|
||||||
|
// stuffing these strings into a script for dagre
|
||||||
|
func randRune() rune {
|
||||||
|
if mathrand.Int31n(100) == 0 {
|
||||||
|
// Generate newline 1% of the time.
|
||||||
|
return '\n'
|
||||||
|
}
|
||||||
|
return mathrand.Int31n(128) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func String(n int, exclude []rune) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
r := randRune()
|
||||||
|
excluded := false
|
||||||
|
for _, xr := range exclude {
|
||||||
|
if r == xr {
|
||||||
|
excluded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if excluded {
|
||||||
|
i--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) randStr(n int, inKey bool) string {
|
||||||
|
// Underscores have semantic meaning (parent)
|
||||||
|
// Backticks are for opening and closing these strings
|
||||||
|
// Curly braces can trigger templating
|
||||||
|
// \\ triggers octal sequences
|
||||||
|
s := String(gs.rand.Intn(n), []rune{
|
||||||
|
rune('_'),
|
||||||
|
rune('`'),
|
||||||
|
rune('}'),
|
||||||
|
rune('{'),
|
||||||
|
rune('\\'),
|
||||||
|
})
|
||||||
|
as := d2ast.RawString(s, inKey)
|
||||||
|
return d2format.Format(as)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) randShape() string {
|
||||||
|
for {
|
||||||
|
s := shapes[gs.rand.Intn(len(shapes))]
|
||||||
|
if s != "image" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gs *dslGenState) roll(probs ...int) int {
|
||||||
|
max := 0
|
||||||
|
for _, p := range probs {
|
||||||
|
max += p
|
||||||
|
}
|
||||||
|
|
||||||
|
n := gs.rand.Intn(max)
|
||||||
|
var acc int
|
||||||
|
for i, p := range probs {
|
||||||
|
if n >= acc && n < acc+p {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
acc += p
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("d2chaos: unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var shapes = d2target.Shapes
|
||||||
183
d2chaos/d2chaos_test.go
Normal file
183
d2chaos/d2chaos_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package d2chaos_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2chaos"
|
||||||
|
"oss.terrastruct.com/d2/d2compiler"
|
||||||
|
"oss.terrastruct.com/d2/d2exporter"
|
||||||
|
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/textmeasure"
|
||||||
|
"oss.terrastruct.com/d2/lib/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usage: D2_CHAOS_MAXI=100 D2_CHAOS_N=100 ./ci/test.sh ./d2chaos
|
||||||
|
//
|
||||||
|
// D2_CHAOS_MAXI controls the number of iterations that the dsl generator
|
||||||
|
// should go through to generate each input D2. It's roughly equivalent to
|
||||||
|
// the complexity level of each input D2.
|
||||||
|
//
|
||||||
|
// D2_CHAOS_N controls the number of D2 texts to generate and run the full
|
||||||
|
// D2 flow on.
|
||||||
|
//
|
||||||
|
// All generated texts are stored in ./out/<n>.d2 and also ./out/<n>.d2.goenc
|
||||||
|
// The goenc version is the text encoded as a Go string. It lets you replay
|
||||||
|
// a test by adding it to testPinned below as you can just copy paste the go
|
||||||
|
// string in.
|
||||||
|
//
|
||||||
|
// If D2Chaos fails on CI and you need to investigate the input text that caused the
|
||||||
|
// failure, all generated texts will be available in the d2chaos-test and d2chaos-race
|
||||||
|
// github actions artifacts.
|
||||||
|
func TestD2Chaos(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const outDir = "out"
|
||||||
|
err := os.MkdirAll(outDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("writing generated files to %s", outDir)
|
||||||
|
|
||||||
|
t.Run("pinned", func(t *testing.T) {
|
||||||
|
testPinned(t, outDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
n := 1
|
||||||
|
if os.Getenv("D2_CHAOS_N") != "" {
|
||||||
|
envn, err := strconv.Atoi(os.Getenv("D2_CHAOS_N"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to atoi $D2_CHAOS_N: %v", err)
|
||||||
|
} else {
|
||||||
|
n = envn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxi := 10
|
||||||
|
if os.Getenv("D2_CHAOS_MAXI") != "" {
|
||||||
|
envMaxi, err := strconv.Atoi(os.Getenv("D2_CHAOS_MAXI"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to atoi $D2_CHAOS_MAXI: %v", err)
|
||||||
|
} else {
|
||||||
|
maxi = envMaxi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
i := i
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
text, err := d2chaos.GenDSL(maxi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
textPath := filepath.Join(outDir, fmt.Sprintf("%d.d2", i))
|
||||||
|
test(t, textPath, text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test(t *testing.T, textPath, text string) {
|
||||||
|
t.Logf("writing d2 to %v (%d bytes)", textPath, len(text))
|
||||||
|
err := ioutil.WriteFile(textPath, []byte(text), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
goencText := fmt.Sprintf("%#v", text)
|
||||||
|
t.Logf("writing d2.goenc to %v (%d bytes)", textPath+".goenc", len(goencText))
|
||||||
|
err = ioutil.WriteFile(textPath+".goenc", []byte(goencText), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := d2compiler.Compile("", strings.NewReader(text), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("layout", func(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
r := recover()
|
||||||
|
if r != nil {
|
||||||
|
t.Errorf("recovered layout engine panic: %#v\n%s", r, debug.Stack())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx := log.WithTB(context.Background(), t, nil)
|
||||||
|
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = g.SetDimensions(nil, ruler)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = d2dagrelayout.Layout(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d2exporter.Export(ctx, g, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPinned(t *testing.T, outDir string) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
outDir = filepath.Join(outDir, t.Name())
|
||||||
|
err := os.MkdirAll(outDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("writing generated files to %v", outDir)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "internal class edge",
|
||||||
|
text: "a: {\n shape: class\n b -> c\n }",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "table edge order",
|
||||||
|
text: "B: {shape: sql_table}\n B.C -- D\n B.C -- D\n D -- B.C\n B -- D\n A -- D",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "child to container edge",
|
||||||
|
text: "a.b -> a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sequence",
|
||||||
|
text: "a: {shape: step}\nb: {shape: step}\na -> b\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orientation",
|
||||||
|
text: "a: {\n b\n c\n }\n a <- a.c\n a.b -> a\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
textPath := filepath.Join(outDir, fmt.Sprintf("%s.d2", tc.name))
|
||||||
|
test(t, textPath, tc.text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
850
d2compiler/compile.go
Normal file
850
d2compiler/compile.go
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
package d2compiler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
"oss.terrastruct.com/d2/d2format"
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2parser"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: should Parse even be exported? guess not. IR should contain list of files and
|
||||||
|
// their AST.
|
||||||
|
type CompileOptions struct {
|
||||||
|
UTF16 bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Compile(path string, r io.RuneReader, opts *CompileOptions) (*d2graph.Graph, error) {
|
||||||
|
if opts == nil {
|
||||||
|
opts = &CompileOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pe d2parser.ParseError
|
||||||
|
|
||||||
|
ast, err := d2parser.Parse(path, r, &d2parser.ParseOptions{
|
||||||
|
UTF16: opts.UTF16,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if !errors.As(err, &pe) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compileAST(path, pe, ast)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileAST(path string, pe d2parser.ParseError, ast *d2ast.Map) (*d2graph.Graph, error) {
|
||||||
|
g := d2graph.NewGraph(ast)
|
||||||
|
|
||||||
|
c := &compiler{
|
||||||
|
path: path,
|
||||||
|
err: pe,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.compileKeys(g.Root, ast)
|
||||||
|
if len(c.err.Errors) == 0 {
|
||||||
|
c.validateKeys(g.Root, ast)
|
||||||
|
}
|
||||||
|
c.compileEdges(g.Root, ast)
|
||||||
|
// TODO: simplify removeContainer by running before compileEdges
|
||||||
|
c.compileShapes(g.Root)
|
||||||
|
c.validateNear(g)
|
||||||
|
|
||||||
|
if len(c.err.Errors) > 0 {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type compiler struct {
|
||||||
|
path string
|
||||||
|
err d2parser.ParseError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) errorf(start d2ast.Position, end d2ast.Position, f string, v ...interface{}) {
|
||||||
|
r := d2ast.Range{
|
||||||
|
Path: c.path,
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
}
|
||||||
|
f = "%v: " + f
|
||||||
|
v = append([]interface{}{r}, v...)
|
||||||
|
c.err.Errors = append(c.err.Errors, d2ast.Error{
|
||||||
|
Range: r,
|
||||||
|
Message: fmt.Sprintf(f, v...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileKeys(obj *d2graph.Object, m *d2ast.Map) {
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Edges) == 0 {
|
||||||
|
c.compileKey(obj, m, n.MapKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileEdges(obj *d2graph.Object, m *d2ast.Map) {
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
if n.MapKey != nil {
|
||||||
|
if len(n.MapKey.Edges) > 0 {
|
||||||
|
obj := obj
|
||||||
|
if n.MapKey.Key != nil {
|
||||||
|
ida := d2graph.Key(n.MapKey.Key)
|
||||||
|
parent, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(n.MapKey.Range.Start, n.MapKey.Range.End, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unresolvedObj := obj
|
||||||
|
obj = parent.EnsureChild(resolvedIDA)
|
||||||
|
|
||||||
|
parent.AppendReferences(ida, d2graph.Reference{
|
||||||
|
Key: n.MapKey.Key,
|
||||||
|
|
||||||
|
MapKey: n.MapKey,
|
||||||
|
Scope: m,
|
||||||
|
}, unresolvedObj)
|
||||||
|
}
|
||||||
|
c.compileEdgeMapKey(obj, m, n.MapKey)
|
||||||
|
}
|
||||||
|
if n.MapKey.Key != nil && n.MapKey.Value.Map != nil {
|
||||||
|
c.compileEdges(obj.EnsureChild(d2graph.Key(n.MapKey.Key)), n.MapKey.Value.Map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileArrowheads compiles keywords for edge arrowhead attributes by
|
||||||
|
// 1. creating a fake, detached parent
|
||||||
|
// 2. compiling the arrowhead field as a fake object onto that fake parent
|
||||||
|
// 3. transferring the relevant attributes onto the edge
|
||||||
|
func (c *compiler) compileArrowheads(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) bool {
|
||||||
|
arrowheadKey := mk.Key
|
||||||
|
if mk.EdgeKey != nil {
|
||||||
|
arrowheadKey = mk.EdgeKey
|
||||||
|
}
|
||||||
|
if arrowheadKey == nil || len(arrowheadKey.Path) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := arrowheadKey.Path[0].Unbox().ScalarString()
|
||||||
|
var field *d2graph.Attributes
|
||||||
|
if key == "source-arrowhead" {
|
||||||
|
if edge.SrcArrowhead == nil {
|
||||||
|
edge.SrcArrowhead = &d2graph.Attributes{}
|
||||||
|
}
|
||||||
|
field = edge.SrcArrowhead
|
||||||
|
} else if key == "target-arrowhead" {
|
||||||
|
if edge.DstArrowhead == nil {
|
||||||
|
edge.DstArrowhead = &d2graph.Attributes{}
|
||||||
|
}
|
||||||
|
field = edge.DstArrowhead
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fakeParent := &d2graph.Object{
|
||||||
|
Children: make(map[string]*d2graph.Object),
|
||||||
|
}
|
||||||
|
detachedMK := &d2ast.Key{
|
||||||
|
Key: arrowheadKey,
|
||||||
|
Primary: mk.Primary,
|
||||||
|
Value: mk.Value,
|
||||||
|
}
|
||||||
|
c.compileKey(fakeParent, m, detachedMK)
|
||||||
|
fakeObj := fakeParent.ChildrenArray[0]
|
||||||
|
c.compileShapes(fakeObj)
|
||||||
|
|
||||||
|
if fakeObj.Attributes.Shape.Value != "" {
|
||||||
|
field.Shape = fakeObj.Attributes.Shape
|
||||||
|
}
|
||||||
|
if fakeObj.Attributes.Label.Value != "" && fakeObj.Attributes.Label.Value != "source-arrowhead" && fakeObj.Attributes.Label.Value != "target-arrowhead" {
|
||||||
|
field.Label = fakeObj.Attributes.Label
|
||||||
|
}
|
||||||
|
if fakeObj.Attributes.Style.Filled != nil {
|
||||||
|
field.Style.Filled = fakeObj.Attributes.Style.Filled
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileAttributes(attrs *d2graph.Attributes, mk *d2ast.Key) {
|
||||||
|
var reserved string
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
if mk.EdgeKey != nil {
|
||||||
|
_, reserved, ok = c.compileFlatKey(mk.EdgeKey)
|
||||||
|
} else if mk.Key != nil {
|
||||||
|
_, reserved, ok = c.compileFlatKey(mk.Key)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if reserved == "" || reserved == "label" {
|
||||||
|
attrs.Label.MapKey = mk
|
||||||
|
} else if reserved == "shape" {
|
||||||
|
attrs.Shape.MapKey = mk
|
||||||
|
} else if reserved == "opacity" {
|
||||||
|
attrs.Style.Opacity = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "stroke" {
|
||||||
|
attrs.Style.Stroke = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "fill" {
|
||||||
|
attrs.Style.Fill = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "stroke-width" {
|
||||||
|
attrs.Style.StrokeWidth = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "stroke-dash" {
|
||||||
|
attrs.Style.StrokeDash = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "border-radius" {
|
||||||
|
attrs.Style.BorderRadius = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "shadow" {
|
||||||
|
attrs.Style.Shadow = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "3d" {
|
||||||
|
// TODO this should be movd to validateKeys, as shape may not be set yet
|
||||||
|
if attrs.Shape.Value != "" && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeRectangle) {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, `key "3d" can only be applied to squares and rectangles`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attrs.Style.ThreeDee = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "multiple" {
|
||||||
|
attrs.Style.Multiple = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "font" {
|
||||||
|
attrs.Style.Font = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "font-size" {
|
||||||
|
attrs.Style.FontSize = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "font-color" {
|
||||||
|
attrs.Style.FontColor = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "animated" {
|
||||||
|
attrs.Style.Animated = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "bold" {
|
||||||
|
attrs.Style.Bold = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "italic" {
|
||||||
|
attrs.Style.Italic = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "underline" {
|
||||||
|
attrs.Style.Underline = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "filled" {
|
||||||
|
attrs.Style.Filled = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "width" {
|
||||||
|
attrs.Width = &d2graph.Scalar{MapKey: mk}
|
||||||
|
} else if reserved == "height" {
|
||||||
|
attrs.Height = &d2graph.Scalar{MapKey: mk}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
|
||||||
|
ida, reserved, ok := c.compileFlatKey(mk.Key)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if reserved == "desc" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resolvedObj != obj {
|
||||||
|
obj = resolvedObj
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := obj
|
||||||
|
if len(resolvedIDA) > 0 {
|
||||||
|
unresolvedObj := obj
|
||||||
|
obj = parent.EnsureChild(resolvedIDA)
|
||||||
|
parent.AppendReferences(ida, d2graph.Reference{
|
||||||
|
Key: mk.Key,
|
||||||
|
|
||||||
|
MapKey: mk,
|
||||||
|
Scope: m,
|
||||||
|
}, unresolvedObj)
|
||||||
|
} else if obj.Parent == nil {
|
||||||
|
// Top level reserved key set on root.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mk.Edges) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.compileAttributes(&obj.Attributes, mk)
|
||||||
|
if obj.Attributes.Style.Animated != nil {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, `key "animated" can only be applied to edges`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.applyScalar(&obj.Attributes, reserved, mk.Value.ScalarBox())
|
||||||
|
if mk.Value.Map != nil {
|
||||||
|
if reserved != "" {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, "cannot set reserved key %q to a map", reserved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
obj.Map = mk.Value.Map
|
||||||
|
c.compileKeys(obj, mk.Value.Map)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.applyScalar(&obj.Attributes, reserved, mk.Primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) applyScalar(attrs *d2graph.Attributes, reserved string, box d2ast.ScalarBox) {
|
||||||
|
scalar := box.Unbox()
|
||||||
|
if scalar == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch reserved {
|
||||||
|
case "shape":
|
||||||
|
in := d2target.IsShape(scalar.ScalarString())
|
||||||
|
_, isArrowhead := d2target.Arrowheads[scalar.ScalarString()]
|
||||||
|
if !in && !isArrowhead {
|
||||||
|
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "unknown shape %q", scalar.ScalarString())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if box.Null != nil {
|
||||||
|
attrs.Shape.Value = ""
|
||||||
|
} else {
|
||||||
|
attrs.Shape.Value = scalar.ScalarString()
|
||||||
|
}
|
||||||
|
if attrs.Shape.Value == d2target.ShapeCode {
|
||||||
|
// Explicit code shape is plaintext.
|
||||||
|
attrs.Language = d2target.ShapeText
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "icon":
|
||||||
|
iconURL, err := url.Parse(scalar.ScalarString())
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "bad icon url %#v: %s", scalar.ScalarString(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attrs.Icon = iconURL
|
||||||
|
return
|
||||||
|
case "near":
|
||||||
|
nearKey, err := d2parser.ParseKey(scalar.ScalarString())
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "bad near key %#v: %s", scalar.ScalarString(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attrs.NearKey = nearKey
|
||||||
|
return
|
||||||
|
case "tooltip":
|
||||||
|
attrs.Tooltip = scalar.ScalarString()
|
||||||
|
return
|
||||||
|
case "width":
|
||||||
|
_, err := strconv.Atoi(scalar.ScalarString())
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "non-integer width %#v: %s", scalar.ScalarString(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attrs.Width.Value = scalar.ScalarString()
|
||||||
|
return
|
||||||
|
case "height":
|
||||||
|
_, err := strconv.Atoi(scalar.ScalarString())
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "non-integer height %#v: %s", scalar.ScalarString(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attrs.Height.Value = scalar.ScalarString()
|
||||||
|
return
|
||||||
|
case "link":
|
||||||
|
attrs.Link = scalar.ScalarString()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := d2graph.StyleKeywords[reserved]; ok {
|
||||||
|
if err := attrs.Style.Apply(reserved, scalar.ScalarString()); err != nil {
|
||||||
|
c.errorf(scalar.GetRange().Start, scalar.GetRange().End, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if box.Null != nil {
|
||||||
|
// TODO: delete obj
|
||||||
|
attrs.Label.Value = ""
|
||||||
|
} else {
|
||||||
|
attrs.Label.Value = scalar.ScalarString()
|
||||||
|
}
|
||||||
|
|
||||||
|
bs := box.BlockString
|
||||||
|
if bs != nil && reserved == "" {
|
||||||
|
attrs.Language = bs.Tag
|
||||||
|
fullTag, ok := ShortToFullLanguageAliases[bs.Tag]
|
||||||
|
if ok {
|
||||||
|
attrs.Language = fullTag
|
||||||
|
}
|
||||||
|
if attrs.Language == "markdown" {
|
||||||
|
attrs.Shape.Value = d2target.ShapeText
|
||||||
|
} else {
|
||||||
|
attrs.Shape.Value = d2target.ShapeCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileEdgeMapKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
|
||||||
|
if mk.EdgeIndex != nil {
|
||||||
|
edge, ok := obj.HasEdge(mk)
|
||||||
|
if ok {
|
||||||
|
c.appendEdgeReferences(obj, m, mk)
|
||||||
|
edge.References = append(edge.References, d2graph.EdgeReference{
|
||||||
|
Edge: mk.Edges[0],
|
||||||
|
|
||||||
|
MapKey: mk,
|
||||||
|
MapKeyEdgeIndex: 0,
|
||||||
|
Scope: m,
|
||||||
|
ScopeObj: obj,
|
||||||
|
})
|
||||||
|
c.compileEdge(edge, m, mk)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, e := range mk.Edges {
|
||||||
|
if e.Src == nil || e.Dst == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edge, err := obj.Connect(d2graph.Key(e.Src), d2graph.Key(e.Dst), e.SrcArrow == "<", e.DstArrow == ">", "")
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(e.Range.Start, e.Range.End, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edge.References = append(edge.References, d2graph.EdgeReference{
|
||||||
|
Edge: e,
|
||||||
|
|
||||||
|
MapKey: mk,
|
||||||
|
MapKeyEdgeIndex: i,
|
||||||
|
Scope: m,
|
||||||
|
ScopeObj: obj,
|
||||||
|
})
|
||||||
|
c.compileEdge(edge, m, mk)
|
||||||
|
}
|
||||||
|
c.appendEdgeReferences(obj, m, mk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileEdge(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) {
|
||||||
|
if mk.Key == nil && mk.EdgeKey == nil {
|
||||||
|
if len(mk.Edges) == 1 {
|
||||||
|
edge.Attributes.Label.MapKey = mk
|
||||||
|
}
|
||||||
|
c.applyScalar(&edge.Attributes, "", mk.Value.ScalarBox())
|
||||||
|
c.applyScalar(&edge.Attributes, "", mk.Primary)
|
||||||
|
} else {
|
||||||
|
c.compileEdgeKey(edge, m, mk)
|
||||||
|
}
|
||||||
|
if mk.Value.Map != nil && mk.EdgeKey == nil {
|
||||||
|
for _, n := range mk.Value.Map.Nodes {
|
||||||
|
if n.MapKey == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(n.MapKey.Edges) > 0 {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, `edges cannot be nested within another edge`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.MapKey.Key == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range n.MapKey.Key.Path {
|
||||||
|
_, ok := d2graph.ReservedKeywords[strings.ToLower(p.Unbox().ScalarString())]
|
||||||
|
if !ok {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, `edge map keys must be reserved keywords`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.compileEdgeKey(edge, m, n.MapKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileEdgeKey(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) {
|
||||||
|
var r string
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
// Give precedence to EdgeKeys
|
||||||
|
// x.(a -> b)[0].style.opacity: 0.4
|
||||||
|
// We want to compile the style.opacity, not the x
|
||||||
|
if mk.EdgeKey != nil {
|
||||||
|
_, r, ok = c.compileFlatKey(mk.EdgeKey)
|
||||||
|
} else if mk.Key != nil {
|
||||||
|
_, r, ok = c.compileFlatKey(mk.Key)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = c.compileArrowheads(edge, m, mk)
|
||||||
|
if ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.compileAttributes(&edge.Attributes, mk)
|
||||||
|
c.applyScalar(&edge.Attributes, r, mk.Value.ScalarBox())
|
||||||
|
if mk.Value.Map != nil {
|
||||||
|
for _, n := range mk.Value.Map.Nodes {
|
||||||
|
if n.MapKey != nil {
|
||||||
|
c.compileEdgeKey(edge, m, n.MapKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) appendEdgeReferences(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
|
||||||
|
for i, e := range mk.Edges {
|
||||||
|
if e.Src != nil {
|
||||||
|
ida := d2graph.Key(e.Src)
|
||||||
|
|
||||||
|
parent, _, err := d2graph.ResolveUnderscoreKey(ida, obj)
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent.AppendReferences(ida, d2graph.Reference{
|
||||||
|
Key: e.Src,
|
||||||
|
|
||||||
|
MapKey: mk,
|
||||||
|
MapKeyEdgeIndex: i,
|
||||||
|
Scope: m,
|
||||||
|
}, obj)
|
||||||
|
}
|
||||||
|
if e.Dst != nil {
|
||||||
|
ida := d2graph.Key(e.Dst)
|
||||||
|
|
||||||
|
parent, _, err := d2graph.ResolveUnderscoreKey(ida, obj)
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent.AppendReferences(ida, d2graph.Reference{
|
||||||
|
Key: e.Dst,
|
||||||
|
|
||||||
|
MapKey: mk,
|
||||||
|
MapKeyEdgeIndex: i,
|
||||||
|
Scope: m,
|
||||||
|
}, obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileFlatKey(k *d2ast.KeyPath) ([]string, string, bool) {
|
||||||
|
k2 := *k
|
||||||
|
var reserved string
|
||||||
|
for i, s := range k.Path {
|
||||||
|
keyword := strings.ToLower(s.Unbox().ScalarString())
|
||||||
|
_, isReserved := d2graph.ReservedKeywords[keyword]
|
||||||
|
_, isReservedHolder := d2graph.ReservedKeywordHolders[keyword]
|
||||||
|
if isReserved && !isReservedHolder {
|
||||||
|
reserved = keyword
|
||||||
|
k2.Path = k2.Path[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(k2.Path) < len(k.Path)-1 {
|
||||||
|
c.errorf(k.Range.Start, k.Range.End, "reserved key %q cannot have children", reserved)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return d2graph.Key(&k2), reserved, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO add more, e.g. C, bash
|
||||||
|
var ShortToFullLanguageAliases = map[string]string{
|
||||||
|
"md": "markdown",
|
||||||
|
"js": "javascript",
|
||||||
|
"go": "golang",
|
||||||
|
"py": "python",
|
||||||
|
"rb": "ruby",
|
||||||
|
"ts": "typescript",
|
||||||
|
}
|
||||||
|
var FullToShortLanguageAliases map[string]string
|
||||||
|
|
||||||
|
func (c *compiler) compileShapes(obj *d2graph.Object) {
|
||||||
|
for _, obj := range obj.ChildrenArray {
|
||||||
|
switch obj.Attributes.Shape.Value {
|
||||||
|
case d2target.ShapeClass:
|
||||||
|
c.compileClass(obj)
|
||||||
|
case d2target.ShapeSQLTable:
|
||||||
|
c.compileSQLTable(obj)
|
||||||
|
case d2target.ShapeImage:
|
||||||
|
c.compileImage(obj)
|
||||||
|
}
|
||||||
|
c.compileShapes(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range obj.ChildrenArray {
|
||||||
|
switch obj.Attributes.Shape.Value {
|
||||||
|
case d2target.ShapeClass, d2target.ShapeSQLTable:
|
||||||
|
flattenContainer(obj.Graph, obj)
|
||||||
|
}
|
||||||
|
if obj.IDVal == "style" {
|
||||||
|
obj.Parent.Attributes.Style = obj.Attributes.Style
|
||||||
|
if obj.Graph != nil {
|
||||||
|
flattenContainer(obj.Graph, obj)
|
||||||
|
removeObject(obj.Graph, obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileImage(obj *d2graph.Object) {
|
||||||
|
if obj.Attributes.Icon == nil {
|
||||||
|
c.errorf(obj.Attributes.Shape.MapKey.Range.Start, obj.Attributes.Shape.MapKey.Range.End, `image shape must include an "icon" field`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileClass(obj *d2graph.Object) {
|
||||||
|
obj.Class = &d2target.Class{}
|
||||||
|
|
||||||
|
for _, f := range obj.ChildrenArray {
|
||||||
|
if f.IDVal == "style" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visiblity := "public"
|
||||||
|
name := f.IDVal
|
||||||
|
// See https://www.uml-diagrams.org/visibility.html
|
||||||
|
if name != "" {
|
||||||
|
switch name[0] {
|
||||||
|
case '+':
|
||||||
|
name = name[1:]
|
||||||
|
case '-':
|
||||||
|
visiblity = "private"
|
||||||
|
name = name[1:]
|
||||||
|
case '#':
|
||||||
|
visiblity = "protected"
|
||||||
|
name = name[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(f.IDVal, "(") {
|
||||||
|
typ := f.Attributes.Label.Value
|
||||||
|
if typ == f.IDVal {
|
||||||
|
typ = ""
|
||||||
|
}
|
||||||
|
obj.Class.Fields = append(obj.Class.Fields, d2target.ClassField{
|
||||||
|
Name: name,
|
||||||
|
Type: typ,
|
||||||
|
Visibility: visiblity,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// TODO: Not great, AST should easily allow specifying alternate primary field
|
||||||
|
// as an explicit label should change the name.
|
||||||
|
returnType := f.Attributes.Label.Value
|
||||||
|
if returnType == f.IDVal {
|
||||||
|
returnType = "void"
|
||||||
|
}
|
||||||
|
obj.Class.Methods = append(obj.Class.Methods, d2target.ClassMethod{
|
||||||
|
Name: name,
|
||||||
|
Return: returnType,
|
||||||
|
Visibility: visiblity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) compileSQLTable(obj *d2graph.Object) {
|
||||||
|
obj.SQLTable = &d2target.SQLTable{}
|
||||||
|
|
||||||
|
parentID := obj.Parent.AbsID()
|
||||||
|
tableID := obj.AbsID()
|
||||||
|
for _, col := range obj.ChildrenArray {
|
||||||
|
if col.IDVal == "style" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
typ := col.Attributes.Label.Value
|
||||||
|
if typ == col.IDVal {
|
||||||
|
// Not great, AST should easily allow specifying alternate primary field
|
||||||
|
// as an explicit label should change the name.
|
||||||
|
typ = ""
|
||||||
|
}
|
||||||
|
d2Col := d2target.SQLColumn{
|
||||||
|
Name: col.IDVal,
|
||||||
|
Type: typ,
|
||||||
|
}
|
||||||
|
// The only map a sql table field could have is to specify constraint
|
||||||
|
if col.Map != nil {
|
||||||
|
for _, n := range col.Map.Nodes {
|
||||||
|
if n.MapKey.Key == nil || len(n.MapKey.Key.Path) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.MapKey.Key.Path[0].Unbox().ScalarString() == "constraint" {
|
||||||
|
d2Col.Constraint = n.MapKey.Value.StringBox().Unbox().ScalarString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
absID := col.AbsID()
|
||||||
|
for _, e := range obj.Graph.Edges {
|
||||||
|
srcID := e.Src.AbsID()
|
||||||
|
dstID := e.Dst.AbsID()
|
||||||
|
// skip edges between columns of the same table
|
||||||
|
if strings.HasPrefix(srcID, tableID) && strings.HasPrefix(dstID, tableID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if srcID == absID {
|
||||||
|
// Frontend isn't aware of container IDs.
|
||||||
|
d2Col.Reference = strings.TrimPrefix(dstID, parentID+".")
|
||||||
|
relSrc := strings.TrimPrefix(absID, parentID+".")
|
||||||
|
e.Attributes.Label.Value = fmt.Sprintf("%s %s %s", relSrc, e.ArrowString(), d2Col.Reference)
|
||||||
|
// removeContainer() will adjust the edge to point to the table and not inside.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.SQLTable.Columns = append(obj.SQLTable.Columns, d2Col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO too similar to flattenContainer, should reconcile in a refactor
|
||||||
|
func removeObject(g *d2graph.Graph, obj *d2graph.Object) {
|
||||||
|
for i := 0; i < len(obj.Graph.Objects); i++ {
|
||||||
|
if obj.Graph.Objects[i] == obj {
|
||||||
|
obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(obj.Parent.Children, obj.ID)
|
||||||
|
for i, child := range obj.Parent.ChildrenArray {
|
||||||
|
if obj == child {
|
||||||
|
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray[:i], obj.Parent.ChildrenArray[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenContainer(g *d2graph.Graph, obj *d2graph.Object) {
|
||||||
|
absID := obj.AbsID()
|
||||||
|
|
||||||
|
toRemove := map[*d2graph.Edge]struct{}{}
|
||||||
|
toAdd := []*d2graph.Edge{}
|
||||||
|
for i := 0; i < len(g.Edges); i++ {
|
||||||
|
e := g.Edges[i]
|
||||||
|
srcID := e.Src.AbsID()
|
||||||
|
dstID := e.Dst.AbsID()
|
||||||
|
|
||||||
|
srcIsChild := strings.HasPrefix(srcID, absID+".")
|
||||||
|
dstIsChild := strings.HasPrefix(dstID, absID+".")
|
||||||
|
if srcIsChild && dstIsChild {
|
||||||
|
toRemove[e] = struct{}{}
|
||||||
|
} else if srcIsChild {
|
||||||
|
toRemove[e] = struct{}{}
|
||||||
|
if dstID == absID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toAdd = append(toAdd, e)
|
||||||
|
} else if dstIsChild {
|
||||||
|
toRemove[e] = struct{}{}
|
||||||
|
if srcID == absID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toAdd = append(toAdd, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range toAdd {
|
||||||
|
var newEdge *d2graph.Edge
|
||||||
|
if strings.HasPrefix(e.Src.AbsID(), absID+".") {
|
||||||
|
newEdge, _ = g.Root.Connect(obj.AbsIDArray(), e.Dst.AbsIDArray(), e.SrcArrow, e.DstArrow, e.Attributes.Label.Value)
|
||||||
|
} else {
|
||||||
|
newEdge, _ = g.Root.Connect(e.Src.AbsIDArray(), obj.AbsIDArray(), e.SrcArrow, e.DstArrow, e.Attributes.Label.Value)
|
||||||
|
}
|
||||||
|
// TODO more attributes
|
||||||
|
newEdge.Attributes.Label = e.Attributes.Label
|
||||||
|
newEdge.References = e.References
|
||||||
|
}
|
||||||
|
updatedEdges := []*d2graph.Edge{}
|
||||||
|
for _, e := range g.Edges {
|
||||||
|
if _, is := toRemove[e]; is {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updatedEdges = append(updatedEdges, e)
|
||||||
|
}
|
||||||
|
g.Edges = updatedEdges
|
||||||
|
|
||||||
|
for i := 0; i < len(g.Objects); i++ {
|
||||||
|
child := g.Objects[i]
|
||||||
|
if strings.HasPrefix(child.AbsID(), absID+".") {
|
||||||
|
g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
|
||||||
|
i--
|
||||||
|
delete(obj.Children, child.ID)
|
||||||
|
for i, child2 := range obj.ChildrenArray {
|
||||||
|
if child == child2 {
|
||||||
|
obj.ChildrenArray = append(obj.ChildrenArray[:i], obj.ChildrenArray[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) validateKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
|
||||||
|
ida, reserved, ok := c.compileFlatKey(mk.Key)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if reserved == "" && obj.Attributes.Shape.Value == d2target.ShapeImage {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, "image shapes cannot have children.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if reserved == "width" && obj.Attributes.Shape.Value != d2target.ShapeImage {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, "width is only applicable to image shapes.")
|
||||||
|
}
|
||||||
|
if reserved == "height" && obj.Attributes.Shape.Value != d2target.ShapeImage {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, "height is only applicable to image shapes.")
|
||||||
|
}
|
||||||
|
|
||||||
|
in := d2target.IsShape(obj.Attributes.Shape.Value)
|
||||||
|
_, arrowheadIn := d2target.Arrowheads[obj.Attributes.Shape.Value]
|
||||||
|
if !in && arrowheadIn {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf(`invalid shape, can only set "%s" for arrowheads`, obj.Attributes.Shape.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
|
||||||
|
if err != nil {
|
||||||
|
c.errorf(mk.Range.Start, mk.Range.End, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resolvedObj != obj {
|
||||||
|
obj = resolvedObj
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := obj
|
||||||
|
if len(resolvedIDA) > 0 {
|
||||||
|
obj, _ = parent.HasChild(resolvedIDA)
|
||||||
|
} else if obj.Parent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mk.Edges) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk.Value.Map != nil {
|
||||||
|
c.validateKeys(obj, mk.Value.Map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ast.Map) {
|
||||||
|
for _, n := range m.Nodes {
|
||||||
|
if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Edges) == 0 {
|
||||||
|
c.validateKey(obj, m, n.MapKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiler) validateNear(g *d2graph.Graph) {
|
||||||
|
for _, obj := range g.Objects {
|
||||||
|
if obj.Attributes.NearKey != nil {
|
||||||
|
_, ok := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
|
||||||
|
if !ok {
|
||||||
|
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "near key %#v does not exist. It must be the absolute path to a shape.", d2format.Format(obj.Attributes.NearKey))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
FullToShortLanguageAliases = make(map[string]string, len(ShortToFullLanguageAliases))
|
||||||
|
for k, v := range ShortToFullLanguageAliases {
|
||||||
|
FullToShortLanguageAliases[v] = k
|
||||||
|
}
|
||||||
|
}
|
||||||
1522
d2compiler/compile_test.go
Normal file
1522
d2compiler/compile_test.go
Normal file
File diff suppressed because it is too large
Load diff
6
d2compiler/doc.go
Normal file
6
d2compiler/doc.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Package d2compiler implements a parser, compiler and autoformatter for the Terrastruct d2
|
||||||
|
// diagramming language.
|
||||||
|
//
|
||||||
|
// https://github.com/terrastruct/d2-vscode
|
||||||
|
// https://terrastruct.com/docs/d2/tour/intro/
|
||||||
|
package d2compiler
|
||||||
233
d2exporter/export.go
Normal file
233
d2exporter/export.go
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
package d2exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/d2themes"
|
||||||
|
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Export(ctx context.Context, g *d2graph.Graph, themeID int64) (*d2target.Diagram, error) {
|
||||||
|
theme := d2themescatalog.Find(themeID)
|
||||||
|
|
||||||
|
diagram := d2target.NewDiagram()
|
||||||
|
|
||||||
|
diagram.Shapes = make([]d2target.Shape, len(g.Objects))
|
||||||
|
for i := range g.Objects {
|
||||||
|
diagram.Shapes[i] = toShape(g.Objects[i], &theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
diagram.Connections = make([]d2target.Connection, len(g.Edges))
|
||||||
|
for i := range g.Edges {
|
||||||
|
diagram.Connections[i] = toConnection(g.Edges[i], &theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagram, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTheme(shape *d2target.Shape, obj *d2graph.Object, theme *d2themes.Theme) {
|
||||||
|
shape.Stroke = obj.GetStroke(theme, shape.StrokeDash)
|
||||||
|
shape.Fill = obj.GetFill(theme)
|
||||||
|
if obj.Attributes.Shape.Value == d2target.ShapeText {
|
||||||
|
shape.Color = theme.Colors.Neutrals.N1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyStyles(shape *d2target.Shape, obj *d2graph.Object) {
|
||||||
|
if obj.Attributes.Style.Opacity != nil {
|
||||||
|
shape.Opacity, _ = strconv.ParseFloat(obj.Attributes.Style.Opacity.Value, 64)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.StrokeDash != nil {
|
||||||
|
shape.StrokeDash, _ = strconv.ParseFloat(obj.Attributes.Style.StrokeDash.Value, 64)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Fill != nil {
|
||||||
|
shape.Fill = obj.Attributes.Style.Fill.Value
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Stroke != nil {
|
||||||
|
shape.Stroke = obj.Attributes.Style.Stroke.Value
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.StrokeWidth != nil {
|
||||||
|
shape.StrokeWidth, _ = strconv.Atoi(obj.Attributes.Style.StrokeWidth.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Shadow != nil {
|
||||||
|
shape.Shadow, _ = strconv.ParseBool(obj.Attributes.Style.Shadow.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.ThreeDee != nil {
|
||||||
|
shape.ThreeDee, _ = strconv.ParseBool(obj.Attributes.Style.ThreeDee.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Multiple != nil {
|
||||||
|
shape.Multiple, _ = strconv.ParseBool(obj.Attributes.Style.Multiple.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.BorderRadius != nil {
|
||||||
|
shape.BorderRadius, _ = strconv.Atoi(obj.Attributes.Style.BorderRadius.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.Attributes.Style.FontColor != nil {
|
||||||
|
shape.Color = obj.Attributes.Style.FontColor.Value
|
||||||
|
}
|
||||||
|
if obj.Attributes.Shape.Value != d2target.ShapeText {
|
||||||
|
if obj.Attributes.Style.Italic != nil {
|
||||||
|
shape.Italic, _ = strconv.ParseBool(obj.Attributes.Style.Italic.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Bold != nil {
|
||||||
|
shape.Bold, _ = strconv.ParseBool(obj.Attributes.Style.Bold.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Underline != nil {
|
||||||
|
shape.Underline, _ = strconv.ParseBool(obj.Attributes.Style.Underline.Value)
|
||||||
|
}
|
||||||
|
if obj.Attributes.Style.Font != nil {
|
||||||
|
shape.FontFamily = obj.Attributes.Style.Font.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
|
||||||
|
shape := d2target.BaseShape()
|
||||||
|
shape.SetType(obj.Attributes.Shape.Value)
|
||||||
|
shape.ID = obj.AbsID()
|
||||||
|
shape.Pos = d2target.NewPoint(int(obj.TopLeft.X), int(obj.TopLeft.Y))
|
||||||
|
shape.Width = int(obj.Width)
|
||||||
|
shape.Height = int(obj.Height)
|
||||||
|
|
||||||
|
text := obj.Text()
|
||||||
|
shape.FontSize = text.FontSize
|
||||||
|
shape.Level = int(obj.Level())
|
||||||
|
|
||||||
|
applyStyles(shape, obj)
|
||||||
|
applyTheme(shape, obj, theme)
|
||||||
|
shape.Color = text.GetColor(theme, shape.Italic)
|
||||||
|
applyStyles(shape, obj)
|
||||||
|
|
||||||
|
switch obj.Attributes.Shape.Value {
|
||||||
|
case d2target.ShapeCode, d2target.ShapeText:
|
||||||
|
shape.Language = obj.Attributes.Language
|
||||||
|
shape.Label = obj.Attributes.Label.Value
|
||||||
|
case d2target.ShapeClass:
|
||||||
|
shape.Class = *obj.Class
|
||||||
|
// The label is the header for classes and tables, which is set in client to be 4 px larger than the object's set font size
|
||||||
|
shape.FontSize -= 4
|
||||||
|
case d2target.ShapeSQLTable:
|
||||||
|
shape.SQLTable = *obj.SQLTable
|
||||||
|
shape.FontSize -= 4
|
||||||
|
}
|
||||||
|
shape.Label = text.Text
|
||||||
|
shape.LabelWidth = text.Dimensions.Width
|
||||||
|
shape.LabelHeight = text.Dimensions.Height
|
||||||
|
if obj.LabelPosition != nil {
|
||||||
|
shape.LabelPosition = *obj.LabelPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
shape.Tooltip = obj.Attributes.Tooltip
|
||||||
|
shape.Link = obj.Attributes.Link
|
||||||
|
shape.Icon = obj.Attributes.Icon
|
||||||
|
if obj.IconPosition != nil {
|
||||||
|
shape.IconPosition = *obj.IconPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
return *shape
|
||||||
|
}
|
||||||
|
|
||||||
|
func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection {
|
||||||
|
connection := d2target.BaseConnection()
|
||||||
|
connection.ID = edge.AbsID()
|
||||||
|
// edge.Edge.ID = go2.StringToIntHash(connection.ID)
|
||||||
|
text := edge.Text()
|
||||||
|
|
||||||
|
if edge.SrcArrow {
|
||||||
|
connection.SrcArrow = d2target.TriangleArrowhead
|
||||||
|
if edge.SrcArrowhead != nil {
|
||||||
|
if edge.SrcArrowhead.Shape.Value != "" {
|
||||||
|
filled := false
|
||||||
|
if edge.SrcArrowhead.Style.Filled != nil {
|
||||||
|
filled, _ = strconv.ParseBool(edge.SrcArrowhead.Style.Filled.Value)
|
||||||
|
}
|
||||||
|
connection.SrcArrow = d2target.ToArrowhead(edge.SrcArrowhead.Shape.Value, filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edge.SrcArrowhead != nil {
|
||||||
|
if edge.SrcArrowhead.Label.Value != "" {
|
||||||
|
connection.SrcLabel = edge.SrcArrowhead.Label.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edge.DstArrow {
|
||||||
|
connection.DstArrow = d2target.TriangleArrowhead
|
||||||
|
if edge.DstArrowhead != nil {
|
||||||
|
if edge.DstArrowhead.Shape.Value != "" {
|
||||||
|
filled := false
|
||||||
|
if edge.DstArrowhead.Style.Filled != nil {
|
||||||
|
filled, _ = strconv.ParseBool(edge.DstArrowhead.Style.Filled.Value)
|
||||||
|
}
|
||||||
|
connection.DstArrow = d2target.ToArrowhead(edge.DstArrowhead.Shape.Value, filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edge.DstArrowhead != nil {
|
||||||
|
if edge.DstArrowhead.Label.Value != "" {
|
||||||
|
connection.DstLabel = edge.DstArrowhead.Label.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if edge.Attributes.Style.Opacity != nil {
|
||||||
|
connection.Opacity, _ = strconv.ParseFloat(edge.Attributes.Style.Opacity.Value, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
if edge.Attributes.Style.StrokeDash != nil {
|
||||||
|
connection.StrokeDash, _ = strconv.ParseFloat(edge.Attributes.Style.StrokeDash.Value, 64)
|
||||||
|
}
|
||||||
|
connection.Stroke = edge.GetStroke(theme, connection.StrokeDash)
|
||||||
|
if edge.Attributes.Style.Stroke != nil {
|
||||||
|
connection.Stroke = edge.Attributes.Style.Stroke.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if edge.Attributes.Style.StrokeWidth != nil {
|
||||||
|
connection.StrokeWidth, _ = strconv.Atoi(edge.Attributes.Style.StrokeWidth.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.FontSize = text.FontSize
|
||||||
|
if edge.Attributes.Style.FontSize != nil {
|
||||||
|
connection.FontSize, _ = strconv.Atoi(edge.Attributes.Style.FontSize.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if edge.Attributes.Style.Animated != nil {
|
||||||
|
connection.Animated, _ = strconv.ParseBool(edge.Attributes.Style.Animated.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.Tooltip = edge.Attributes.Tooltip
|
||||||
|
connection.Icon = edge.Attributes.Icon
|
||||||
|
|
||||||
|
if edge.Attributes.Style.Italic != nil {
|
||||||
|
connection.Italic, _ = strconv.ParseBool(edge.Attributes.Style.Italic.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.Color = text.GetColor(theme, connection.Italic)
|
||||||
|
if edge.Attributes.Style.FontColor != nil {
|
||||||
|
connection.Color = edge.Attributes.Style.FontColor.Value
|
||||||
|
}
|
||||||
|
if edge.Attributes.Style.Bold != nil {
|
||||||
|
connection.Bold, _ = strconv.ParseBool(edge.Attributes.Style.Bold.Value)
|
||||||
|
}
|
||||||
|
if edge.Attributes.Style.Font != nil {
|
||||||
|
connection.FontFamily = edge.Attributes.Style.Font.Value
|
||||||
|
}
|
||||||
|
connection.Label = text.Text
|
||||||
|
connection.LabelWidth = text.Dimensions.Width
|
||||||
|
connection.LabelHeight = text.Dimensions.Height
|
||||||
|
|
||||||
|
if edge.LabelPosition != nil {
|
||||||
|
connection.LabelPosition = *edge.LabelPosition
|
||||||
|
}
|
||||||
|
if edge.LabelPercentage != nil {
|
||||||
|
connection.LabelPercentage = *edge.LabelPercentage
|
||||||
|
}
|
||||||
|
connection.Route = edge.Route
|
||||||
|
connection.IsCurve = edge.IsCurve
|
||||||
|
|
||||||
|
connection.Src = edge.Src.AbsID()
|
||||||
|
connection.Dst = edge.Dst.AbsID()
|
||||||
|
|
||||||
|
return *connection
|
||||||
|
}
|
||||||
259
d2exporter/export_test.go
Normal file
259
d2exporter/export_test.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package d2exporter_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/diff"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2compiler"
|
||||||
|
"oss.terrastruct.com/d2/d2exporter"
|
||||||
|
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/textmeasure"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
"oss.terrastruct.com/d2/lib/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
name string
|
||||||
|
dsl string
|
||||||
|
themeID int64
|
||||||
|
|
||||||
|
assertions func(t *testing.T, d *d2target.Diagram)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("shape", testShape)
|
||||||
|
t.Run("connection", testConnection)
|
||||||
|
t.Run("label", testLabel)
|
||||||
|
t.Run("theme", testTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShape(t *testing.T) {
|
||||||
|
tcs := []testCase{
|
||||||
|
{
|
||||||
|
name: "basic",
|
||||||
|
dsl: `x
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "synonyms",
|
||||||
|
dsl: `x: {shape: circle}
|
||||||
|
y: {shape: square}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text_color",
|
||||||
|
dsl: `x: |md yo | { style.font-color: red }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "border-radius",
|
||||||
|
dsl: `Square: "" { style.border-radius: 5 }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image_dimensions",
|
||||||
|
|
||||||
|
dsl: `hey: "" {
|
||||||
|
icon: https://icons.terrastruct.com/essentials/004-picture.svg
|
||||||
|
shape: image
|
||||||
|
width: 200
|
||||||
|
height: 230
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
assertions: func(t *testing.T, d *d2target.Diagram) {
|
||||||
|
if d.Shapes[0].Width != 200 {
|
||||||
|
t.Fatalf("expected width 200, got %v", d.Shapes[0].Width)
|
||||||
|
}
|
||||||
|
if d.Shapes[0].Height != 230 {
|
||||||
|
t.Fatalf("expected height 230, got %v", d.Shapes[0].Height)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runa(t, tcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConnection(t *testing.T) {
|
||||||
|
tcs := []testCase{
|
||||||
|
{
|
||||||
|
name: "basic",
|
||||||
|
dsl: `x -> y
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stroke-dash",
|
||||||
|
dsl: `x -> y: { style.stroke-dash: 4 }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "arrowhead",
|
||||||
|
dsl: `x -> y: {
|
||||||
|
source-arrowhead: If you've done six impossible things before breakfast, why not round it
|
||||||
|
target-arrowhead: {
|
||||||
|
label: A man with one watch knows what time it is.
|
||||||
|
shape: diamond
|
||||||
|
style.filled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// This is a regression test where a connection with stroke-dash of 0 on terrastruct flagship theme would have a diff color
|
||||||
|
// than a connection without stroke dash
|
||||||
|
themeID: d2themescatalog.FlagshipTerrastruct.ID,
|
||||||
|
name: "theme_stroke-dash",
|
||||||
|
dsl: `x -> y: { style.stroke-dash: 0 }
|
||||||
|
x -> y
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runa(t, tcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLabel(t *testing.T) {
|
||||||
|
tcs := []testCase{
|
||||||
|
{
|
||||||
|
name: "basic_shape",
|
||||||
|
dsl: `x: yo
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shape_font_color",
|
||||||
|
dsl: `x: yo { style.font-color: red }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection_font_color",
|
||||||
|
dsl: `x -> y: yo { style.font-color: red }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runa(t, tcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheme(t *testing.T) {
|
||||||
|
tcs := []testCase{
|
||||||
|
{
|
||||||
|
name: "shape_without_bold",
|
||||||
|
themeID: d2themescatalog.FlagshipTerrastruct.ID,
|
||||||
|
dsl: `x: {
|
||||||
|
style.bold: false
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shape_with_italic",
|
||||||
|
themeID: d2themescatalog.FlagshipTerrastruct.ID,
|
||||||
|
dsl: `x: {
|
||||||
|
style.italic: true
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection_without_italic",
|
||||||
|
dsl: `x -> y: asdf { style.italic: false }
|
||||||
|
`,
|
||||||
|
themeID: d2themescatalog.FlagshipTerrastruct.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection_with_italic",
|
||||||
|
dsl: `x -> y: asdf {
|
||||||
|
style.italic: true
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
themeID: d2themescatalog.FlagshipTerrastruct.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection_with_bold",
|
||||||
|
dsl: `x -> y: asdf {
|
||||||
|
style.bold: true
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
themeID: d2themescatalog.FlagshipTerrastruct.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runa(t, tcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runa(t *testing.T, tcs []testCase) {
|
||||||
|
for _, tc := range tcs {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
run(t, tc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(t *testing.T, tc testCase) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx = log.WithTB(ctx, t, nil)
|
||||||
|
ctx = log.Leveled(ctx, slog.LevelDebug)
|
||||||
|
|
||||||
|
g, err := d2compiler.Compile("", strings.NewReader(tc.dsl), &d2compiler.CompileOptions{
|
||||||
|
UTF16: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = g.SetDimensions(nil, ruler)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = d2dagrelayout.Layout(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := d2exporter.Export(ctx, g, tc.themeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.assertions != nil {
|
||||||
|
t.Run("assertions", func(t *testing.T) {
|
||||||
|
tc.assertions(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test is agnostic of layout changes
|
||||||
|
for i := range got.Shapes {
|
||||||
|
got.Shapes[i].Pos = d2target.Point{}
|
||||||
|
got.Shapes[i].Width = 0
|
||||||
|
got.Shapes[i].Height = 0
|
||||||
|
got.Shapes[i].LabelWidth = 0
|
||||||
|
got.Shapes[i].LabelHeight = 0
|
||||||
|
got.Shapes[i].LabelPosition = ""
|
||||||
|
}
|
||||||
|
for i := range got.Connections {
|
||||||
|
got.Connections[i].Route = []*geo.Point{}
|
||||||
|
got.Connections[i].LabelWidth = 0
|
||||||
|
got.Connections[i].LabelHeight = 0
|
||||||
|
got.Connections[i].LabelPosition = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
err = diff.Testdata(filepath.Join("..", "testdata", "d2exporter", t.Name()), got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
d2format/escape.go
Normal file
88
d2format/escape.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package d2format
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
)
|
||||||
|
|
||||||
|
func escapeSingleQuotedValue(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '\'':
|
||||||
|
b.WriteByte('\'')
|
||||||
|
case '\n':
|
||||||
|
// TODO: Unified string syntax.
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteByte('n')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeDoubledQuotedValue(s string, inKey bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '"', '\\':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
case '\n':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteByte('n')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inKey && r == '$' {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeUnquotedValue(s string, inKey bool) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return `""`
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(s, "null") {
|
||||||
|
return "\\null"
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '\'', '"', '|':
|
||||||
|
if i == 0 {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
case '\n':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteByte('n')
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
if inKey {
|
||||||
|
switch r {
|
||||||
|
case '-':
|
||||||
|
if i+1 < len(s) && s[i+1] == '-' {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
case '&':
|
||||||
|
if i == 0 {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if strings.ContainsRune(d2ast.UnquotedKeySpecials, r) {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.ContainsRune(d2ast.UnquotedValueSpecials, r) {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
294
d2format/escape_test.go
Normal file
294
d2format/escape_test.go
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
package d2format_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/diff"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
"oss.terrastruct.com/d2/d2format"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEscapeSingleQuoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
str string
|
||||||
|
exp string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
str: `Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips.`,
|
||||||
|
exp: `'Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips.'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single_quotes",
|
||||||
|
str: `'rawr'`,
|
||||||
|
exp: `'''rawr'''`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newlines",
|
||||||
|
str: `
|
||||||
|
|
||||||
|
|
||||||
|
`,
|
||||||
|
exp: `'\n\n\n'`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
diff.AssertStringEq(t, tc.exp, d2format.Format(&d2ast.SingleQuotedString{
|
||||||
|
Value: tc.str,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeDoubleQuoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
str string
|
||||||
|
exp string
|
||||||
|
inKey bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
str: `Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips.`,
|
||||||
|
exp: `"Things will be bright in P.M. Love is a snowmobile racing across the tundra, which suddenly flips."`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_1",
|
||||||
|
str: `"\x`,
|
||||||
|
exp: `"\"\\x"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_2",
|
||||||
|
str: `$$3es`,
|
||||||
|
exp: `"\$\$3es"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newlines",
|
||||||
|
str: `
|
||||||
|
|
||||||
|
|
||||||
|
`,
|
||||||
|
exp: `"\n\n\n"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_key",
|
||||||
|
str: `$$3es`,
|
||||||
|
exp: `"$$3es"`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var n d2ast.Node
|
||||||
|
if tc.inKey {
|
||||||
|
n = &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
d2ast.MakeValueBox(d2ast.FlatDoubleQuotedString(tc.str)).StringBox(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n = d2ast.FlatDoubleQuotedString(tc.str)
|
||||||
|
}
|
||||||
|
diff.AssertStringEq(t, tc.exp, d2format.Format(n))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeUnquoted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
str string
|
||||||
|
exp string
|
||||||
|
inKey bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
str: `Change your thoughts and you change your world.`,
|
||||||
|
exp: `Change your thoughts and you change your world.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_1",
|
||||||
|
str: `meow;{};meow`,
|
||||||
|
exp: `meow\;\{\}\;meow`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_2",
|
||||||
|
str: `'meow-#'`,
|
||||||
|
exp: `\'meow-\#'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_3",
|
||||||
|
str: `#meow|`,
|
||||||
|
exp: `\#meow|`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_key_1",
|
||||||
|
str: `<---->`,
|
||||||
|
exp: `\<\-\-\--\>`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_key_2",
|
||||||
|
str: `:::::`,
|
||||||
|
exp: `\:\:\:\:\:`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_key_3",
|
||||||
|
str: `&&OKAY!! Turn on the sound ONLY for TRYNEL CARPETING, FULLY-EQUIPPED`,
|
||||||
|
exp: `\&&OKAY!! Turn on the sound ONLY for TRYNEL CARPETING, FULLY-EQUIPPED`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_key_4",
|
||||||
|
str: `*-->`,
|
||||||
|
exp: `\*\--\>`,
|
||||||
|
inKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_key_4_notkey",
|
||||||
|
str: `*-->`,
|
||||||
|
exp: `*-->`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null",
|
||||||
|
str: `null`,
|
||||||
|
exp: `\null`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
str: ``,
|
||||||
|
exp: `""`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newlines",
|
||||||
|
str: `
|
||||||
|
|
||||||
|
|
||||||
|
`,
|
||||||
|
exp: `\n\n\n`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var n d2ast.Node
|
||||||
|
if tc.inKey {
|
||||||
|
n = &d2ast.KeyPath{
|
||||||
|
Path: []*d2ast.StringBox{
|
||||||
|
d2ast.MakeValueBox(d2ast.FlatUnquotedString(tc.str)).StringBox(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
n = d2ast.FlatUnquotedString(tc.str)
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.AssertStringEq(t, tc.exp, d2format.Format(n))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeBlockString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
|
||||||
|
tag string
|
||||||
|
quote string
|
||||||
|
value string
|
||||||
|
|
||||||
|
exp string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "oneline",
|
||||||
|
value: `Change your thoughts and you change your world.`,
|
||||||
|
exp: `| Change your thoughts and you change your world. |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiline",
|
||||||
|
value: `Change your thoughts and you change your world.
|
||||||
|
`,
|
||||||
|
exp: `|
|
||||||
|
Change your thoughts and you change your world.
|
||||||
|
|`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
value: ``,
|
||||||
|
exp: `| |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quote_1",
|
||||||
|
value: `|%%% %%%|`,
|
||||||
|
quote: "%%%",
|
||||||
|
|
||||||
|
exp: `|%%%% |%%% %%%| %%%%|`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quote_2",
|
||||||
|
value: `|%%% %%%%|`,
|
||||||
|
quote: "%%%",
|
||||||
|
|
||||||
|
exp: `|%%%%% |%%% %%%%| %%%%%|`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quote_3",
|
||||||
|
value: `||`,
|
||||||
|
quote: "",
|
||||||
|
|
||||||
|
exp: `||| || |||`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tag",
|
||||||
|
value: `This must be morning. I never could get the hang of mornings.`,
|
||||||
|
tag: "html",
|
||||||
|
|
||||||
|
exp: `|html This must be morning. I never could get the hang of mornings. |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad_tag",
|
||||||
|
value: `This must be morning. I never could get the hang of mornings.`,
|
||||||
|
tag: "ok ok",
|
||||||
|
|
||||||
|
exp: `|ok ok This must be morning. I never could get the hang of mornings. |`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
n := &d2ast.BlockString{
|
||||||
|
Quote: tc.quote,
|
||||||
|
Tag: tc.tag,
|
||||||
|
Value: tc.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.AssertStringEq(t, tc.exp, d2format.Format(n))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: chaos test each
|
||||||
399
d2format/format.go
Normal file
399
d2format/format.go
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
package d2format
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: edges with shared path should be fmted as <rel>.(x -> y)
|
||||||
|
func Format(n d2ast.Node) string {
|
||||||
|
var p printer
|
||||||
|
p.node(n)
|
||||||
|
return p.sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type printer struct {
|
||||||
|
sb strings.Builder
|
||||||
|
indentStr string
|
||||||
|
inKey bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) indent() {
|
||||||
|
p.indentStr += " " + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) deindent() {
|
||||||
|
p.indentStr = p.indentStr[:len(p.indentStr)-2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) newline() {
|
||||||
|
p.sb.WriteByte('\n')
|
||||||
|
p.sb.WriteString(p.indentStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) node(n d2ast.Node) {
|
||||||
|
switch n := n.(type) {
|
||||||
|
case *d2ast.Comment:
|
||||||
|
p.comment(n)
|
||||||
|
case *d2ast.BlockComment:
|
||||||
|
p.blockComment(n)
|
||||||
|
case *d2ast.Null:
|
||||||
|
p.sb.WriteString("null")
|
||||||
|
case *d2ast.Boolean:
|
||||||
|
p.sb.WriteString(strconv.FormatBool(n.Value))
|
||||||
|
case *d2ast.Number:
|
||||||
|
p.sb.WriteString(n.Raw)
|
||||||
|
case *d2ast.UnquotedString:
|
||||||
|
p.interpolationBoxes(n.Value, false)
|
||||||
|
case *d2ast.DoubleQuotedString:
|
||||||
|
p.sb.WriteByte('"')
|
||||||
|
p.interpolationBoxes(n.Value, true)
|
||||||
|
p.sb.WriteByte('"')
|
||||||
|
case *d2ast.SingleQuotedString:
|
||||||
|
p.sb.WriteByte('\'')
|
||||||
|
if n.Raw == "" {
|
||||||
|
n.Raw = escapeSingleQuotedValue(n.Value)
|
||||||
|
}
|
||||||
|
p.sb.WriteString(escapeSingleQuotedValue(n.Value))
|
||||||
|
p.sb.WriteByte('\'')
|
||||||
|
case *d2ast.BlockString:
|
||||||
|
p.blockString(n)
|
||||||
|
case *d2ast.Substitution:
|
||||||
|
p.substitution(n)
|
||||||
|
case *d2ast.Array:
|
||||||
|
p.array(n)
|
||||||
|
case *d2ast.Map:
|
||||||
|
p._map(n)
|
||||||
|
case *d2ast.Key:
|
||||||
|
p.mapKey(n)
|
||||||
|
case *d2ast.KeyPath:
|
||||||
|
p.key(n)
|
||||||
|
case *d2ast.Edge:
|
||||||
|
p.edge(n)
|
||||||
|
case *d2ast.EdgeIndex:
|
||||||
|
p.edgeIndex(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) comment(c *d2ast.Comment) {
|
||||||
|
lines := strings.Split(c.Value, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
p.sb.WriteString("#")
|
||||||
|
if line != "" && !strings.HasPrefix(line, " ") {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
p.sb.WriteString(line)
|
||||||
|
if i < len(lines)-1 {
|
||||||
|
p.newline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) blockComment(bc *d2ast.BlockComment) {
|
||||||
|
p.sb.WriteString(`"""`)
|
||||||
|
if bc.Range.OneLine() {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(bc.Value, "\n")
|
||||||
|
for _, l := range lines {
|
||||||
|
if !bc.Range.OneLine() {
|
||||||
|
if l == "" {
|
||||||
|
p.sb.WriteByte('\n')
|
||||||
|
} else {
|
||||||
|
p.newline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.sb.WriteString(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bc.Range.OneLine() {
|
||||||
|
p.newline()
|
||||||
|
} else {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
p.sb.WriteString(`"""`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) interpolationBoxes(boxes []d2ast.InterpolationBox, isDoubleString bool) {
|
||||||
|
for _, b := range boxes {
|
||||||
|
if b.Substitution != nil {
|
||||||
|
p.substitution(b.Substitution)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.StringRaw == nil {
|
||||||
|
var s string
|
||||||
|
if isDoubleString {
|
||||||
|
s = escapeDoubledQuotedValue(*b.String, p.inKey)
|
||||||
|
} else {
|
||||||
|
s = escapeUnquotedValue(*b.String, p.inKey)
|
||||||
|
}
|
||||||
|
b.StringRaw = &s
|
||||||
|
}
|
||||||
|
p.sb.WriteString(*b.StringRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) blockString(bs *d2ast.BlockString) {
|
||||||
|
quote := bs.Quote
|
||||||
|
for strings.Contains(bs.Value, "|"+quote) {
|
||||||
|
if quote == "" {
|
||||||
|
quote += "|"
|
||||||
|
} else {
|
||||||
|
quote += string(quote[len(quote)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for strings.Contains(bs.Value, quote+"|") {
|
||||||
|
quote += string(quote[len(quote)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if bs.Range == (d2ast.Range{}) {
|
||||||
|
if strings.IndexByte(bs.Value, '\n') > -1 {
|
||||||
|
bs.Range = d2ast.MakeRange(",1:0:0-2:0:0")
|
||||||
|
}
|
||||||
|
bs.Value = strings.TrimSpace(bs.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sb.WriteString("|" + quote)
|
||||||
|
p.sb.WriteString(bs.Tag)
|
||||||
|
if !bs.Range.OneLine() {
|
||||||
|
p.indent()
|
||||||
|
} else {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(bs.Value, "\n")
|
||||||
|
for _, l := range lines {
|
||||||
|
if !bs.Range.OneLine() {
|
||||||
|
if l == "" {
|
||||||
|
p.sb.WriteByte('\n')
|
||||||
|
} else {
|
||||||
|
p.newline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.sb.WriteString(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bs.Range.OneLine() {
|
||||||
|
p.deindent()
|
||||||
|
p.newline()
|
||||||
|
} else if bs.Value != "" {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
p.sb.WriteString(quote + "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) path(els []*d2ast.StringBox) {
|
||||||
|
for i, s := range els {
|
||||||
|
p.node(s.Unbox())
|
||||||
|
if i < len(els)-1 {
|
||||||
|
p.sb.WriteByte('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) substitution(s *d2ast.Substitution) {
|
||||||
|
if s.Spread {
|
||||||
|
p.sb.WriteString("...")
|
||||||
|
}
|
||||||
|
p.sb.WriteString("${")
|
||||||
|
p.path(s.Path)
|
||||||
|
p.sb.WriteByte('}')
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) array(a *d2ast.Array) {
|
||||||
|
p.sb.WriteByte('[')
|
||||||
|
if !a.Range.OneLine() {
|
||||||
|
p.indent()
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := d2ast.Node(a)
|
||||||
|
for i := 0; i < len(a.Nodes); i++ {
|
||||||
|
nb := a.Nodes[i]
|
||||||
|
n := nb.Unbox()
|
||||||
|
|
||||||
|
// Handle inline comments.
|
||||||
|
if i > 0 && (nb.Comment != nil || nb.BlockComment != nil) {
|
||||||
|
if n.GetRange().Start.Line == prev.GetRange().End.Line && n.GetRange().OneLine() {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
p.node(n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Range.OneLine() {
|
||||||
|
if prev != a {
|
||||||
|
if n.GetRange().Start.Line-prev.GetRange().End.Line > 1 {
|
||||||
|
p.sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.newline()
|
||||||
|
} else if i > 0 {
|
||||||
|
p.sb.WriteString("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.node(n)
|
||||||
|
prev = n
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Range.OneLine() {
|
||||||
|
p.deindent()
|
||||||
|
p.newline()
|
||||||
|
}
|
||||||
|
p.sb.WriteByte(']')
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) _map(m *d2ast.Map) {
|
||||||
|
if !m.IsFileMap() {
|
||||||
|
p.sb.WriteByte('{')
|
||||||
|
if !m.Range.OneLine() {
|
||||||
|
p.indent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := d2ast.Node(m)
|
||||||
|
for i := 0; i < len(m.Nodes); i++ {
|
||||||
|
nb := m.Nodes[i]
|
||||||
|
n := nb.Unbox()
|
||||||
|
|
||||||
|
// Handle inline comments.
|
||||||
|
if i > 0 && (nb.Comment != nil || nb.BlockComment != nil) {
|
||||||
|
if n.GetRange().Start.Line == prev.GetRange().End.Line && n.GetRange().OneLine() {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
p.node(n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.Range.OneLine() {
|
||||||
|
if prev != m {
|
||||||
|
if n.GetRange().Start.Line-prev.GetRange().End.Line > 1 {
|
||||||
|
p.sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.IsFileMap() || i > 0 {
|
||||||
|
p.newline()
|
||||||
|
}
|
||||||
|
} else if i > 0 {
|
||||||
|
p.sb.WriteString("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.node(n)
|
||||||
|
prev = n
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.IsFileMap() {
|
||||||
|
if !m.Range.OneLine() {
|
||||||
|
p.deindent()
|
||||||
|
p.newline()
|
||||||
|
}
|
||||||
|
p.sb.WriteByte('}')
|
||||||
|
} else if len(m.Nodes) > 0 {
|
||||||
|
// Always write a trailing newline for nonempty file maps.
|
||||||
|
p.sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) mapKey(mk *d2ast.Key) {
|
||||||
|
if mk.Ampersand {
|
||||||
|
p.sb.WriteByte('&')
|
||||||
|
}
|
||||||
|
if mk.Key != nil {
|
||||||
|
p.key(mk.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mk.Edges) > 0 {
|
||||||
|
if mk.Key != nil {
|
||||||
|
p.sb.WriteByte('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk.Key != nil || mk.EdgeIndex != nil || mk.EdgeKey != nil {
|
||||||
|
p.sb.WriteByte('(')
|
||||||
|
}
|
||||||
|
if mk.Edges[0].Src != nil {
|
||||||
|
p.key(mk.Edges[0].Src)
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
for i, e := range mk.Edges {
|
||||||
|
p.edgeArrowAndDst(e)
|
||||||
|
if i < len(mk.Edges)-1 {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mk.Key != nil || mk.EdgeIndex != nil || mk.EdgeKey != nil {
|
||||||
|
p.sb.WriteByte(')')
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk.EdgeIndex != nil {
|
||||||
|
p.edgeIndex(mk.EdgeIndex)
|
||||||
|
}
|
||||||
|
if mk.EdgeKey != nil {
|
||||||
|
p.sb.WriteByte('.')
|
||||||
|
p.key(mk.EdgeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk.Primary.Unbox() != nil {
|
||||||
|
p.sb.WriteString(": ")
|
||||||
|
p.node(mk.Primary.Unbox())
|
||||||
|
}
|
||||||
|
if mk.Value.Map != nil && len(mk.Value.Map.Nodes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mk.Value.Unbox() != nil {
|
||||||
|
if mk.Primary.Unbox() == nil {
|
||||||
|
p.sb.WriteString(": ")
|
||||||
|
} else {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
p.node(mk.Value.Unbox())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) key(k *d2ast.KeyPath) {
|
||||||
|
p.inKey = true
|
||||||
|
if k != nil {
|
||||||
|
p.path(k.Path)
|
||||||
|
}
|
||||||
|
p.inKey = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) edge(e *d2ast.Edge) {
|
||||||
|
if e.Src != nil {
|
||||||
|
p.key(e.Src)
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
p.edgeArrowAndDst(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) edgeArrowAndDst(e *d2ast.Edge) {
|
||||||
|
if e.SrcArrow == "" {
|
||||||
|
p.sb.WriteByte('-')
|
||||||
|
} else {
|
||||||
|
p.sb.WriteString(e.SrcArrow)
|
||||||
|
}
|
||||||
|
if e.DstArrow == "" {
|
||||||
|
p.sb.WriteByte('-')
|
||||||
|
} else {
|
||||||
|
if e.SrcArrow != "" {
|
||||||
|
p.sb.WriteByte('-')
|
||||||
|
}
|
||||||
|
p.sb.WriteString(e.DstArrow)
|
||||||
|
}
|
||||||
|
if e.Dst != nil {
|
||||||
|
p.sb.WriteByte(' ')
|
||||||
|
p.key(e.Dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *printer) edgeIndex(ei *d2ast.EdgeIndex) {
|
||||||
|
p.sb.WriteByte('[')
|
||||||
|
if ei.Glob {
|
||||||
|
p.sb.WriteByte('*')
|
||||||
|
} else {
|
||||||
|
p.sb.WriteString(strconv.Itoa(*ei.Int))
|
||||||
|
}
|
||||||
|
p.sb.WriteByte(']')
|
||||||
|
}
|
||||||
626
d2format/format_test.go
Normal file
626
d2format/format_test.go
Normal file
|
|
@ -0,0 +1,626 @@
|
||||||
|
package d2format_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/diff"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2format"
|
||||||
|
"oss.terrastruct.com/d2/d2parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
exp string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic",
|
||||||
|
in: `
|
||||||
|
x -> y
|
||||||
|
`,
|
||||||
|
exp: `x -> y
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "complex",
|
||||||
|
in: `
|
||||||
|
sql_example : sql_example {
|
||||||
|
board : {
|
||||||
|
shape: sql_table
|
||||||
|
id: int {constraint: primary_key}
|
||||||
|
frame: int {constraint: foreign_key}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
board_objects: jsonb
|
||||||
|
last_updated: timestamp with time zone
|
||||||
|
last_thumbgen: timestamp with time zone
|
||||||
|
dsl : text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normal.
|
||||||
|
board.diagram -> diagrams.id
|
||||||
|
|
||||||
|
# Self referential.
|
||||||
|
diagrams.id -> diagrams.representation
|
||||||
|
|
||||||
|
# SrcArrow test.
|
||||||
|
diagrams.id <- views . diagram
|
||||||
|
diagrams.id <-> steps . diagram
|
||||||
|
|
||||||
|
diagrams: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int ; constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
}
|
||||||
|
|
||||||
|
views: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int; constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
|
||||||
|
steps: {
|
||||||
|
shape: sql_table
|
||||||
|
id: { type: int; constraint: primary_key }
|
||||||
|
representation: { type: jsonb }
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
meow <- diagrams.id
|
||||||
|
}
|
||||||
|
|
||||||
|
D2 AST Parser {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
+prevRune : rune
|
||||||
|
prevColumn : int
|
||||||
|
|
||||||
|
+eatSpace(eatNewlines bool): (rune, error)
|
||||||
|
unreadRune()
|
||||||
|
|
||||||
|
\#scanKey(r rune): (k Key, _ error)
|
||||||
|
}
|
||||||
|
|
||||||
|
"""dmaskkldsamkld """
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
dmaskdmasl
|
||||||
|
mdlkasdaskml
|
||||||
|
daklsmdakms
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
bs: |
|
||||||
|
dmasmdkals
|
||||||
|
dkmsamdklsa
|
||||||
|
|
|
||||||
|
bs2: | mdsalldkams|
|
||||||
|
|
||||||
|
y-->q: meow
|
||||||
|
x->y->z
|
||||||
|
|
||||||
|
meow: {
|
||||||
|
x: |` + "`" + `
|
||||||
|
meow
|
||||||
|
meow
|
||||||
|
` + "`" + `| {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
"meow\t": ok
|
||||||
|
`,
|
||||||
|
exp: `sql_example: sql_example {
|
||||||
|
board: {
|
||||||
|
shape: sql_table
|
||||||
|
id: int {constraint: primary_key}
|
||||||
|
frame: int {constraint: foreign_key}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
board_objects: jsonb
|
||||||
|
last_updated: timestamp with time zone
|
||||||
|
last_thumbgen: timestamp with time zone
|
||||||
|
dsl: text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normal.
|
||||||
|
board.diagram -> diagrams.id
|
||||||
|
|
||||||
|
# Self referential.
|
||||||
|
diagrams.id -> diagrams.representation
|
||||||
|
|
||||||
|
# SrcArrow test.
|
||||||
|
diagrams.id <- views.diagram
|
||||||
|
diagrams.id <-> steps.diagram
|
||||||
|
|
||||||
|
diagrams: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int; constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
}
|
||||||
|
|
||||||
|
views: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int; constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
|
||||||
|
steps: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int; constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
meow <- diagrams.id
|
||||||
|
}
|
||||||
|
|
||||||
|
D2 AST Parser: {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
+prevRune: rune
|
||||||
|
prevColumn: int
|
||||||
|
|
||||||
|
+eatSpace(eatNewlines bool): (rune, error)
|
||||||
|
unreadRune()
|
||||||
|
|
||||||
|
\#scanKey(r rune): (k Key, _ error)
|
||||||
|
}
|
||||||
|
|
||||||
|
""" dmaskkldsamkld """
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
dmaskdmasl
|
||||||
|
mdlkasdaskml
|
||||||
|
daklsmdakms
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
bs: |md
|
||||||
|
dmasmdkals
|
||||||
|
dkmsamdklsa
|
||||||
|
|
|
||||||
|
bs2: |md mdsalldkams |
|
||||||
|
|
||||||
|
y -> q: meow
|
||||||
|
x -> y -> z
|
||||||
|
|
||||||
|
meow: {
|
||||||
|
x: |` + "`" + `md
|
||||||
|
meow
|
||||||
|
meow
|
||||||
|
` + "`" + `|
|
||||||
|
}
|
||||||
|
|
||||||
|
"meow\t": ok
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "block_comment",
|
||||||
|
in: `
|
||||||
|
"""
|
||||||
|
D2 AST Parser2: {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
reader: io.RuneReader
|
||||||
|
readerPos: d2ast.Position
|
||||||
|
|
||||||
|
lookahead: "[]rune"
|
||||||
|
lookaheadPos: d2ast.Position
|
||||||
|
|
||||||
|
peek() (r rune, eof bool)
|
||||||
|
-rewind(): ()
|
||||||
|
+commit()
|
||||||
|
\#peekn(n int) (s string, eof bool)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
`,
|
||||||
|
exp: `"""
|
||||||
|
D2 AST Parser2: {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
reader: io.RuneReader
|
||||||
|
readerPos: d2ast.Position
|
||||||
|
|
||||||
|
lookahead: "[]rune"
|
||||||
|
lookaheadPos: d2ast.Position
|
||||||
|
|
||||||
|
peek() (r rune, eof bool)
|
||||||
|
-rewind(): ()
|
||||||
|
+commit()
|
||||||
|
\#peekn(n int) (s string, eof bool)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "block_string_indent",
|
||||||
|
in: `
|
||||||
|
parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|}`,
|
||||||
|
exp: `parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// This one we test that the common indent is stripped before the correct indent is
|
||||||
|
// applied.
|
||||||
|
name: "block_string_indent_2",
|
||||||
|
in: `
|
||||||
|
parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|}`,
|
||||||
|
exp: `parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// This one we test that the common indent is stripped before the correct indent is
|
||||||
|
// applied even when there's too much indent.
|
||||||
|
name: "block_string_indent_3",
|
||||||
|
in: `
|
||||||
|
parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|}`,
|
||||||
|
exp: `parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// This one has 3 space indent and whitespace only lines.
|
||||||
|
name: "block_string_uneven_indent",
|
||||||
|
in: `
|
||||||
|
parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|}`,
|
||||||
|
exp: `parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type File interface {
|
||||||
|
Stat() (FileInfo, error)
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalid = errInvalid() // "invalid argument"
|
||||||
|
ErrPermission = errPermission() // "permission denied"
|
||||||
|
ErrExist = errExist() // "file already exists"
|
||||||
|
ErrNotExist = errNotExist() // "file does not exist"
|
||||||
|
ErrClosed = errClosed() // "file already closed"
|
||||||
|
)
|
||||||
|
` + "`" + `|
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// This one has 3 space indent and large whitespace only lines.
|
||||||
|
name: "block_string_uneven_indent_2",
|
||||||
|
in: `
|
||||||
|
parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
` + "`" + `|}`,
|
||||||
|
exp: `parent: {
|
||||||
|
example_code: |` + "`" + `go
|
||||||
|
package fs
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
Open(name string) (File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
` + "`" + `|
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "block_comment_indent",
|
||||||
|
in: `
|
||||||
|
parent: {
|
||||||
|
"""
|
||||||
|
hello
|
||||||
|
""" }`,
|
||||||
|
exp: `parent: {
|
||||||
|
"""
|
||||||
|
hello
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "scalars",
|
||||||
|
in: `x: null
|
||||||
|
y: true
|
||||||
|
z: 343`,
|
||||||
|
exp: `x: null
|
||||||
|
y: true
|
||||||
|
z: 343
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "substitution",
|
||||||
|
in: `x: ${ok}; y: [...${yes}]`,
|
||||||
|
exp: `x: ${ok}; y: [...${yes}]
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "line_comment_block",
|
||||||
|
in: `# wsup
|
||||||
|
# hello
|
||||||
|
# The Least Successful Collector`,
|
||||||
|
exp: `# wsup
|
||||||
|
# hello
|
||||||
|
# The Least Successful Collector
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "inline_comment",
|
||||||
|
in: `hello: x # soldier
|
||||||
|
more`,
|
||||||
|
exp: `hello: x # soldier
|
||||||
|
more
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "array_one_line",
|
||||||
|
in: `a: [1;2;3;4]`,
|
||||||
|
exp: `a: [1; 2; 3; 4]
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array",
|
||||||
|
in: `a: [
|
||||||
|
hi # Fraud is the homage that force pays to reason.
|
||||||
|
1
|
||||||
|
2
|
||||||
|
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5; 6; 7
|
||||||
|
]`,
|
||||||
|
exp: `a: [
|
||||||
|
hi # Fraud is the homage that force pays to reason.
|
||||||
|
1
|
||||||
|
2
|
||||||
|
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
]
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "ampersand",
|
||||||
|
in: `&scenario: red`,
|
||||||
|
exp: `&scenario: red
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "complex_edge",
|
||||||
|
in: `pre.(src -> dst -> more)[3].post`,
|
||||||
|
exp: `pre.(src -> dst -> more)[3].post
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge_index_glob",
|
||||||
|
in: `(x -> y)[*]`,
|
||||||
|
exp: `(x -> y)[*]
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bidirectional",
|
||||||
|
in: `x<>y`,
|
||||||
|
exp: `x <-> y
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_map",
|
||||||
|
in: `x: {}
|
||||||
|
`,
|
||||||
|
exp: `x
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ast, err := d2parser.Parse(fmt.Sprintf("%s.d2", t.Name()), strings.NewReader(tc.in), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diff.AssertStringEq(t, tc.exp, d2format.Format(ast))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mk, err := d2parser.ParseMapKey(`(x -> y)[0]`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mk.Edges) != 1 {
|
||||||
|
t.Fatalf("expected one edge: %#v", mk.Edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.AssertStringEq(t, `x -> y`, d2format.Format(mk.Edges[0]))
|
||||||
|
diff.AssertStringEq(t, `[0]`, d2format.Format(mk.EdgeIndex))
|
||||||
|
}
|
||||||
158
d2graph/color_helper.go
Normal file
158
d2graph/color_helper.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
package d2graph
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
// namedColors is a list of valid CSS colors
|
||||||
|
var namedColors = []string{
|
||||||
|
"transparent",
|
||||||
|
"aliceblue",
|
||||||
|
"antiquewhite",
|
||||||
|
"aqua",
|
||||||
|
"aquamarine",
|
||||||
|
"azure",
|
||||||
|
"beige",
|
||||||
|
"bisque",
|
||||||
|
"black",
|
||||||
|
"blanchedalmond",
|
||||||
|
"blue",
|
||||||
|
"blueviolet",
|
||||||
|
"brown",
|
||||||
|
"burlywood",
|
||||||
|
"cadetblue",
|
||||||
|
"chartreuse",
|
||||||
|
"chocolate",
|
||||||
|
"coral",
|
||||||
|
"cornflowerblue",
|
||||||
|
"cornsilk",
|
||||||
|
"crimson",
|
||||||
|
"cyan",
|
||||||
|
"darkblue",
|
||||||
|
"darkcyan",
|
||||||
|
"darkgoldenrod",
|
||||||
|
"darkgray",
|
||||||
|
"darkgrey",
|
||||||
|
"darkgreen",
|
||||||
|
"darkkhaki",
|
||||||
|
"darkmagenta",
|
||||||
|
"darkolivegreen",
|
||||||
|
"darkorange",
|
||||||
|
"darkorchid",
|
||||||
|
"darkred",
|
||||||
|
"darksalmon",
|
||||||
|
"darkseagreen",
|
||||||
|
"darkslateblue",
|
||||||
|
"darkslategray",
|
||||||
|
"darkslategrey",
|
||||||
|
"darkturquoise",
|
||||||
|
"darkviolet",
|
||||||
|
"deeppink",
|
||||||
|
"deepskyblue",
|
||||||
|
"dimgray",
|
||||||
|
"dimgrey",
|
||||||
|
"dodgerblue",
|
||||||
|
"firebrick",
|
||||||
|
"floralwhite",
|
||||||
|
"forestgreen",
|
||||||
|
"fuchsia",
|
||||||
|
"gainsboro",
|
||||||
|
"ghostwhite",
|
||||||
|
"gold",
|
||||||
|
"goldenrod",
|
||||||
|
"gray",
|
||||||
|
"grey",
|
||||||
|
"green",
|
||||||
|
"greenyellow",
|
||||||
|
"honeydew",
|
||||||
|
"hotpink",
|
||||||
|
"indianred",
|
||||||
|
"indigo",
|
||||||
|
"ivory",
|
||||||
|
"khaki",
|
||||||
|
"lavender",
|
||||||
|
"lavenderblush",
|
||||||
|
"lawngreen",
|
||||||
|
"lemonchiffon",
|
||||||
|
"lightblue",
|
||||||
|
"lightcoral",
|
||||||
|
"lightcyan",
|
||||||
|
"lightgoldenrodyellow",
|
||||||
|
"lightgray",
|
||||||
|
"lightgrey",
|
||||||
|
"lightgreen",
|
||||||
|
"lightpink",
|
||||||
|
"lightsalmon",
|
||||||
|
"lightseagreen",
|
||||||
|
"lightskyblue",
|
||||||
|
"lightslategray",
|
||||||
|
"lightslategrey",
|
||||||
|
"lightsteelblue",
|
||||||
|
"lightyellow",
|
||||||
|
"lime",
|
||||||
|
"limegreen",
|
||||||
|
"linen",
|
||||||
|
"magenta",
|
||||||
|
"maroon",
|
||||||
|
"mediumaquamarine",
|
||||||
|
"mediumblue",
|
||||||
|
"mediumorchid",
|
||||||
|
"mediumpurple",
|
||||||
|
"mediumseagreen",
|
||||||
|
"mediumslateblue",
|
||||||
|
"mediumspringgreen",
|
||||||
|
"mediumturquoise",
|
||||||
|
"mediumvioletred",
|
||||||
|
"midnightblue",
|
||||||
|
"mintcream",
|
||||||
|
"mistyrose",
|
||||||
|
"moccasin",
|
||||||
|
"navajowhite",
|
||||||
|
"navy",
|
||||||
|
"oldlace",
|
||||||
|
"olive",
|
||||||
|
"olivedrab",
|
||||||
|
"orange",
|
||||||
|
"orangered",
|
||||||
|
"orchid",
|
||||||
|
"palegoldenrod",
|
||||||
|
"palegreen",
|
||||||
|
"paleturquoise",
|
||||||
|
"palevioletred",
|
||||||
|
"papayawhip",
|
||||||
|
"peachpuff",
|
||||||
|
"peru",
|
||||||
|
"pink",
|
||||||
|
"plum",
|
||||||
|
"powderblue",
|
||||||
|
"purple",
|
||||||
|
"rebeccapurple",
|
||||||
|
"red",
|
||||||
|
"rosybrown",
|
||||||
|
"royalblue",
|
||||||
|
"saddlebrown",
|
||||||
|
"salmon",
|
||||||
|
"sandybrown",
|
||||||
|
"seagreen",
|
||||||
|
"seashell",
|
||||||
|
"sienna",
|
||||||
|
"silver",
|
||||||
|
"skyblue",
|
||||||
|
"slateblue",
|
||||||
|
"slategray",
|
||||||
|
"slategrey",
|
||||||
|
"snow",
|
||||||
|
"springgreen",
|
||||||
|
"steelblue",
|
||||||
|
"tan",
|
||||||
|
"teal",
|
||||||
|
"thistle",
|
||||||
|
"tomato",
|
||||||
|
"turquoise",
|
||||||
|
"violet",
|
||||||
|
"wheat",
|
||||||
|
"white",
|
||||||
|
"whitesmoke",
|
||||||
|
"yellow",
|
||||||
|
"yellowgreen",
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorHexRegex = regexp.MustCompile(`^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`)
|
||||||
1092
d2graph/d2graph.go
Normal file
1092
d2graph/d2graph.go
Normal file
File diff suppressed because it is too large
Load diff
50
d2graph/d2graph_test.go
Normal file
50
d2graph/d2graph_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package d2graph_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/diff"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
exp string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
key: "meow.foo.bar",
|
||||||
|
exp: "meow.foo.bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_1",
|
||||||
|
key: `'null.$$$.---'''.",,,.{}{}-\\-><"`,
|
||||||
|
exp: `"null.$$$.---'".",,,.{}{}-\\-><"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specials_2",
|
||||||
|
key: `"&&####;;".| ;;::** |`,
|
||||||
|
exp: `"&&####;;".";;::**"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
k, err := d2parser.ParseKey(tc.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diff.AssertStringEq(t, tc.exp, strings.Join(d2graph.Key(k), "."))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
10
d2graph/font_helper.go
Normal file
10
d2graph/font_helper.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package d2graph
|
||||||
|
|
||||||
|
var systemFonts = []string{
|
||||||
|
"DEFAULT",
|
||||||
|
"SERIOUS",
|
||||||
|
"DIGITAL",
|
||||||
|
"EDUCATIONAL",
|
||||||
|
"NEWSPAPER",
|
||||||
|
"MONO",
|
||||||
|
}
|
||||||
159
d2graph/serde.go
Normal file
159
d2graph/serde.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package d2graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SerializedGraph struct {
|
||||||
|
Root SerializedObject `json:"root"`
|
||||||
|
Edges []SerializedEdge `json:"edges"`
|
||||||
|
Objects []SerializedObject `json:"objects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SerializedObject map[string]interface{}
|
||||||
|
|
||||||
|
type SerializedEdge map[string]interface{}
|
||||||
|
|
||||||
|
func DeserializeGraph(bytes []byte, g *Graph) error {
|
||||||
|
var sg *SerializedGraph
|
||||||
|
err := json.Unmarshal(bytes, &sg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Root = &Object{
|
||||||
|
Graph: g,
|
||||||
|
Children: make(map[string]*Object),
|
||||||
|
}
|
||||||
|
|
||||||
|
idToObj := make(map[string]*Object)
|
||||||
|
idToObj[""] = g.Root
|
||||||
|
var objects []*Object
|
||||||
|
for _, so := range sg.Objects {
|
||||||
|
var o Object
|
||||||
|
if err := convert(so, &o); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objects = append(objects, &o)
|
||||||
|
idToObj[so["AbsID"].(string)] = &o
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, so := range append(sg.Objects, sg.Root) {
|
||||||
|
if so["ChildrenArray"] != nil {
|
||||||
|
children := make(map[string]*Object)
|
||||||
|
var childrenArray []*Object
|
||||||
|
|
||||||
|
for _, id := range so["ChildrenArray"].([]interface{}) {
|
||||||
|
o := idToObj[id.(string)]
|
||||||
|
childrenArray = append(childrenArray, o)
|
||||||
|
children[id.(string)] = o
|
||||||
|
|
||||||
|
o.Parent = idToObj[so["AbsID"].(string)]
|
||||||
|
}
|
||||||
|
|
||||||
|
idToObj[so["AbsID"].(string)].Children = children
|
||||||
|
idToObj[so["AbsID"].(string)].ChildrenArray = childrenArray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var edges []*Edge
|
||||||
|
for _, se := range sg.Edges {
|
||||||
|
var e Edge
|
||||||
|
if err := convert(se, &e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if se["Src"] != nil {
|
||||||
|
e.Src = idToObj[se["Src"].(string)]
|
||||||
|
}
|
||||||
|
if se["Dst"] != nil {
|
||||||
|
e.Dst = idToObj[se["Dst"].(string)]
|
||||||
|
}
|
||||||
|
edges = append(edges, &e)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Objects = objects
|
||||||
|
g.Edges = edges
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SerializeGraph(g *Graph) ([]byte, error) {
|
||||||
|
sg := SerializedGraph{}
|
||||||
|
|
||||||
|
root, err := toSerializedObject(g.Root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sg.Root = root
|
||||||
|
|
||||||
|
var sobjects []SerializedObject
|
||||||
|
for _, o := range g.Objects {
|
||||||
|
so, err := toSerializedObject(o)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sobjects = append(sobjects, so)
|
||||||
|
}
|
||||||
|
sg.Objects = sobjects
|
||||||
|
|
||||||
|
var sedges []SerializedEdge
|
||||||
|
for _, e := range g.Edges {
|
||||||
|
se, err := toSerializedEdge(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sedges = append(sedges, se)
|
||||||
|
}
|
||||||
|
sg.Edges = sedges
|
||||||
|
|
||||||
|
return json.Marshal(sg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSerializedObject(o *Object) (SerializedObject, error) {
|
||||||
|
var so SerializedObject
|
||||||
|
if err := convert(o, &so); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
so["AbsID"] = o.AbsID()
|
||||||
|
|
||||||
|
if len(o.ChildrenArray) > 0 {
|
||||||
|
var children []string
|
||||||
|
for _, c := range o.ChildrenArray {
|
||||||
|
children = append(children, c.AbsID())
|
||||||
|
}
|
||||||
|
so["ChildrenArray"] = children
|
||||||
|
}
|
||||||
|
|
||||||
|
return so, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSerializedEdge(e *Edge) (SerializedEdge, error) {
|
||||||
|
var se SerializedEdge
|
||||||
|
if err := convert(e, &se); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Src != nil {
|
||||||
|
se["Src"] = go2.Pointer(e.Src.AbsID())
|
||||||
|
}
|
||||||
|
if e.Dst != nil {
|
||||||
|
se["Dst"] = go2.Pointer(e.Dst.AbsID())
|
||||||
|
}
|
||||||
|
|
||||||
|
return se, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convert[T, Q any](from T, to *Q) error {
|
||||||
|
b, err := json.Marshal(from)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, to); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
55
d2graph/serde_test.go
Normal file
55
d2graph/serde_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package d2graph_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2compiler"
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSerialization(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g, err := d2compiler.Compile("", strings.NewReader("a.a.b -> a.a.c"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asserts := func(g *d2graph.Graph) {
|
||||||
|
assert.Equal(t, 4, len(g.Objects))
|
||||||
|
assert.Equal(t, 1, len(g.Root.ChildrenArray))
|
||||||
|
assert.Equal(t, 1, len(g.Root.ChildrenArray[0].ChildrenArray))
|
||||||
|
assert.Equal(t, 2, len(g.Root.ChildrenArray[0].ChildrenArray[0].ChildrenArray))
|
||||||
|
assert.Equal(t,
|
||||||
|
g.Root.ChildrenArray[0],
|
||||||
|
g.Root.ChildrenArray[0].ChildrenArray[0].Parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
g.Root,
|
||||||
|
g.Root.ChildrenArray[0].Parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(g.Edges))
|
||||||
|
assert.Equal(t, "b", g.Edges[0].Src.ID)
|
||||||
|
assert.Equal(t, "c", g.Edges[0].Dst.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
asserts(g)
|
||||||
|
|
||||||
|
b, err := d2graph.SerializeGraph(g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newG d2graph.Graph
|
||||||
|
err = d2graph.DeserializeGraph(b, &newG)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asserts(&newG)
|
||||||
|
}
|
||||||
6754
d2layouts/d2dagrelayout/dagre.js
Normal file
6754
d2layouts/d2dagrelayout/dagre.js
Normal file
File diff suppressed because it is too large
Load diff
248
d2layouts/d2dagrelayout/layout.go
Normal file
248
d2layouts/d2dagrelayout/layout.go
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
package d2dagrelayout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
v8 "rogchap.com/v8go"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/xdefer"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
"oss.terrastruct.com/d2/lib/label"
|
||||||
|
"oss.terrastruct.com/d2/lib/log"
|
||||||
|
"oss.terrastruct.com/d2/lib/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed setup.js
|
||||||
|
var setupJS string
|
||||||
|
|
||||||
|
//go:embed dagre.js
|
||||||
|
var dagreJS string
|
||||||
|
|
||||||
|
type DagreNode struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DagreEdge struct {
|
||||||
|
Points []*geo.Point `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dagreGraphAttrs struct {
|
||||||
|
// for a top to bottom graph: ranksep is y spacing, nodesep is x spacing, edgesep is x spacing
|
||||||
|
ranksep int
|
||||||
|
edgesep int
|
||||||
|
nodesep int
|
||||||
|
// graph direction: tb (top to bottom)| bt | lr | rl
|
||||||
|
rankdir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Layout(ctx context.Context, d2graph *d2graph.Graph) (err error) {
|
||||||
|
defer xdefer.Errorf(&err, "failed to dagre layout")
|
||||||
|
|
||||||
|
debugJS := false
|
||||||
|
v8ctx := v8.NewContext()
|
||||||
|
if _, err := v8ctx.RunScript(dagreJS, "dagre.js"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := v8ctx.RunScript(setupJS, "setup.js"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configJS := setGraphAttrs(dagreGraphAttrs{
|
||||||
|
ranksep: 100,
|
||||||
|
edgesep: 40,
|
||||||
|
nodesep: 60,
|
||||||
|
rankdir: "tb",
|
||||||
|
})
|
||||||
|
if _, err := v8ctx.RunScript(configJS, "config.js"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
loadScript := ""
|
||||||
|
for _, obj := range d2graph.Objects {
|
||||||
|
id := obj.AbsID()
|
||||||
|
loadScript += generateAddNodeLine(id, obj.Attributes.Label.Value, int(obj.Width), int(obj.Height))
|
||||||
|
if obj.Parent != d2graph.Root {
|
||||||
|
loadScript += generateAddParentLine(id, obj.Parent.AbsID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, edge := range d2graph.Edges {
|
||||||
|
// dagre doesn't work with edges to containers so we connect container edges to their first child instead (going all the way down)
|
||||||
|
// we will chop the edge where it intersects the container border so it only shows the edge from the container
|
||||||
|
src := edge.Src
|
||||||
|
for len(src.Children) > 0 && src.Class == nil && src.SQLTable == nil {
|
||||||
|
src = src.ChildrenArray[0]
|
||||||
|
}
|
||||||
|
dst := edge.Dst
|
||||||
|
for len(dst.Children) > 0 && dst.Class == nil && dst.SQLTable == nil {
|
||||||
|
dst = dst.ChildrenArray[0]
|
||||||
|
}
|
||||||
|
if edge.SrcArrow && !edge.DstArrow {
|
||||||
|
// for `b <- a`, edge.Edge is `a -> b` and we expect this routing result
|
||||||
|
src, dst = dst, src
|
||||||
|
}
|
||||||
|
loadScript += generateAddEdgeLine(src.AbsID(), dst.AbsID(), edge.AbsID(), edge.Attributes.Label.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debugJS {
|
||||||
|
log.Debug(ctx, "script", slog.F("all", setupJS+configJS+loadScript))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := v8ctx.RunScript(loadScript, "load.js"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := v8ctx.RunScript(`dagre.layout(g)`, "layout.js"); err != nil {
|
||||||
|
if debugJS {
|
||||||
|
log.Warn(ctx, "layout error", slog.F("err", err))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// val, err := v8ctx.RunScript("JSON.stringify(dagre.graphlib.json.write(g))", "q.js")
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// log.Debug(ctx, "graph", slog.F("json", val.String()))
|
||||||
|
|
||||||
|
for i, obj := range d2graph.Objects {
|
||||||
|
val, err := v8ctx.RunScript(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i), "value.js")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var dn DagreNode
|
||||||
|
if err := json.Unmarshal([]byte(val.String()), &dn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// dagre gives center of node
|
||||||
|
obj.TopLeft = geo.NewPoint(math.Round(dn.X-dn.Width/2), math.Round(dn.Y-dn.Height/2))
|
||||||
|
obj.Width = dn.Width
|
||||||
|
obj.Height = dn.Height
|
||||||
|
|
||||||
|
if obj.LabelWidth != nil && obj.LabelHeight != nil {
|
||||||
|
if len(obj.ChildrenArray) > 0 {
|
||||||
|
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
|
||||||
|
} else {
|
||||||
|
obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if obj.Attributes.Icon != nil {
|
||||||
|
obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, edge := range d2graph.Edges {
|
||||||
|
val, err := v8ctx.RunScript(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i), "value.js")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var de DagreEdge
|
||||||
|
if err := json.Unmarshal([]byte(val.String()), &de); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
points := make([]*geo.Point, len(de.Points))
|
||||||
|
for i := range de.Points {
|
||||||
|
if edge.SrcArrow && !edge.DstArrow {
|
||||||
|
points[len(de.Points)-i-1] = de.Points[i].Copy()
|
||||||
|
} else {
|
||||||
|
points[i] = de.Points[i].Copy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex, endIndex := 0, len(points)-1
|
||||||
|
start, end := points[startIndex], points[endIndex]
|
||||||
|
|
||||||
|
// chop where edge crosses the source/target boxes since container edges were routed to a descendant
|
||||||
|
for i := 1; i < len(points); i++ {
|
||||||
|
segment := *geo.NewSegment(points[i-1], points[i])
|
||||||
|
if intersections := edge.Src.Box.Intersections(segment); len(intersections) > 0 {
|
||||||
|
start = intersections[0]
|
||||||
|
startIndex = i - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if intersections := edge.Dst.Box.Intersections(segment); len(intersections) > 0 {
|
||||||
|
end = intersections[0]
|
||||||
|
endIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srcShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(edge.Src.Attributes.Shape.Value)], edge.Src.Box)
|
||||||
|
dstShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(edge.Dst.Attributes.Shape.Value)], edge.Dst.Box)
|
||||||
|
|
||||||
|
// trace the edge to the specific shape's border
|
||||||
|
points[startIndex] = shape.TraceToShapeBorder(srcShape, start, points[startIndex+1])
|
||||||
|
points[endIndex] = shape.TraceToShapeBorder(dstShape, end, points[endIndex-1])
|
||||||
|
points = points[startIndex : endIndex+1]
|
||||||
|
|
||||||
|
// build a curved path from the dagre route
|
||||||
|
vectors := make([]geo.Vector, 0, len(points)-1)
|
||||||
|
for i := 1; i < len(points); i++ {
|
||||||
|
vectors = append(vectors, points[i-1].VectorTo(points[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
path := make([]*geo.Point, 0)
|
||||||
|
path = append(path, points[0])
|
||||||
|
path = append(path, points[0].AddVector(vectors[0].Multiply(.8)))
|
||||||
|
for i := 1; i < len(vectors)-2; i++ {
|
||||||
|
p := points[i]
|
||||||
|
v := vectors[i]
|
||||||
|
path = append(path, p.AddVector(v.Multiply(.2)))
|
||||||
|
path = append(path, p.AddVector(v.Multiply(.5)))
|
||||||
|
path = append(path, p.AddVector(v.Multiply(.8)))
|
||||||
|
}
|
||||||
|
path = append(path, points[len(points)-2].AddVector(vectors[len(vectors)-1].Multiply(.2)))
|
||||||
|
path = append(path, points[len(points)-1])
|
||||||
|
|
||||||
|
edge.IsCurve = true
|
||||||
|
edge.Route = path
|
||||||
|
// compile needs to assign edge label positions
|
||||||
|
if edge.Attributes.Label.Value != "" {
|
||||||
|
edge.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGraphAttrs(attrs dagreGraphAttrs) string {
|
||||||
|
return fmt.Sprintf(`g.setGraph({
|
||||||
|
ranksep: %d,
|
||||||
|
edgesep: %d,
|
||||||
|
nodesep: %d,
|
||||||
|
rankdir: "%s",
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
attrs.ranksep,
|
||||||
|
attrs.edgesep,
|
||||||
|
attrs.nodesep,
|
||||||
|
attrs.rankdir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAddNodeLine(id, label string, width, height int) string {
|
||||||
|
return fmt.Sprintf("g.setNode(`%s`, { label: `%s`, width: %d, height: %d });\n", id, label, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAddParentLine(childID, parentID string) string {
|
||||||
|
return fmt.Sprintf("g.setParent(`%s`, `%s`);\n", childID, parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAddEdgeLine(fromID, toID, edgeID, label string) string {
|
||||||
|
// in dagre v is from, w is to, name is to uniquely identify
|
||||||
|
return fmt.Sprintf("g.setEdge({v:`%s`, w:`%s`, name:`%s`, label:`%s`});\n", fromID, toID, edgeID, label)
|
||||||
|
}
|
||||||
7
d2layouts/d2dagrelayout/setup.js
Normal file
7
d2layouts/d2dagrelayout/setup.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
var g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
|
||||||
|
g.setDefaultNodeLabel(function () {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
g.setDefaultEdgeLabel(function () {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
2102
d2oracle/edit.go
Normal file
2102
d2oracle/edit.go
Normal file
File diff suppressed because it is too large
Load diff
4989
d2oracle/edit_test.go
Normal file
4989
d2oracle/edit_test.go
Normal file
File diff suppressed because it is too large
Load diff
51
d2oracle/get.go
Normal file
51
d2oracle/get.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package d2oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetParentID(g *d2graph.Graph, absID string) (string, error) {
|
||||||
|
mk, err := d2parser.ParseMapKey(absID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
obj, ok := g.Root.HasChild(d2graph.Key(mk.Key))
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%v parent not found", absID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.Parent.AbsID(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetObj(g *d2graph.Graph, absID string) *d2graph.Object {
|
||||||
|
mk, _ := d2parser.ParseMapKey(absID)
|
||||||
|
obj, _ := g.Root.HasChild(d2graph.Key(mk.Key))
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetEdge(g *d2graph.Graph, absID string) *d2graph.Edge {
|
||||||
|
for _, e := range g.Edges {
|
||||||
|
if e.AbsID() == absID {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsLabelKeyID(key, label string) bool {
|
||||||
|
mk, err := d2parser.ParseMapKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(mk.Edges) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if mk.Key == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return mk.Key.Path[len(mk.Key.Path)-1].Unbox().ScalarString() == label
|
||||||
|
}
|
||||||
20
d2oracle/get_test.go
Normal file
20
d2oracle/get_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package d2oracle_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2oracle"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsLabelKeyID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, true, d2oracle.IsLabelKeyID("x", "x"))
|
||||||
|
assert.Equal(t, true, d2oracle.IsLabelKeyID("y.x", "x"))
|
||||||
|
assert.Equal(t, true, d2oracle.IsLabelKeyID(`x."y.x"`, "y.x"))
|
||||||
|
|
||||||
|
assert.Equal(t, false, d2oracle.IsLabelKeyID("x", "y"))
|
||||||
|
assert.Equal(t, false, d2oracle.IsLabelKeyID("x->y", "y"))
|
||||||
|
}
|
||||||
1707
d2parser/parse.go
Normal file
1707
d2parser/parse.go
Normal file
File diff suppressed because it is too large
Load diff
391
d2parser/parse_test.go
Normal file
391
d2parser/parse_test.go
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
package d2parser_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/diff"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2ast"
|
||||||
|
"oss.terrastruct.com/d2/d2parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: next step for parser is writing as many tests and grouping them nicely
|
||||||
|
// TODO: add assertions
|
||||||
|
// to layout *all* expected behavior.
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
|
||||||
|
// exp is in testdata/d2parser/TestParse/${name}.json
|
||||||
|
}{
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
text: ``,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "semicolons",
|
||||||
|
text: `;;;;;`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad_curly",
|
||||||
|
text: `;;;};;;`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one_line_comment",
|
||||||
|
text: `
|
||||||
|
# hello
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiline_comment",
|
||||||
|
text: `
|
||||||
|
|
||||||
|
# hello
|
||||||
|
# world
|
||||||
|
# earth
|
||||||
|
#
|
||||||
|
#globe
|
||||||
|
# very good
|
||||||
|
# not so bad
|
||||||
|
#
|
||||||
|
#yes indeed
|
||||||
|
#The good (I am convinced, for one)
|
||||||
|
#Is but the bad one leaves undone.
|
||||||
|
#Once your reputation's done
|
||||||
|
#You can live a life of fun.
|
||||||
|
# -- Wilhelm Busch
|
||||||
|
|
||||||
|
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one_line_block_comment",
|
||||||
|
text: `
|
||||||
|
""" dmaslkmdlksa """
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block_comment",
|
||||||
|
text: `
|
||||||
|
""" dmaslkmdlksa
|
||||||
|
|
||||||
|
dasmlkdas
|
||||||
|
mkdlasdmkas
|
||||||
|
dmsakldmklsadsa
|
||||||
|
|
||||||
|
dsmakldmaslk
|
||||||
|
damklsdmklas
|
||||||
|
|
||||||
|
echo hi
|
||||||
|
x """
|
||||||
|
|
||||||
|
""" ok
|
||||||
|
meow
|
||||||
|
"""
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key",
|
||||||
|
text: `
|
||||||
|
x
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge",
|
||||||
|
text: `
|
||||||
|
x -> y
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_edges",
|
||||||
|
text: `
|
||||||
|
x -> y -> z
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key_with_edge",
|
||||||
|
text: `
|
||||||
|
x.(z->q)
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge_key",
|
||||||
|
text: `
|
||||||
|
x.(z->q)[343].hola: false
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subst",
|
||||||
|
text: `
|
||||||
|
x -> y: ${meow.ok}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "primary",
|
||||||
|
text: `
|
||||||
|
x -> y: ${meow.ok} {
|
||||||
|
label: |
|
||||||
|
"Hi, I'm Preston A. Mantis, president of Consumers Retail Law Outlet. As you
|
||||||
|
can see by my suit and the fact that I have all these books of equal height
|
||||||
|
on the shelves behind me, I am a trained legal attorney. Do you have a car
|
||||||
|
or a job? Do you ever walk around? If so, you probably have the makings of
|
||||||
|
an excellent legal case. Although of course every case is different, I
|
||||||
|
would definitely say that based on my experience and training, there's no
|
||||||
|
reason why you shouldn't come out of this thing with at least a cabin
|
||||||
|
cruiser.
|
||||||
|
|
||||||
|
"Remember, at the Preston A. Mantis Consumers Retail Law Outlet, our motto
|
||||||
|
is: 'It is very difficult to disprove certain kinds of pain.'"
|
||||||
|
-- Dave Barry, "Pain and Suffering"
|
||||||
|
|
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "()_keys",
|
||||||
|
text: `
|
||||||
|
my_fn() -> wowa()
|
||||||
|
meow.(x -> y -> z)[3].shape: "all hail corn"
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "errs",
|
||||||
|
text: `
|
||||||
|
--: meow]]]
|
||||||
|
meow][: ok
|
||||||
|
ok: "dmsadmakls" dsamkldkmsa
|
||||||
|
|
||||||
|
s.shape: orochimaru
|
||||||
|
x.shape: dasdasdas
|
||||||
|
|
||||||
|
wow:
|
||||||
|
|
||||||
|
:
|
||||||
|
|
||||||
|
[]
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
wsup
|
||||||
|
"""
|
||||||
|
|
||||||
|
'
|
||||||
|
|
||||||
|
meow: ${ok}
|
||||||
|
meow.(x->)[:
|
||||||
|
x -> x
|
||||||
|
|
||||||
|
x: [][]𐀀𐀀𐀀𐀀𐀀𐀀
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block_string",
|
||||||
|
text: `
|
||||||
|
x: ||
|
||||||
|
meow
|
||||||
|
meo
|
||||||
|
# ok
|
||||||
|
code
|
||||||
|
yes
|
||||||
|
||
|
||||||
|
x: || meow
|
||||||
|
meo
|
||||||
|
# ok
|
||||||
|
code
|
||||||
|
yes ||
|
||||||
|
|
||||||
|
# compat
|
||||||
|
x: |` + "`" + `
|
||||||
|
meow
|
||||||
|
meow
|
||||||
|
meow
|
||||||
|
` + "`" + `| {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing_whitespace",
|
||||||
|
text: `
|
||||||
|
s.shape: orochimaru
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "table_and_class",
|
||||||
|
text: `
|
||||||
|
sql_example: sql_example {
|
||||||
|
board: {
|
||||||
|
shape: sql_table
|
||||||
|
id: int {constraint: primary_key}
|
||||||
|
frame: int {constraint: foreign_key}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
board_objects: jsonb
|
||||||
|
last_updated: timestamp with time zone
|
||||||
|
last_thumbgen: timestamp with time zone
|
||||||
|
dsl: text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normal.
|
||||||
|
board.diagram -> diagrams.id
|
||||||
|
|
||||||
|
# Self referential.
|
||||||
|
diagrams.id -> diagrams.representation
|
||||||
|
|
||||||
|
# SrcArrow test.
|
||||||
|
diagrams.id <- views.diagram
|
||||||
|
diagrams.id <-> steps.diagram
|
||||||
|
|
||||||
|
diagrams: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int, constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
}
|
||||||
|
|
||||||
|
views: {
|
||||||
|
shape: sql_table
|
||||||
|
id: {type: int, constraint: primary_key}
|
||||||
|
representation: {type: jsonb}
|
||||||
|
diagram: int {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
|
||||||
|
# steps: {
|
||||||
|
# shape: sql_table
|
||||||
|
# id: {type: int, constraint: primary_key}
|
||||||
|
# representation: {type: jsonb}
|
||||||
|
# diagram: int {constraint: foreign_key}
|
||||||
|
# }
|
||||||
|
# Uncomment to make autolayout panic:
|
||||||
|
meow <- diagrams.id
|
||||||
|
}
|
||||||
|
|
||||||
|
D2 AST Parser: {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
+prevRune: rune
|
||||||
|
prevColumn: int
|
||||||
|
|
||||||
|
+eatSpace(eatNewlines bool): (rune, error)
|
||||||
|
unreadRune()
|
||||||
|
|
||||||
|
\#scanKey(r rune): (k Key, _ error)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_map_value",
|
||||||
|
text: `
|
||||||
|
x:
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge_line_continuation",
|
||||||
|
text: `
|
||||||
|
super long shape id here --\
|
||||||
|
-> super long shape id even longer here
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge_line_continuation_2",
|
||||||
|
text: `
|
||||||
|
super long shape id here --\
|
||||||
|
> super long shape id even longer here
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "field_line_continuation",
|
||||||
|
text: `
|
||||||
|
meow \
|
||||||
|
ok \
|
||||||
|
super: yes \
|
||||||
|
wow so cool
|
||||||
|
\
|
||||||
|
xd \
|
||||||
|
\
|
||||||
|
ok does it work: hopefully
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block_with_delims",
|
||||||
|
text: `
|
||||||
|
a: ||
|
||||||
|
|pipe|
|
||||||
|
||
|
||||||
|
|
||||||
|
"""
|
||||||
|
b: ""
|
||||||
|
"""
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block_one_line",
|
||||||
|
text: `
|
||||||
|
a: | hello |
|
||||||
|
""" hello """
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block_trailing_space",
|
||||||
|
text: `
|
||||||
|
x: |
|
||||||
|
meow
|
||||||
|
|
|
||||||
|
""" hello
|
||||||
|
"""
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "block_edge_case",
|
||||||
|
text: `
|
||||||
|
x: | meow
|
||||||
|
hello
|
||||||
|
yes
|
||||||
|
|
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single_quote_block_string",
|
||||||
|
text: `
|
||||||
|
x: |'
|
||||||
|
bs
|
||||||
|
'|
|
||||||
|
not part of block string
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge_group_value",
|
||||||
|
text: `
|
||||||
|
q.(x -> y).z: (rawr)
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d2Path := fmt.Sprintf("d2/testdata/d2parser/%v.d2", t.Name())
|
||||||
|
ast, err := d2parser.Parse(d2Path, strings.NewReader(tc.text), nil)
|
||||||
|
|
||||||
|
got := struct {
|
||||||
|
AST *d2ast.Map `json:"ast"`
|
||||||
|
Err error `json:"err"`
|
||||||
|
}{
|
||||||
|
AST: ast,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = diff.Testdata(filepath.Join("..", "testdata", "d2parser", t.Name()), got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
116
d2plugin/exec.go
Normal file
116
d2plugin/exec.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package d2plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/xdefer"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// execPlugin uses the binary at pathname with the plugin protocol to implement
|
||||||
|
// the Plugin interface.
|
||||||
|
//
|
||||||
|
// The layout plugin protocol works as follows.
|
||||||
|
//
|
||||||
|
// Info
|
||||||
|
// 1. The binary is invoked with info as the first argument.
|
||||||
|
// 2. The stdout of the binary is unmarshalled into PluginInfo.
|
||||||
|
//
|
||||||
|
// Layout
|
||||||
|
// 1. The binary is invoked with layout as the first argument and the json marshalled
|
||||||
|
// d2graph.Graph on stdin.
|
||||||
|
// 2. The stdout of the binary is unmarshalled into a d2graph.Graph
|
||||||
|
//
|
||||||
|
// PostProcess
|
||||||
|
// 1. The binary is invoked with postprocess as the first argument and the
|
||||||
|
// bytes of the SVG render on stdin.
|
||||||
|
// 2. The stdout of the binary is bytes of SVG with any post-processing.
|
||||||
|
//
|
||||||
|
// If any errors occur the binary will exit with a non zero status code and write
|
||||||
|
// the error to stderr.
|
||||||
|
type execPlugin struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p execPlugin) Info(ctx context.Context) (_ *PluginInfo, err error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, p.path, "info")
|
||||||
|
defer xdefer.Errorf(&err, "failed to run %v", cmd.Args)
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
ee := &exec.ExitError{}
|
||||||
|
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||||
|
return nil, fmt.Errorf("%v\nstderr:\n%s", ee, ee.Stderr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var info PluginInfo
|
||||||
|
|
||||||
|
err = json.Unmarshal(stdout, &info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p execPlugin) Layout(ctx context.Context, g *d2graph.Graph) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
graphBytes, err := d2graph.SerializeGraph(g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, p.path, "layout")
|
||||||
|
|
||||||
|
buffer := bytes.Buffer{}
|
||||||
|
buffer.Write(graphBytes)
|
||||||
|
cmd.Stdin = &buffer
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
ee := &exec.ExitError{}
|
||||||
|
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||||
|
return fmt.Errorf("%v\nstderr:\n%s", ee, ee.Stderr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = d2graph.DeserializeGraph(stdout, g)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p execPlugin) PostProcess(ctx context.Context, in []byte) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, p.path, "postprocess")
|
||||||
|
|
||||||
|
cmd.Stdin = bytes.NewBuffer(in)
|
||||||
|
|
||||||
|
stdout, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
ee := &exec.ExitError{}
|
||||||
|
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||||
|
return nil, fmt.Errorf("%v\nstderr:\n%s", ee, ee.Stderr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout, nil
|
||||||
|
}
|
||||||
110
d2plugin/plugin.go
Normal file
110
d2plugin/plugin.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Package d2plugin enables the d2 CLI to run functions bundled
|
||||||
|
// with the d2 binary or via external plugin binaries.
|
||||||
|
//
|
||||||
|
// Binary plugins are stored in $PATH with the prefix d2plugin-*. i.e the binary for
|
||||||
|
// dagre might be d2plugin-dagre. See ListPlugins() below.
|
||||||
|
package d2plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/lib/xexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// plugins contains the bundled d2 plugins.
|
||||||
|
//
|
||||||
|
// See plugin_* files for the plugins available for bundling.
|
||||||
|
var plugins []Plugin
|
||||||
|
|
||||||
|
type Plugin interface {
|
||||||
|
// Info returns the current info information of the plugin.
|
||||||
|
Info(context.Context) (*PluginInfo, error)
|
||||||
|
|
||||||
|
// Layout runs the plugin's autolayout algorithm on the input graph
|
||||||
|
// and returns a new graph with the computed placements.
|
||||||
|
Layout(context.Context, *d2graph.Graph) error
|
||||||
|
|
||||||
|
// PostProcess makes changes to the default render
|
||||||
|
PostProcess(context.Context, []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginInfo is the current info information of a plugin.
|
||||||
|
// note: The two fields Type and Path are not set by the plugin
|
||||||
|
// itself but only in ListPlugins.
|
||||||
|
type PluginInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ShortHelp string `json:"shortHelp"`
|
||||||
|
LongHelp string `json:"longHelp"`
|
||||||
|
|
||||||
|
// These two are set by ListPlugins and not the plugin itself.
|
||||||
|
// bundled | binary
|
||||||
|
Type string `json:"type"`
|
||||||
|
// If Type == binary then this contains the absolute path to the binary.
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryPrefix = "d2plugin-"
|
||||||
|
|
||||||
|
func ListPlugins(ctx context.Context) ([]*PluginInfo, error) {
|
||||||
|
// 1. Run Info on all bundled plugins in the global plugins array.
|
||||||
|
// - set Type for each bundled plugin to "bundled".
|
||||||
|
// 2. Iterate through directories in $PATH and look for executables within these
|
||||||
|
// directories with the prefix d2plugin-*
|
||||||
|
// 3. Run each plugin binary with the argument info. e.g. d2plugin-dagre info
|
||||||
|
|
||||||
|
var infoSlice []*PluginInfo
|
||||||
|
|
||||||
|
for _, p := range plugins {
|
||||||
|
info, err := p.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info.Type = "bundled"
|
||||||
|
infoSlice = append(infoSlice, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := xexec.SearchPath(binaryPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, path := range matches {
|
||||||
|
p := &execPlugin{path: path}
|
||||||
|
info, err := p.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info.Type = "binary"
|
||||||
|
info.Path = path
|
||||||
|
infoSlice = append(infoSlice, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return infoSlice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPlugin finds the plugin with the given name.
|
||||||
|
// 1. It first searches the bundled plugins in the global plugins slice.
|
||||||
|
// 2. If not found, it then searches each directory in $PATH for a binary with the name
|
||||||
|
// d2plugin-<name>.
|
||||||
|
// **NOTE** When D2 upgrades to go 1.19, remember to ignore exec.ErrDot
|
||||||
|
// 3. If such a binary is found, it builds an execPlugin in exec.go
|
||||||
|
// to get a plugin implementation around the binary and returns it.
|
||||||
|
func FindPlugin(ctx context.Context, name string) (Plugin, string, error) {
|
||||||
|
for _, p := range plugins {
|
||||||
|
info, err := p.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if info.Name == name {
|
||||||
|
return p, "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := exec.LookPath(binaryPrefix + name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &execPlugin{path: path}, path, nil
|
||||||
|
}
|
||||||
41
d2plugin/plugin_dagre.go
Normal file
41
d2plugin/plugin_dagre.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
//go:build cgo && !nodagre
|
||||||
|
|
||||||
|
package d2plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DagrePlugin = dagrePlugin{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
plugins = append(plugins, DagrePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dagrePlugin struct{}
|
||||||
|
|
||||||
|
func (p dagrePlugin) Info(context.Context) (*PluginInfo, error) {
|
||||||
|
return &PluginInfo{
|
||||||
|
Name: "dagre",
|
||||||
|
ShortHelp: "The directed graph layout library Dagre",
|
||||||
|
LongHelp: `dagre is a directed graph layout library for JavaScript.
|
||||||
|
See https://github.com/dagrejs/dagre
|
||||||
|
The implementation of this plugin is at: https://github.com/terrastruct/d2/tree/master/d2plugin/d2dagrelayout
|
||||||
|
|
||||||
|
note: dagre is the primary layout algorithm for text to diagram generator Mermaid.js.
|
||||||
|
See https://github.com/mermaid-js/mermaid
|
||||||
|
We have a useful comparison at https://text-to-diagram.com/?example=basic&a=d2&b=mermaid
|
||||||
|
`,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p dagrePlugin) Layout(ctx context.Context, g *d2graph.Graph) error {
|
||||||
|
return d2dagrelayout.Layout(ctx, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p dagrePlugin) PostProcess(ctx context.Context, in []byte) ([]byte, error) {
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
96
d2plugin/serve.go
Normal file
96
d2plugin/serve.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package d2plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2graph"
|
||||||
|
"oss.terrastruct.com/d2/lib/xmain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Serve returns a xmain.RunFunc that will invoke the plugin p as necessary to service the
|
||||||
|
// calling d2 CLI.
|
||||||
|
//
|
||||||
|
// See implementation of d2plugin-dagre in the ./cmd directory.
|
||||||
|
//
|
||||||
|
// Also see execPlugin in exec.go for the d2 binary plugin protocol.
|
||||||
|
func Serve(p Plugin) func(context.Context, *xmain.State) error {
|
||||||
|
return func(ctx context.Context, ms *xmain.State) (err error) {
|
||||||
|
if len(ms.Args) < 1 {
|
||||||
|
return errors.New("expected first argument to plugin binary to be function name")
|
||||||
|
}
|
||||||
|
reqFunc := ms.Args[0]
|
||||||
|
|
||||||
|
switch ms.Args[0] {
|
||||||
|
case "info":
|
||||||
|
return info(ctx, p, ms)
|
||||||
|
case "layout":
|
||||||
|
return layout(ctx, p, ms)
|
||||||
|
case "postprocess":
|
||||||
|
return postProcess(ctx, p, ms)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unrecognized command: %s", reqFunc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(ctx context.Context, p Plugin, ms *xmain.State) error {
|
||||||
|
info, err := p.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = ms.Stdout.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func layout(ctx context.Context, p Plugin, ms *xmain.State) error {
|
||||||
|
in, err := io.ReadAll(ms.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var g d2graph.Graph
|
||||||
|
if err := d2graph.DeserializeGraph(in, &g); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal input to graph: %s", in)
|
||||||
|
}
|
||||||
|
err = p.Layout(ctx, &g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := d2graph.SerializeGraph(&g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = ms.Stdout.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postProcess(ctx context.Context, p Plugin, ms *xmain.State) error {
|
||||||
|
in, err := io.ReadAll(ms.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := p.PostProcess(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ms.Stdout.Write(out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
6
d2renderers/d2fonts/NOTICE.txt
Normal file
6
d2renderers/d2fonts/NOTICE.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
The fonts bundled with D2 are open-source fonts licensed under SIL OPEN FONT
|
||||||
|
LICENSE.
|
||||||
|
|
||||||
|
Attribution:
|
||||||
|
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
16
d2renderers/d2fonts/README.md
Normal file
16
d2renderers/d2fonts/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# d2fonts
|
||||||
|
|
||||||
|
The SVG renderer embeds fonts directly into the SVG as base64 data. This is to give
|
||||||
|
determinstic outputs and load without a network call.
|
||||||
|
|
||||||
|
To include your own font, e.g. `Helvetica`, you must include the Truetype glyphs:
|
||||||
|
- `./ttf/Helvetica-Bold.ttf`
|
||||||
|
- `./ttf/Helvetica-Italic.ttf`
|
||||||
|
- `./ttf/Helvetica-Regular.ttf`
|
||||||
|
|
||||||
|
You must also include an encoded version of these of mimetype `application/font-woff`:
|
||||||
|
- `./ttf/Helvetica-Bold.txt`
|
||||||
|
- `./ttf/Helvetica-Italic.txt`
|
||||||
|
- `./ttf/Helvetica-Regular.txt`
|
||||||
|
|
||||||
|
If you include a font to contribute, it must have an open license.
|
||||||
141
d2renderers/d2fonts/d2fonts.go
Normal file
141
d2renderers/d2fonts/d2fonts.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// d2fonts holds fonts for renderings
|
||||||
|
|
||||||
|
// TODO write a script to do this as part of CI
|
||||||
|
package d2fonts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FontFamily int
|
||||||
|
type FontStyle string
|
||||||
|
|
||||||
|
type Font struct {
|
||||||
|
Family FontFamily
|
||||||
|
Style FontStyle
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FontFamily) Font(size int, style FontStyle) Font {
|
||||||
|
return Font{
|
||||||
|
Family: f,
|
||||||
|
Style: style,
|
||||||
|
Size: size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
FONT_SIZE_XS = 13
|
||||||
|
FONT_SIZE_S = 14
|
||||||
|
FONT_SIZE_M = 16
|
||||||
|
FONT_SIZE_L = 20
|
||||||
|
FONT_SIZE_XL = 24
|
||||||
|
FONT_SIZE_XXL = 28
|
||||||
|
FONT_SIZE_XXXL = 32
|
||||||
|
|
||||||
|
FONT_STYLE_REGULAR FontStyle = "regular"
|
||||||
|
FONT_STYLE_BOLD FontStyle = "bold"
|
||||||
|
FONT_STYLE_ITALIC FontStyle = "italic"
|
||||||
|
|
||||||
|
SourceSansPro FontFamily = iota
|
||||||
|
SourceCodePro FontFamily = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
var FontSizes = []int{
|
||||||
|
FONT_SIZE_XS,
|
||||||
|
FONT_SIZE_S,
|
||||||
|
FONT_SIZE_M,
|
||||||
|
FONT_SIZE_L,
|
||||||
|
FONT_SIZE_XL,
|
||||||
|
FONT_SIZE_XXL,
|
||||||
|
FONT_SIZE_XXXL,
|
||||||
|
}
|
||||||
|
|
||||||
|
var FontStyles = []FontStyle{
|
||||||
|
FONT_STYLE_REGULAR,
|
||||||
|
FONT_STYLE_BOLD,
|
||||||
|
FONT_STYLE_ITALIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
var FontFamilies = []FontFamily{
|
||||||
|
SourceSansPro,
|
||||||
|
SourceCodePro,
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed encoded/SourceSansPro-Regular.txt
|
||||||
|
var sourceSansProRegularBase64 string
|
||||||
|
|
||||||
|
//go:embed encoded/SourceSansPro-Bold.txt
|
||||||
|
var sourceSansProBoldBase64 string
|
||||||
|
|
||||||
|
//go:embed encoded/SourceSansPro-Italic.txt
|
||||||
|
var sourceSansProItalicBase64 string
|
||||||
|
|
||||||
|
//go:embed encoded/SourceCodePro-Regular.txt
|
||||||
|
var sourceCodeProRegularBase64 string
|
||||||
|
|
||||||
|
//go:embed ttf/*
|
||||||
|
var fontFacesFS embed.FS
|
||||||
|
|
||||||
|
var FontEncodings map[Font]string
|
||||||
|
var FontFaces map[Font][]byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
FontEncodings = map[Font]string{
|
||||||
|
{
|
||||||
|
Family: SourceSansPro,
|
||||||
|
Style: FONT_STYLE_REGULAR,
|
||||||
|
}: sourceSansProRegularBase64,
|
||||||
|
{
|
||||||
|
Family: SourceSansPro,
|
||||||
|
Style: FONT_STYLE_BOLD,
|
||||||
|
}: sourceSansProBoldBase64,
|
||||||
|
{
|
||||||
|
Family: SourceSansPro,
|
||||||
|
Style: FONT_STYLE_ITALIC,
|
||||||
|
}: sourceSansProItalicBase64,
|
||||||
|
{
|
||||||
|
Family: SourceCodePro,
|
||||||
|
Style: FONT_STYLE_REGULAR,
|
||||||
|
}: sourceCodeProRegularBase64,
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range FontEncodings {
|
||||||
|
FontEncodings[k] = strings.TrimSuffix(v, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
FontFaces = map[Font][]byte{}
|
||||||
|
b, err := fontFacesFS.ReadFile("ttf/SourceSansPro-Regular.ttf")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
FontFaces[Font{
|
||||||
|
Family: SourceSansPro,
|
||||||
|
Style: FONT_STYLE_REGULAR,
|
||||||
|
}] = b
|
||||||
|
b, err = fontFacesFS.ReadFile("ttf/SourceCodePro-Regular.ttf")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
FontFaces[Font{
|
||||||
|
Family: SourceCodePro,
|
||||||
|
Style: FONT_STYLE_REGULAR,
|
||||||
|
}] = b
|
||||||
|
b, err = fontFacesFS.ReadFile("ttf/SourceSansPro-Bold.ttf")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
FontFaces[Font{
|
||||||
|
Family: SourceSansPro,
|
||||||
|
Style: FONT_STYLE_BOLD,
|
||||||
|
}] = b
|
||||||
|
b, err = fontFacesFS.ReadFile("ttf/SourceSansPro-Italic.ttf")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
FontFaces[Font{
|
||||||
|
Family: SourceSansPro,
|
||||||
|
Style: FONT_STYLE_ITALIC,
|
||||||
|
}] = b
|
||||||
|
}
|
||||||
1
d2renderers/d2fonts/encoded/SourceCodePro-Regular.txt
Normal file
1
d2renderers/d2fonts/encoded/SourceCodePro-Regular.txt
Normal file
File diff suppressed because one or more lines are too long
1
d2renderers/d2fonts/encoded/SourceSansPro-Bold.txt
Normal file
1
d2renderers/d2fonts/encoded/SourceSansPro-Bold.txt
Normal file
File diff suppressed because one or more lines are too long
1
d2renderers/d2fonts/encoded/SourceSansPro-Italic.txt
Normal file
1
d2renderers/d2fonts/encoded/SourceSansPro-Italic.txt
Normal file
File diff suppressed because one or more lines are too long
1
d2renderers/d2fonts/encoded/SourceSansPro-Regular.txt
Normal file
1
d2renderers/d2fonts/encoded/SourceSansPro-Regular.txt
Normal file
File diff suppressed because one or more lines are too long
BIN
d2renderers/d2fonts/ttf/SourceCodePro-Regular.ttf
Normal file
BIN
d2renderers/d2fonts/ttf/SourceCodePro-Regular.ttf
Normal file
Binary file not shown.
BIN
d2renderers/d2fonts/ttf/SourceSansPro-Bold.ttf
Normal file
BIN
d2renderers/d2fonts/ttf/SourceSansPro-Bold.ttf
Normal file
Binary file not shown.
BIN
d2renderers/d2fonts/ttf/SourceSansPro-Italic.ttf
Normal file
BIN
d2renderers/d2fonts/ttf/SourceSansPro-Italic.ttf
Normal file
Binary file not shown.
BIN
d2renderers/d2fonts/ttf/SourceSansPro-Regular.ttf
Normal file
BIN
d2renderers/d2fonts/ttf/SourceSansPro-Regular.ttf
Normal file
Binary file not shown.
6
d2renderers/d2svg/NOTICE.txt
Normal file
6
d2renderers/d2svg/NOTICE.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
github-markdown.css derived and modified from https://github.com/sindresorhus/github-markdown-css.
|
||||||
|
|
||||||
|
Attribution:
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
https://github.com/sindresorhus/github-markdown-css/blob/main/license
|
||||||
135
d2renderers/d2svg/class.go
Normal file
135
d2renderers/d2svg/class.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package d2svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
"oss.terrastruct.com/d2/lib/label"
|
||||||
|
)
|
||||||
|
|
||||||
|
func classHeader(box *geo.Box, text string, textWidth, textHeight, fontSize float64) string {
|
||||||
|
str := fmt.Sprintf(`<rect class="class_header" x="%f" y="%f" width="%f" height="%f" fill="black" />`,
|
||||||
|
box.TopLeft.X, box.TopLeft.Y, box.Width, box.Height)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
tl := label.InsideMiddleCenter.GetPointOnBox(
|
||||||
|
box,
|
||||||
|
0,
|
||||||
|
textWidth,
|
||||||
|
textHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
str += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
// TODO use monospace font
|
||||||
|
"text",
|
||||||
|
tl.X+textWidth/2,
|
||||||
|
tl.Y+textHeight*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
|
||||||
|
"middle",
|
||||||
|
4+fontSize,
|
||||||
|
"white",
|
||||||
|
),
|
||||||
|
escapeText(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
prefixPadding = 10
|
||||||
|
prefixWidth = 20
|
||||||
|
typePadding = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
func classRow(box *geo.Box, prefix, nameText, typeText string, fontSize float64) string {
|
||||||
|
// Row is made up of prefix, name, and type
|
||||||
|
// e.g. | + firstName string |
|
||||||
|
prefixTL := label.InsideMiddleLeft.GetPointOnBox(
|
||||||
|
box,
|
||||||
|
prefixPadding,
|
||||||
|
box.Width,
|
||||||
|
fontSize,
|
||||||
|
)
|
||||||
|
typeTR := label.InsideMiddleRight.GetPointOnBox(
|
||||||
|
box,
|
||||||
|
typePadding,
|
||||||
|
0,
|
||||||
|
fontSize,
|
||||||
|
)
|
||||||
|
accentColor := "rgb(13, 50, 178)"
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
prefixTL.X,
|
||||||
|
prefixTL.Y+fontSize*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, accentColor),
|
||||||
|
prefix,
|
||||||
|
),
|
||||||
|
|
||||||
|
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
prefixTL.X+prefixWidth,
|
||||||
|
prefixTL.Y+fontSize*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, "black"),
|
||||||
|
escapeText(nameText),
|
||||||
|
),
|
||||||
|
|
||||||
|
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
typeTR.X,
|
||||||
|
typeTR.Y+fontSize*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "end", fontSize, accentColor),
|
||||||
|
escapeText(typeText),
|
||||||
|
),
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibilityToken(visibility string) string {
|
||||||
|
switch visibility {
|
||||||
|
case "protected":
|
||||||
|
return "#"
|
||||||
|
case "private":
|
||||||
|
return "-"
|
||||||
|
default:
|
||||||
|
return "+"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawClass(writer io.Writer, targetShape d2target.Shape) {
|
||||||
|
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
|
||||||
|
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, shapeStyle(targetShape))
|
||||||
|
|
||||||
|
box := geo.NewBox(
|
||||||
|
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
|
||||||
|
float64(targetShape.Width),
|
||||||
|
float64(targetShape.Height),
|
||||||
|
)
|
||||||
|
rowHeight := box.Height / float64(2+len(targetShape.Class.Fields)+len(targetShape.Class.Methods))
|
||||||
|
headerBox := geo.NewBox(box.TopLeft, box.Width, 2*rowHeight)
|
||||||
|
|
||||||
|
fmt.Fprint(writer,
|
||||||
|
classHeader(headerBox, targetShape.Label, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight), float64(targetShape.FontSize)),
|
||||||
|
)
|
||||||
|
|
||||||
|
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
|
||||||
|
rowBox.TopLeft.Y += headerBox.Height
|
||||||
|
for _, f := range targetShape.Class.Fields {
|
||||||
|
fmt.Fprint(writer,
|
||||||
|
classRow(rowBox, visibilityToken(f.Visibility), f.Name, f.Type, float64(targetShape.FontSize)),
|
||||||
|
)
|
||||||
|
rowBox.TopLeft.Y += rowHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(writer, `<line x1="%f" y1="%f" x2="%f" y2="%f" style="%s" />`,
|
||||||
|
rowBox.TopLeft.X, rowBox.TopLeft.Y,
|
||||||
|
rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y,
|
||||||
|
fmt.Sprintf("stroke-width:1;stroke:%v", targetShape.Stroke))
|
||||||
|
|
||||||
|
for _, m := range targetShape.Class.Methods {
|
||||||
|
fmt.Fprint(writer,
|
||||||
|
classRow(rowBox, visibilityToken(m.Visibility), m.Name, m.Return, float64(targetShape.FontSize)),
|
||||||
|
)
|
||||||
|
rowBox.TopLeft.Y += rowHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
53
d2renderers/d2svg/code.go
Normal file
53
d2renderers/d2svg/code.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package d2svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma"
|
||||||
|
"github.com/alecthomas/chroma/formatters/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copied private functions from chroma. Their public functions do too much (write the whole SVG document)
|
||||||
|
// https://github.com/alecthomas/chroma
|
||||||
|
// >>> BEGIN
|
||||||
|
|
||||||
|
var svgEscaper = strings.NewReplacer(
|
||||||
|
`&`, "&",
|
||||||
|
`<`, "<",
|
||||||
|
`>`, ">",
|
||||||
|
`"`, """,
|
||||||
|
` `, " ",
|
||||||
|
` `, "    ",
|
||||||
|
)
|
||||||
|
|
||||||
|
func styleToSVG(style *chroma.Style) map[chroma.TokenType]string {
|
||||||
|
converted := map[chroma.TokenType]string{}
|
||||||
|
bg := style.Get(chroma.Background)
|
||||||
|
// Convert the style.
|
||||||
|
for t := range chroma.StandardTypes {
|
||||||
|
entry := style.Get(t)
|
||||||
|
if t != chroma.Background {
|
||||||
|
entry = entry.Sub(bg)
|
||||||
|
}
|
||||||
|
if entry.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
converted[t] = svg.StyleEntryToSVG(entry)
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType) string {
|
||||||
|
if _, ok := styles[tt]; !ok {
|
||||||
|
tt = tt.SubCategory()
|
||||||
|
if _, ok := styles[tt]; !ok {
|
||||||
|
tt = tt.Category()
|
||||||
|
if _, ok := styles[tt]; !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return styles[tt]
|
||||||
|
}
|
||||||
|
|
||||||
|
// <<< END
|
||||||
794
d2renderers/d2svg/d2svg.go
Normal file
794
d2renderers/d2svg/d2svg.go
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
// d2svg implements an SVG renderer for d2 diagrams.
|
||||||
|
// The input is d2exporter's output
|
||||||
|
package d2svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma"
|
||||||
|
"github.com/alecthomas/chroma/formatters"
|
||||||
|
"github.com/alecthomas/chroma/lexers"
|
||||||
|
"github.com/alecthomas/chroma/styles"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/textmeasure"
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
"oss.terrastruct.com/d2/lib/label"
|
||||||
|
"oss.terrastruct.com/d2/lib/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
padding = 100
|
||||||
|
MIN_ARROWHEAD_STROKE_WIDTH = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed github-markdown.css
|
||||||
|
var mdCSS string
|
||||||
|
|
||||||
|
func setViewbox(writer io.Writer, diagram *d2target.Diagram) (width int, height int) {
|
||||||
|
tl, br := diagram.BoundingBox()
|
||||||
|
w := br.X - tl.X + padding*2
|
||||||
|
h := br.Y - tl.Y + padding*2
|
||||||
|
// TODO minify
|
||||||
|
|
||||||
|
// TODO background stuff. e.g. dotted, grid, colors
|
||||||
|
fmt.Fprintf(writer, `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg
|
||||||
|
style="background: white;"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="%d" height="%d" viewBox="%d %d %d %d">`, w, h, tl.X-padding, tl.Y-padding, w, h)
|
||||||
|
|
||||||
|
return w, h
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrowheadMarkerID(isTarget bool, connection d2target.Connection) string {
|
||||||
|
var arrowhead d2target.Arrowhead
|
||||||
|
if isTarget {
|
||||||
|
arrowhead = connection.DstArrow
|
||||||
|
} else {
|
||||||
|
arrowhead = connection.SrcArrow
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("mk-%s", hash(fmt.Sprintf("%s,%t,%d,%s",
|
||||||
|
arrowhead, isTarget, connection.StrokeWidth, connection.Stroke,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrowheadDimensions(arrowhead d2target.Arrowhead, strokeWidth float64) (width, height float64) {
|
||||||
|
var widthMultiplier float64
|
||||||
|
var heightMultiplier float64
|
||||||
|
switch arrowhead {
|
||||||
|
case d2target.ArrowArrowhead:
|
||||||
|
widthMultiplier = 5
|
||||||
|
heightMultiplier = 5
|
||||||
|
case d2target.TriangleArrowhead:
|
||||||
|
widthMultiplier = 5
|
||||||
|
heightMultiplier = 6
|
||||||
|
case d2target.LineArrowhead:
|
||||||
|
widthMultiplier = 5
|
||||||
|
heightMultiplier = 8
|
||||||
|
case d2target.FilledDiamondArrowhead:
|
||||||
|
widthMultiplier = 11
|
||||||
|
heightMultiplier = 7
|
||||||
|
case d2target.DiamondArrowhead:
|
||||||
|
widthMultiplier = 11
|
||||||
|
heightMultiplier = 9
|
||||||
|
}
|
||||||
|
|
||||||
|
clippedStrokeWidth := go2.Max(MIN_ARROWHEAD_STROKE_WIDTH, strokeWidth)
|
||||||
|
return clippedStrokeWidth * widthMultiplier, clippedStrokeWidth * heightMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrowheadMarker(isTarget bool, id string, connection d2target.Connection) string {
|
||||||
|
arrowhead := connection.DstArrow
|
||||||
|
if !isTarget {
|
||||||
|
arrowhead = connection.SrcArrow
|
||||||
|
}
|
||||||
|
strokeWidth := float64(connection.StrokeWidth)
|
||||||
|
width, height := arrowheadDimensions(arrowhead, strokeWidth)
|
||||||
|
|
||||||
|
var path string
|
||||||
|
switch arrowhead {
|
||||||
|
case d2target.ArrowArrowhead:
|
||||||
|
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
|
||||||
|
if isTarget {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
0., 0.,
|
||||||
|
width, height/2,
|
||||||
|
0., height,
|
||||||
|
width/4, height/2,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
0., height/2,
|
||||||
|
width, 0.,
|
||||||
|
width*3/4, height/2,
|
||||||
|
width, height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case d2target.TriangleArrowhead:
|
||||||
|
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
|
||||||
|
if isTarget {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
0., 0.,
|
||||||
|
width, height/2.0,
|
||||||
|
0., height,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
width, 0.,
|
||||||
|
0., height/2.0,
|
||||||
|
width, height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case d2target.LineArrowhead:
|
||||||
|
attrs := fmt.Sprintf(`class="connection" fill="none" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
|
||||||
|
if isTarget {
|
||||||
|
path = fmt.Sprintf(`<polyline %s points="%f,%f %f,%f %f,%f"/>`,
|
||||||
|
attrs,
|
||||||
|
strokeWidth/2, strokeWidth/2,
|
||||||
|
width-strokeWidth/2, height/2,
|
||||||
|
strokeWidth/2, height-strokeWidth/2,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf(`<polyline %s points="%f,%f %f,%f %f,%f"/>`,
|
||||||
|
attrs,
|
||||||
|
width-strokeWidth/2, strokeWidth/2,
|
||||||
|
strokeWidth/2, height/2,
|
||||||
|
width-strokeWidth/2, height-strokeWidth/2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case d2target.FilledDiamondArrowhead:
|
||||||
|
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
|
||||||
|
if isTarget {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
0., height/2.0,
|
||||||
|
width/2.0, 0.,
|
||||||
|
width, height/2.0,
|
||||||
|
width/2.0, height,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
0., height/2.0,
|
||||||
|
width/2.0, 0.,
|
||||||
|
width, height/2.0,
|
||||||
|
width/2.0, height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case d2target.DiamondArrowhead:
|
||||||
|
attrs := fmt.Sprintf(`class="connection" fill="white" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
|
||||||
|
if isTarget {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
0., height/2.0,
|
||||||
|
width/2, height/8,
|
||||||
|
width, height/2.0,
|
||||||
|
width/2.0, height*0.9,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
|
||||||
|
attrs,
|
||||||
|
width/8, height/2.0,
|
||||||
|
width*0.6, height/8,
|
||||||
|
width*1.1, height/2.0,
|
||||||
|
width*0.6, height*7/8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var refX float64
|
||||||
|
refY := height / 2
|
||||||
|
switch arrowhead {
|
||||||
|
case d2target.DiamondArrowhead:
|
||||||
|
if isTarget {
|
||||||
|
refX = width - 0.6*strokeWidth
|
||||||
|
} else {
|
||||||
|
refX = width/8 + 0.6*strokeWidth
|
||||||
|
}
|
||||||
|
width *= 1.1
|
||||||
|
default:
|
||||||
|
if isTarget {
|
||||||
|
refX = width - 3/2*strokeWidth
|
||||||
|
} else {
|
||||||
|
refX = 3 / 2 * strokeWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf(`<marker id="%s" markerWidth="%f" markerHeight="%f" refX="%f" refY="%f"`,
|
||||||
|
id, width, height, refX, refY,
|
||||||
|
),
|
||||||
|
fmt.Sprintf(`viewBox="%f %f %f %f"`, 0., 0., width, height),
|
||||||
|
`orient="auto" markerUnits="userSpaceOnUse">`,
|
||||||
|
path,
|
||||||
|
"</marker>",
|
||||||
|
}, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute the (dx, dy) adjustment to apply to get the arrowhead-adjusted end point
|
||||||
|
func arrowheadAdjustment(start, end *geo.Point, arrowhead d2target.Arrowhead, strokeWidth int) *geo.Point {
|
||||||
|
distance := float64(strokeWidth) / 2.0
|
||||||
|
if arrowhead != d2target.NoArrowhead {
|
||||||
|
distance += float64(strokeWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := geo.NewVector(end.X-start.X, end.Y-start.Y)
|
||||||
|
return v.Unit().Multiply(-distance).ToPoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns the path's d attribute for the given connection
|
||||||
|
func pathData(connection d2target.Connection) string {
|
||||||
|
var path []string
|
||||||
|
route := connection.Route
|
||||||
|
|
||||||
|
sourceAdjustment := arrowheadAdjustment(route[0], route[1], connection.SrcArrow, connection.StrokeWidth)
|
||||||
|
path = append(path, fmt.Sprintf("M %f %f",
|
||||||
|
route[0].X-sourceAdjustment.X,
|
||||||
|
route[0].Y-sourceAdjustment.Y,
|
||||||
|
))
|
||||||
|
|
||||||
|
if connection.IsCurve {
|
||||||
|
i := 1
|
||||||
|
for ; i < len(route)-3; i += 3 {
|
||||||
|
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
|
||||||
|
route[i].X, route[i].Y,
|
||||||
|
route[i+1].X, route[i+1].Y,
|
||||||
|
route[i+2].X, route[i+2].Y,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// final curve target adjustment
|
||||||
|
targetAdjustment := arrowheadAdjustment(route[i+1], route[i+2], connection.DstArrow, connection.StrokeWidth)
|
||||||
|
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
|
||||||
|
route[i].X, route[i].Y,
|
||||||
|
route[i+1].X, route[i+1].Y,
|
||||||
|
route[i+2].X+targetAdjustment.X,
|
||||||
|
route[i+2].Y+targetAdjustment.Y,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
for i := 1; i < len(route)-1; i++ {
|
||||||
|
prevSource := route[i-1]
|
||||||
|
prevTarget := route[i]
|
||||||
|
currTarget := route[i+1]
|
||||||
|
prevVector := prevSource.VectorTo(prevTarget)
|
||||||
|
currVector := prevTarget.VectorTo(currTarget)
|
||||||
|
|
||||||
|
dist := geo.EuclideanDistance(prevTarget.X, prevTarget.Y, currTarget.X, currTarget.Y)
|
||||||
|
units := math.Min(10, dist/2)
|
||||||
|
|
||||||
|
prevTranslations := prevVector.Unit().Multiply(units).ToPoint()
|
||||||
|
currTranslations := currVector.Unit().Multiply(units).ToPoint()
|
||||||
|
|
||||||
|
path = append(path, fmt.Sprintf("L %f %f",
|
||||||
|
prevTarget.X-prevTranslations.X,
|
||||||
|
prevTarget.Y-prevTranslations.Y,
|
||||||
|
))
|
||||||
|
|
||||||
|
// If the segment length is too small, instead of drawing 2 arcs, just skip this segment and bezier curve to the next one
|
||||||
|
if units < 10 && i < len(route)-2 {
|
||||||
|
nextTarget := route[i+2]
|
||||||
|
nextVector := geo.NewVector(nextTarget.X-currTarget.X, nextTarget.Y-currTarget.Y)
|
||||||
|
i++
|
||||||
|
nextTranslations := nextVector.Unit().Multiply(units).ToPoint()
|
||||||
|
|
||||||
|
// These 2 bezier control points aren't just at the corner -- they are reflected at the corner, which causes the curve to be ~tangent to the corner,
|
||||||
|
// which matches how the two arcs look
|
||||||
|
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
|
||||||
|
// Control point
|
||||||
|
prevTarget.X+prevTranslations.X,
|
||||||
|
prevTarget.Y+prevTranslations.Y,
|
||||||
|
// Control point
|
||||||
|
currTarget.X-nextTranslations.X,
|
||||||
|
currTarget.Y-nextTranslations.Y,
|
||||||
|
// Where curve ends
|
||||||
|
currTarget.X+nextTranslations.X,
|
||||||
|
currTarget.Y+nextTranslations.Y,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
path = append(path, fmt.Sprintf("S %f %f %f %f",
|
||||||
|
prevTarget.X,
|
||||||
|
prevTarget.Y,
|
||||||
|
prevTarget.X+currTranslations.X,
|
||||||
|
prevTarget.Y+currTranslations.Y,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPoint := route[len(route)-1]
|
||||||
|
secondToLastPoint := route[len(route)-2]
|
||||||
|
|
||||||
|
targetAdjustment := arrowheadAdjustment(secondToLastPoint, lastPoint, connection.DstArrow, connection.StrokeWidth)
|
||||||
|
path = append(path, fmt.Sprintf("L %f %f",
|
||||||
|
lastPoint.X+targetAdjustment.X,
|
||||||
|
lastPoint.Y+targetAdjustment.Y,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(path, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelMask(id string, connection d2target.Connection, labelTL, tl, br *geo.Point) string {
|
||||||
|
width := br.X - tl.X
|
||||||
|
height := br.Y - tl.Y
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%f" y="%f" width="%f" height="%f">`,
|
||||||
|
id, tl.X, tl.Y, width, height,
|
||||||
|
),
|
||||||
|
fmt.Sprintf(`<rect x="%f" y="%f" width="%f" height="%f" fill="white"></rect>`,
|
||||||
|
tl.X, tl.Y, width, height,
|
||||||
|
),
|
||||||
|
fmt.Sprintf(`<rect x="%f" y="%f" width="%d" height="%d" fill="black"></rect>`,
|
||||||
|
labelTL.X, labelTL.Y,
|
||||||
|
connection.LabelWidth,
|
||||||
|
connection.LabelHeight,
|
||||||
|
),
|
||||||
|
`</mask>`,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawConnection(writer io.Writer, connection d2target.Connection, markers map[string]struct{}) {
|
||||||
|
var markerStart string
|
||||||
|
if connection.SrcArrow != d2target.NoArrowhead {
|
||||||
|
id := arrowheadMarkerID(false, connection)
|
||||||
|
if _, in := markers[id]; !in {
|
||||||
|
marker := arrowheadMarker(false, id, connection)
|
||||||
|
if marker == "" {
|
||||||
|
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
|
||||||
|
}
|
||||||
|
fmt.Fprint(writer, marker)
|
||||||
|
markers[id] = struct{}{}
|
||||||
|
}
|
||||||
|
markerStart = fmt.Sprintf(`marker-start="url(#%s)" `, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var markerEnd string
|
||||||
|
if connection.DstArrow != d2target.NoArrowhead {
|
||||||
|
id := arrowheadMarkerID(true, connection)
|
||||||
|
if _, in := markers[id]; !in {
|
||||||
|
marker := arrowheadMarker(true, id, connection)
|
||||||
|
if marker == "" {
|
||||||
|
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
|
||||||
|
}
|
||||||
|
fmt.Fprint(writer, marker)
|
||||||
|
markers[id] = struct{}{}
|
||||||
|
}
|
||||||
|
markerEnd = fmt.Sprintf(`marker-end="url(#%s)" `, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelTL *geo.Point
|
||||||
|
var mask string
|
||||||
|
if connection.Label != "" {
|
||||||
|
labelTL = connection.GetLabelTopLeft()
|
||||||
|
labelTL.X = math.Round(labelTL.X)
|
||||||
|
labelTL.Y = math.Round(labelTL.Y)
|
||||||
|
|
||||||
|
if label.Position(connection.LabelPosition).IsOnEdge() {
|
||||||
|
strokeWidth := float64(connection.StrokeWidth)
|
||||||
|
tl, br := geo.Route(connection.Route).GetBoundingBox()
|
||||||
|
tl.X -= strokeWidth
|
||||||
|
tl.Y -= strokeWidth
|
||||||
|
br.X += strokeWidth
|
||||||
|
br.Y += strokeWidth
|
||||||
|
if connection.SrcArrow != d2target.NoArrowhead {
|
||||||
|
width, height := arrowheadDimensions(connection.SrcArrow, strokeWidth)
|
||||||
|
tl.X -= width
|
||||||
|
tl.Y -= height
|
||||||
|
br.X += width
|
||||||
|
br.Y += height
|
||||||
|
}
|
||||||
|
if connection.DstArrow != d2target.NoArrowhead {
|
||||||
|
width, height := arrowheadDimensions(connection.DstArrow, strokeWidth)
|
||||||
|
tl.X -= width
|
||||||
|
tl.Y -= height
|
||||||
|
br.X += width
|
||||||
|
br.Y += height
|
||||||
|
}
|
||||||
|
|
||||||
|
tl.X = math.Min(tl.X, labelTL.X)
|
||||||
|
tl.Y = math.Min(tl.Y, labelTL.Y)
|
||||||
|
br.X = math.Max(br.X, labelTL.X+float64(connection.LabelWidth))
|
||||||
|
br.Y = math.Max(br.Y, labelTL.Y+float64(connection.LabelHeight))
|
||||||
|
|
||||||
|
maskID := fmt.Sprintf("mask-%s", hash(connection.ID))
|
||||||
|
fmt.Fprint(writer, labelMask(maskID, connection, labelTL, tl, br))
|
||||||
|
mask = fmt.Sprintf(`mask="url(#%s)" `, maskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(writer, `<path d="%s" class="connection" style="fill:none;%s" %s%s%s/>`,
|
||||||
|
pathData(connection),
|
||||||
|
connectionStyle(connection),
|
||||||
|
markerStart,
|
||||||
|
markerEnd,
|
||||||
|
mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
if connection.Label != "" {
|
||||||
|
fontClass := "text"
|
||||||
|
if connection.Bold {
|
||||||
|
fontClass += "-bold"
|
||||||
|
} else if connection.Italic {
|
||||||
|
fontClass += "-italic"
|
||||||
|
}
|
||||||
|
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", connection.FontSize, "black")
|
||||||
|
x := labelTL.X + float64(connection.LabelWidth)/2
|
||||||
|
y := labelTL.Y + float64(connection.FontSize)
|
||||||
|
fmt.Fprintf(writer, `<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
fontClass,
|
||||||
|
x, y,
|
||||||
|
textStyle,
|
||||||
|
renderText(connection.Label, x, float64(connection.LabelHeight)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawShape(writer io.Writer, targetShape d2target.Shape) error {
|
||||||
|
tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y))
|
||||||
|
width := float64(targetShape.Width)
|
||||||
|
height := float64(targetShape.Height)
|
||||||
|
style := shapeStyle(targetShape)
|
||||||
|
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
|
||||||
|
|
||||||
|
s := shape.NewShape(shapeType, geo.NewBox(tl, width, height))
|
||||||
|
|
||||||
|
switch targetShape.Type {
|
||||||
|
case d2target.ShapeClass:
|
||||||
|
drawClass(writer, targetShape)
|
||||||
|
return nil
|
||||||
|
case d2target.ShapeSQLTable:
|
||||||
|
drawTable(writer, targetShape)
|
||||||
|
return nil
|
||||||
|
case d2target.ShapeOval:
|
||||||
|
rx := width / 2
|
||||||
|
ry := height / 2
|
||||||
|
cx := tl.X + rx
|
||||||
|
cy := tl.Y + ry
|
||||||
|
fmt.Fprintf(writer, `<ellipse class="shape" cx="%f" cy="%f" rx="%f" ry="%f" style="%s" />`,
|
||||||
|
cx, cy, rx, ry, style,
|
||||||
|
)
|
||||||
|
|
||||||
|
case d2target.ShapeImage:
|
||||||
|
fmt.Fprintf(writer, `<image class="shape" href="%s" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
|
||||||
|
targetShape.Icon.String(),
|
||||||
|
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
|
||||||
|
case d2target.ShapeText:
|
||||||
|
case d2target.ShapeCode:
|
||||||
|
// TODO should standardize "" to rectangle
|
||||||
|
case d2target.ShapeRectangle, "":
|
||||||
|
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
|
||||||
|
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
|
||||||
|
|
||||||
|
default:
|
||||||
|
for _, pathData := range s.GetSVGPathData() {
|
||||||
|
fmt.Fprintf(writer, `<path class="shape" d="%s" style="%s" />`, pathData, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetShape.Icon != nil && targetShape.Type != d2target.ShapeImage {
|
||||||
|
iconPosition := label.Position(targetShape.IconPosition)
|
||||||
|
var box *geo.Box
|
||||||
|
if iconPosition.IsOutside() {
|
||||||
|
box = s.GetBox()
|
||||||
|
} else {
|
||||||
|
box = s.GetInnerBox()
|
||||||
|
}
|
||||||
|
iconSize := targetShape.GetIconSize(box)
|
||||||
|
|
||||||
|
tl := iconPosition.GetPointOnBox(box, label.PADDING, float64(iconSize), float64(iconSize))
|
||||||
|
|
||||||
|
fmt.Fprintf(writer, `<image href="%s" x="%f" y="%f" width="%d" height="%d" />`,
|
||||||
|
targetShape.Icon.String(),
|
||||||
|
tl.X,
|
||||||
|
tl.Y,
|
||||||
|
iconSize,
|
||||||
|
iconSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetShape.Label != "" {
|
||||||
|
labelPosition := label.Position(targetShape.LabelPosition)
|
||||||
|
var box *geo.Box
|
||||||
|
if labelPosition.IsOutside() {
|
||||||
|
box = s.GetBox()
|
||||||
|
} else {
|
||||||
|
box = s.GetInnerBox()
|
||||||
|
}
|
||||||
|
labelTL := labelPosition.GetPointOnBox(box, label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
|
||||||
|
|
||||||
|
fontClass := "text"
|
||||||
|
if targetShape.Bold {
|
||||||
|
fontClass += "-bold"
|
||||||
|
} else if targetShape.Italic {
|
||||||
|
fontClass += "-italic"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch targetShape.Type {
|
||||||
|
case d2target.ShapeCode:
|
||||||
|
lexer := lexers.Get(targetShape.Language)
|
||||||
|
if lexer == nil {
|
||||||
|
return fmt.Errorf("code snippet lexer for %s not found", targetShape.Language)
|
||||||
|
}
|
||||||
|
style := styles.Get("github")
|
||||||
|
if style == nil {
|
||||||
|
return errors.New(`code snippet style "github" not found`)
|
||||||
|
}
|
||||||
|
formatter := formatters.Get("svg")
|
||||||
|
if formatter == nil {
|
||||||
|
return errors.New(`code snippet formatter "svg" not found`)
|
||||||
|
}
|
||||||
|
iterator, err := lexer.Tokenise(nil, targetShape.Label)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
svgStyles := styleToSVG(style)
|
||||||
|
containerStyle := fmt.Sprintf(`stroke: %s;fill:%s`, targetShape.Stroke, style.Get(chroma.Background).Background.String())
|
||||||
|
|
||||||
|
fmt.Fprintf(writer, `<g transform="translate(%f %f)" style="opacity:%f">`, box.TopLeft.X, box.TopLeft.Y, targetShape.Opacity)
|
||||||
|
fmt.Fprintf(writer, `<rect class="shape" width="%d" height="%d" style="%s" />`,
|
||||||
|
targetShape.Width, targetShape.Height, containerStyle)
|
||||||
|
// Padding
|
||||||
|
fmt.Fprintf(writer, `<g transform="translate(6 6)">`)
|
||||||
|
|
||||||
|
for index, tokens := range chroma.SplitTokensIntoLines(iterator.Tokens()) {
|
||||||
|
// TODO mono font looks better with 1.2 em (use px equivalent), but textmeasure needs to account for it. Not obvious how that should be done
|
||||||
|
fmt.Fprintf(writer, "<text class=\"text-mono\" x=\"0\" y=\"%fem\" xml:space=\"preserve\">", 1*float64(index+1))
|
||||||
|
for _, token := range tokens {
|
||||||
|
text := svgEscaper.Replace(token.String())
|
||||||
|
attr := styleAttr(svgStyles, token.Type)
|
||||||
|
if attr != "" {
|
||||||
|
text = fmt.Sprintf("<tspan %s>%s</tspan>", attr, text)
|
||||||
|
}
|
||||||
|
fmt.Fprint(writer, text)
|
||||||
|
}
|
||||||
|
fmt.Fprint(writer, "</text>")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(writer, "</g></g>")
|
||||||
|
case d2target.ShapeText:
|
||||||
|
render, err := textmeasure.RenderMarkdown(targetShape.Label)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(writer, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`,
|
||||||
|
box.TopLeft.X, box.TopLeft.Y, targetShape.Width, targetShape.Height,
|
||||||
|
)
|
||||||
|
// we need the self closing form in this svg/xhtml context
|
||||||
|
render = strings.ReplaceAll(render, "<hr>", "<hr />")
|
||||||
|
fmt.Fprintf(writer, `<div xmlns="http://www.w3.org/1999/xhtml" class="md">%v</div>`, render)
|
||||||
|
fmt.Fprint(writer, `</foreignObject></g>`)
|
||||||
|
default:
|
||||||
|
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", targetShape.FontSize, "black")
|
||||||
|
x := labelTL.X + float64(targetShape.LabelWidth)/2
|
||||||
|
// text is vertically positioned at its baseline which is at labelTL+FontSize
|
||||||
|
y := labelTL.Y + float64(targetShape.FontSize)
|
||||||
|
fmt.Fprintf(writer, `<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
fontClass,
|
||||||
|
x, y,
|
||||||
|
textStyle,
|
||||||
|
renderText(targetShape.Label, x, float64(targetShape.LabelHeight)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeText(text string) string {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_ = xml.EscapeText(buf, []byte(text))
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderText(text string, x, height float64) string {
|
||||||
|
if !strings.Contains(text, "\n") {
|
||||||
|
return escapeText(text)
|
||||||
|
}
|
||||||
|
rendered := []string{}
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
dy := height / float64(len(lines))
|
||||||
|
if i == 0 {
|
||||||
|
dy = 0
|
||||||
|
}
|
||||||
|
escaped := escapeText(line)
|
||||||
|
if escaped == "" {
|
||||||
|
// if there are multiple newlines in a row we still need text for the tspan to render
|
||||||
|
escaped = " "
|
||||||
|
}
|
||||||
|
rendered = append(rendered, fmt.Sprintf(`<tspan x="%f" dy="%f">%s</tspan>`, x, dy, escaped))
|
||||||
|
}
|
||||||
|
return strings.Join(rendered, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shapeStyle(shape d2target.Shape) string {
|
||||||
|
out := ""
|
||||||
|
|
||||||
|
out += fmt.Sprintf(`fill:%s;`, shape.Fill)
|
||||||
|
out += fmt.Sprintf(`stroke:%s;`, shape.Stroke)
|
||||||
|
out += fmt.Sprintf(`opacity:%f;`, shape.Opacity)
|
||||||
|
out += fmt.Sprintf(`stroke-width:%d;`, shape.StrokeWidth)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectionStyle(connection d2target.Connection) string {
|
||||||
|
out := ""
|
||||||
|
|
||||||
|
out += fmt.Sprintf(`stroke:%s;`, connection.Stroke)
|
||||||
|
out += fmt.Sprintf(`opacity:%f;`, connection.Opacity)
|
||||||
|
out += fmt.Sprintf(`stroke-width:%d;`, connection.StrokeWidth)
|
||||||
|
if connection.StrokeDash != 0 {
|
||||||
|
dashSize, gapSize := getStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash)
|
||||||
|
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStrokeDashAttributes(strokeWidth, dashGapSize float64) (float64, float64) {
|
||||||
|
// as the stroke width gets thicker, the dash gap gets smaller
|
||||||
|
scale := math.Log10(-0.6*strokeWidth+10.6)*0.5 + 0.5
|
||||||
|
scaledDashSize := strokeWidth * dashGapSize
|
||||||
|
scaledGapSize := scale * scaledDashSize
|
||||||
|
return scaledDashSize, scaledGapSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedFonts(buf *bytes.Buffer) {
|
||||||
|
content := buf.String()
|
||||||
|
buf.WriteString(`<style type="text/css"><![CDATA[`)
|
||||||
|
|
||||||
|
triggers := []string{
|
||||||
|
`class="text"`,
|
||||||
|
`class="md"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range triggers {
|
||||||
|
if strings.Contains(content, t) {
|
||||||
|
fmt.Fprintf(buf, `
|
||||||
|
.text {
|
||||||
|
font-family: "font-regular";
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: font-regular;
|
||||||
|
src: url("%s");
|
||||||
|
}`,
|
||||||
|
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers = []string{
|
||||||
|
`class="text-bold"`,
|
||||||
|
`<b>`,
|
||||||
|
`<strong>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range triggers {
|
||||||
|
if strings.Contains(content, t) {
|
||||||
|
fmt.Fprintf(buf, `
|
||||||
|
.text-bold {
|
||||||
|
font-family: "font-bold";
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: font-bold;
|
||||||
|
src: url("%s");
|
||||||
|
}`,
|
||||||
|
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_BOLD)])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers = []string{
|
||||||
|
`class="text-italic"`,
|
||||||
|
`<em>`,
|
||||||
|
`<dfn>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range triggers {
|
||||||
|
if strings.Contains(content, t) {
|
||||||
|
fmt.Fprintf(buf, `
|
||||||
|
.text-italic {
|
||||||
|
font-family: "font-italic";
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: font-italic;
|
||||||
|
src: url("%s");
|
||||||
|
}`,
|
||||||
|
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_ITALIC)])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(content, `class="text-mono"`) {
|
||||||
|
fmt.Fprintf(buf, `
|
||||||
|
.text-mono {
|
||||||
|
font-family: "font-mono";
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: font-mono;
|
||||||
|
src: url("%s");
|
||||||
|
}`,
|
||||||
|
d2fonts.FontEncodings[d2fonts.SourceCodePro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(`]]></style>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO minify output at end
|
||||||
|
func Render(diagram *d2target.Diagram) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
_, _ = setViewbox(buf, diagram)
|
||||||
|
|
||||||
|
buf.WriteString(`<style type="text/css">
|
||||||
|
<![CDATA[
|
||||||
|
.shape {
|
||||||
|
shape-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
.connection {
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
]]>
|
||||||
|
</style>`)
|
||||||
|
|
||||||
|
hasMarkdown := false
|
||||||
|
for _, s := range diagram.Shapes {
|
||||||
|
if s.Label != "" && s.Type == d2target.ShapeText {
|
||||||
|
hasMarkdown = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasMarkdown {
|
||||||
|
fmt.Fprintf(buf, `<style type="text/css">%s</style>`, mdCSS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG has no notion of z-index. The z-index is effectively the order it's drawn.
|
||||||
|
// So draw from the least nested to most nested
|
||||||
|
highest := 1
|
||||||
|
for _, s := range diagram.Shapes {
|
||||||
|
highest = go2.Max(highest, s.Level)
|
||||||
|
}
|
||||||
|
for i := 1; i <= highest; i++ {
|
||||||
|
for _, s := range diagram.Shapes {
|
||||||
|
if s.Level == i {
|
||||||
|
err := drawShape(buf, s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markers := map[string]struct{}{}
|
||||||
|
for _, c := range diagram.Connections {
|
||||||
|
drawConnection(buf, c, markers)
|
||||||
|
}
|
||||||
|
|
||||||
|
embedFonts(buf)
|
||||||
|
|
||||||
|
buf.WriteString(`</svg>`)
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(s string) string {
|
||||||
|
const secret = "lalalas"
|
||||||
|
h := fnv.New32a()
|
||||||
|
h.Write([]byte(fmt.Sprintf("%s%s", s, secret)))
|
||||||
|
return fmt.Sprint(h.Sum32())
|
||||||
|
}
|
||||||
785
d2renderers/d2svg/github-markdown.css
Normal file
785
d2renderers/d2svg/github-markdown.css
Normal file
|
|
@ -0,0 +1,785 @@
|
||||||
|
.md em,
|
||||||
|
.md dfn {
|
||||||
|
font-family: "font-italic";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md b,
|
||||||
|
.md strong {
|
||||||
|
font-family: "font-bold";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* based on https://github.com/sindresorhus/github-markdown-css */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.md {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-fg-default: #c9d1d9;
|
||||||
|
--color-fg-muted: #8b949e;
|
||||||
|
--color-fg-subtle: #484f58;
|
||||||
|
--color-canvas-default: #0d1117;
|
||||||
|
--color-canvas-subtle: #161b22;
|
||||||
|
--color-border-default: #30363d;
|
||||||
|
--color-border-muted: #21262d;
|
||||||
|
--color-neutral-muted: rgba(110, 118, 129, 0.4);
|
||||||
|
--color-accent-fg: #58a6ff;
|
||||||
|
--color-accent-emphasis: #1f6feb;
|
||||||
|
--color-attention-subtle: rgba(187, 128, 9, 0.15);
|
||||||
|
--color-danger-fg: #f85149;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.md {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-fg-default: #24292f;
|
||||||
|
--color-fg-muted: #57606a;
|
||||||
|
--color-fg-subtle: #6e7781;
|
||||||
|
--color-canvas-default: #ffffff;
|
||||||
|
--color-canvas-subtle: #f6f8fa;
|
||||||
|
--color-border-default: #d0d7de;
|
||||||
|
--color-border-muted: hsla(210, 18%, 87%, 1);
|
||||||
|
--color-neutral-muted: rgba(175, 184, 193, 0.2);
|
||||||
|
--color-accent-fg: #0969da;
|
||||||
|
--color-accent-emphasis: #0969da;
|
||||||
|
--color-attention-subtle: #fff8c5;
|
||||||
|
--color-danger-fg: #cf222e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-fg-default);
|
||||||
|
background-color: var(--color-canvas-default);
|
||||||
|
font-family: "font-regular";
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md details,
|
||||||
|
.md figcaption,
|
||||||
|
.md figure {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md a {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-accent-fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md a:active,
|
||||||
|
.md a:hover {
|
||||||
|
outline-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md dfn {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h1 {
|
||||||
|
margin: 0.67em 0;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
font-size: 2em;
|
||||||
|
border-bottom: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md mark {
|
||||||
|
background-color: var(--color-attention-subtle);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md small {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md sub,
|
||||||
|
.md sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md img {
|
||||||
|
border-style: none;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: content-box;
|
||||||
|
background-color: var(--color-canvas-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md code,
|
||||||
|
.md kbd,
|
||||||
|
.md pre,
|
||||||
|
.md samp {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md figure {
|
||||||
|
margin: 1em 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid var(--color-border-muted);
|
||||||
|
height: 0.25em;
|
||||||
|
padding: 0;
|
||||||
|
margin: 24px 0;
|
||||||
|
background-color: var(--color-border-default);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md input {
|
||||||
|
font: inherit;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="button"],
|
||||||
|
.md [type="reset"],
|
||||||
|
.md [type="submit"] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="button"]::-moz-focus-inner,
|
||||||
|
.md [type="reset"]::-moz-focus-inner,
|
||||||
|
.md [type="submit"]::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="button"]:-moz-focusring,
|
||||||
|
.md [type="reset"]:-moz-focusring,
|
||||||
|
.md [type="submit"]:-moz-focusring {
|
||||||
|
outline: 1px dotted ButtonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="checkbox"],
|
||||||
|
.md [type="radio"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="number"]::-webkit-inner-spin-button,
|
||||||
|
.md [type="number"]::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="search"] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md [type="search"]::-webkit-search-cancel-button,
|
||||||
|
.md [type="search"]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ::-webkit-input-placeholder {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md hr::before {
|
||||||
|
display: table;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md hr::after {
|
||||||
|
display: table;
|
||||||
|
clear: both;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table {
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md td,
|
||||||
|
.md th {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md details:not([open]) > *:not(summary) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 5px;
|
||||||
|
font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,
|
||||||
|
monospace;
|
||||||
|
line-height: 10px;
|
||||||
|
color: var(--color-fg-default);
|
||||||
|
vertical-align: middle;
|
||||||
|
background-color: var(--color-canvas-subtle);
|
||||||
|
border: solid 1px var(--color-neutral-muted);
|
||||||
|
border-bottom-color: var(--color-neutral-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: inset 0 -1px 0 var(--color-neutral-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h1,
|
||||||
|
.md h2,
|
||||||
|
.md h3,
|
||||||
|
.md h4,
|
||||||
|
.md h5,
|
||||||
|
.md h6 {
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
font-size: 1.5em;
|
||||||
|
border-bottom: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h3 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h4 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h5 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md blockquote {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1em;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
border-left: 0.25em solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ul,
|
||||||
|
.md ol {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ol ol,
|
||||||
|
.md ul ol {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ul ul ol,
|
||||||
|
.md ul ol ol,
|
||||||
|
.md ol ul ol,
|
||||||
|
.md ol ol ol {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md dd {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md tt,
|
||||||
|
.md code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md pre {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ::placeholder {
|
||||||
|
color: var(--color-fg-subtle);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md input::-webkit-outer-spin-button,
|
||||||
|
.md input::-webkit-inner-spin-button {
|
||||||
|
margin: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md::before {
|
||||||
|
display: table;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md::after {
|
||||||
|
display: table;
|
||||||
|
clear: both;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md > *:first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md > *:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md a:not([href]) {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .absent {
|
||||||
|
color: var(--color-danger-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .anchor {
|
||||||
|
float: left;
|
||||||
|
padding-right: 4px;
|
||||||
|
margin-left: -20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .anchor:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md p,
|
||||||
|
.md blockquote,
|
||||||
|
.md ul,
|
||||||
|
.md ol,
|
||||||
|
.md dl,
|
||||||
|
.md table,
|
||||||
|
.md pre,
|
||||||
|
.md details {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md sup > a::before {
|
||||||
|
content: "[";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md sup > a::after {
|
||||||
|
content: "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h1:hover .anchor,
|
||||||
|
.md h2:hover .anchor,
|
||||||
|
.md h3:hover .anchor,
|
||||||
|
.md h4:hover .anchor,
|
||||||
|
.md h5:hover .anchor,
|
||||||
|
.md h6:hover .anchor {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h1 tt,
|
||||||
|
.md h1 code,
|
||||||
|
.md h2 tt,
|
||||||
|
.md h2 code,
|
||||||
|
.md h3 tt,
|
||||||
|
.md h3 code,
|
||||||
|
.md h4 tt,
|
||||||
|
.md h4 code,
|
||||||
|
.md h5 tt,
|
||||||
|
.md h5 code,
|
||||||
|
.md h6 tt,
|
||||||
|
.md h6 code {
|
||||||
|
padding: 0 0.2em;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ul.no-list,
|
||||||
|
.md ol.no-list {
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ol[type="1"] {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ol[type="a"] {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ol[type="i"] {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md div > ol:not([type]) {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md ul ul,
|
||||||
|
.md ul ol,
|
||||||
|
.md ol ol,
|
||||||
|
.md ol ul {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md li > p {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md li + li {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md dl {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md dl dt {
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md dl dd {
|
||||||
|
padding: 0 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table th {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table th,
|
||||||
|
.md table td {
|
||||||
|
padding: 6px 13px;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table tr {
|
||||||
|
background-color: var(--color-canvas-default);
|
||||||
|
border-top: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table tr:nth-child(2n) {
|
||||||
|
background-color: var(--color-canvas-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md table img {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md img[align="right"] {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md img[align="left"] {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.frame {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.frame > span {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
width: auto;
|
||||||
|
padding: 7px;
|
||||||
|
margin: 13px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.frame span img {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.frame span span {
|
||||||
|
display: block;
|
||||||
|
padding: 5px 0 0;
|
||||||
|
clear: both;
|
||||||
|
color: var(--color-fg-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.align-center {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.align-center > span {
|
||||||
|
display: block;
|
||||||
|
margin: 13px auto 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.align-center span img {
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.align-right {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.align-right > span {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.align-right span img {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.float-left {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
margin-right: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.float-left span {
|
||||||
|
margin: 13px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.float-right {
|
||||||
|
display: block;
|
||||||
|
float: right;
|
||||||
|
margin-left: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md span.float-right > span {
|
||||||
|
display: block;
|
||||||
|
margin: 13px auto 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md code,
|
||||||
|
.md tt {
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 85%;
|
||||||
|
background-color: var(--color-neutral-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md code br,
|
||||||
|
.md tt br {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md del code {
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md pre code {
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md pre > code {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
word-break: normal;
|
||||||
|
white-space: pre;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .highlight {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .highlight pre {
|
||||||
|
margin-bottom: 0;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .highlight pre,
|
||||||
|
.md pre {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 85%;
|
||||||
|
line-height: 1.45;
|
||||||
|
background-color: var(--color-canvas-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md pre code,
|
||||||
|
.md pre tt {
|
||||||
|
display: inline;
|
||||||
|
max-width: auto;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: inherit;
|
||||||
|
word-wrap: normal;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .csv-data td,
|
||||||
|
.md .csv-data th {
|
||||||
|
padding: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .csv-data .blob-num {
|
||||||
|
padding: 10px 8px 9px;
|
||||||
|
text-align: right;
|
||||||
|
background: var(--color-canvas-default);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .csv-data tr {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .csv-data th {
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .footnotes {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
border-top: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .footnotes ol {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .footnotes li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .footnotes li:target::before {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
bottom: -8px;
|
||||||
|
left: -24px;
|
||||||
|
pointer-events: none;
|
||||||
|
content: "";
|
||||||
|
border: 2px solid var(--color-accent-emphasis);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .footnotes li:target {
|
||||||
|
color: var(--color-fg-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .task-list-item {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .task-list-item label {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .task-list-item.enabled label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .task-list-item + .task-list-item {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .task-list-item .handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .task-list-item-checkbox {
|
||||||
|
margin: 0 0.2em 0.25em -1.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md .contains-task-list:dir(rtl) .task-list-item-checkbox {
|
||||||
|
margin: 0 -1.6em 0.25em 0.2em;
|
||||||
|
}
|
||||||
136
d2renderers/d2svg/table.go
Normal file
136
d2renderers/d2svg/table.go
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package d2svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2target"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
"oss.terrastruct.com/d2/lib/label"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tableHeader(box *geo.Box, text string, textWidth, textHeight, fontSize float64) string {
|
||||||
|
str := fmt.Sprintf(`<rect class="class_header" x="%f" y="%f" width="%f" height="%f" fill="%s" />`,
|
||||||
|
box.TopLeft.X, box.TopLeft.Y, box.Width, box.Height, "#0a0f25")
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
tl := label.InsideMiddleLeft.GetPointOnBox(
|
||||||
|
box,
|
||||||
|
20,
|
||||||
|
textWidth,
|
||||||
|
textHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
str += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
"text",
|
||||||
|
tl.X,
|
||||||
|
tl.Y+textHeight*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
|
||||||
|
"start",
|
||||||
|
4+fontSize,
|
||||||
|
"white",
|
||||||
|
),
|
||||||
|
escapeText(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableRow(box *geo.Box, nameText, typeText, constraintText string, fontSize, longestNameWidth float64) string {
|
||||||
|
// Row is made up of name, type, and constraint
|
||||||
|
// e.g. | diagram int FK |
|
||||||
|
nameTL := label.InsideMiddleLeft.GetPointOnBox(
|
||||||
|
box,
|
||||||
|
prefixPadding,
|
||||||
|
box.Width,
|
||||||
|
fontSize,
|
||||||
|
)
|
||||||
|
constraintTR := label.InsideMiddleRight.GetPointOnBox(
|
||||||
|
box,
|
||||||
|
typePadding,
|
||||||
|
0,
|
||||||
|
fontSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO theme based
|
||||||
|
primaryColor := "rgb(13, 50, 178)"
|
||||||
|
accentColor := "rgb(74, 111, 243)"
|
||||||
|
neutralColor := "rgb(103, 108, 126)"
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
nameTL.X,
|
||||||
|
nameTL.Y+fontSize*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, primaryColor),
|
||||||
|
escapeText(nameText),
|
||||||
|
),
|
||||||
|
|
||||||
|
// TODO light font
|
||||||
|
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
nameTL.X+longestNameWidth,
|
||||||
|
nameTL.Y+fontSize*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, neutralColor),
|
||||||
|
escapeText(typeText),
|
||||||
|
),
|
||||||
|
|
||||||
|
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
|
||||||
|
constraintTR.X,
|
||||||
|
constraintTR.Y+fontSize*3/4,
|
||||||
|
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;letter-spacing:2px;", "end", fontSize, accentColor),
|
||||||
|
constraintText,
|
||||||
|
),
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func constraintAbbr(constraint string) string {
|
||||||
|
switch constraint {
|
||||||
|
case "primary_key":
|
||||||
|
return "PK"
|
||||||
|
case "foreign_key":
|
||||||
|
return "FK"
|
||||||
|
case "unique":
|
||||||
|
return "UNQ"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawTable(writer io.Writer, targetShape d2target.Shape) {
|
||||||
|
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
|
||||||
|
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, shapeStyle(targetShape))
|
||||||
|
|
||||||
|
box := geo.NewBox(
|
||||||
|
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
|
||||||
|
float64(targetShape.Width),
|
||||||
|
float64(targetShape.Height),
|
||||||
|
)
|
||||||
|
rowHeight := box.Height / float64(1+len(targetShape.SQLTable.Columns))
|
||||||
|
headerBox := geo.NewBox(box.TopLeft, box.Width, rowHeight)
|
||||||
|
|
||||||
|
fmt.Fprint(writer,
|
||||||
|
tableHeader(headerBox, targetShape.Label, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight), float64(targetShape.FontSize)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fontSize := float64(targetShape.FontSize)
|
||||||
|
var longestNameWidth float64
|
||||||
|
for _, f := range targetShape.SQLTable.Columns {
|
||||||
|
// TODO measure text
|
||||||
|
longestNameWidth = math.Max(longestNameWidth, float64(len(f.Name))*fontSize*5/9)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
|
||||||
|
rowBox.TopLeft.Y += headerBox.Height
|
||||||
|
for _, f := range targetShape.SQLTable.Columns {
|
||||||
|
fmt.Fprint(writer,
|
||||||
|
tableRow(rowBox, f.Name, f.Type, constraintAbbr(f.Constraint), fontSize, longestNameWidth),
|
||||||
|
)
|
||||||
|
rowBox.TopLeft.Y += rowHeight
|
||||||
|
fmt.Fprintf(writer, `<line x1="%f" y1="%f" x2="%f" y2="%f" style="stroke-width:2;stroke:#0a0f25" />`,
|
||||||
|
rowBox.TopLeft.X, rowBox.TopLeft.Y,
|
||||||
|
rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
7
d2renderers/textmeasure/NOTICE.txt
Normal file
7
d2renderers/textmeasure/NOTICE.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
Parts of this package have been derived and modified from a portion of
|
||||||
|
https://github.com/faiface/pixel.
|
||||||
|
|
||||||
|
Attribution:
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
https://github.com/faiface/pixel/blob/master/LICENSE
|
||||||
242
d2renderers/textmeasure/atlas.go
Normal file
242
d2renderers/textmeasure/atlas.go
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
package textmeasure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// glyph describes one glyph in an atlas.
|
||||||
|
type glyph struct {
|
||||||
|
dot *geo.Point
|
||||||
|
frame *rect
|
||||||
|
advance float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// atlas is a set of pre-drawn glyphs of a fixed set of runes. This allows for efficient text drawing.
|
||||||
|
type atlas struct {
|
||||||
|
face font.Face
|
||||||
|
mapping map[rune]glyph
|
||||||
|
ascent float64
|
||||||
|
descent float64
|
||||||
|
lineHeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAtlas creates a new atlas containing glyphs of the union of the given sets of runes (plus
|
||||||
|
// unicode.ReplacementChar) from the given font face.
|
||||||
|
//
|
||||||
|
// Creating an atlas is rather expensive, do not create a new atlas each frame.
|
||||||
|
//
|
||||||
|
// Do not destroy or close the font.Face after creating the atlas. atlas still uses it.
|
||||||
|
func NewAtlas(face font.Face, runeSets ...[]rune) *atlas {
|
||||||
|
seen := make(map[rune]bool)
|
||||||
|
runes := []rune{unicode.ReplacementChar}
|
||||||
|
for _, set := range runeSets {
|
||||||
|
for _, r := range set {
|
||||||
|
if !seen[r] {
|
||||||
|
runes = append(runes, r)
|
||||||
|
seen[r] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedMapping, fixedBounds := makeSquareMapping(face, runes, fixed.I(2))
|
||||||
|
|
||||||
|
bounds := &rect{
|
||||||
|
tl: geo.NewPoint(
|
||||||
|
i2f(fixedBounds.Min.X),
|
||||||
|
i2f(fixedBounds.Min.Y),
|
||||||
|
),
|
||||||
|
br: geo.NewPoint(
|
||||||
|
i2f(fixedBounds.Max.X),
|
||||||
|
i2f(fixedBounds.Max.Y),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping := make(map[rune]glyph)
|
||||||
|
for r, fg := range fixedMapping {
|
||||||
|
mapping[r] = glyph{
|
||||||
|
dot: geo.NewPoint(
|
||||||
|
i2f(fg.dot.X),
|
||||||
|
bounds.br.Y-(i2f(fg.dot.Y)-bounds.tl.Y),
|
||||||
|
),
|
||||||
|
frame: rect{
|
||||||
|
tl: geo.NewPoint(
|
||||||
|
i2f(fg.frame.Min.X),
|
||||||
|
bounds.br.Y-(i2f(fg.frame.Min.Y)-bounds.tl.Y),
|
||||||
|
),
|
||||||
|
br: geo.NewPoint(
|
||||||
|
i2f(fg.frame.Max.X),
|
||||||
|
bounds.br.Y-(i2f(fg.frame.Max.Y)-bounds.tl.Y),
|
||||||
|
),
|
||||||
|
}.norm(),
|
||||||
|
advance: i2f(fg.advance),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &atlas{
|
||||||
|
face: face,
|
||||||
|
mapping: mapping,
|
||||||
|
ascent: i2f(face.Metrics().Ascent),
|
||||||
|
descent: i2f(face.Metrics().Descent),
|
||||||
|
lineHeight: i2f(face.Metrics().Height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *atlas) contains(r rune) bool {
|
||||||
|
_, ok := a.mapping[r]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyph returns the description of r within the atlas.
|
||||||
|
func (a *atlas) glyph(r rune) glyph {
|
||||||
|
return a.mapping[r]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kern returns the kerning distance between runes r0 and r1. Positive distance means that the
|
||||||
|
// glyphs should be further apart.
|
||||||
|
func (a *atlas) Kern(r0, r1 rune) float64 {
|
||||||
|
return i2f(a.face.Kern(r0, r1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ascent returns the distance from the top of the line to the baseline.
|
||||||
|
func (a *atlas) Ascent() float64 {
|
||||||
|
return a.ascent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descent returns the distance from the baseline to the bottom of the line.
|
||||||
|
func (a *atlas) Descent() float64 {
|
||||||
|
return a.descent
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawRune returns parameters necessary for drawing a rune glyph.
|
||||||
|
//
|
||||||
|
// Rect is a rectangle where the glyph should be positioned. frame is the glyph frame inside the
|
||||||
|
// atlas's Picture. NewDot is the new position of the dot.
|
||||||
|
func (a *atlas) DrawRune(prevR, r rune, dot *geo.Point) (rect2, frame, bounds *rect, newDot *geo.Point) {
|
||||||
|
if !a.contains(r) {
|
||||||
|
r = unicode.ReplacementChar
|
||||||
|
}
|
||||||
|
if !a.contains(unicode.ReplacementChar) {
|
||||||
|
return newRect(), newRect(), newRect(), dot
|
||||||
|
}
|
||||||
|
if !a.contains(prevR) {
|
||||||
|
prevR = unicode.ReplacementChar
|
||||||
|
}
|
||||||
|
|
||||||
|
if prevR >= 0 {
|
||||||
|
dot.X += a.Kern(prevR, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
glyph := a.glyph(r)
|
||||||
|
|
||||||
|
subbed := geo.NewPoint(
|
||||||
|
dot.X-glyph.dot.X,
|
||||||
|
dot.Y-glyph.dot.Y,
|
||||||
|
)
|
||||||
|
|
||||||
|
rect2 = &rect{
|
||||||
|
tl: geo.NewPoint(
|
||||||
|
glyph.frame.tl.X+subbed.X,
|
||||||
|
glyph.frame.tl.Y+subbed.Y,
|
||||||
|
),
|
||||||
|
br: geo.NewPoint(
|
||||||
|
glyph.frame.br.X+subbed.X,
|
||||||
|
glyph.frame.br.Y+subbed.Y,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
bounds = rect2
|
||||||
|
|
||||||
|
if bounds.w()*bounds.h() != 0 {
|
||||||
|
bounds = &rect{
|
||||||
|
tl: geo.NewPoint(
|
||||||
|
bounds.tl.X,
|
||||||
|
dot.Y-a.Descent(),
|
||||||
|
),
|
||||||
|
br: geo.NewPoint(
|
||||||
|
bounds.br.X,
|
||||||
|
dot.Y+a.Ascent(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dot.X += glyph.advance
|
||||||
|
|
||||||
|
return rect2, glyph.frame, bounds, dot
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedGlyph struct {
|
||||||
|
dot fixed.Point26_6
|
||||||
|
frame fixed.Rectangle26_6
|
||||||
|
advance fixed.Int26_6
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeSquareMapping finds an optimal glyph arrangement of the given runes, so that their common
|
||||||
|
// bounding box is as square as possible.
|
||||||
|
func makeSquareMapping(face font.Face, runes []rune, padding fixed.Int26_6) (map[rune]fixedGlyph, fixed.Rectangle26_6) {
|
||||||
|
width := sort.Search(int(fixed.I(1024*1024)), func(i int) bool {
|
||||||
|
width := fixed.Int26_6(i)
|
||||||
|
_, bounds := makeMapping(face, runes, padding, width)
|
||||||
|
return bounds.Max.X-bounds.Min.X >= bounds.Max.Y-bounds.Min.Y
|
||||||
|
})
|
||||||
|
return makeMapping(face, runes, padding, fixed.Int26_6(width))
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeMapping arranges glyphs of the given runes into rows in such a way, that no glyph is located
|
||||||
|
// fully to the right of the specified width. Specifically, it places glyphs in a row one by one and
|
||||||
|
// once it reaches the specified width, it starts a new row.
|
||||||
|
func makeMapping(face font.Face, runes []rune, padding, width fixed.Int26_6) (map[rune]fixedGlyph, fixed.Rectangle26_6) {
|
||||||
|
mapping := make(map[rune]fixedGlyph)
|
||||||
|
bounds := fixed.Rectangle26_6{}
|
||||||
|
|
||||||
|
dot := fixed.P(0, 0)
|
||||||
|
|
||||||
|
for _, r := range runes {
|
||||||
|
b, advance, ok := face.GlyphBounds(r)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is important for drawing, artifacts arise otherwise
|
||||||
|
frame := fixed.Rectangle26_6{
|
||||||
|
Min: fixed.P(b.Min.X.Floor(), b.Min.Y.Floor()),
|
||||||
|
Max: fixed.P(b.Max.X.Ceil(), b.Max.Y.Ceil()),
|
||||||
|
}
|
||||||
|
|
||||||
|
dot.X -= frame.Min.X
|
||||||
|
frame = frame.Add(dot)
|
||||||
|
|
||||||
|
mapping[r] = fixedGlyph{
|
||||||
|
dot: dot,
|
||||||
|
frame: frame,
|
||||||
|
advance: advance,
|
||||||
|
}
|
||||||
|
bounds = bounds.Union(frame)
|
||||||
|
|
||||||
|
dot.X = frame.Max.X
|
||||||
|
|
||||||
|
// padding + align to integer
|
||||||
|
dot.X += padding
|
||||||
|
dot.X = fixed.I(dot.X.Ceil())
|
||||||
|
|
||||||
|
// width exceeded, new row
|
||||||
|
if frame.Max.X >= width {
|
||||||
|
dot.X = 0
|
||||||
|
dot.Y += face.Metrics().Ascent + face.Metrics().Descent
|
||||||
|
|
||||||
|
// padding + align to integer
|
||||||
|
dot.Y += padding
|
||||||
|
dot.Y = fixed.I(dot.Y.Ceil())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping, bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func i2f(i fixed.Int26_6) float64 {
|
||||||
|
return float64(i) / (1 << 6)
|
||||||
|
}
|
||||||
325
d2renderers/textmeasure/markdown.go
Normal file
325
d2renderers/textmeasure/markdown.go
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
package textmeasure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
goldmarkHtml "github.com/yuin/goldmark/renderer/html"
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var markdownRenderer goldmark.Markdown
|
||||||
|
|
||||||
|
const (
|
||||||
|
MarkdownFontSize = d2fonts.FONT_SIZE_M
|
||||||
|
MarkdownLineHeight = 1.5
|
||||||
|
MarkdownLineHeightPx = MarkdownFontSize * MarkdownLineHeight
|
||||||
|
|
||||||
|
PaddingLeft_ul_ol = 32
|
||||||
|
MarginBottom_ul = MarkdownFontSize
|
||||||
|
|
||||||
|
MarginTop_li_p = MarkdownFontSize
|
||||||
|
MarginBottom_p = MarkdownFontSize
|
||||||
|
|
||||||
|
LineHeight_h = 1.25
|
||||||
|
MarginTop_h = 24
|
||||||
|
MarginBottom_h = 16
|
||||||
|
PaddingBottom_h1_h2_em = 0.3
|
||||||
|
|
||||||
|
Height_hr = 4
|
||||||
|
MarginTopBottom_hr = 24
|
||||||
|
|
||||||
|
Padding_pre = 16
|
||||||
|
MarginBottom_pre = 16
|
||||||
|
|
||||||
|
PaddingLR_blockquote_em = 1.
|
||||||
|
MarginBottom_blockquote = 16
|
||||||
|
BorderLeft_blockquote_em = 0.25
|
||||||
|
|
||||||
|
FONT_SIZE_H1 = d2fonts.FONT_SIZE_XXXL
|
||||||
|
FONT_SIZE_H2 = d2fonts.FONT_SIZE_XL
|
||||||
|
FONT_SIZE_H3 = d2fonts.FONT_SIZE_L
|
||||||
|
FONT_SIZE_H4 = d2fonts.FONT_SIZE_M
|
||||||
|
FONT_SIZE_H5 = d2fonts.FONT_SIZE_S
|
||||||
|
FONT_SIZE_H6 = d2fonts.FONT_SIZE_XS
|
||||||
|
)
|
||||||
|
|
||||||
|
var HeaderToFontSize = map[string]int{
|
||||||
|
"h1": FONT_SIZE_H1,
|
||||||
|
"h2": FONT_SIZE_H2,
|
||||||
|
"h3": FONT_SIZE_H3,
|
||||||
|
"h4": FONT_SIZE_H4,
|
||||||
|
"h5": FONT_SIZE_H5,
|
||||||
|
"h6": FONT_SIZE_H6,
|
||||||
|
}
|
||||||
|
|
||||||
|
var HeaderFonts map[string]d2fonts.Font
|
||||||
|
|
||||||
|
func RenderMarkdown(m string) (string, error) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := markdownRenderer.Convert([]byte(m), &output); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return output.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
HeaderFonts = make(map[string]d2fonts.Font)
|
||||||
|
for header, fontSize := range HeaderToFontSize {
|
||||||
|
HeaderFonts[header] = d2fonts.SourceSansPro.Font(fontSize, d2fonts.FONT_STYLE_BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownRenderer = goldmark.New(
|
||||||
|
goldmark.WithRendererOptions(
|
||||||
|
goldmarkHtml.WithUnsafe(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MeasureMarkdown(mdText string, ruler *Ruler) (width, height int, err error) {
|
||||||
|
render, err := RenderMarkdown(mdText)
|
||||||
|
if err != nil {
|
||||||
|
return width, height, err
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(render))
|
||||||
|
if err != nil {
|
||||||
|
return width, height, err
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
originalLineHeight := ruler.LineHeightFactor
|
||||||
|
ruler.LineHeightFactor = MarkdownLineHeight
|
||||||
|
defer func() {
|
||||||
|
ruler.LineHeightFactor = originalLineHeight
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
font := d2fonts.SourceSansPro.Font(MarkdownFontSize, d2fonts.FONT_STYLE_REGULAR)
|
||||||
|
|
||||||
|
// TODO consider setting a max width + (manual) text wrapping
|
||||||
|
bodyNode := doc.Find("body").First().Nodes[0]
|
||||||
|
bodyWidth, bodyHeight, _, _ := ruler.measureNode(0, bodyNode, font)
|
||||||
|
|
||||||
|
return int(math.Ceil(bodyWidth)), int(math.Ceil(bodyHeight)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPrev(n *html.Node) bool {
|
||||||
|
if n.PrevSibling == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(n.PrevSibling.Data) == "" {
|
||||||
|
return hasPrev(n.PrevSibling)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNext(n *html.Node) bool {
|
||||||
|
if n.NextSibling == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// skip over empty text nodes
|
||||||
|
if strings.TrimSpace(n.NextSibling.Data) == "" {
|
||||||
|
return hasNext(n.NextSibling)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrev(n *html.Node) *html.Node {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(n.Data) == "" {
|
||||||
|
if next := getNext(n.PrevSibling); next != nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNext(n *html.Node) *html.Node {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(n.Data) == "" {
|
||||||
|
if next := getNext(n.NextSibling); next != nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlockElement(elType string) bool {
|
||||||
|
switch elType {
|
||||||
|
case "blockquote",
|
||||||
|
"div",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"hr",
|
||||||
|
"li",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"ul":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAncestorElement(n *html.Node, elType string) bool {
|
||||||
|
if n.Parent == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if n.Parent.Type == html.ElementNode && n.Parent.Data == elType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasAncestorElement(n.Parent, elType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// measures node dimensions to match rendering with styles in github-markdown.css
|
||||||
|
func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) (width, height, marginTop, marginBottom float64) {
|
||||||
|
switch n.Type {
|
||||||
|
case html.TextNode:
|
||||||
|
if strings.TrimSpace(n.Data) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spaceWidths := 0.
|
||||||
|
|
||||||
|
// consecutive leading/trailing spaces end up rendered as a single space
|
||||||
|
spaceRune, _ := utf8.DecodeRuneInString(" ")
|
||||||
|
// measure will not include leading or trailing whitespace, so we have to add in the space width
|
||||||
|
spaceWidth := ruler.atlases[font].glyph(spaceRune).advance
|
||||||
|
|
||||||
|
str := n.Data
|
||||||
|
hasCodeParent := n.Parent != nil && n.Parent.Type == html.ElementNode && (n.Parent.Data == "pre" || n.Parent.Data == "code")
|
||||||
|
if !hasCodeParent {
|
||||||
|
str = strings.ReplaceAll(n.Data, "\n", " ")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(str, " ") {
|
||||||
|
str = strings.TrimPrefix(str, " ")
|
||||||
|
if hasPrev(n) {
|
||||||
|
spaceWidths += spaceWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(str, " ") {
|
||||||
|
str = strings.TrimSuffix(str, " ")
|
||||||
|
if hasNext(n) {
|
||||||
|
spaceWidths += spaceWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w, h := ruler.MeasurePrecise(font, str)
|
||||||
|
w += spaceWidths
|
||||||
|
// fmt.Printf("%d:%s width %v height %v fontStyle %s\n", depth, n.Data, w, h, font.Style)
|
||||||
|
if h > 0 && h < MarkdownLineHeightPx {
|
||||||
|
h = MarkdownLineHeightPx
|
||||||
|
}
|
||||||
|
return w, h, 0, 0
|
||||||
|
case html.ElementNode:
|
||||||
|
// fmt.Printf("%d: %v node\n", depth, n.Data)
|
||||||
|
switch n.Data {
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
font = HeaderFonts[n.Data]
|
||||||
|
originalLineHeight := ruler.LineHeightFactor
|
||||||
|
ruler.LineHeightFactor = LineHeight_h
|
||||||
|
defer func() {
|
||||||
|
ruler.LineHeightFactor = originalLineHeight
|
||||||
|
}()
|
||||||
|
case "em":
|
||||||
|
font.Style = d2fonts.FONT_STYLE_ITALIC
|
||||||
|
case "b", "strong":
|
||||||
|
font.Style = d2fonts.FONT_STYLE_BOLD
|
||||||
|
case "pre", "code":
|
||||||
|
// TODO monospaced font
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.FirstChild != nil {
|
||||||
|
first := getNext(n.FirstChild)
|
||||||
|
last := getPrev(n.LastChild)
|
||||||
|
|
||||||
|
var prevMarginBottom float64
|
||||||
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
childWidth, childHeight, childMarginTop, childMarginBottom := ruler.measureNode(depth+1, child, font)
|
||||||
|
|
||||||
|
if child.Type == html.ElementNode && isBlockElement(child.Data) {
|
||||||
|
if child == first {
|
||||||
|
if n.Data == "blockquote" {
|
||||||
|
childMarginTop = 0.
|
||||||
|
}
|
||||||
|
marginTop = go2.Max(marginTop, childMarginTop)
|
||||||
|
} else {
|
||||||
|
marginDiff := childMarginTop - prevMarginBottom
|
||||||
|
if marginDiff > 0 {
|
||||||
|
childHeight += marginDiff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if child == last {
|
||||||
|
if n.Data == "blockquote" {
|
||||||
|
childMarginBottom = 0.
|
||||||
|
}
|
||||||
|
marginBottom = go2.Max(marginBottom, childMarginBottom)
|
||||||
|
} else {
|
||||||
|
childHeight += childMarginBottom
|
||||||
|
prevMarginBottom = childMarginBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
height += childHeight
|
||||||
|
width = go2.Max(width, childWidth)
|
||||||
|
} else {
|
||||||
|
marginTop = go2.Max(marginTop, childMarginTop)
|
||||||
|
marginBottom = go2.Max(marginBottom, childMarginBottom)
|
||||||
|
|
||||||
|
width += childWidth
|
||||||
|
height = go2.Max(height, childHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch n.Data {
|
||||||
|
case "blockquote":
|
||||||
|
width += float64(font.Size) * (2*PaddingLR_blockquote_em + BorderLeft_blockquote_em)
|
||||||
|
marginBottom = go2.Max(marginBottom, MarginBottom_blockquote)
|
||||||
|
case "p":
|
||||||
|
if n.Parent != nil && n.Parent.Type == html.ElementNode && n.Parent.Data == "li" {
|
||||||
|
marginTop = go2.Max(marginTop, MarginTop_li_p)
|
||||||
|
}
|
||||||
|
marginBottom = go2.Max(marginBottom, MarginBottom_p)
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
marginTop = go2.Max(marginTop, MarginTop_h)
|
||||||
|
marginBottom = go2.Max(marginBottom, MarginBottom_h)
|
||||||
|
switch n.Data {
|
||||||
|
case "h1", "h2":
|
||||||
|
height += float64(HeaderToFontSize[n.Data]) * PaddingBottom_h1_h2_em
|
||||||
|
}
|
||||||
|
case "li":
|
||||||
|
width += PaddingLeft_ul_ol
|
||||||
|
if hasPrev(n) {
|
||||||
|
marginTop = go2.Max(marginTop, 4)
|
||||||
|
}
|
||||||
|
case "ol", "ul":
|
||||||
|
if hasAncestorElement(n, "ul") || hasAncestorElement(n, "ol") {
|
||||||
|
marginTop = 0
|
||||||
|
marginBottom = 0
|
||||||
|
} else {
|
||||||
|
marginBottom = go2.Max(marginBottom, MarginBottom_ul)
|
||||||
|
}
|
||||||
|
case "pre":
|
||||||
|
width += 2 * Padding_pre
|
||||||
|
height += 2 * Padding_pre
|
||||||
|
marginBottom = go2.Max(marginBottom, MarginBottom_pre)
|
||||||
|
case "hr":
|
||||||
|
height += Height_hr
|
||||||
|
marginTop = go2.Max(marginTop, MarginTopBottom_hr)
|
||||||
|
marginBottom = go2.Max(marginBottom, MarginTopBottom_hr)
|
||||||
|
}
|
||||||
|
// fmt.Printf("%d:%s width %v height %v mt %v mb %v\n", depth, n.Data, width, height, marginTop, marginBottom)
|
||||||
|
}
|
||||||
|
return width, height, marginTop, marginBottom
|
||||||
|
}
|
||||||
51
d2renderers/textmeasure/rect.go
Normal file
51
d2renderers/textmeasure/rect.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package textmeasure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rect struct {
|
||||||
|
tl *geo.Point
|
||||||
|
br *geo.Point
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRect() *rect {
|
||||||
|
return &rect{
|
||||||
|
tl: geo.NewPoint(0, 0),
|
||||||
|
br: geo.NewPoint(0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r rect) w() float64 {
|
||||||
|
return r.br.X - r.tl.X
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r rect) h() float64 {
|
||||||
|
return r.br.Y - r.tl.Y
|
||||||
|
}
|
||||||
|
|
||||||
|
// norm returns the Rect in normal form, such that Max is component-wise greater or equal than Min.
|
||||||
|
func (r rect) norm() *rect {
|
||||||
|
return &rect{
|
||||||
|
tl: geo.NewPoint(
|
||||||
|
math.Min(r.tl.X, r.br.X),
|
||||||
|
math.Min(r.tl.Y, r.br.Y),
|
||||||
|
),
|
||||||
|
br: geo.NewPoint(
|
||||||
|
math.Max(r.tl.X, r.br.X),
|
||||||
|
math.Max(r.tl.Y, r.br.Y),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r1 *rect) union(r2 *rect) *rect {
|
||||||
|
r := newRect()
|
||||||
|
r.tl.X = math.Min(r1.tl.X, r2.tl.X)
|
||||||
|
r.tl.Y = math.Min(r1.tl.Y, r2.tl.Y)
|
||||||
|
r.br.X = math.Max(r1.br.X, r2.br.X)
|
||||||
|
r.br.Y = math.Max(r1.br.Y, r2.br.Y)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
208
d2renderers/textmeasure/textmeasure.go
Normal file
208
d2renderers/textmeasure/textmeasure.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
// Ported from https://github.com/faiface/pixel/tree/master/text
|
||||||
|
// Trimmed down to essentials of measuring text
|
||||||
|
|
||||||
|
package textmeasure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ASCII is a set of all ASCII runes. These runes are codepoints from 32 to 127 inclusive.
|
||||||
|
var ASCII []rune
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
ASCII = make([]rune, unicode.MaxASCII-32)
|
||||||
|
for i := range ASCII {
|
||||||
|
ASCII[i] = rune(32 + i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruler allows for effiecient and convenient text drawing.
|
||||||
|
//
|
||||||
|
// To create a Ruler object, use the New constructor:
|
||||||
|
// txt := text.New(pixel.ZV, text.NewAtlas(face, text.ASCII))
|
||||||
|
//
|
||||||
|
// As suggested by the constructor, a Ruler object is always associated with one font face and a
|
||||||
|
// fixed set of runes. For example, the Ruler we created above can draw text using the font face
|
||||||
|
// contained in the face variable and is capable of drawing ASCII characters.
|
||||||
|
//
|
||||||
|
// Here we create a Ruler object which can draw ASCII and Katakana characters:
|
||||||
|
// txt := text.New(0, text.NewAtlas(face, text.ASCII, text.RangeTable(unicode.Katakana)))
|
||||||
|
//
|
||||||
|
// Similarly to IMDraw, Ruler functions as a buffer. It implements io.Writer interface, so writing
|
||||||
|
// text to it is really simple:
|
||||||
|
// fmt.Print(txt, "Hello, world!")
|
||||||
|
//
|
||||||
|
// Newlines, tabs and carriage returns are supported.
|
||||||
|
//
|
||||||
|
// Finally, if we want the written text to show up on some other Target, we can draw it:
|
||||||
|
// txt.Draw(target)
|
||||||
|
//
|
||||||
|
// Ruler exports two important fields: Orig and Dot. Dot is the position where the next character
|
||||||
|
// will be written. Dot is automatically moved when writing to a Ruler object, but you can also
|
||||||
|
// manipulate it manually. Orig specifies the text origin, usually the top-left dot position. Dot is
|
||||||
|
// always aligned to Orig when writing newlines. The Clear method resets the Dot to Orig.
|
||||||
|
type Ruler struct {
|
||||||
|
// Orig specifies the text origin, usually the top-left dot position. Dot is always aligned
|
||||||
|
// to Orig when writing newlines.
|
||||||
|
Orig *geo.Point
|
||||||
|
|
||||||
|
// Dot is the position where the next character will be written. Dot is automatically moved
|
||||||
|
// when writing to a Ruler object, but you can also manipulate it manually
|
||||||
|
Dot *geo.Point
|
||||||
|
|
||||||
|
// lineHeight is the vertical distance between two lines of text.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// txt.lineHeight = 1.5 * txt.atlas.lineHeight
|
||||||
|
LineHeightFactor float64
|
||||||
|
lineHeights map[d2fonts.Font]float64
|
||||||
|
|
||||||
|
// tabWidth is the horizontal tab width. Tab characters will align to the multiples of this
|
||||||
|
// width.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// txt.tabWidth = 8 * txt.atlas.glyph(' ').Advance
|
||||||
|
tabWidths map[d2fonts.Font]float64
|
||||||
|
|
||||||
|
atlases map[d2fonts.Font]*atlas
|
||||||
|
|
||||||
|
buf []byte
|
||||||
|
prevR rune
|
||||||
|
bounds *rect
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Ruler capable of drawing runes contained in the provided atlas. Orig and Dot
|
||||||
|
// will be initially set to orig.
|
||||||
|
//
|
||||||
|
// Here we create a Ruler capable of drawing ASCII characters using the Go Regular font.
|
||||||
|
// ttf, err := truetype.Parse(goregular.TTF)
|
||||||
|
// if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
// }
|
||||||
|
// face := truetype.NewFace(ttf, &truetype.Options{
|
||||||
|
// Size: 14,
|
||||||
|
// })
|
||||||
|
// txt := text.New(orig, text.NewAtlas(face, text.ASCII))
|
||||||
|
func NewRuler() (*Ruler, error) {
|
||||||
|
lineHeights := make(map[d2fonts.Font]float64)
|
||||||
|
tabWidths := make(map[d2fonts.Font]float64)
|
||||||
|
atlases := make(map[d2fonts.Font]*atlas)
|
||||||
|
|
||||||
|
for _, fontFamily := range d2fonts.FontFamilies {
|
||||||
|
for _, fontSize := range d2fonts.FontSizes {
|
||||||
|
for _, fontStyle := range d2fonts.FontStyles {
|
||||||
|
font := d2fonts.Font{
|
||||||
|
Family: fontFamily,
|
||||||
|
Style: fontStyle,
|
||||||
|
}
|
||||||
|
if _, ok := d2fonts.FontFaces[font]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ttf, err := truetype.Parse(d2fonts.FontFaces[font])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Added after, since FontFaces lookup is size-agnostic
|
||||||
|
font.Size = fontSize
|
||||||
|
face := truetype.NewFace(ttf, &truetype.Options{
|
||||||
|
Size: float64(fontSize),
|
||||||
|
})
|
||||||
|
atlas := NewAtlas(face, ASCII)
|
||||||
|
atlases[font] = atlas
|
||||||
|
lineHeights[font] = atlas.lineHeight
|
||||||
|
tabWidths[font] = atlas.glyph(' ').advance * 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
origin := geo.NewPoint(0, 0)
|
||||||
|
txt := &Ruler{
|
||||||
|
Orig: origin,
|
||||||
|
Dot: origin.Copy(),
|
||||||
|
LineHeightFactor: 1.,
|
||||||
|
lineHeights: lineHeights,
|
||||||
|
tabWidths: tabWidths,
|
||||||
|
atlases: atlases,
|
||||||
|
}
|
||||||
|
|
||||||
|
txt.clear()
|
||||||
|
|
||||||
|
return txt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Ruler) Measure(font d2fonts.Font, s string) (width, height int) {
|
||||||
|
w, h := t.MeasurePrecise(font, s)
|
||||||
|
return int(math.Ceil(w)), int(math.Ceil(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Ruler) MeasurePrecise(font d2fonts.Font, s string) (width, height float64) {
|
||||||
|
t.clear()
|
||||||
|
t.buf = append(t.buf, s...)
|
||||||
|
t.drawBuf(font)
|
||||||
|
b := t.bounds
|
||||||
|
return b.w(), b.h()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear removes all written text from the Ruler. The Dot field is reset to Orig.
|
||||||
|
func (txt *Ruler) clear() {
|
||||||
|
txt.prevR = -1
|
||||||
|
txt.bounds = newRect()
|
||||||
|
txt.Dot = txt.Orig.Copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// controlRune checks if r is a control rune (newline, tab, ...). If it is, a new dot position and
|
||||||
|
// true is returned. If r is not a control rune, the original dot and false is returned.
|
||||||
|
func (txt *Ruler) controlRune(r rune, dot *geo.Point, font d2fonts.Font) (newDot *geo.Point, control bool) {
|
||||||
|
switch r {
|
||||||
|
case '\n':
|
||||||
|
dot.X = txt.Orig.X
|
||||||
|
dot.Y -= txt.LineHeightFactor * txt.lineHeights[font]
|
||||||
|
case '\r':
|
||||||
|
dot.X = txt.Orig.X
|
||||||
|
case '\t':
|
||||||
|
rem := math.Mod(dot.X-txt.Orig.X, txt.tabWidths[font])
|
||||||
|
rem = math.Mod(rem, rem+txt.tabWidths[font])
|
||||||
|
if rem == 0 {
|
||||||
|
rem = txt.tabWidths[font]
|
||||||
|
}
|
||||||
|
dot.X += rem
|
||||||
|
default:
|
||||||
|
return dot, false
|
||||||
|
}
|
||||||
|
return dot, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (txt *Ruler) drawBuf(font d2fonts.Font) {
|
||||||
|
if !utf8.FullRune(txt.buf) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for utf8.FullRune(txt.buf) {
|
||||||
|
r, l := utf8.DecodeRune(txt.buf)
|
||||||
|
txt.buf = txt.buf[l:]
|
||||||
|
|
||||||
|
var control bool
|
||||||
|
txt.Dot, control = txt.controlRune(r, txt.Dot, font)
|
||||||
|
if control {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var bounds *rect
|
||||||
|
_, _, bounds, txt.Dot = txt.atlases[font].DrawRune(txt.prevR, r, txt.Dot)
|
||||||
|
|
||||||
|
txt.prevR = r
|
||||||
|
|
||||||
|
if txt.bounds.w()*txt.bounds.h() == 0 {
|
||||||
|
txt.bounds = bounds
|
||||||
|
} else {
|
||||||
|
txt.bounds = txt.bounds.union(bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
d2renderers/textmeasure/textmeasure_test.go
Normal file
115
d2renderers/textmeasure/textmeasure_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package textmeasure_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/textmeasure"
|
||||||
|
)
|
||||||
|
|
||||||
|
var txts = []string{
|
||||||
|
"Jesus is my POSTMASTER GENERAL ...",
|
||||||
|
"Don't let go of what you've got hold of, until you have hold of something else.",
|
||||||
|
"To get something clean, one has to get something dirty.",
|
||||||
|
"The notes blatted skyward as they rose over the Canada geese, feathered",
|
||||||
|
"There is no such thing as a problem without a gift for you in its hands.",
|
||||||
|
"Baseball is a skilled game. It's America's game - it, and high taxes.",
|
||||||
|
"He is truly wise who gains wisdom from another's mishap.",
|
||||||
|
"If you have never been hated by your child, you have never been a parent.",
|
||||||
|
"Your only obligation in any lifetime is to be true to yourself. Being",
|
||||||
|
"The computing field is always in need of new cliches.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextMeasure(t *testing.T) {
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a set of random strings, test each char increases width but not height
|
||||||
|
for _, txt := range txts {
|
||||||
|
txt = strings.ReplaceAll(txt, " ", "")
|
||||||
|
for i := 1; i < len(txt)-1; i++ {
|
||||||
|
w1, h1 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt[:i])
|
||||||
|
w2, h2 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt[:i+1])
|
||||||
|
assert.Equal(t, h1, h2)
|
||||||
|
assert.Less(t, w1, w2, fmt.Sprintf(`"%s" vs "%s"`, txt[:i], txt[:i+1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a set of random strings, test that adding newlines increases height each time
|
||||||
|
for _, txt := range txts {
|
||||||
|
whitespaces := strings.Count(txt, " ")
|
||||||
|
for i := 0; i < whitespaces-1; i++ {
|
||||||
|
txt1 := strings.Replace(txt, " ", "\n", i)
|
||||||
|
txt2 := strings.Replace(txt, " ", "\n", i+1)
|
||||||
|
|
||||||
|
w1, h1 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt1)
|
||||||
|
w2, h2 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FONT_SIZE_M, d2fonts.FONT_STYLE_REGULAR), txt2)
|
||||||
|
|
||||||
|
assert.Less(t, h1, h2)
|
||||||
|
assert.Less(t, w2, w1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFontMeasure(t *testing.T) {
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a set of random strings, test that font sizes are strictly increasing
|
||||||
|
for _, txt := range txts {
|
||||||
|
for i := 0; i < len(d2fonts.FontSizes)-1; i++ {
|
||||||
|
w1, h1 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FontSizes[i], d2fonts.FONT_STYLE_REGULAR), txt)
|
||||||
|
w2, h2 := ruler.Measure(d2fonts.SourceSansPro.Font(d2fonts.FontSizes[i+1], d2fonts.FONT_STYLE_REGULAR), txt)
|
||||||
|
assert.Less(t, h1, h2)
|
||||||
|
assert.Less(t, w1, w2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type dimensions struct {
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
var mdTexts = map[string]dimensions{
|
||||||
|
`
|
||||||
|
- [Overview](#overview) ok _this is all measured_
|
||||||
|
`: {245, 24},
|
||||||
|
`
|
||||||
|
_italics are all measured correctly_
|
||||||
|
`: {214, 24},
|
||||||
|
`
|
||||||
|
**bold is measured correctly**
|
||||||
|
`: {187, 24},
|
||||||
|
`
|
||||||
|
**Note:** This document
|
||||||
|
`: {141, 24},
|
||||||
|
`
|
||||||
|
**Note:**
|
||||||
|
`: {37, 24},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextMeasureMarkdown(t *testing.T) {
|
||||||
|
ruler, err := textmeasure.NewRuler()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for text, dims := range mdTexts {
|
||||||
|
width, height, err := textmeasure.MeasureMarkdown(text, ruler)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, dims.width, width, text)
|
||||||
|
assert.Equal(t, dims.height, height, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
44
d2target/class.go
Normal file
44
d2target/class.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package d2target
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Class struct {
|
||||||
|
Fields []ClassField `json:"fields"`
|
||||||
|
Methods []ClassMethod `json:"methods"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClassField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cf ClassField) Text() *MText {
|
||||||
|
return &MText{
|
||||||
|
Text: fmt.Sprintf("%s%s", cf.Name, cf.Type),
|
||||||
|
FontSize: d2fonts.FONT_SIZE_L,
|
||||||
|
IsBold: false,
|
||||||
|
IsItalic: false,
|
||||||
|
Shape: "class",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClassMethod struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Return string `json:"return"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm ClassMethod) Text() *MText {
|
||||||
|
return &MText{
|
||||||
|
Text: fmt.Sprintf("%s%s", cm.Name, cm.Return),
|
||||||
|
FontSize: d2fonts.FONT_SIZE_L,
|
||||||
|
IsBold: false,
|
||||||
|
IsItalic: false,
|
||||||
|
Shape: "class",
|
||||||
|
}
|
||||||
|
}
|
||||||
411
d2target/d2target.go
Normal file
411
d2target/d2target.go
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
package d2target
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2themes"
|
||||||
|
"oss.terrastruct.com/d2/lib/geo"
|
||||||
|
"oss.terrastruct.com/d2/lib/go2"
|
||||||
|
"oss.terrastruct.com/d2/lib/label"
|
||||||
|
"oss.terrastruct.com/d2/lib/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DEFAULT_ICON_SIZE = 32
|
||||||
|
MAX_ICON_SIZE = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type Diagram struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
|
||||||
|
Shapes []Shape `json:"shapes"`
|
||||||
|
Connections []Connection `json:"connections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (diagram Diagram) BoundingBox() (topLeft, bottomRight Point) {
|
||||||
|
x1 := int(math.MaxInt64)
|
||||||
|
y1 := int(math.MaxInt64)
|
||||||
|
x2 := int(-math.MaxInt64)
|
||||||
|
y2 := int(-math.MaxInt64)
|
||||||
|
|
||||||
|
for _, targetShape := range diagram.Shapes {
|
||||||
|
x1 = go2.Min(x1, targetShape.Pos.X)
|
||||||
|
y1 = go2.Min(y1, targetShape.Pos.Y)
|
||||||
|
x2 = go2.Max(x2, targetShape.Pos.X+targetShape.Width)
|
||||||
|
y2 = go2.Max(y2, targetShape.Pos.Y+targetShape.Height)
|
||||||
|
|
||||||
|
if targetShape.Label != "" {
|
||||||
|
labelPosition := label.Position(targetShape.LabelPosition)
|
||||||
|
if !labelPosition.IsOutside() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
shapeType := DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
|
||||||
|
s := shape.NewShape(shapeType,
|
||||||
|
geo.NewBox(
|
||||||
|
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
|
||||||
|
float64(targetShape.Width),
|
||||||
|
float64(targetShape.Height),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
labelTL := labelPosition.GetPointOnBox(s.GetBox(), label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
|
||||||
|
x1 = go2.Min(x1, int(labelTL.X))
|
||||||
|
y1 = go2.Min(y1, int(labelTL.Y))
|
||||||
|
x2 = go2.Max(x2, int(labelTL.X)+targetShape.LabelWidth)
|
||||||
|
y2 = go2.Max(y2, int(labelTL.Y)+targetShape.LabelHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, connection := range diagram.Connections {
|
||||||
|
for _, point := range connection.Route {
|
||||||
|
x1 = go2.Min(x1, int(math.Floor(point.X)))
|
||||||
|
y1 = go2.Min(y1, int(math.Floor(point.Y)))
|
||||||
|
x2 = go2.Max(x2, int(math.Ceil(point.X)))
|
||||||
|
y2 = go2.Max(y2, int(math.Ceil(point.Y)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if connection.Label != "" {
|
||||||
|
labelTL := connection.GetLabelTopLeft()
|
||||||
|
x1 = go2.Min(x1, int(labelTL.X))
|
||||||
|
y1 = go2.Min(y1, int(labelTL.Y))
|
||||||
|
x2 = go2.Max(x2, int(labelTL.X)+connection.LabelWidth)
|
||||||
|
y2 = go2.Max(y2, int(labelTL.Y)+connection.LabelHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Point{x1, y1}, Point{x2, y2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiagram() *Diagram {
|
||||||
|
return &Diagram{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shape struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
Pos Point `json:"pos"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
|
||||||
|
Opacity float64 `json:"opacity"`
|
||||||
|
StrokeDash float64 `json:"strokeDash"`
|
||||||
|
StrokeWidth int `json:"strokeWidth"`
|
||||||
|
|
||||||
|
BorderRadius int `json:"borderRadius"`
|
||||||
|
|
||||||
|
Fill string `json:"fill"`
|
||||||
|
Stroke string `json:"stroke"`
|
||||||
|
|
||||||
|
Shadow bool `json:"shadow"`
|
||||||
|
ThreeDee bool `json:"3d"`
|
||||||
|
Multiple bool `json:"multiple"`
|
||||||
|
|
||||||
|
Tooltip string `json:"tooltip"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Icon *url.URL `json:"icon"`
|
||||||
|
IconPosition string `json:"iconPosition"`
|
||||||
|
|
||||||
|
Class
|
||||||
|
SQLTable
|
||||||
|
|
||||||
|
Text
|
||||||
|
|
||||||
|
LabelPosition string `json:"labelPosition,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shape) SetType(t string) {
|
||||||
|
// Some types are synonyms of other types, but with hinting for autolayout
|
||||||
|
// They should only have one representation in the final export
|
||||||
|
if strings.EqualFold(t, ShapeCircle) {
|
||||||
|
t = ShapeOval
|
||||||
|
} else if strings.EqualFold(t, ShapeSquare) {
|
||||||
|
t = ShapeRectangle
|
||||||
|
}
|
||||||
|
s.Type = strings.ToLower(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Text struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
FontSize int `json:"fontSize"`
|
||||||
|
FontFamily string `json:"fontFamily"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
|
||||||
|
Italic bool `json:"italic"`
|
||||||
|
Bold bool `json:"bold"`
|
||||||
|
Underline bool `json:"underline"`
|
||||||
|
|
||||||
|
LabelWidth int `json:"labelWidth"`
|
||||||
|
LabelHeight int `json:"labelHeight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BaseShape() *Shape {
|
||||||
|
return &Shape{
|
||||||
|
Opacity: 1,
|
||||||
|
StrokeDash: 0,
|
||||||
|
StrokeWidth: 2,
|
||||||
|
Text: Text{
|
||||||
|
Bold: true,
|
||||||
|
FontFamily: "DEFAULT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
|
||||||
|
Src string `json:"src"`
|
||||||
|
SrcArrow Arrowhead `json:"srcArrow"`
|
||||||
|
SrcLabel string `json:"srcLabel"`
|
||||||
|
|
||||||
|
Dst string `json:"dst"`
|
||||||
|
DstArrow Arrowhead `json:"dstArrow"`
|
||||||
|
DstLabel string `json:"dstLabel"`
|
||||||
|
|
||||||
|
Opacity float64 `json:"opacity"`
|
||||||
|
StrokeDash float64 `json:"strokeDash"`
|
||||||
|
StrokeWidth int `json:"strokeWidth"`
|
||||||
|
Stroke string `json:"stroke"`
|
||||||
|
|
||||||
|
Text
|
||||||
|
LabelPosition string `json:"labelPosition"`
|
||||||
|
LabelPercentage float64 `json:"labelPercentage"`
|
||||||
|
|
||||||
|
Route []*geo.Point `json:"route"`
|
||||||
|
IsCurve bool `json:"isCurve,omitempty"`
|
||||||
|
|
||||||
|
Animated bool `json:"animated"`
|
||||||
|
Tooltip string `json:"tooltip"`
|
||||||
|
Icon *url.URL `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BaseConnection() *Connection {
|
||||||
|
return &Connection{
|
||||||
|
SrcArrow: NoArrowhead,
|
||||||
|
DstArrow: NoArrowhead,
|
||||||
|
Route: make([]*geo.Point, 0),
|
||||||
|
Opacity: 1,
|
||||||
|
StrokeDash: 0,
|
||||||
|
StrokeWidth: 2,
|
||||||
|
Text: Text{
|
||||||
|
Italic: true,
|
||||||
|
FontFamily: "DEFAULT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) GetLabelTopLeft() *geo.Point {
|
||||||
|
return label.Position(c.LabelPosition).GetPointOnRoute(
|
||||||
|
c.Route,
|
||||||
|
float64(c.StrokeWidth),
|
||||||
|
c.LabelPercentage,
|
||||||
|
float64(c.LabelWidth),
|
||||||
|
float64(c.LabelHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Arrowhead string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoArrowhead Arrowhead = "none"
|
||||||
|
ArrowArrowhead Arrowhead = "arrow"
|
||||||
|
TriangleArrowhead Arrowhead = "triangle"
|
||||||
|
DiamondArrowhead Arrowhead = "diamond"
|
||||||
|
FilledDiamondArrowhead Arrowhead = "filled-diamond"
|
||||||
|
|
||||||
|
// For fat arrows
|
||||||
|
LineArrowhead Arrowhead = "line"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Arrowheads = map[string]struct{}{
|
||||||
|
string(NoArrowhead): {},
|
||||||
|
string(ArrowArrowhead): {},
|
||||||
|
string(TriangleArrowhead): {},
|
||||||
|
string(DiamondArrowhead): {},
|
||||||
|
string(FilledDiamondArrowhead): {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToArrowhead(arrowheadType string, filled bool) Arrowhead {
|
||||||
|
switch arrowheadType {
|
||||||
|
case string(DiamondArrowhead):
|
||||||
|
if filled {
|
||||||
|
return FilledDiamondArrowhead
|
||||||
|
}
|
||||||
|
return DiamondArrowhead
|
||||||
|
case string(ArrowArrowhead):
|
||||||
|
return ArrowArrowhead
|
||||||
|
default:
|
||||||
|
return TriangleArrowhead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Point struct {
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoint(x, y int) Point {
|
||||||
|
return Point{X: x, Y: y}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ShapeRectangle = "rectangle"
|
||||||
|
ShapeSquare = "square"
|
||||||
|
ShapePage = "page"
|
||||||
|
ShapeParallelogram = "parallelogram"
|
||||||
|
ShapeDocument = "document"
|
||||||
|
ShapeCylinder = "cylinder"
|
||||||
|
ShapeQueue = "queue"
|
||||||
|
ShapePackage = "package"
|
||||||
|
ShapeStep = "step"
|
||||||
|
ShapeCallout = "callout"
|
||||||
|
ShapeStoredData = "stored_data"
|
||||||
|
ShapePerson = "person"
|
||||||
|
ShapeDiamond = "diamond"
|
||||||
|
ShapeOval = "oval"
|
||||||
|
ShapeCircle = "circle"
|
||||||
|
ShapeHexagon = "hexagon"
|
||||||
|
ShapeCloud = "cloud"
|
||||||
|
ShapeText = "text"
|
||||||
|
ShapeCode = "code"
|
||||||
|
ShapeClass = "class"
|
||||||
|
ShapeSQLTable = "sql_table"
|
||||||
|
ShapeImage = "image"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Shapes = []string{
|
||||||
|
ShapeRectangle,
|
||||||
|
ShapeSquare,
|
||||||
|
ShapePage,
|
||||||
|
ShapeParallelogram,
|
||||||
|
ShapeDocument,
|
||||||
|
ShapeCylinder,
|
||||||
|
ShapeQueue,
|
||||||
|
ShapePackage,
|
||||||
|
ShapeStep,
|
||||||
|
ShapeCallout,
|
||||||
|
ShapeStoredData,
|
||||||
|
ShapePerson,
|
||||||
|
ShapeDiamond,
|
||||||
|
ShapeOval,
|
||||||
|
ShapeCircle,
|
||||||
|
ShapeHexagon,
|
||||||
|
ShapeCloud,
|
||||||
|
ShapeText,
|
||||||
|
ShapeCode,
|
||||||
|
ShapeClass,
|
||||||
|
ShapeSQLTable,
|
||||||
|
ShapeImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsShape(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
// Default shape is rectangle.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, s2 := range Shapes {
|
||||||
|
if strings.EqualFold(s, s2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type MText struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
FontSize int `json:"fontSize"`
|
||||||
|
IsBold bool `json:"isBold"`
|
||||||
|
IsItalic bool `json:"isItalic"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Shape string `json:"shape"`
|
||||||
|
|
||||||
|
Dimensions TextDimensions `json:"dimensions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextDimensions struct {
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTextDimensions(w, h int) *TextDimensions {
|
||||||
|
return &TextDimensions{Width: w, Height: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (text MText) GetColor(theme *d2themes.Theme, isItalic bool) string {
|
||||||
|
if isItalic {
|
||||||
|
return theme.Colors.Neutrals.N2
|
||||||
|
}
|
||||||
|
return theme.Colors.Neutrals.N1
|
||||||
|
}
|
||||||
|
|
||||||
|
var DSL_SHAPE_TO_SHAPE_TYPE = map[string]string{
|
||||||
|
"": shape.SQUARE_TYPE,
|
||||||
|
ShapeRectangle: shape.SQUARE_TYPE,
|
||||||
|
ShapeSquare: shape.REAL_SQUARE_TYPE,
|
||||||
|
ShapePage: shape.PAGE_TYPE,
|
||||||
|
ShapeParallelogram: shape.PARALLELOGRAM_TYPE,
|
||||||
|
ShapeDocument: shape.DOCUMENT_TYPE,
|
||||||
|
ShapeCylinder: shape.CYLINDER_TYPE,
|
||||||
|
ShapeQueue: shape.QUEUE_TYPE,
|
||||||
|
ShapePackage: shape.PACKAGE_TYPE,
|
||||||
|
ShapeStep: shape.STEP_TYPE,
|
||||||
|
ShapeCallout: shape.CALLOUT_TYPE,
|
||||||
|
ShapeStoredData: shape.STORED_DATA_TYPE,
|
||||||
|
ShapePerson: shape.PERSON_TYPE,
|
||||||
|
ShapeDiamond: shape.DIAMOND_TYPE,
|
||||||
|
ShapeOval: shape.OVAL_TYPE,
|
||||||
|
ShapeCircle: shape.CIRCLE_TYPE,
|
||||||
|
ShapeHexagon: shape.HEXAGON_TYPE,
|
||||||
|
ShapeCloud: shape.CLOUD_TYPE,
|
||||||
|
ShapeText: shape.TEXT_TYPE,
|
||||||
|
ShapeCode: shape.CODE_TYPE,
|
||||||
|
ShapeClass: shape.CLASS_TYPE,
|
||||||
|
ShapeSQLTable: shape.TABLE_TYPE,
|
||||||
|
ShapeImage: shape.IMAGE_TYPE,
|
||||||
|
}
|
||||||
|
|
||||||
|
var SHAPE_TYPE_TO_DSL_SHAPE map[string]string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
SHAPE_TYPE_TO_DSL_SHAPE = make(map[string]string, len(DSL_SHAPE_TO_SHAPE_TYPE))
|
||||||
|
for k, v := range DSL_SHAPE_TO_SHAPE_TYPE {
|
||||||
|
SHAPE_TYPE_TO_DSL_SHAPE[v] = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shape) GetIconSize(box *geo.Box) int {
|
||||||
|
iconPosition := label.Position(s.IconPosition)
|
||||||
|
|
||||||
|
minDimension := int(math.Min(box.Width, box.Height))
|
||||||
|
halfMinDimension := int(math.Ceil(0.5 * float64(minDimension)))
|
||||||
|
|
||||||
|
var size int
|
||||||
|
|
||||||
|
if iconPosition == label.InsideMiddleCenter {
|
||||||
|
size = halfMinDimension
|
||||||
|
} else {
|
||||||
|
size = go2.IntMin(
|
||||||
|
minDimension,
|
||||||
|
go2.IntMax(DEFAULT_ICON_SIZE, halfMinDimension),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
size = go2.IntMin(size, MAX_ICON_SIZE)
|
||||||
|
|
||||||
|
if !iconPosition.IsOutside() {
|
||||||
|
size = go2.IntMin(size,
|
||||||
|
go2.IntMin(
|
||||||
|
go2.IntMax(int(box.Width)-2*label.PADDING, 0),
|
||||||
|
go2.IntMax(int(box.Height)-2*label.PADDING, 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return size
|
||||||
|
}
|
||||||
28
d2target/sqltable.go
Normal file
28
d2target/sqltable.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package d2target
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLTable struct {
|
||||||
|
Columns []SQLColumn `json:"columns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SQLColumn struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Constraint string `json:"constraint"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SQLColumn) Text() *MText {
|
||||||
|
return &MText{
|
||||||
|
Text: fmt.Sprintf("%s%s%s%s", c.Name, c.Type, c.Constraint, c.Reference),
|
||||||
|
FontSize: d2fonts.FONT_SIZE_L,
|
||||||
|
IsBold: false,
|
||||||
|
IsItalic: false,
|
||||||
|
Shape: "sql_table",
|
||||||
|
}
|
||||||
|
}
|
||||||
18
d2themes/README.md
Normal file
18
d2themes/README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# d2themes
|
||||||
|
|
||||||
|
`d2themes` defines themes for D2. You can add a new one in `./d2themescatalog`, give a
|
||||||
|
unique ID, and specify it in the CLI or library to see it.
|
||||||
|
|
||||||
|
# Color coding guide
|
||||||
|
|
||||||
|
<img src="../docs/assets/themes_coding.png" />
|
||||||
|
|
||||||
|
# Color coding example
|
||||||
|
|
||||||
|
<img src="../docs/assets/themes_coding_example.png" />
|
||||||
|
|
||||||
|
# Container gradients
|
||||||
|
|
||||||
|
To distinguish container nesting, objects get progressively lighter the more nested it is.
|
||||||
|
|
||||||
|
<img src="../docs/assets/themes_gradients.png" width="300px" />
|
||||||
60
d2themes/d2themes.go
Normal file
60
d2themes/d2themes.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// d2themes defines themes to make d2 diagrams pretty
|
||||||
|
// Color codes: darkest (N1) -> lightest (N7)
|
||||||
|
package d2themes
|
||||||
|
|
||||||
|
type Theme struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Colors ColorPalette `json:"colors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Neutral struct {
|
||||||
|
N1 string `json:"n1"`
|
||||||
|
N2 string `json:"n2"`
|
||||||
|
N3 string `json:"n3"`
|
||||||
|
N4 string `json:"n4"`
|
||||||
|
N5 string `json:"n5"`
|
||||||
|
N7 string `json:"n7"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorPalette struct {
|
||||||
|
// So far the palette only contains the colors used in d2 shapes, the full theme includes more colors
|
||||||
|
// https://www.figma.com/file/n79RbPiHFUTO4PPPdpDu7w/%5BKW%5D-GUI-features?node-id=2268%3A120792
|
||||||
|
|
||||||
|
Neutrals Neutral `json:"neutrals"`
|
||||||
|
|
||||||
|
// Base Colors: used for containers
|
||||||
|
B1 string `json:"b1"`
|
||||||
|
B2 string `json:"b2"`
|
||||||
|
B3 string `json:"b3"`
|
||||||
|
B4 string `json:"b4"`
|
||||||
|
B5 string `json:"b5"`
|
||||||
|
B6 string `json:"b6"`
|
||||||
|
|
||||||
|
// Alternative colors A
|
||||||
|
AA2 string `json:"aa2"`
|
||||||
|
AA4 string `json:"aa4"`
|
||||||
|
AA5 string `json:"aa5"`
|
||||||
|
|
||||||
|
// Alternative colors B
|
||||||
|
AB4 string `json:"ab4"`
|
||||||
|
AB5 string `json:"ab5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var CoolNeutral = Neutral{
|
||||||
|
N1: "#0A0F25",
|
||||||
|
N2: "#676C7E",
|
||||||
|
N3: "#9499AB",
|
||||||
|
N4: "#CFD2DD",
|
||||||
|
N5: "#F0F3F9",
|
||||||
|
N7: "#FFFFFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
var WarmNeutral = Neutral{
|
||||||
|
N1: "#170206",
|
||||||
|
N2: "#535152",
|
||||||
|
N3: "#787777",
|
||||||
|
N4: "#CCCACA",
|
||||||
|
N5: "#DFDCDC",
|
||||||
|
N7: "#FFFFFF",
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/aubergine.go
Normal file
25
d2themes/d2themescatalog/aubergine.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var Aubergine = d2themes.Theme{
|
||||||
|
ID: 7,
|
||||||
|
Name: "Aubergine",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#170034",
|
||||||
|
B2: "#7639C5",
|
||||||
|
B3: "#8F70D1",
|
||||||
|
B4: "#D0B9F5",
|
||||||
|
B5: "#E7DEFF",
|
||||||
|
B6: "#F4F0FF",
|
||||||
|
|
||||||
|
AA2: "#0F66B7",
|
||||||
|
AA4: "#87BFF3",
|
||||||
|
AA5: "#BCDDFB",
|
||||||
|
|
||||||
|
AB4: "#92E3E3",
|
||||||
|
AB5: "#D7F5F5",
|
||||||
|
},
|
||||||
|
}
|
||||||
41
d2themes/d2themescatalog/catalog.go
Normal file
41
d2themes/d2themescatalog/catalog.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oss.terrastruct.com/d2/d2themes"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Catalog = []d2themes.Theme{
|
||||||
|
NeutralDefault,
|
||||||
|
NeutralGrey,
|
||||||
|
FlagshipTerrastruct,
|
||||||
|
MixedBerryBlue,
|
||||||
|
CoolClassics,
|
||||||
|
GrapeSoda,
|
||||||
|
Aubergine,
|
||||||
|
ColorblindClear,
|
||||||
|
VanillaNitroCola,
|
||||||
|
OrangeCreamsicle,
|
||||||
|
ShirleyTemple,
|
||||||
|
EarthTones,
|
||||||
|
}
|
||||||
|
|
||||||
|
func Find(id int64) d2themes.Theme {
|
||||||
|
for _, theme := range Catalog {
|
||||||
|
if theme.ID == id {
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d2themes.Theme{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CLIString() string {
|
||||||
|
var s strings.Builder
|
||||||
|
for _, t := range Catalog {
|
||||||
|
s.WriteString(fmt.Sprintf("- %s: %d\n", t.Name, t.ID))
|
||||||
|
}
|
||||||
|
return s.String()
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/colorblind_clear.go
Normal file
25
d2themes/d2themescatalog/colorblind_clear.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var ColorblindClear = d2themes.Theme{
|
||||||
|
ID: 8,
|
||||||
|
Name: "Colorblind clear",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#010E31",
|
||||||
|
B2: "#173688",
|
||||||
|
B3: "#5679D4",
|
||||||
|
B4: "#84A1EC",
|
||||||
|
B5: "#C8D6F9",
|
||||||
|
B6: "#E5EDFF",
|
||||||
|
|
||||||
|
AA2: "#048E63",
|
||||||
|
AA4: "#A6E2D0",
|
||||||
|
AA5: "#CAF2E6",
|
||||||
|
|
||||||
|
AB4: "#FFDA90",
|
||||||
|
AB5: "#FFF0D1",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/cool_classics.go
Normal file
25
d2themes/d2themescatalog/cool_classics.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var CoolClassics = d2themes.Theme{
|
||||||
|
ID: 4,
|
||||||
|
Name: "Cool classics",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#000536",
|
||||||
|
B2: "#0F66B7",
|
||||||
|
B3: "#4393DD",
|
||||||
|
B4: "#87BFF3",
|
||||||
|
B5: "#BCDDFB",
|
||||||
|
B6: "#E5F3FF",
|
||||||
|
|
||||||
|
AA2: "#076F6F",
|
||||||
|
AA4: "#77DEDE",
|
||||||
|
AA5: "#C3F8F8",
|
||||||
|
|
||||||
|
AB4: "#C1A2F3",
|
||||||
|
AB5: "#DACEFB",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/default.go
Normal file
25
d2themes/d2themescatalog/default.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var NeutralDefault = d2themes.Theme{
|
||||||
|
ID: 0,
|
||||||
|
Name: "Neutral default",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#0D32B2",
|
||||||
|
B2: "#0D32B2",
|
||||||
|
B3: "#E3E9FD",
|
||||||
|
B4: "#E3E9FD",
|
||||||
|
B5: "#EDF0FD",
|
||||||
|
B6: "#F7F8FE",
|
||||||
|
|
||||||
|
AA2: "#4A6FF3",
|
||||||
|
AA4: "#EDF0FD",
|
||||||
|
AA5: "#F7F8FE",
|
||||||
|
|
||||||
|
AB4: "#EDF0FD",
|
||||||
|
AB5: "#F7F8FE",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/earth_tones.go
Normal file
25
d2themes/d2themescatalog/earth_tones.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var EarthTones = d2themes.Theme{
|
||||||
|
ID: 103,
|
||||||
|
Name: "Earth tones",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.WarmNeutral,
|
||||||
|
|
||||||
|
B1: "#1E1303",
|
||||||
|
B2: "#55452F",
|
||||||
|
B3: "#9A876C",
|
||||||
|
B4: "#C9B9A1",
|
||||||
|
B5: "#E9DBCA",
|
||||||
|
B6: "#FAF1E6",
|
||||||
|
|
||||||
|
AA2: "#D35F0A",
|
||||||
|
AA4: "#FABA8A",
|
||||||
|
AA5: "#FFE0C7",
|
||||||
|
|
||||||
|
AB4: "#FFE767",
|
||||||
|
AB5: "#FFF2AA",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/flagship_terrastruct.go
Normal file
25
d2themes/d2themescatalog/flagship_terrastruct.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var FlagshipTerrastruct = d2themes.Theme{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Flagship Terrastruct",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#000E3D",
|
||||||
|
B2: "#234CDA",
|
||||||
|
B3: "#6B8AFB",
|
||||||
|
B4: "#A6B8F8",
|
||||||
|
B5: "#D2DBFD",
|
||||||
|
B6: "#E7EAFF",
|
||||||
|
|
||||||
|
AA2: "#5829DC",
|
||||||
|
AA4: "#B4AEF8",
|
||||||
|
AA5: "#E4DBFF",
|
||||||
|
|
||||||
|
AB4: "#7FDBF8",
|
||||||
|
AB5: "#C3F0FF",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/grape_soda.go
Normal file
25
d2themes/d2themescatalog/grape_soda.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var GrapeSoda = d2themes.Theme{
|
||||||
|
ID: 6,
|
||||||
|
Name: "Grape soda",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#170034",
|
||||||
|
B2: "#7639C5",
|
||||||
|
B3: "#8F70D1",
|
||||||
|
B4: "#C1A2F3",
|
||||||
|
B5: "#DACEFB",
|
||||||
|
B6: "#F2EDFF",
|
||||||
|
|
||||||
|
AA2: "#0F66B7",
|
||||||
|
AA4: "#87BFF3",
|
||||||
|
AA5: "#BCDDFB",
|
||||||
|
|
||||||
|
AB4: "#EA99C6",
|
||||||
|
AB5: "#FFDAEF",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/mixed_berry_blue.go
Normal file
25
d2themes/d2themescatalog/mixed_berry_blue.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var MixedBerryBlue = d2themes.Theme{
|
||||||
|
ID: 5,
|
||||||
|
Name: "Mixed berry blue",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#000536",
|
||||||
|
B2: "#0F66B7",
|
||||||
|
B3: "#4393DD",
|
||||||
|
B4: "#87BFF3",
|
||||||
|
B5: "#BCDDFB",
|
||||||
|
B6: "#E5F3FF",
|
||||||
|
|
||||||
|
AA2: "#7639C5",
|
||||||
|
AA4: "#C1A2F3",
|
||||||
|
AA5: "#DACEFB",
|
||||||
|
|
||||||
|
AB4: "#EA99C6",
|
||||||
|
AB5: "#FFDEF1",
|
||||||
|
},
|
||||||
|
}
|
||||||
25
d2themes/d2themescatalog/neutral_grey.go
Normal file
25
d2themes/d2themescatalog/neutral_grey.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package d2themescatalog
|
||||||
|
|
||||||
|
import "oss.terrastruct.com/d2/d2themes"
|
||||||
|
|
||||||
|
var NeutralGrey = d2themes.Theme{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Neutral Grey",
|
||||||
|
Colors: d2themes.ColorPalette{
|
||||||
|
Neutrals: d2themes.CoolNeutral,
|
||||||
|
|
||||||
|
B1: "#0A0F25",
|
||||||
|
B2: "#676C7E",
|
||||||
|
B3: "#9499AB",
|
||||||
|
B4: "#CFD2DD",
|
||||||
|
B5: "#DEE1EB",
|
||||||
|
B6: "#EEF1F8",
|
||||||
|
|
||||||
|
AA2: "#676C7E",
|
||||||
|
AA4: "#CFD2DD",
|
||||||
|
AA5: "#DEE1EB",
|
||||||
|
|
||||||
|
AB4: "#CFD2DD",
|
||||||
|
AB5: "#DEE1EB",
|
||||||
|
},
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue