Compare commits

...

20 Commits

Author SHA1 Message Date
Duansg 2c0ced322d fix: use TagStatusDeleted instead of QuestionStatusDeleted in tag permission
Lint / Lint (ubuntu-latest) (push) Has been cancelled
2026-08-17 12:23:24 +08:00
Nikita Ermilov 413bbd0c7f i18n: complete Russian (ru_RU) translation 2026-08-14 17:00:03 +08:00
LinkinStars 1ccb4b7adc fix: add ASF header to tag search test
Signed-off-by: LinkinStars <linkinstar@foxmail.com>
2026-08-11 19:50:54 +08:00
ferhat elmas da622a4927 test(converter): pin renderLinkIsUrl behavior
Add a table-driven test covering the markdown link destination check
before replacing the govalidator dependency with stdlib logic.

Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
2026-08-11 19:34:58 +08:00
Max Engine 4488ccc689 fix: tag search never matches on slug name
The search term was formatted into LOWER(%s) and passed as the *value* of the
LIKE, so the function name ended up inside the pattern:

    slug_name LIKE '%LOWER(coco)%'

That can never match. Only the display_name clause did any work, and LIKE is
case-sensitive on Postgres, so searching a tag by the name it is written in
returns nothing:

    slug_name=Coco  -> matches
    slug_name=coco  -> no match

Tags are lower case by convention, so lower case is what users type, and the
filter appears to report that no such tag exists.

Lower both sides instead. The term normalisation is extracted so it can be
covered by a test without a database.
2026-08-11 19:31:39 +08:00
ferhat elmas 98329f3b05 fix: advanced site settings setup from migration 30
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
2026-07-29 19:09:48 +08:00
LinkinStars ecef4f11dc Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	docs/release/LICENSE
#	internal/service/notification/new_question_notification_test.go
2026-07-29 17:19:20 +08:00
LinkinStars 9df5853942 fix(tests): update goroutine handling in new question email worker tests 2026-07-07 19:32:24 +08:00
LinkinStars c17e0c94c8 fix(notification): remove buffer size parameter from new question email worker for test 2026-07-07 19:03:27 +08:00
Artur Iusupov b70dda997a fix(notification): make new question email queue configurable 2026-07-06 22:31:16 +08:00
Artur Iusupov d10e6aad70 fix(notification): move new question email throttling to worker 2026-07-06 22:31:16 +08:00
Artur Iusupov e1d58ab635 feat(notification): add interval for new question emails 2026-07-06 22:31:16 +08:00
Artur Iusupov 3b6f981b81 fix(site): require explicit email verification setting
Replace OptionalBool with an explicit require_email_verification value for the login settings save request while keeping legacy read defaults intact.

Use positive RequireEmailVerification naming through the registration flow and inline the site setting mapping.

