Add localized mod documentation with syntax highlighting
This commit is contained in:
1 parent
7ae31f180c
commit
55a3e9b157
24 files changed
+1255
-1
No files matched your search
@@ -0,0 +1,23 @@
|
|||||||
|
Copyright (c) 2018-2019, Alexandre Mutel
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification
|
||||||
|
, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
Binary file not shown.
@@ -0,0 +1,78 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: DejaVu fonts
|
||||||
|
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
||||||
|
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
||||||
|
Source: https://dejavu-fonts.github.io/
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||||
|
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||||
|
DejaVu changes are in public domain.
|
||||||
|
License: bitstream-vera
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of the fonts accompanying this license ("Fonts") and associated
|
||||||
|
documentation files (the "Font Software"), to reproduce and distribute the
|
||||||
|
Font Software, including without limitation the rights to use, copy, merge,
|
||||||
|
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||||
|
persons to whom the Font Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
.
|
||||||
|
The above copyright and trademark notices and this permission notice shall
|
||||||
|
be included in all copies of one or more of the Font Software typefaces.
|
||||||
|
.
|
||||||
|
The Font Software may be modified, altered, or added to, and in particular
|
||||||
|
the designs of glyphs or characters in the Fonts may be modified and
|
||||||
|
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||||
|
are renamed to names not containing either the words "Bitstream" or the word
|
||||||
|
"Vera".
|
||||||
|
.
|
||||||
|
This License becomes null and void to the extent applicable to Fonts or Font
|
||||||
|
Software that has been modified and is distributed under the "Bitstream
|
||||||
|
Vera" names.
|
||||||
|
.
|
||||||
|
The Font Software may be sold as part of a larger software package but no
|
||||||
|
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||||
|
.
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||||
|
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||||
|
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||||
|
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||||
|
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||||
|
FONT SOFTWARE.
|
||||||
|
.
|
||||||
|
Except as contained in this notice, the names of Gnome, the Gnome
|
||||||
|
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||||
|
otherwise to promote the sale, use or other dealings in this Font Software
|
||||||
|
without prior written authorization from the Gnome Foundation or Bitstream
|
||||||
|
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||||
|
org.
|
||||||
|
|
||||||
|
Files: debian/*
|
||||||
|
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
||||||
|
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
||||||
|
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
||||||
|
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
||||||
|
License: GPL-2+
|
||||||
|
This program is free software; you can redistribute it
|
||||||
|
and/or modify it under the terms of the GNU General Public
|
||||||
|
License as published by the Free Software Foundation; either
|
||||||
|
version 2 of the License, or (at your option) any later
|
||||||
|
version.
|
||||||
|
.
|
||||||
|
This program is distributed in the hope that it will be
|
||||||
|
useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. See the GNU General Public License for more
|
||||||
|
details.
|
||||||
|
.
|
||||||
|
You should have received a copy of the GNU General Public
|
||||||
|
License along with this package; if not, write to the Free
|
||||||
|
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
||||||
|
Boston, MA 02110-1301 USA
|
||||||
|
.
|
||||||
|
On Debian systems, the full text of the GNU General Public
|
||||||
|
License version 2 can be found in the file
|
||||||
|
/usr/share/common-licenses/GPL-2'.
|
||||||
Binary file not shown.
@@ -309,3 +309,13 @@ mod_manager/collection_used_by = Required by: {0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = Dependencies and group purpose
|
mod_manager/hint_collection_dependencies_title = Dependencies and group purpose
|
||||||
mod_manager/hint_collection_dependencies_body = Dependency → the mod that needs it. Card positions and group boundaries do not define load order. A group describes the mod’s purpose in this game; another game may assign it a different role.
|
mod_manager/hint_collection_dependencies_body = Dependency → the mod that needs it. Card positions and group boundaries do not define load order. A group describes the mod’s purpose in this game; another game may assign it a different role.
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = Documentation
|
||||||
|
mod_manager/docs_auto = Auto · interface language
|
||||||
|
mod_manager/docs_default = Default
|
||||||
|
mod_manager/docs_source = Installed version documentation {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = A different download version is selected. The guide below belongs to the installed mod.
|
||||||
|
mod_manager/docs_image_unavailable = Image unavailable
|
||||||
|
mod_manager/hint_docs_language_title = Documentation language
|
||||||
|
mod_manager/hint_docs_language_body = Auto follows the interface language. Missing translations fall back to default.md, English, then the first available document. You can select a language manually.
|
||||||
@@ -309,3 +309,13 @@ mod_manager/collection_used_by = Requerido por: {0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = Dependencias y función de los grupos
|
mod_manager/hint_collection_dependencies_title = Dependencias y función de los grupos
|
||||||
mod_manager/hint_collection_dependencies_body = Dependencia → mod que la necesita. La posición y los grupos no definen el orden de carga. El grupo describe la función del mod en este juego; otro juego puede asignarle otra función.
|
mod_manager/hint_collection_dependencies_body = Dependencia → mod que la necesita. La posición y los grupos no definen el orden de carga. El grupo describe la función del mod en este juego; otro juego puede asignarle otra función.
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = Documentación
|
||||||
|
mod_manager/docs_auto = Auto · idioma de la interfaz
|
||||||
|
mod_manager/docs_default = Predeterminado
|
||||||
|
mod_manager/docs_source = Documentación de la versión instalada {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = Se ha seleccionado otra versión para descargar. Esta guía corresponde al mod instalado.
|
||||||
|
mod_manager/docs_image_unavailable = Imagen no disponible
|
||||||
|
mod_manager/hint_docs_language_title = Idioma de la documentación
|
||||||
|
mod_manager/hint_docs_language_body = Auto usa el idioma de la interfaz. Si falta una traducción: default.md, inglés y luego el primer documento disponible. También puedes elegir el idioma.
|
||||||
@@ -309,3 +309,13 @@ mod_manager/collection_used_by = 必要とするMOD:{0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = 依存関係とグループの用途
|
mod_manager/hint_collection_dependencies_title = 依存関係とグループの用途
|
||||||
mod_manager/hint_collection_dependencies_body = 依存先 → それを必要とするMOD。カードの位置やグループの境界は読み込み順を決めません。 グループはこのゲームでのMODの用途です。他のゲームでは異なる役割になり得ます。
|
mod_manager/hint_collection_dependencies_body = 依存先 → それを必要とするMOD。カードの位置やグループの境界は読み込み順を決めません。 グループはこのゲームでのMODの用途です。他のゲームでは異なる役割になり得ます。
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = ドキュメント
|
||||||
|
mod_manager/docs_auto = 自動 · UIの言語
|
||||||
|
mod_manager/docs_default = 既定
|
||||||
|
mod_manager/docs_source = インストール済みバージョンの説明 {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = 別のダウンロードバージョンが選択されています。以下はインストール済みModのガイドです。
|
||||||
|
mod_manager/docs_image_unavailable = 画像を表示できません
|
||||||
|
mod_manager/hint_docs_language_title = ドキュメントの言語
|
||||||
|
mod_manager/hint_docs_language_body = 自動はUIの言語に従います。翻訳がない場合は default.md、英語、最初の文書の順に表示します。手動でも選択できます。
|
||||||
@@ -318,3 +318,13 @@ mod_manager/collection_used_by = 필요로 하는 모드: {0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = 의존성과 그룹 용도
|
mod_manager/hint_collection_dependencies_title = 의존성과 그룹 용도
|
||||||
mod_manager/hint_collection_dependencies_body = 의존 모드 → 이를 필요로 하는 모드. 카드 위치와 그룹 경계는 로드 순서를 정하지 않습니다. 그룹은 이 게임에서 모드의 용도를 설명합니다. 다른 게임에서는 역할이 다를 수 있습니다.
|
mod_manager/hint_collection_dependencies_body = 의존 모드 → 이를 필요로 하는 모드. 카드 위치와 그룹 경계는 로드 순서를 정하지 않습니다. 그룹은 이 게임에서 모드의 용도를 설명합니다. 다른 게임에서는 역할이 다를 수 있습니다.
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = 문서
|
||||||
|
mod_manager/docs_auto = 자동 · 인터페이스 언어
|
||||||
|
mod_manager/docs_default = 기본값
|
||||||
|
mod_manager/docs_source = 설치된 버전의 문서 {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = 다른 다운로드 버전이 선택되었습니다. 아래 안내는 설치된 모드에 해당합니다.
|
||||||
|
mod_manager/docs_image_unavailable = 이미지를 표시할 수 없음
|
||||||
|
mod_manager/hint_docs_language_title = 문서 언어
|
||||||
|
mod_manager/hint_docs_language_body = 자동은 인터페이스 언어를 따릅니다. 번역이 없으면 default.md, 영어, 첫 문서 순으로 표시합니다. 직접 언어를 선택할 수도 있습니다.
|
||||||
@@ -309,3 +309,13 @@ mod_manager/collection_used_by = Нужен для: {0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = Зависимости и назначение групп
|
mod_manager/hint_collection_dependencies_title = Зависимости и назначение групп
|
||||||
mod_manager/hint_collection_dependencies_body = Зависимость → мод, которому она нужна. Положение карточки и границы групп не задают порядок загрузки. Группа задаёт назначение мода в этой игре; в другой игре его роль может отличаться.
|
mod_manager/hint_collection_dependencies_body = Зависимость → мод, которому она нужна. Положение карточки и границы групп не задают порядок загрузки. Группа задаёт назначение мода в этой игре; в другой игре его роль может отличаться.
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = Документация
|
||||||
|
mod_manager/docs_auto = Авто · язык интерфейса
|
||||||
|
mod_manager/docs_default = По умолчанию
|
||||||
|
mod_manager/docs_source = Документация установленной версии {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = Выбрана другая версия для загрузки. Ниже показано руководство установленного мода.
|
||||||
|
mod_manager/docs_image_unavailable = Изображение недоступно
|
||||||
|
mod_manager/hint_docs_language_title = Язык документации
|
||||||
|
mod_manager/hint_docs_language_body = Авто использует язык интерфейса. Если перевода нет: default.md → английский → первый доступный документ. Можно выбрать язык вручную.
|
||||||
@@ -309,3 +309,13 @@ mod_manager/collection_used_by = Cần cho: {0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = Phụ thuộc và vai trò nhóm
|
mod_manager/hint_collection_dependencies_title = Phụ thuộc và vai trò nhóm
|
||||||
mod_manager/hint_collection_dependencies_body = Phụ thuộc → mod cần nó. Vị trí thẻ và ranh giới nhóm không quyết định thứ tự tải. Nhóm mô tả vai trò của mod trong game này; game khác có thể gán vai trò khác.
|
mod_manager/hint_collection_dependencies_body = Phụ thuộc → mod cần nó. Vị trí thẻ và ranh giới nhóm không quyết định thứ tự tải. Nhóm mô tả vai trò của mod trong game này; game khác có thể gán vai trò khác.
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = Tài liệu
|
||||||
|
mod_manager/docs_auto = Tự động · ngôn ngữ giao diện
|
||||||
|
mod_manager/docs_default = Mặc định
|
||||||
|
mod_manager/docs_source = Tài liệu phiên bản đã cài {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = Đang chọn phiên bản tải xuống khác. Hướng dẫn bên dưới dành cho mod đã cài.
|
||||||
|
mod_manager/docs_image_unavailable = Không có hình ảnh
|
||||||
|
mod_manager/hint_docs_language_title = Ngôn ngữ tài liệu
|
||||||
|
mod_manager/hint_docs_language_body = Tự động dùng ngôn ngữ giao diện. Nếu thiếu bản dịch: default.md, tiếng Anh, rồi tài liệu đầu tiên. Bạn cũng có thể chọn ngôn ngữ thủ công.
|
||||||
@@ -309,3 +309,13 @@ mod_manager/collection_used_by = 被以下模组依赖:{0}
|
|||||||
mod_manager/collection_other_role = {0} → {1}
|
mod_manager/collection_other_role = {0} → {1}
|
||||||
mod_manager/hint_collection_dependencies_title = 依赖关系与分组用途
|
mod_manager/hint_collection_dependencies_title = 依赖关系与分组用途
|
||||||
mod_manager/hint_collection_dependencies_body = 依赖项 → 使用它的模组。卡片位置和分组边界不决定加载顺序。 分组说明模组在本游戏中的用途;在其他游戏中,它可以有不同的作用。
|
mod_manager/hint_collection_dependencies_body = 依赖项 → 使用它的模组。卡片位置和分组边界不决定加载顺序。 分组说明模组在本游戏中的用途;在其他游戏中,它可以有不同的作用。
|
||||||
|
|
||||||
|
# Bundled mod documentation
|
||||||
|
mod_manager/documentation = 文档
|
||||||
|
mod_manager/docs_auto = 自动 · 界面语言
|
||||||
|
mod_manager/docs_default = 默认
|
||||||
|
mod_manager/docs_source = 已安装版本的文档 {0} · {1}
|
||||||
|
mod_manager/docs_installed_notice = 当前选择了其他下载版本。下方指南适用于已安装的模组。
|
||||||
|
mod_manager/docs_image_unavailable = 图片不可用
|
||||||
|
mod_manager/hint_docs_language_title = 文档语言
|
||||||
|
mod_manager/hint_docs_language_body = 自动使用界面语言。缺少翻译时依次使用 default.md、英语、首个可用文档。也可手动选择语言。
|
||||||
+3
-1
@@ -8,6 +8,7 @@
|
|||||||
"sources": [
|
"sources": [
|
||||||
"src/**/*.cs"
|
"src/**/*.cs"
|
||||||
],
|
],
|
||||||
|
"test_sources": ["tests/*.cs"],
|
||||||
"depends": ["ui_base>=1"],
|
"depends": ["ui_base>=1"],
|
||||||
"main_menu": [
|
"main_menu": [
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"test_suites": [
|
"test_suites": [
|
||||||
{ "id": "help", "activate": ["--itest=mod_help"] }
|
{ "id": "help", "activate": ["--itest=mod_help"] },
|
||||||
|
{ "id": "documentation", "activate": ["--itest=mod_documentation"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[gd_scene format=3]
|
[gd_scene format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="src/UI/ModDetailView.cs" id="1_detail"]
|
[ext_resource type="Script" path="src/UI/ModDetailView.cs" id="1_detail"]
|
||||||
|
[ext_resource type="Script" path="src/UI/ModMarkdownView.cs" id="2_markdown"]
|
||||||
|
|
||||||
[node name="ModDetailView" type="VBoxContainer"]
|
[node name="ModDetailView" type="VBoxContainer"]
|
||||||
theme_override_constants/separation = 8
|
theme_override_constants/separation = 8
|
||||||
@@ -124,6 +125,35 @@ theme_override_font_sizes/font_size = 12
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
autowrap_mode = 3
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="DocumentationBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="Scroll/Content/DocumentationBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/DocumentationBox/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="Languages" type="OptionButton" parent="Scroll/Content/DocumentationBox/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Source" type="Label" parent="Scroll/Content/DocumentationBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Body" type="RichTextLabel" parent="Scroll/Content/DocumentationBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
fit_content = true
|
||||||
|
scroll_active = false
|
||||||
|
selection_enabled = true
|
||||||
|
script = ExtResource("2_markdown")
|
||||||
|
|
||||||
[node name="ChangelogBox" type="VBoxContainer" parent="Scroll/Content"]
|
[node name="ChangelogBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 4
|
theme_override_constants/separation = 4
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Rebuild the bundled parser with: dotnet build source_code/MarkdownDependencies.csproj.
|
||||||
|
Its BSD license is shipped alongside dll/Markdig.dll. No kernel dependency is needed. -->
|
||||||
|
<PackageReference Include="Markdig" Version="0.37.0" GeneratePathProperty="true" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="CopyParser" AfterTargets="Build">
|
||||||
|
<Copy SourceFiles="$(PkgMarkdig)/lib/net6.0/Markdig.dll" DestinationFolder="../dll" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lexical colouring for fenced documentation examples. Follows the site's
|
||||||
|
/// explicit-language approach and custom scenario/filestree/jsonc rules,
|
||||||
|
/// returning literal text spans rather than HTML or BBCode. Unknown languages,
|
||||||
|
/// oversized examples and timeouts retain their complete uncoloured source.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModCodeSyntax
|
||||||
|
{
|
||||||
|
private const string Strings = "(?<string>\"\"\"[\\s\\S]*?\"\"\"|'''[\\s\\S]*?'''|(?:\\$@|@\\$|@)\"(?:\"\"|[^\"])*\"|\\$?\"(?:\\\\.|[^\"\\\\\\r\\n])*\"|'(?:\\\\.|[^'\\\\\\r\\n])*')";
|
||||||
|
private const string Tokens = @"(?<number>\b(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)[fFdDmM]?\b)|(?<variable>\$\{[^}\r\n]+\}|\$[A-Za-z_]\w*)|(?<name>[A-Za-z_]\w*)|(?<operator>[{}\[\](),:;=+*/<>!&|.\-?])";
|
||||||
|
private static readonly Regex CStyle = Create(@"(?<comment>//[^\r\n]*|/\*[\s\S]*?\*/)|" + Strings + "|" + Tokens);
|
||||||
|
private static readonly Regex Json = Create(Strings + "|" + Tokens);
|
||||||
|
private static readonly Regex HashStyle = Create(@"(?<comment>\#[^\r\n]*)|" + Strings + "|" + Tokens);
|
||||||
|
private static readonly Regex Scenario = Create(@"(?<meta>^---[ \t]*$)|(?<comment>\#[^\r\n]*|--[^\r\n]*)|" + Strings + @"|(?<label>:[A-Za-z_]\w*)|(?<operator>->)|" + Tokens);
|
||||||
|
private static readonly Regex Glsl = Create(@"(?<meta>^[ \t]*\#[^\r\n]*)|(?<comment>//[^\r\n]*|/\*[\s\S]*?\*/)|" + Strings + "|" + Tokens);
|
||||||
|
private static readonly Regex FileTree = Create(@"(?<treecomment>[#←][^\r\n]*)|(?<comment>[│├└][─ \t]*)|(?<directory>[\p{L}\p{N}_.{}-]+/|/)");
|
||||||
|
private static readonly Regex Properties = Create(@"(?<comment>^[ \t]*[#!;][^\r\n]*)|(?<meta>^[ \t]*\[[^\]\r\n]+\])|(?<attr>^[ \t]*[^=\r\n]+(?==))|(?<string>(?<==)[^\r\n]*)");
|
||||||
|
private static readonly Dictionary<string, HashSet<string>> Keywords = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["csharp"] = Words("abstract as async await base bool break byte case catch char checked class const continue decimal default delegate do double dynamic else enum event explicit extern false finally fixed float for foreach get global goto if implicit in init int interface internal is lock long namespace new null object operator out override params partial private protected public readonly record ref required return sbyte sealed set short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unmanaged unsafe ushort using value var virtual void volatile when where while with yield"),
|
||||||
|
["gdscript"] = Words("and as assert await break breakpoint class class_name const continue elif else enum extends false for func if in is match not null or pass preload return self signal static super true var void while yield"),
|
||||||
|
["scenario"] = Words("scene priority condition is_one_run save_allowed light_use_time background foreground background_color background_show_hide text p_text title select option switch_scene end action music music_play music_pause sound light_set delay effect animation interactive_add interactive_remove interactive_clear interactive_set show_dialog hide_dialog set flag empty goto"),
|
||||||
|
["glsl"] = Words("attribute bool break bvec2 bvec3 bvec4 case centroid coherent const continue default discard do double else flat float for highp if in inout int invariant ivec2 ivec3 ivec4 layout lowp mat2 mat3 mat4 mediump out precision readonly restrict return sampler2D samplerCube smooth struct switch uniform uint uvec2 uvec3 uvec4 varying vec2 vec3 vec4 void volatile while writeonly shader_type render_mode spatial canvas_item particles sky fog"),
|
||||||
|
["bash"] = Words("alias case cd do done echo elif else esac exit export fi for function if in local printf read readonly return set source then unset until while"),
|
||||||
|
["python"] = Words("and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Reads the first fence-info word and resolves the aliases used in mod documentation.</summary>
|
||||||
|
public static string Language(string info)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(info))
|
||||||
|
return "";
|
||||||
|
var tag = info.Trim();
|
||||||
|
var end = tag.IndexOfAny(new[] { ' ', '\t', '\r', '\n' });
|
||||||
|
if (end >= 0)
|
||||||
|
tag = tag.Substring(0, end);
|
||||||
|
return tag.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"cs" or "c#" => "csharp",
|
||||||
|
"gd" => "gdscript",
|
||||||
|
"sh" or "shell" => "bash",
|
||||||
|
"py" => "python",
|
||||||
|
"yml" => "yaml",
|
||||||
|
"cfg" or "toml" => "ini",
|
||||||
|
"tscn" or "tres" => "ini",
|
||||||
|
"gdshader" or "shader" => "glsl",
|
||||||
|
var language => language,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns spans whose concatenation is exactly source, including newlines,
|
||||||
|
/// tabs and trailing spaces. Kind names follow the site's syntax palette.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<(string Text, string Kind)> Tokenize(string info, string source)
|
||||||
|
{
|
||||||
|
source ??= "";
|
||||||
|
var language = Language(info);
|
||||||
|
var grammar = Grammar(language);
|
||||||
|
if (grammar == null || source.Length > 65536)
|
||||||
|
return Plain(source);
|
||||||
|
var result = new List<(string Text, string Kind)>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
foreach (Match match in grammar.Matches(source))
|
||||||
|
{
|
||||||
|
if (match.Index > offset)
|
||||||
|
result.Add((source.Substring(offset, match.Index - offset), ""));
|
||||||
|
var kind = Kind(match, source, language);
|
||||||
|
result.Add((match.Value, kind));
|
||||||
|
offset = match.Index + match.Length;
|
||||||
|
if (result.Count > 10000)
|
||||||
|
return Plain(source);
|
||||||
|
}
|
||||||
|
if (offset < source.Length)
|
||||||
|
result.Add((source.Substring(offset), ""));
|
||||||
|
}
|
||||||
|
catch (RegexMatchTimeoutException)
|
||||||
|
{
|
||||||
|
return Plain(source);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<(string Text, string Kind)> Plain(string source)
|
||||||
|
=> new[] { (source, "") };
|
||||||
|
|
||||||
|
private static Regex Create(string pattern)
|
||||||
|
=> new Regex(pattern, RegexOptions.Multiline | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
private static HashSet<string> Words(string words)
|
||||||
|
=> new HashSet<string>(words.Split(' '), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static Regex Grammar(string language) => language switch
|
||||||
|
{
|
||||||
|
"json" => Json,
|
||||||
|
"jsonc" or "csharp" => CStyle,
|
||||||
|
"scenario" => Scenario,
|
||||||
|
"glsl" => Glsl,
|
||||||
|
"gdscript" or "bash" or "python" or "yaml" => HashStyle,
|
||||||
|
"filestree" => FileTree,
|
||||||
|
"ini" or "properties" => Properties,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Kind(Match token, string source, string language)
|
||||||
|
{
|
||||||
|
foreach (var kind in new[] { "comment", "meta", "label", "directory", "treecomment", "attr" })
|
||||||
|
if (token.Groups[kind].Success)
|
||||||
|
return kind;
|
||||||
|
var next = token.Index + token.Length;
|
||||||
|
while (next < source.Length && (source[next] == ' ' || source[next] == '\t'))
|
||||||
|
next++;
|
||||||
|
var following = next < source.Length ? source[next] : '\0';
|
||||||
|
if (token.Groups["string"].Success)
|
||||||
|
return (language == "json" || language == "jsonc" || language == "yaml") && following == ':' ? "attr" : "string";
|
||||||
|
if (token.Groups["number"].Success)
|
||||||
|
return "number";
|
||||||
|
if (token.Groups["operator"].Success)
|
||||||
|
return "operator";
|
||||||
|
if (token.Groups["variable"].Success)
|
||||||
|
return language == "bash" ? "variable" : "";
|
||||||
|
var word = token.Value;
|
||||||
|
if (word == "true" || word == "false" || word == "null" || word == "True" || word == "False" || word == "None")
|
||||||
|
return "literal";
|
||||||
|
if (language == "scenario" && following == '=' || language == "yaml" && following == ':')
|
||||||
|
return "attr";
|
||||||
|
if (Keywords.TryGetValue(language, out var keywords) && keywords.Contains(word))
|
||||||
|
return "keyword";
|
||||||
|
if (language == "glsl" && word.StartsWith("gl_", StringComparison.Ordinal))
|
||||||
|
return "built_in";
|
||||||
|
if (language == "csharp" || language == "gdscript" || language == "python" || language == "glsl")
|
||||||
|
{
|
||||||
|
if (following == '(')
|
||||||
|
return "function";
|
||||||
|
if (char.IsUpper(word[0]))
|
||||||
|
return "type";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>A readable, nonempty UTF-8 document bundled with an installed mod.</summary>
|
||||||
|
public sealed class ModDocument
|
||||||
|
{
|
||||||
|
public string Language { get; }
|
||||||
|
public string Path { get; }
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public ModDocument(string language, string path, string text)
|
||||||
|
{
|
||||||
|
Language = language;
|
||||||
|
Path = path;
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Cthangover.Core.Localization;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads documentation independently of mod activation and storage format.
|
||||||
|
/// Only language-named files and default.md directly under docs participate
|
||||||
|
/// in fallback; README/CHANGELOG and nested assets never become translations.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModDocumentation
|
||||||
|
{
|
||||||
|
/// <summary>Finds valid documents in stable path order; broken files do not hide other translations.</summary>
|
||||||
|
public static IReadOnlyList<ModDocument> Discover(IModFileProvider provider)
|
||||||
|
{
|
||||||
|
var result = new List<ModDocument>();
|
||||||
|
if (provider == null)
|
||||||
|
return result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var path in provider.ListFiles("docs").Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (!path.EndsWith(".md", StringComparison.Ordinal) || path.EndsWith("/"))
|
||||||
|
continue;
|
||||||
|
var language = System.IO.Path.GetFileNameWithoutExtension(path);
|
||||||
|
if (!IsLanguage(language))
|
||||||
|
continue;
|
||||||
|
var bytes = ReadResource(provider, path, 512 * 1024);
|
||||||
|
if (bytes == null)
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = new UTF8Encoding(false, true).GetString(bytes).TrimStart('\uFEFF');
|
||||||
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
|
result.Add(new ModDocument(language, path, text));
|
||||||
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MODS", $"Documentation '{provider.Mod}/{path}' is not UTF-8", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
if (result.Count >= 32)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is InvalidDataException || ex is ObjectDisposedException)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MODS", $"Cannot list documentation for '{provider.Mod}': {ex.Message}", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Requested tag, primary language, default.md, English, then stable first document.</summary>
|
||||||
|
public static ModDocument Select(IReadOnlyList<ModDocument> documents, string language)
|
||||||
|
{
|
||||||
|
if (documents == null)
|
||||||
|
return null;
|
||||||
|
var tag = language?.Replace('_', '-');
|
||||||
|
foreach (var candidate in new[] { tag, LanguageCode.Primary(tag), "default", "en" })
|
||||||
|
foreach (var document in documents)
|
||||||
|
if (string.Equals(document.Language, candidate, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return document;
|
||||||
|
return documents.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves local media relative to a document, rejecting schemes, absolute paths and escapes from docs.</summary>
|
||||||
|
public static string ResolveResource(string documentPath, string reference)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/') || reference.StartsWith('\\')
|
||||||
|
|| reference.Contains(':') || reference.Contains('?') || reference.Contains('#'))
|
||||||
|
return null;
|
||||||
|
var path = ModPath.ResolveRelative(reference, documentPath);
|
||||||
|
return path.StartsWith("docs/", StringComparison.Ordinal) ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded stream reads work for compressed entries too. Folder symlinks
|
||||||
|
/// are rejected along the resource path before delegating to the provider.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] ReadResource(IModFileProvider provider, string path, int limit)
|
||||||
|
{
|
||||||
|
if (provider == null || string.IsNullOrEmpty(path) || !path.StartsWith("docs/", StringComparison.Ordinal) || ModPath.Normalize(path) != path)
|
||||||
|
return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (provider.RootPath != null && Directory.Exists(provider.RootPath))
|
||||||
|
{
|
||||||
|
var current = provider.RootPath;
|
||||||
|
foreach (var segment in path.Split('/'))
|
||||||
|
{
|
||||||
|
current = System.IO.Path.Combine(current, segment);
|
||||||
|
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
using var stream = provider.OpenStream(path);
|
||||||
|
if (stream == null)
|
||||||
|
return null;
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
var buffer = new byte[16384];
|
||||||
|
int read;
|
||||||
|
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||||
|
{
|
||||||
|
if (output.Length + read > limit)
|
||||||
|
return null;
|
||||||
|
output.Write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is InvalidDataException || ex is ObjectDisposedException)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MODS", $"Cannot read documentation resource '{provider.Mod}/{path}': {ex.Message}", LogLevel.Warning);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLanguage(string code)
|
||||||
|
{
|
||||||
|
if (code == "default")
|
||||||
|
return true;
|
||||||
|
var parts = code.Split('-');
|
||||||
|
if (parts[0].Length < 2 || parts[0].Length > 3 || parts.Length > 3)
|
||||||
|
return false;
|
||||||
|
foreach (var c in parts[0])
|
||||||
|
if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z'))
|
||||||
|
return false;
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (part.Length == 0 || part.Length > 8)
|
||||||
|
return false;
|
||||||
|
foreach (var c in part)
|
||||||
|
if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9'))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Cthangover.Core.UI.Theming;
|
using Cthangover.Core.UI.Theming;
|
||||||
using Godot;
|
using Godot;
|
||||||
|
|
||||||
@@ -38,6 +39,12 @@ namespace Mods.ModManager
|
|||||||
private Label _usedByText;
|
private Label _usedByText;
|
||||||
private VBoxContainer _conflictsBox;
|
private VBoxContainer _conflictsBox;
|
||||||
private Label _conflictsText;
|
private Label _conflictsText;
|
||||||
|
private VBoxContainer _documentationBox;
|
||||||
|
private OptionButton _documentationLanguages;
|
||||||
|
private Label _documentationSource;
|
||||||
|
private ModMarkdownView _documentationBody;
|
||||||
|
private IReadOnlyList<ModDocument> _documents = Array.Empty<ModDocument>();
|
||||||
|
private string _documentationLanguage;
|
||||||
|
|
||||||
/// <summary>Raised when the primary contextual action is pressed.</summary>
|
/// <summary>Raised when the primary contextual action is pressed.</summary>
|
||||||
public event Action<ModCardData> ActionPressed;
|
public event Action<ModCardData> ActionPressed;
|
||||||
@@ -57,10 +64,19 @@ namespace Mods.ModManager
|
|||||||
/// <summary>Displays the given mod; call again with refreshed data after actions.</summary>
|
/// <summary>Displays the given mod; call again with refreshed data after actions.</summary>
|
||||||
public void Open(ModCardData data)
|
public void Open(ModCardData data)
|
||||||
{
|
{
|
||||||
|
if (_data?.MatchKey != data?.MatchKey)
|
||||||
|
_documentationLanguage = null;
|
||||||
_data = data;
|
_data = data;
|
||||||
|
_documents = ModDocumentation.Discover(data?.LocalInfo?.FileProvider);
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override void _Notification(int what)
|
||||||
|
{
|
||||||
|
if (what == NotificationTranslationChanged && _nodesReady)
|
||||||
|
BuildDocumentation();
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureNodes()
|
private void EnsureNodes()
|
||||||
{
|
{
|
||||||
if (_nodesReady)
|
if (_nodesReady)
|
||||||
@@ -85,6 +101,14 @@ namespace Mods.ModManager
|
|||||||
_usedByText = GetNode<Label>("Scroll/Content/UsedByBox/Text");
|
_usedByText = GetNode<Label>("Scroll/Content/UsedByBox/Text");
|
||||||
_conflictsBox = GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");
|
_conflictsBox = GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");
|
||||||
_conflictsText = GetNode<Label>("Scroll/Content/ConflictsBox/Text");
|
_conflictsText = GetNode<Label>("Scroll/Content/ConflictsBox/Text");
|
||||||
|
_documentationBox = GetNode<VBoxContainer>("Scroll/Content/DocumentationBox");
|
||||||
|
_documentationLanguages = GetNode<OptionButton>("Scroll/Content/DocumentationBox/Header/Languages");
|
||||||
|
_documentationSource = GetNode<Label>("Scroll/Content/DocumentationBox/Source");
|
||||||
|
_documentationBody = GetNode<ModMarkdownView>("Scroll/Content/DocumentationBox/Body");
|
||||||
|
_documentationLanguages.ItemSelected += OnDocumentationLanguageSelected;
|
||||||
|
_documentationLanguages.TooltipText = TranslationServer.Translate("mod_manager/hint_docs_language_body");
|
||||||
|
ModHelpPanel.Attach(_documentationLanguages, "docs_language");
|
||||||
|
_documentationSource.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
|
||||||
ModHelpPanel.Attach(_versions, "mod_version");
|
ModHelpPanel.Attach(_versions, "mod_version");
|
||||||
|
|
||||||
@@ -143,6 +167,7 @@ namespace Mods.ModManager
|
|||||||
BuildConflictBox();
|
BuildConflictBox();
|
||||||
|
|
||||||
_descriptionText.Text = NullIfEmpty(_data.Description) ?? "—";
|
_descriptionText.Text = NullIfEmpty(_data.Description) ?? "—";
|
||||||
|
BuildDocumentation();
|
||||||
_changelogText.Text = NullIfEmpty(_data.SelectedDownload?.Changelog) ?? "—";
|
_changelogText.Text = NullIfEmpty(_data.SelectedDownload?.Changelog) ?? "—";
|
||||||
SetOptional(_dependsBox, _dependsText, JoinOrEmpty(ModQueries.GetDependencies(_data)));
|
SetOptional(_dependsBox, _dependsText, JoinOrEmpty(ModQueries.GetDependencies(_data)));
|
||||||
SetOptional(_usedByBox, _usedByText, JoinOrEmpty(ModQueries.GetDependents(_data.Id)));
|
SetOptional(_usedByBox, _usedByText, JoinOrEmpty(ModQueries.GetDependents(_data.Id)));
|
||||||
@@ -151,6 +176,44 @@ namespace Mods.ModManager
|
|||||||
LoadCover();
|
LoadCover();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BuildDocumentation()
|
||||||
|
{
|
||||||
|
_documentationBox.Visible = _documents.Count > 0;
|
||||||
|
_documentationBody.Display(null, null);
|
||||||
|
if (_documents.Count == 0 || _data == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SetCaption("Scroll/Content/DocumentationBox/Header/Caption", "mod_manager/documentation");
|
||||||
|
_documentationLanguages.TooltipText = TranslationServer.Translate("mod_manager/hint_docs_language_body");
|
||||||
|
_documentationLanguages.Clear();
|
||||||
|
_documentationLanguages.AddItem(TranslationServer.Translate("mod_manager/docs_auto"));
|
||||||
|
var selected = 0;
|
||||||
|
for (var i = 0; i < _documents.Count; i++)
|
||||||
|
{
|
||||||
|
var language = _documents[i].Language;
|
||||||
|
_documentationLanguages.AddItem(language == "default"
|
||||||
|
? TranslationServer.Translate("mod_manager/docs_default").ToString() : language.ToUpperInvariant());
|
||||||
|
if (language == _documentationLanguage)
|
||||||
|
selected = i + 1;
|
||||||
|
}
|
||||||
|
_documentationLanguages.Selected = selected;
|
||||||
|
var requested = selected == 0 ? TranslationServer.GetLocale() : _documentationLanguage;
|
||||||
|
var document = ModDocumentation.Select(_documents, requested);
|
||||||
|
_documentationSource.Text = string.Format(TranslationServer.Translate("mod_manager/docs_source"),
|
||||||
|
_data.LocalVersion ?? "—", document.Language.ToUpperInvariant());
|
||||||
|
if (!string.IsNullOrEmpty(_data.SelectedDownload?.Version) && _data.SelectedDownload.Version != _data.LocalVersion)
|
||||||
|
_documentationSource.Text += "\n" + TranslationServer.Translate("mod_manager/docs_installed_notice");
|
||||||
|
_documentationBody.Display(_data.LocalInfo?.FileProvider, document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDocumentationLanguageSelected(long index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index > _documents.Count)
|
||||||
|
return;
|
||||||
|
_documentationLanguage = index == 0 ? null : _documents[(int)index - 1].Language;
|
||||||
|
BuildDocumentation();
|
||||||
|
}
|
||||||
|
|
||||||
private void PopulateVersions()
|
private void PopulateVersions()
|
||||||
{
|
{
|
||||||
_versions.Clear();
|
_versions.Clear();
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
using Markdig;
|
||||||
|
using Markdig.Syntax;
|
||||||
|
using Markdig.Syntax.Inlines;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// CommonMark rendered through Godot's rich-text API, never as HTML or
|
||||||
|
/// interpolated BBCode. Images come from the same provider as the document;
|
||||||
|
/// percentage widths keep them inside the page when the window is resized.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModMarkdownView : RichTextLabel
|
||||||
|
{
|
||||||
|
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
|
||||||
|
private readonly Dictionary<string, ImageTexture> textures = new(StringComparer.Ordinal);
|
||||||
|
private IModFileProvider provider;
|
||||||
|
private ModDocument document;
|
||||||
|
private bool connected;
|
||||||
|
private FontFile codeFont;
|
||||||
|
|
||||||
|
/// <summary>Replaces the document and releases images owned by the previous page.</summary>
|
||||||
|
public void Display(IModFileProvider source, ModDocument value)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
ReleaseTextures();
|
||||||
|
provider = source;
|
||||||
|
document = value;
|
||||||
|
if (!connected)
|
||||||
|
{
|
||||||
|
MetaClicked += OnLinkClicked;
|
||||||
|
connected = true;
|
||||||
|
AddThemeColorOverride("default_color", UiTokens.Body);
|
||||||
|
AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBody);
|
||||||
|
}
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
EnsureCodeFont();
|
||||||
|
RenderBlocks(Markdown.Parse(value.Text, Pipeline), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _ExitTree()
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
ReleaseTextures();
|
||||||
|
provider = null;
|
||||||
|
document = null;
|
||||||
|
ReleaseCodeFont();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Notification(int what)
|
||||||
|
{
|
||||||
|
// Detached preview/test controls may be freed without entering a tree.
|
||||||
|
if (what == NotificationPredelete)
|
||||||
|
ReleaseCodeFont();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureCodeFont()
|
||||||
|
{
|
||||||
|
if (codeFont != null || !ModRegistry.Instance.AllDiscoveredMods.TryGetValue("mod_manager", out var owner))
|
||||||
|
return;
|
||||||
|
var bytes = owner.FileProvider?.ReadFileBinary("fonts/DejaVuSansMono.ttf");
|
||||||
|
if (bytes == null)
|
||||||
|
return;
|
||||||
|
// Read bytes through the provider so the font also works in ZIP mods,
|
||||||
|
// without Godot import files or a system-installed monospace font.
|
||||||
|
codeFont = new FontFile { Data = bytes };
|
||||||
|
AddThemeFontOverride("mono_font", codeFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseCodeFont()
|
||||||
|
{
|
||||||
|
if (codeFont == null)
|
||||||
|
return;
|
||||||
|
RemoveThemeFontOverride("mono_font");
|
||||||
|
codeFont.Dispose();
|
||||||
|
codeFont = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseTextures()
|
||||||
|
{
|
||||||
|
foreach (var texture in textures.Values)
|
||||||
|
texture.Dispose();
|
||||||
|
textures.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderBlocks(ContainerBlock blocks, int depth)
|
||||||
|
{
|
||||||
|
if (depth > 32)
|
||||||
|
return;
|
||||||
|
foreach (var block in blocks)
|
||||||
|
{
|
||||||
|
switch (block)
|
||||||
|
{
|
||||||
|
case HeadingBlock heading:
|
||||||
|
PushFontSize(Math.Max(UiTokens.FontBody + 2, 30 - heading.Level * 2));
|
||||||
|
PushBold();
|
||||||
|
RenderInlines(heading.Inline, depth + 1);
|
||||||
|
Pop();
|
||||||
|
Pop();
|
||||||
|
break;
|
||||||
|
case ParagraphBlock paragraph:
|
||||||
|
RenderInlines(paragraph.Inline, depth + 1);
|
||||||
|
break;
|
||||||
|
case CodeBlock code:
|
||||||
|
PushMono();
|
||||||
|
PushBgcolor(UiTokens.Surface0);
|
||||||
|
foreach (var span in ModCodeSyntax.Tokenize(code is FencedCodeBlock fence ? fence.Info : null, code.Lines.ToString()))
|
||||||
|
{
|
||||||
|
if (span.Kind.Length > 0)
|
||||||
|
PushColor(CodeColor(span.Kind));
|
||||||
|
AddText(span.Text);
|
||||||
|
if (span.Kind.Length > 0)
|
||||||
|
Pop();
|
||||||
|
}
|
||||||
|
Pop();
|
||||||
|
Pop();
|
||||||
|
break;
|
||||||
|
case ListBlock list:
|
||||||
|
var number = int.TryParse(list.OrderedStart, out var start) ? start : 1;
|
||||||
|
foreach (var item in list)
|
||||||
|
{
|
||||||
|
PushIndent(depth + 1);
|
||||||
|
AddText(list.IsOrdered ? $"{number++}. " : "• ");
|
||||||
|
RenderBlocks((ContainerBlock)item, depth + 1);
|
||||||
|
Pop();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case QuoteBlock quote:
|
||||||
|
PushIndent(1);
|
||||||
|
PushColor(UiTokens.Muted);
|
||||||
|
RenderBlocks(quote, depth + 1);
|
||||||
|
Pop();
|
||||||
|
Pop();
|
||||||
|
break;
|
||||||
|
case ThematicBreakBlock:
|
||||||
|
AddText("────────────────────────");
|
||||||
|
break;
|
||||||
|
case ContainerBlock container:
|
||||||
|
RenderBlocks(container, depth + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
AddText("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderInlines(ContainerInline container, int depth)
|
||||||
|
{
|
||||||
|
if (container == null || depth > 32)
|
||||||
|
return;
|
||||||
|
foreach (var inline in container)
|
||||||
|
{
|
||||||
|
switch (inline)
|
||||||
|
{
|
||||||
|
case LiteralInline literal:
|
||||||
|
AddText(literal.Content.ToString());
|
||||||
|
break;
|
||||||
|
case CodeInline code:
|
||||||
|
PushMono();
|
||||||
|
AddText(code.Content);
|
||||||
|
Pop();
|
||||||
|
break;
|
||||||
|
case LineBreakInline line:
|
||||||
|
AddText(line.IsHard ? "\n" : " ");
|
||||||
|
break;
|
||||||
|
case EmphasisInline emphasis:
|
||||||
|
if (emphasis.DelimiterCount >= 2)
|
||||||
|
PushBold();
|
||||||
|
else
|
||||||
|
PushItalics();
|
||||||
|
RenderInlines(emphasis, depth + 1);
|
||||||
|
Pop();
|
||||||
|
break;
|
||||||
|
case LinkInline image when image.IsImage:
|
||||||
|
RenderImage(image);
|
||||||
|
break;
|
||||||
|
case LinkInline link:
|
||||||
|
var allowed = IsWebLink(link.Url);
|
||||||
|
if (allowed)
|
||||||
|
{
|
||||||
|
PushColor(UiTokens.TypeTarget);
|
||||||
|
PushMeta(link.Url);
|
||||||
|
}
|
||||||
|
RenderInlines(link, depth + 1);
|
||||||
|
if (allowed)
|
||||||
|
{
|
||||||
|
Pop();
|
||||||
|
Pop();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case AutolinkInline autoLink:
|
||||||
|
if (IsWebLink(autoLink.Url))
|
||||||
|
{
|
||||||
|
PushMeta(autoLink.Url);
|
||||||
|
AddText(autoLink.Url);
|
||||||
|
Pop();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
AddText(autoLink.Url);
|
||||||
|
break;
|
||||||
|
case ContainerInline nested:
|
||||||
|
RenderInlines(nested, depth + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderImage(LinkInline image)
|
||||||
|
{
|
||||||
|
var alt = image.FirstChild is LiteralInline literal ? literal.Content.ToString() : image.Title ?? "";
|
||||||
|
var path = ModDocumentation.ResolveResource(document.Path, image.Url);
|
||||||
|
var texture = path == null ? null : LoadImage(path);
|
||||||
|
if (texture == null)
|
||||||
|
{
|
||||||
|
AddText($"[{alt}] ({TranslationServer.Translate("mod_manager/docs_image_unavailable")})");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AddImage(texture, width: 100, height: 0, widthInPercent: true, altText: alt, tooltip: image.Title ?? alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageTexture LoadImage(string path)
|
||||||
|
{
|
||||||
|
if (textures.TryGetValue(path, out var cached))
|
||||||
|
return cached;
|
||||||
|
// Do not allow a document to retain an unbounded number of textures.
|
||||||
|
if (textures.Count >= 16)
|
||||||
|
return null;
|
||||||
|
var extension = System.IO.Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".webp")
|
||||||
|
return null;
|
||||||
|
var bytes = ModDocumentation.ReadResource(provider, path, 8 * 1024 * 1024);
|
||||||
|
if (bytes == null || bytes.Length == 0)
|
||||||
|
return null;
|
||||||
|
using var image = new Image();
|
||||||
|
var error = extension == ".png" ? image.LoadPngFromBuffer(bytes)
|
||||||
|
: extension == ".webp" ? image.LoadWebpFromBuffer(bytes) : image.LoadJpgFromBuffer(bytes);
|
||||||
|
if (error != Error.Ok || image.GetWidth() > 4096 || image.GetHeight() > 4096)
|
||||||
|
return null;
|
||||||
|
var texture = ImageTexture.CreateFromImage(image);
|
||||||
|
textures[path] = texture;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWebLink(string value)
|
||||||
|
=> Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == "https" || uri.Scheme == "http");
|
||||||
|
|
||||||
|
// Same semantic palette as site/css/highlight-theme.css, independent of
|
||||||
|
// the application's theme so examples remain recognisable on both UIs.
|
||||||
|
private static Color CodeColor(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"keyword" => new Color("ff7b72"),
|
||||||
|
"function" or "type" => new Color("d2a8ff"),
|
||||||
|
"string" => new Color("a5d6ff"),
|
||||||
|
"comment" => new Color("8b949e"),
|
||||||
|
"built_in" => new Color("ffa657"),
|
||||||
|
"label" => new Color("1f6feb"),
|
||||||
|
"directory" => new Color("e3b341"),
|
||||||
|
"treecomment" => new Color("3fb950"),
|
||||||
|
_ => new Color("79c0ff"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private void OnLinkClicked(Variant value)
|
||||||
|
{
|
||||||
|
var url = value.AsString();
|
||||||
|
if (IsWebLink(url))
|
||||||
|
OS.ShellOpen(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Mod documentation test",
|
||||||
|
"sources": ["src/*.cs"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.Localization;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI.Menu;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Cthangover.Mods.Interface.Menu;
|
||||||
|
using Cthangover.Mods.Interface.View;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager.DocumentationTest
|
||||||
|
{
|
||||||
|
/// <summary>Opens music_pack through the hub and exercises language selection on the actual page.</summary>
|
||||||
|
public partial class DocumentationTestDriver : Node
|
||||||
|
{
|
||||||
|
private double elapsed;
|
||||||
|
private double stepAt;
|
||||||
|
private int step;
|
||||||
|
private int passed;
|
||||||
|
private bool done;
|
||||||
|
private string originalLocale;
|
||||||
|
private ModHubScreen hub;
|
||||||
|
private ModManagerScreen manager;
|
||||||
|
private ModDetailView detail;
|
||||||
|
|
||||||
|
public override void _Process(double delta)
|
||||||
|
{
|
||||||
|
if (done)
|
||||||
|
return;
|
||||||
|
elapsed += delta;
|
||||||
|
if (elapsed > 60)
|
||||||
|
{
|
||||||
|
Finish("timeout at step " + step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (elapsed - stepAt < 0.6)
|
||||||
|
return;
|
||||||
|
stepAt = elapsed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunStep();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Finish(ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunStep()
|
||||||
|
{
|
||||||
|
switch (step)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
if (!Boot.IsReady)
|
||||||
|
return;
|
||||||
|
if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcherPlay)
|
||||||
|
{
|
||||||
|
launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (GetTree().CurrentScene?.FindChild("play", true, false) is not Button)
|
||||||
|
return;
|
||||||
|
originalLocale = TranslationServer.GetLocale();
|
||||||
|
LocaleLoader.LoadLocale("ru");
|
||||||
|
var entry = MenuRegistry.GetButtonById("mods_hub");
|
||||||
|
hub = MenuBuilder.OpenScene((Control)GetTree().CurrentScene, entry.ScenePath, "mods_hub") as ModHubScreen;
|
||||||
|
Check(hub != null, "hub opens");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
manager = hub.FindChildren("*", "", true, false).OfType<ModManagerScreen>().First();
|
||||||
|
manager.SetSearch("music_pack");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
var card = hub.FindChildren("*", "", true, false).OfType<ModCard>().First(IsVisibleCard);
|
||||||
|
card.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
detail = hub.FindChildren("*", "", true, false).OfType<ModDetailView>().First(IsVisibleDetail);
|
||||||
|
Check(Body().GetParsedText().Contains("музыка для вашей игры"), "automatic Russian document");
|
||||||
|
Check(Body().GetContentHeight() > 600, "long document lays out");
|
||||||
|
var codeFont = Body().GetThemeFont("mono_font");
|
||||||
|
Check(codeFont.HasChar('└') && codeFont.HasChar('─') && codeFont.HasChar('├'), "bundled monospace font covers tree glyphs");
|
||||||
|
Check(ModDocumentation.Discover(ModRegistry.Instance.AllDiscoveredMods["music_pack"].FileProvider).Count == 2, "two bundled languages");
|
||||||
|
Shot("music_pack_ru");
|
||||||
|
SelectLanguage("EN");
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
Check(Body().GetParsedText().Contains("a soundtrack for your game"), "manual English document");
|
||||||
|
Check(!Body().GetParsedText().Contains(TranslationServer.Translate("mod_manager/docs_image_unavailable").ToString()), "bundled image resolves");
|
||||||
|
Shot("music_pack_en");
|
||||||
|
ScrollToText("// Add the dependency");
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
Shot("music_pack_code_en");
|
||||||
|
var languages = Languages();
|
||||||
|
languages.Select(0);
|
||||||
|
languages.EmitSignal(OptionButton.SignalName.ItemSelected, 0L);
|
||||||
|
LocaleLoader.LoadLocale("ja");
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
Check(Body().GetParsedText().Contains("a soundtrack for your game"), "missing Japanese falls back to English after locale change");
|
||||||
|
Check(detail.GetNode<Label>("Scroll/Content/DocumentationBox/Source").Text.Contains("EN"), "actual fallback language is visible");
|
||||||
|
manager.CloseDetail();
|
||||||
|
manager.SetSearch("audio_base");
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
var legacyCard = hub.FindChildren("*", "", true, false).OfType<ModCard>().First(IsVisibleCard);
|
||||||
|
legacyCard.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
|
||||||
|
break;
|
||||||
|
case 8:
|
||||||
|
Check(!detail.GetNode<Control>("Scroll/Content/DocumentationBox").Visible, "legacy mod keeps manifest description");
|
||||||
|
Check(!string.IsNullOrWhiteSpace(detail.GetNode<Label>("Scroll/Content/DescriptionBox/Text").Text), "legacy description remains readable");
|
||||||
|
Finish(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModMarkdownView Body() => detail.GetNode<ModMarkdownView>("Scroll/Content/DocumentationBox/Body");
|
||||||
|
private OptionButton Languages() => detail.GetNode<OptionButton>("Scroll/Content/DocumentationBox/Header/Languages");
|
||||||
|
private static bool IsVisibleCard(ModCard card) => card.IsVisibleInTree();
|
||||||
|
private static bool IsVisibleDetail(ModDetailView page) => page.IsVisibleInTree();
|
||||||
|
private static bool IsScreenshotArg(string arg) => arg.StartsWith("--shots=");
|
||||||
|
|
||||||
|
private void ScrollToText(string needle)
|
||||||
|
{
|
||||||
|
var text = Body().GetParsedText();
|
||||||
|
var offset = text.IndexOf(needle, StringComparison.Ordinal);
|
||||||
|
Check(offset >= 0, "code example remains literal and selectable");
|
||||||
|
var paragraph = 0;
|
||||||
|
for (var i = 0; i < offset; i++)
|
||||||
|
if (text[i] == '\n')
|
||||||
|
paragraph++;
|
||||||
|
var scroll = detail.GetNode<ScrollContainer>("Scroll");
|
||||||
|
var content = detail.GetNode<Control>("Scroll/Content");
|
||||||
|
scroll.ScrollVertical = (int)(Body().GlobalPosition.Y - content.GlobalPosition.Y + Body().GetParagraphOffset(paragraph)) - 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectLanguage(string language)
|
||||||
|
{
|
||||||
|
var languages = Languages();
|
||||||
|
for (var i = 0; i < languages.ItemCount; i++)
|
||||||
|
if (languages.GetItemText(i) == language)
|
||||||
|
{
|
||||||
|
languages.Select(i);
|
||||||
|
languages.EmitSignal(OptionButton.SignalName.ItemSelected, (long)i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Missing language " + language);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Check(bool ok, string label)
|
||||||
|
{
|
||||||
|
if (!ok)
|
||||||
|
throw new InvalidOperationException(label);
|
||||||
|
passed++;
|
||||||
|
GameLogger.Log("TEST", "Documentation: [OK] " + label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shot(string name)
|
||||||
|
{
|
||||||
|
var arg = OS.GetCmdlineUserArgs().FirstOrDefault(IsScreenshotArg);
|
||||||
|
if (arg == null || DisplayServer.GetName() == "headless")
|
||||||
|
return;
|
||||||
|
var directory = ProjectSettings.GlobalizePath(arg.Substring(8));
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
ViewportCapture.SavePng(GetViewport(), Path.Combine(directory, name + ".png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Finish(string failure)
|
||||||
|
{
|
||||||
|
if (done)
|
||||||
|
return;
|
||||||
|
done = true;
|
||||||
|
hub?.QueueFree();
|
||||||
|
if (originalLocale != null)
|
||||||
|
LocaleLoader.LoadLocale(originalLocale);
|
||||||
|
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||||
|
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||||
|
if (failure != null)
|
||||||
|
GameLogger.Log("TEST", "Documentation: " + failure, LogLevel.Error);
|
||||||
|
GameLogger.Log("TEST", $"[TEST_RESULT] name=mod_documentation passed={passed} failed={(failure == null ? 0 : 1)}");
|
||||||
|
GetTree().Quit(failure == null ? 0 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Mods.ModManager.DocumentationTest
|
||||||
|
{
|
||||||
|
/// <summary>Mounting this suite explicitly enables the documentation UI walk-through.</summary>
|
||||||
|
public class DocumentationTestInitializer : IModInitializer
|
||||||
|
{
|
||||||
|
private static bool queued;
|
||||||
|
public void OnModLoaded(string modId) { }
|
||||||
|
public void OnModResourcesReady()
|
||||||
|
{
|
||||||
|
if (queued)
|
||||||
|
return;
|
||||||
|
queued = true;
|
||||||
|
ModRoot.AddDeferred(new DocumentationTestDriver());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
namespace Mods.ModManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>Colouring preserves source literally and recognises each supported fence grammar.</summary>
|
||||||
|
public class ModCodeSyntaxTests
|
||||||
|
{
|
||||||
|
[ModTest("documentation/code_languages")]
|
||||||
|
static void Languages(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.Equal("csharp", ModCodeSyntax.Language(" C# title=example "));
|
||||||
|
ModAssert.Equal("gdscript", ModCodeSyntax.Language("GD"));
|
||||||
|
ModAssert.Equal("bash", ModCodeSyntax.Language("shell"));
|
||||||
|
ModAssert.Equal("yaml", ModCodeSyntax.Language("yml"));
|
||||||
|
ModAssert.Equal("glsl", ModCodeSyntax.Language("gdshader"));
|
||||||
|
ModAssert.Equal("", ModCodeSyntax.Language(null));
|
||||||
|
ModAssert.Equal("unknown", ModCodeSyntax.Language("unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("documentation/code_jsonc")]
|
||||||
|
static void Jsonc(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
const string source = "{\r\n\t\"name\": \"// literal \\\"quoted\\\" Русский\", // comment\r\n \"enabled\": true, /* two\nlines */ \"count\": 22\n} ";
|
||||||
|
var spans = ModCodeSyntax.Tokenize("jsonc", source);
|
||||||
|
ModAssert.Equal(source, Join(spans), "all whitespace and Unicode preserved");
|
||||||
|
Expect(spans, "\"name\"", "attr");
|
||||||
|
Expect(spans, "\"// literal \\\"quoted\\\" Русский\"", "string");
|
||||||
|
Expect(spans, "// comment", "comment");
|
||||||
|
Expect(spans, "/* two\nlines */", "comment");
|
||||||
|
Expect(spans, "true", "literal");
|
||||||
|
Expect(spans, "22", "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("documentation/code_site_languages")]
|
||||||
|
static void SiteLanguages(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
Expect(ModCodeSyntax.Tokenize("csharp", "public class Example { var value = 42; }"), "public", "keyword");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("gdscript", "func start():\n # comment\n return true"), "func", "keyword");
|
||||||
|
var scenario = ModCodeSyntax.Tokenize("scenario", "scene: intro\n---\ntext \"# literal\" key=intro\noption \"Go\" -> :next\n-- comment");
|
||||||
|
Expect(scenario, "text", "keyword");
|
||||||
|
Expect(scenario, "\"# literal\"", "string");
|
||||||
|
Expect(scenario, "key", "attr");
|
||||||
|
Expect(scenario, ":next", "label");
|
||||||
|
Expect(scenario, "---", "meta");
|
||||||
|
Expect(scenario, "-- comment", "comment");
|
||||||
|
var tree = ModCodeSyntax.Tokenize("filestree", "mods/\n└── music_pack/ # shared music\n └── manifest.json");
|
||||||
|
Expect(tree, "music_pack/", "directory");
|
||||||
|
Expect(tree, "# shared music", "treecomment");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("glsl", "uniform vec4 color;\nvoid fragment() { COLOR = color; }"), "uniform", "keyword");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("bash", "export GAME=demo # comment\necho $GAME"), "$GAME", "variable");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("python", "def start():\n return True"), "def", "keyword");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("yaml", "enabled: true\ncount: 22"), "enabled", "attr");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("ini", "[section]\nname=value\n# comment"), "[section]", "meta");
|
||||||
|
Expect(ModCodeSyntax.Tokenize("properties", "mod/name=Музыка\n"), "Музыка", "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("documentation/code_plain_fallback")]
|
||||||
|
static void PlainFallback(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
const string literal = "[url=javascript:bad]literal[/url] <script> // # true\n\t ";
|
||||||
|
foreach (var language in new[] { "", "text", "unknown", "mermaid" })
|
||||||
|
{
|
||||||
|
var spans = ModCodeSyntax.Tokenize(language, literal);
|
||||||
|
ModAssert.Equal(literal, Join(spans), language + " preserves literal source");
|
||||||
|
foreach (var span in spans)
|
||||||
|
ModAssert.Equal("", span.Kind, language + " does not guess a language");
|
||||||
|
}
|
||||||
|
var large = new string('x', 65537);
|
||||||
|
var oversized = ModCodeSyntax.Tokenize("json", large);
|
||||||
|
ModAssert.Equal(large, Join(oversized), "oversized source remains readable");
|
||||||
|
ModAssert.Equal("", oversized[0].Kind, "oversized source skips colouring");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Join(IReadOnlyList<(string Text, string Kind)> spans)
|
||||||
|
{
|
||||||
|
var result = new StringBuilder();
|
||||||
|
foreach (var span in spans)
|
||||||
|
result.Append(span.Text);
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Expect(IReadOnlyList<(string Text, string Kind)> spans, string text, string kind)
|
||||||
|
{
|
||||||
|
foreach (var span in spans)
|
||||||
|
if (span.Text == text && span.Kind == kind)
|
||||||
|
return;
|
||||||
|
ModAssert.True(false, "expected " + kind + " token: " + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods.Providers;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
namespace Mods.ModManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>Storage and fallback contracts, including malformed files and compressed mods.</summary>
|
||||||
|
public class ModDocumentationTests
|
||||||
|
{
|
||||||
|
[ModTest("documentation/language_fallback")]
|
||||||
|
static void LanguageFallback(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var docs = new[]
|
||||||
|
{
|
||||||
|
new ModDocument("de", "docs/de.md", "Deutsch"),
|
||||||
|
new ModDocument("en", "docs/en.md", "English"),
|
||||||
|
new ModDocument("ru", "docs/ru.md", "Русский"),
|
||||||
|
new ModDocument("default", "docs/default.md", "Default"),
|
||||||
|
};
|
||||||
|
ModAssert.Equal("ru", ModDocumentation.Select(docs, "RU_ru").Language, "regional language");
|
||||||
|
ModAssert.Equal("default", ModDocumentation.Select(docs, "ja").Language, "author fallback");
|
||||||
|
ModAssert.Equal("en", ModDocumentation.Select(docs.Take(3).ToArray(), "ja").Language, "English fallback");
|
||||||
|
ModAssert.Equal("de", ModDocumentation.Select(docs.Take(1).ToArray(), "ja").Language, "first available");
|
||||||
|
ModAssert.Null(ModDocumentation.Select(Array.Empty<ModDocument>(), "ru"), "legacy mod");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("documentation/folder_and_zip")]
|
||||||
|
static void FolderAndZip(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var root = Path.Combine(ctx.SandboxDir, "documentation");
|
||||||
|
var zip = Path.Combine(ctx.SandboxDir, "documentation.zip");
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, "docs", "assets"));
|
||||||
|
File.WriteAllText(Path.Combine(root, "docs", "ru.md"), "# Русский\n");
|
||||||
|
File.WriteAllText(Path.Combine(root, "docs", "en.md"), "# English");
|
||||||
|
File.WriteAllText(Path.Combine(root, "docs", "README.md"), "Not a language");
|
||||||
|
File.WriteAllText(Path.Combine(root, "docs", "ja.md"), " \n ");
|
||||||
|
File.WriteAllBytes(Path.Combine(root, "docs", "ko.md"), new byte[] { 0xFF, 0xFF });
|
||||||
|
File.WriteAllBytes(Path.Combine(root, "docs", "assets", "picture.png"), new byte[] { 1, 2, 3 });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var folder = new FolderModFileProvider(root, "documentation_fixture");
|
||||||
|
var local = ModDocumentation.Discover(folder);
|
||||||
|
ModAssert.Equal(2, local.Count, "only readable language files");
|
||||||
|
ModAssert.True(ModDocumentation.Select(local, "ru").Text.Contains("Русский"), "UTF-8 preserved");
|
||||||
|
ModAssert.Null(ModDocumentation.ReadResource(folder, "docs/en.md", 2), "size limit");
|
||||||
|
ModAssert.Null(ModDocumentation.ReadResource(folder, "docs/missing.md", 100), "missing file");
|
||||||
|
ZipFile.CreateFromDirectory(root, zip);
|
||||||
|
using var archive = new ZipModFileProvider(zip, "documentation_fixture");
|
||||||
|
var packed = ModDocumentation.Discover(archive);
|
||||||
|
ModAssert.Equal(local.Count, packed.Count, "ZIP discovery");
|
||||||
|
ModAssert.Equal(ModDocumentation.Select(local, "ru").Text, ModDocumentation.Select(packed, "ru").Text, "ZIP text");
|
||||||
|
ModAssert.Equal(3, ModDocumentation.ReadResource(archive, "docs/assets/picture.png", 100).Length, "ZIP image bytes");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(zip))
|
||||||
|
File.Delete(zip);
|
||||||
|
Directory.Delete(root, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("documentation/resource_paths")]
|
||||||
|
static void ResourcePaths(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.Equal("docs/assets/a.png", ModDocumentation.ResolveResource("docs/ru.md", "assets/a.png"));
|
||||||
|
ModAssert.Equal("docs/assets/a.png", ModDocumentation.ResolveResource("docs/ru/page.md", "../assets/a.png"));
|
||||||
|
foreach (var path in new[] { "../../secret.png", "/tmp/a.png", "C:\\a.png", "https://example.com/a.png", "file:///etc/passwd", "\\server\\a.png" })
|
||||||
|
ModAssert.Null(ModDocumentation.ResolveResource("docs/ru.md", path), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("documentation/markdown_literals")]
|
||||||
|
static void MarkdownLiterals(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var view = new ModMarkdownView();
|
||||||
|
ctx.SandboxRoot.AddChild(view);
|
||||||
|
view.Display(null, new ModDocument("en", "docs/en.md", "# Heading\n\n**Bold** and `code`\n\n```json\n[url=javascript:bad]literal[/url]\n```\n\n<script>literal HTML</script>\n\n[Visible](javascript:bad)"));
|
||||||
|
var text = view.GetParsedText();
|
||||||
|
ModAssert.True(text.Contains("Heading") && text.Contains("Bold and code"), "Markdown text preserved");
|
||||||
|
ModAssert.True(text.Contains("[url=javascript:bad]literal[/url]"), "code never becomes BBCode");
|
||||||
|
ModAssert.True(text.Contains("<script>literal HTML</script>"), "HTML never executes");
|
||||||
|
ModAssert.True(text.Contains("Visible"), "unsupported link retains label");
|
||||||
|
view.Display(null, null);
|
||||||
|
ModAssert.Equal("", view.GetParsedText(), "clearing previous document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user