Add validation, save-path, and registration coverage for the explicit email verification setting.
2026-06-15 14:44:34 +08:00
Artur Iusupov d93e31e92a feat(site): allow disabling email verification 2026-06-15 14:44:34 +08:00
hgaol 43a91313d8 fix: accept answer fails when short links enabled (#1541)
The ownership check added to AcceptAnswer compared the answer's QuestionID
against the request's QuestionID directly. When short links are enabled,
answerRepo.GetByID re-encodes QuestionID to its short form while the
controller de-shorts req.QuestionID to its long form, so the two encodings
of the same question never matched and every accept returned "Answer do not
found". Normalize both ids via uid.DeShortID before comparing, preserving the
privilege-escalation guard for answers that truly belong to another question.
2026-06-12 16:10:22 +08:00
hhc7 682811f769 fix: scope JSON 500 to API routes, skip rewriting already-flushed responses 2026-06-05 11:26:55 +08:00
hhc7 cece87f9dc feat: add recovery middleware to handle panic gracefully 2026-06-05 11:26:55 +08:00
Ahmed Qasid e884bb61cb fix: avoid topic fallback for non-Latin titles via pragmatic ASCII transliteration (#1526)
# fix: avoid `topic` fallback for non-Latin titles via pragmatic ASCII
transliteration

> **Scope update (in response to review):** this PR is intentionally
broader than its original "Arabic-only" framing. The implementation
changes URL slug generation for **every non-Latin, non-CJK script** that
`slugify` previously stripped — see *Scope* below for the explicit list.
The goal is *not* linguistically correct romanization; it is "avoid
collapsing to `/topic` by producing a usable ASCII slug."

## What this PR is (and isn't)

**Goal:** when a question title contains characters outside Basic Latin
/ Latin Extended / CJK Han, generate a URL slug that is a deterministic
ASCII approximation instead of letting `slugify` strip everything and
falling back to the literal `"topic"`.

**Non-goal:** this is *not* a linguistically correct multi-language
romanizer. The output is a machine-acceptable ASCII slug, not what a
native speaker would choose. For example, `こんにちは` → `konnichiha` (not
the more natural `kon'nichiwa`), `ไทย` → `aithy` (not `thai`). Treat the
slug as an opaque, stable, indexable identifier — the
path-after-`/questions/<id>/` is for SEO and shareability, the canonical
reference is always the ID.

## The bug

Pure non-Latin titles previously got stripped by `slugify.Slugify`, hit
the empty-result fallback in `htmltext.UrlTitle`, and collapsed to the
literal slug `"topic"`. On a live multilingual site, every Arabic / Thai
/ Japanese-hiragana / Korean / Hebrew / Cyrillic question ended up at
`/questions/<id>/topic`.

## The fix

`UrlTitle()` gets a `convertNonLatin` pre-step that mirrors the existing
`convertChinese` pre-step pattern, using
`github.com/mozillazg/go-unidecode` (same author as `go-pinyin` already
in the repo, to minimise new-dep friction).

```
UrlTitle(title)
  → convertChinese(title)        // pre-existing: Han-block → pinyin
  → convertNonLatin(title)       // NEW: detect non-Latin letters → unidecode to ASCII
  → clearEmoji / slugify / url.QueryEscape / cutLongTitle (unchanged)
```

The non-Latin detector skips ASCII, Latin-1 Supplement, Latin
Extended-A/B, and CJK Han. Inputs that hit none of those non-Latin
letter categories short-circuit and return unchanged, so Latin-only and
Chinese-only inputs remain byte-identical (pinned by tests).

## Scope — what scripts are affected

This PR changes behavior for **any** title containing letters in scripts
that `slugify` doesn't handle. Confirmed by tests in
`pkg/htmltext/htmltext_test.go`:

| Script | Example title | Before | After |
| --- | --- | --- | --- |
| Arabic | `كيف حالك` | `topic` | `kyf-hlk` |
| Mixed Latin + Arabic | `مرحبا hello` | `hello` | `mrhb-hello` |
| Thai | `ไทย ไทย` | `topic` | `aithy-aithy` |
| Japanese hiragana | `こんにちは` | `topic` | `konnichiha` |
| Korean | `안녕하세요` | `topic` | `annyeonghaseyo` |
| Hebrew | `שלום עולם` | `topic` | `shlvm-vlm` |
| Cyrillic | `Привет мир` | `topic` | `privet-mir` |

**Unchanged:**

| Case | Behavior |
| --- | --- |
| Pure Latin (`hello world`) | unchanged → `hello-world` |
| Pure Chinese (`这是一个,标题,title`) | unchanged → `zhe-shi-yi-ge-biao-ti`
(pinyin path) |
| Japanese with Han-block kanji (`日本`) | unchanged → `ri-ben` (caught by
pre-existing pinyin path; treated as Chinese reading, not Japanese — a
pre-existing limitation, **not** introduced by this PR) |
| Emoji only (`😂😂😂`) | unchanged → `topic` |
| Empty / whitespace | unchanged → `topic` |

## Transliteration quality — explicit acknowledgement

`go-unidecode` is a generic Unicode → ASCII approximation. It is **not**
a per-language romanization library. Specifically:

- It will pick *one* approximation per codepoint regardless of language
context. `ใ` → `ai` (Thai romanization is `i` or `ai` depending on
standard), `한` → `han`, `語` → `Yu` (Chinese pinyin reading even when
used in Japanese), etc.
- The result is *good enough* to be a stable, URL-safe,
human-recognizable handle, but speakers of the source language will not
consider it "correct."
- It is deterministic, so the same title always produces the same slug —
important since `url_title` is recomputed on every request.

If maintainers prefer to scope this PR more narrowly (e.g. Arabic only,
and reject Thai/Hebrew/Cyrillic/etc.), the detector in
`containsNonLatin` can be tightened to specific Unicode blocks — but
that means the other scripts continue to collapse to `topic`, which is
the bug we're trying to fix. I'd argue the broader fix is preferable to
a piecemeal one, but happy to narrow if you want.

## Live deployment / real-world verification

This patch has been running in production on
**[ask.namasoft.com](https://ask.namasoft.com)** (an Apache Answer
instance we operate) since deployment, built directly from this branch
via `docker compose build`. The site hosts Arabic-language questions, so
the fix exercises the affected code path on every page load.

Sample question URL on the deployed instance:

> `https://ask.namasoft.com/questions/10010000000000115`

The slug in the URL is the transliterated Arabic title rather than
`topic`. No data migration was needed since `url_title` is computed on
every request from `Title` and never persisted (see *Why this is safe to
ship* below).

## Admin-configurable

The transliteration is gated by a package-level `atomic.Bool` (default
**on**, since the current behavior is objectively broken for affected
users):

- `htmltext.SetTransliterateNonLatin(enabled bool)`
- `htmltext.IsTransliterateNonLatinEnabled() bool`

This is deliberately the minimum surface needed to satisfy "the setting
must be readable from `UrlTitle()`". A follow-up PR can add an admin UI
section that calls `SetTransliterateNonLatin` on save and on startup,
without having to re-plumb every `htmltext.UrlTitle` call site through
`context.Context`.

**Default choice — please confirm:** I picked **default-on** because the
existing `topic` behavior is a bug for affected users. If you'd prefer
default-off for strict backward compat on existing installs, flip the
`init()` in `pkg/htmltext/htmltext.go` to `Store(false)` and surface the
toggle as opt-in.

## Why this is safe to ship

- `url_title` is **not** a persisted column. It's not on the `Question`
entity in `internal/entity/question_entity.go`, no migration has ever
added/dropped it, and every call site (`question_service.go`,
`revision_service.go`, `vote_service.go`,
search/report/review/rank/comment services, controllers, repos)
recomputes it from `Title` at response-build time via
`htmltext.UrlTitle(...)`.
- That means the fix is read-only: existing rows light up with correct
slugs on the next request, with no migration and no data rewrite.
- Rollback is just redeploying the prior image; nothing on disk changes.

## Test coverage

`pkg/htmltext/htmltext_test.go`:

- **`TestUrlTitleTable`** — table-driven, one case per affected script
(the full matrix above), plus:
  - `empty` → `topic`
  - `pure latin unchanged` → byte-identical to pre-fix
- `pure chinese unchanged` → byte-identical to pre-fix (pins existing
pinyin behavior)
- `japanese kanji goes through pinyin path unchanged` → documents the
pre-existing Han-block limitation
  - `emoji only falls back to topic` → unchanged
- `long arabic truncates at cutLongTitle boundary` → exercises the
150-byte cap and UTF-8 boundary safety
- **`TestUrlTitleTransliterationToggle`** — with the toggle off,
non-Latin titles collapse to `topic` (pre-fix behavior); with it on,
they transliterate.
- Existing `TestUrlTitle` left untouched.

Test plan for reviewers:

- [ ] `go test ./pkg/htmltext/...` — all pass
- [ ] Visit the live sample URL above and confirm slug is
transliterated, not `topic`
- [ ] Verify Chinese / Latin / emoji-only / empty behavior is
byte-identical to `main` (covered by table tests)

## Out of scope (intentionally)

- No admin UI / site setting plumbing in this PR — see
*Admin-configurable* above. Happy to do the React `Non-Latin Languages
Handling` admin page + `SiteType` + service / controller / migration in
a follow-up if maintainers want it.
- No change to the `"topic"` empty-result fallback.
- No plugin interface for slug generation — mirrored the existing
`convertChinese` pre-step pattern instead.
- No per-language romanization library — this is an explicit non-goal;
see *Transliteration quality* above.

## Issues / discussion

I didn't find an existing upstream issue covering this — happy to be
pointed at one if there is.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LinkinStars <linkinstar@foxmail.com>
2026-06-03 22:06:26 +08:00
Luffy 68085ab742 fix: update license entries 2026-06-03 22:03:58 +08:00
Luke Gao 7c210a4855 feat: add reasoning content to AI conversation records and update related components (#1530)
Fix #1524 

Root cause

DeepSeek's reasoning models stream reasoning_content alongside content.
Answer ignored it, so follow-up requests failed with 400: The
reasoning_content in the thinking mode must be passed back to the API,
and the thinking text was never shown or saved.

Fix

- Capture reasoning_content from the stream and pass it back to theAPI
on subsequent rounds.
 - Persist it with the conversation (new DB column via migrationv2.0.2).
- Render it in the chat UI as a collapsible "Thinking…/Thoughts"panel
above the answer.

Compatibility

Nullable column, omitempty field, UI hides the panel when empty — old
conversations and non-reasoning models behave exactly as before.

Demo



https://github.com/user-attachments/assets/49b1a2a1-9133-4ac2-bbeb-860215a50285
2026-05-30 15:33:54 +08:00
9 changed files with 709 additions and 402 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ language_options:
progress: 96
- label: "Русский"
value: "ru_RU"
progress: 80
progress: 100
- label: "简体中文"
value: "zh_CN"
progress: 100
+400 -397
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -110,6 +110,7 @@ var migrations = []Migration{
NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false),
NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false),
NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true),
NewMigration("v2.0.4", "repair missing advanced site settings", repairAdvancedSiteInfo, true),
}
func GetMigrations() []Migration {
+72
View File
@@ -0,0 +1,72 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"context"
"encoding/json"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"xorm.io/builder"
"xorm.io/xorm"
)
func repairAdvancedSiteInfo(ctx context.Context, x *xorm.Engine) error {
advanced := &entity.SiteInfo{}
exists, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeAdvanced}).Get(advanced)
if err != nil {
return err
}
if exists {
return nil
}
write := &entity.SiteInfo{}
exists, err = x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(write)
if err != nil {
return err
}
if !exists {
return nil
}
siteWrite := &schema.SiteWriteResp{}
if err := json.Unmarshal([]byte(write.Content), siteWrite); err != nil {
return err
}
content, err := json.Marshal(&schema.SiteAdvancedResp{
MaxImageSize: siteWrite.MaxImageSize,
MaxAttachmentSize: siteWrite.MaxAttachmentSize,
MaxImageMegapixel: siteWrite.MaxImageMegapixel,
AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions,
AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions,
})
if err != nil {
return err
}
_, err = x.Context(ctx).Insert(&entity.SiteInfo{
Type: constant.SiteTypeAdvanced,
Content: string(content),
Status: 1,
})
return err
}
+101
View File
@@ -0,0 +1,101 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"context"
"testing"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestRepairAdvancedSiteInfoAddsMissingSettings(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
_, err = x.Insert(&entity.SiteInfo{
Type: constant.SiteTypeWrite,
Content: `{"max_image_size":5}`,
Status: 1,
})
require.NoError(t, err)
var repairMigration Migration
for _, m := range GetMigrations() {
if m.Version() == "v2.0.4" {
repairMigration = m
break
}
}
require.NotNil(t, repairMigration)
require.NoError(t, repairMigration.Migrate(context.Background(), x))
advanced := &entity.SiteInfo{}
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
require.NoError(t, err)
require.True(t, exists)
assert.JSONEq(t, `{
"max_image_size": 5,
"max_attachment_size": 0,
"max_image_megapixel": 0,
"authorized_image_extensions": null,
"authorized_attachment_extensions": null
}`, advanced.Content)
}
func TestRepairAdvancedSiteInfoPreservesExistingSettings(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
const existingContent = `{"max_image_size":99}`
_, err = x.Insert(
&entity.SiteInfo{
Type: constant.SiteTypeWrite,
Content: `{invalid`,
Status: 1,
},
&entity.SiteInfo{
Type: constant.SiteTypeAdvanced,
Content: existingContent,
Status: 1,
},
)
require.NoError(t, err)
require.NoError(t, repairAdvancedSiteInfo(context.Background(), x))
advanced := &entity.SiteInfo{}
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
require.NoError(t, err)
require.True(t, exists)
assert.JSONEq(t, existingContent, advanced.Content)
}
+19 -3
View File
@@ -21,7 +21,6 @@ package tag_common
import (
"context"
"fmt"
"strconv"
"strings"
@@ -171,10 +170,20 @@ func (tr *tagCommonRepo) GetTagPage(ctx context.Context, page, pageSize int, tag
session := tr.data.DB.Context(ctx)
if len(tag.SlugName) > 0 {
// Both sides lowered, so the search is case-insensitive.
//
// This previously read LOWER(%s) formatted against the *search term*,
// which put the function name into the value: the query became
// slug_name LIKE '%LOWER(coco)%' and could never match. Only the
// display_name clause did anything, and that is case-sensitive on
// Postgres, so typing a tag in lower case -- which is how tags are
// written and therefore how anyone types them -- returned nothing at all
// and read as "no such tag".
search := searchTermForTag(tag.SlugName)
mainTagCond := builder.And(
builder.Or(
builder.Like{"slug_name", fmt.Sprintf("LOWER(%s)", tag.SlugName)},
builder.Like{"display_name", tag.SlugName},
builder.Like{"LOWER(slug_name)", search},
builder.Like{"LOWER(display_name)", search},
),
builder.Eq{"main_tag_id": 0},
)
@@ -293,3 +302,10 @@ func (tr *tagCommonRepo) UpdateTagsAttribute(ctx context.Context, tags []string,
}
return
}
// searchTermForTag normalises a tag search term. Lowering it here, and lowering
// the columns in the query, is what makes the search case-insensitive: tags are
// written in lower case, so that is how people type them.
func searchTermForTag(term string) string {
return strings.ToLower(strings.TrimSpace(term))
}
@@ -0,0 +1,36 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package tag_common
import "testing"
// The bug: the search term was formatted into LOWER(%s), which put the function
// name into the value rather than applying it to the column, so the query became
// slug_name LIKE '%LOWER(coco)%' and matched nothing. Only display_name did any
// work, and that is case-sensitive on Postgres -- so typing a tag the way tags
// are actually written returned "no such tag".
func TestSearchTermIsLoweredNotWrapped(t *testing.T) {
for _, in := range []string{"Coco", "COCO", "coco"} {
got := searchTermForTag(in)
if got != "coco" {
t.Errorf("searchTermForTag(%q) = %q, want %q", in, got, "coco")
}
}
}
@@ -58,7 +58,7 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMe
})
}
if canRecover && status == entity.QuestionStatusDeleted {
if canRecover && status == entity.TagStatusDeleted {
actions = append(actions, &schema.PermissionMemberAction{
Action: "undelete",
Name: translator.Tr(lang, undeleteActionName),
+78
View File
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package converter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRenderLinkIsUrl(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{"absolute http URL", "http://example.com/path?q=1#f", true},
{"absolute https URL", "https://example.com", true},
{"ftp URL", "ftp://example.com/file", true},
{"uppercase scheme and host", "HTTP://EXAMPLE.COM", false},
{"bare domain", "example.com", true},
{"bare domain with path", "example.com/questions/123", true},
{"www subdomain", "www.example.com", true},
{"bare IP", "10.0.0.1", true},
{"IP with port and path", "10.0.0.1:8080/a", true},
{"host with port", "localhost:8080", true},
{"domain with port and path", "example.com:8080/x", true},
{"IPv6 with port", "[::1]:8080", true},
{"userinfo", "user:pass@example.com", true},
{"mailto", "mailto:a@b.com", true},
{"email-like destination", "a@b.co", true},
{"userinfo without scheme", "user@h.co", true},
{"trailing dot FQDN", "example.com.", true},
{"empty", "", false},
{"single word", "foo", false},
{"path segment no dot", "questions/123", false},
{"absolute path", "/questions/123", true},
{"scheme-less authority path", "//cdn.example.com/x", true},
{"anchor", "#section", false},
{"leading dot", ".hidden", false},
{"javascript scheme", "javascript:alert(1)", false},
{"tel scheme", "tel:+1234", false},
{"host with leading dot", "http://.example.com", false},
{"trailing colon", "example.com:", false},
{"single label with scheme", "http://localhost", true},
{"single label no scheme no port", "localhost", false},
{"not a url", "not a url", false},
{"whitespace in path", "h.co/p q", false},
{"label with leading hyphen", "-ex.com", false},
{"label with trailing hyphen", "ex-.com", false},
{"invalid IPv4 quad", "999.1.1.1", false},
{"IPv4 with leading zeros", "01.2.3.4", false},
{"three letter domain", "a.b", false},
}
r := &DangerousHTMLRenderer{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, r.renderLinkIsUrl(tc.in))
})
}
}