จะรับโหนดข้อความขององค์ประกอบได้อย่างไร


101
<div class="title">
   I am text node
   <a class="edit">Edit</a>
</div>

ฉันต้องการรับ "I am text node" ไม่ต้องการลบแท็ก "แก้ไข" และต้องการโซลูชันข้ามเบราว์เซอร์


คำถามนี้ค่อนข้างเหมือนกันกับstackoverflow.com/questions/3172166/… - ดูคำตอบเหล่านั้นสำหรับคำตอบของ James เวอร์ชัน JS ธรรมดา
Mala

คำตอบ:


80
var text = $(".title").contents().filter(function() {
  return this.nodeType == Node.TEXT_NODE;
}).text();

สิ่งนี้จะได้รับcontentsขององค์ประกอบที่เลือกและใช้ฟังก์ชันตัวกรองกับองค์ประกอบนั้น ฟังก์ชันตัวกรองจะส่งคืนเฉพาะโหนดข้อความ (เช่นโหนดที่มีnodeType == Node.TEXT_NODE)


@Val - ขออภัยฉันพลาดรหัสเดิม ฉันจะอัปเดตคำตอบเพื่อแสดงให้เห็น คุณต้องการtext()เนื่องจากfilterฟังก์ชันส่งคืนโหนดเองไม่ใช่เนื้อหาของโหนด
James Allardice

1
ไม่แน่ใจว่าทำไม แต่ฉันไม่เข้าใจเมื่อทดสอบทฤษฎีข้างต้น ฉันรันสิ่งต่อไปนี้ jQuery("*").each(function() { console.log(this.nodeType); })และฉันได้1สำหรับโหนดทุกประเภท
Batandwa

เป็นไปได้ไหมที่จะรับข้อความที่โหนดที่คลิกและข้อความในลูกทั้งหมด
Jenna Kwon

สิ่งนี้น่าสนใจและช่วยแก้ปัญหานี้ได้ แต่จะเกิดอะไรขึ้นเมื่อสถานการณ์ซับซ้อนขึ้น มีวิธีที่ยืดหยุ่นมากขึ้นในการทำงานให้ลุล่วง
Anthony Rutledge

ไม่มี jQuery, document.querySelector (". title"). childNodes [0] .nodeValue
Balaji Gunasekaran

57

คุณสามารถรับ nodeValue ของ childNode แรกโดยใช้

$('.title')[0].childNodes[0].nodeValue

http://jsfiddle.net/TU4FB/


5
แม้ว่าจะได้ผล แต่ก็ขึ้นอยู่กับตำแหน่งของโหนดลูก ถ้า (เมื่อ) เปลี่ยนแปลงมันจะพัง
Armstrongest

หากโหนดข้อความไม่ใช่ลูกคนแรกคุณอาจได้รับnullค่าตอบแทน
Anthony Rutledge

16

หากคุณหมายถึงรับค่าของโหนดข้อความแรกในองค์ประกอบรหัสนี้จะทำงาน:

var oDiv = document.getElementById("MyDiv");
var firstText = "";
for (var i = 0; i < oDiv.childNodes.length; i++) {
    var curNode = oDiv.childNodes[i];
    if (curNode.nodeName === "#text") {
        firstText = curNode.nodeValue;
        break;
    }
}

คุณสามารถดูการดำเนินการได้ที่นี่: http://jsfiddle.net/ZkjZJ/


ฉันคิดว่าคุณสามารถใช้curNode.nodeType == 3แทนได้nodeNameเช่นกัน
Nilloc

1
@Nilloc อาจจะ แต่กำไรคืออะไร?
Shadow Wizard is Ear For You

5
@ShadowWizard @Nilloc วิธีที่แนะนำคือการใช้ค่าคงที่ ... curNode.nodeType == Node.TEXT_NODE(การเปรียบเทียบตัวเลขเร็วกว่า แต่ curNode.nodeType == 3 ไม่สามารถอ่านได้ - โหนดใดมีหมายเลข 3)
mikep

1
@ShadowWizard curNode.NodeType === Node.TEXT_NODEใช้ การเปรียบเทียบนี้เกิดขึ้นภายในวงรอบของการทำซ้ำที่ไม่รู้จัก การเปรียบเทียบตัวเลขขนาดเล็กสองตัวจะดีกว่าการเปรียบเทียบสตริงที่มีความยาวต่างกัน (การพิจารณาเวลาและพื้นที่) คำถามที่ถูกต้องที่จะถามในสถานการณ์นี้คือ "ฉันมีโหนดแบบใด / ประเภทใด" ไม่ใช่ "ฉันมีชื่ออะไร" developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
Anthony Rutledge

2
@ShadowWizard นอกจากนี้หากคุณจะใช้ลูปเพื่อลอดผ่านchildNodesโปรดทราบว่าโหนดองค์ประกอบสามารถมีโหนดข้อความได้มากกว่าหนึ่งโหนด ในโซลูชันทั่วไปเราอาจต้องระบุอินสแตนซ์ของโหนดข้อความภายในโหนดองค์ประกอบที่คุณต้องการกำหนดเป้าหมาย (ตัวแรกวินาทีที่สาม ฯลฯ ... )
Anthony Rutledge

14

อีกวิธี JS พื้นเมืองที่สามารถเป็นประโยชน์สำหรับองค์ประกอบ "ซับซ้อน" หรือซ้อนกันอย่างลึกซึ้งคือการใช้NodeIterator ใส่NodeFilter.SHOW_TEXTเป็นอาร์กิวเมนต์ที่สอง ("whatToShow") และวนซ้ำเพียงลูกโหนดข้อความขององค์ประกอบ

var root = document.querySelector('p'),
    iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT),
    textnode;

// print all text nodes
while (textnode = iter.nextNode()) {
  console.log(textnode.textContent)
}
<p>
<br>some text<br>123
</p>

TreeWalkerนอกจากนี้คุณยังสามารถใช้ ความแตกต่างระหว่างทั้งสองคือNodeIteratorเป็นตัววนซ้ำเชิงเส้นแบบง่ายในขณะที่TreeWalkerให้คุณนำทางผ่านพี่น้องและบรรพบุรุษได้เช่นกัน


9

JavaScript บริสุทธิ์: เรียบง่าย

ก่อนอื่นโปรดคำนึงถึงสิ่งนี้เสมอเมื่อค้นหาข้อความใน DOM

MDN - ช่องว่างใน DOM

ปัญหานี้จะทำให้คุณใส่ใจกับโครงสร้างของ XML / HTML ของคุณ

ในตัวอย่างนี้ JavaScript บริสุทธิ์ผมบัญชีสำหรับความเป็นไปได้ของโหนดข้อความหลายรายการที่สามารถแทรกกับชนิดอื่น ๆ ของโหนด อย่างไรก็ตามในตอนแรกฉันจะไม่ตัดสินเรื่องช่องว่างโดยปล่อยให้งานกรองนั้นไปยังรหัสอื่น

ในเวอร์ชันนี้ฉันส่งผ่านรหัสNodeListการโทร / ไคลเอนต์

/**
* Gets strings from text nodes. Minimalist. Non-robust. Pre-test loop version.
* Generic, cross platform solution. No string filtering or conditioning.
*
* @author Anthony Rutledge
* @param nodeList The child nodes of a Node, as in node.childNodes.
* @param target A positive whole number >= 1
* @return String The text you targeted.
*/
function getText(nodeList, target)
{
    var trueTarget = target - 1,
        length = nodeList.length; // Because you may have many child nodes.

    for (var i = 0; i < length; i++) {
        if ((nodeList[i].nodeType === Node.TEXT_NODE) && (i === trueTarget)) {
            return nodeList[i].nodeValue;  // Done! No need to keep going.
        }
    }

    return null;
}

แน่นอนโดยการทดสอบnode.hasChildNodes()ก่อนไม่จำเป็นต้องใช้forลูปการทดสอบล่วงหน้า

/**
* Gets strings from text nodes. Minimalist. Non-robust. Post-test loop version.
* Generic, cross platform solution. No string filtering or conditioning.
*
* @author Anthony Rutledge
* @param nodeList The child nodes of a Node, as in node.childNodes.
* @param target A positive whole number >= 1
* @return String The text you targeted.
*/
function getText(nodeList, target)
{
    var trueTarget = target - 1,
        length = nodeList.length,
        i = 0;

    do {
        if ((nodeList[i].nodeType === Node.TEXT_NODE) && (i === trueTarget)) {
            return nodeList[i].nodeValue;  // Done! No need to keep going.
         }

        i++;
    } while (i < length);

    return null;
}

JavaScript บริสุทธิ์: แข็งแกร่ง

ฟังก์ชันgetTextById()นี้ใช้ฟังก์ชันตัวช่วยสองอย่าง: getStringsFromChildren()และfilterWhitespaceLines().


getStringsFromChildren ()

/**
* Collects strings from child text nodes.
* Generic, cross platform solution. No string filtering or conditioning.
*
* @author Anthony Rutledge
* @version 7.0
* @param parentNode An instance of the Node interface, such as an Element. object.
* @return Array of strings, or null.
* @throws TypeError if the parentNode is not a Node object.
*/
function getStringsFromChildren(parentNode)
{
    var strings = [],
        nodeList,
        length,
        i = 0;

    if (!parentNode instanceof Node) {
        throw new TypeError("The parentNode parameter expects an instance of a Node.");
    }

    if (!parentNode.hasChildNodes()) {
        return null; // We are done. Node may resemble <element></element>
    }

    nodeList = parentNode.childNodes;
    length = nodeList.length;

    do {
        if ((nodeList[i].nodeType === Node.TEXT_NODE)) {
            strings.push(nodeList[i].nodeValue);
         }

        i++;
    } while (i < length);

    if (strings.length > 0) {
        return strings;
    }

    return null;
}

filterWhitespaceLines ()

/**
* Filters an array of strings to remove whitespace lines.
* Generic, cross platform solution.
*
* @author Anthony Rutledge
* @version 6.0
* @param textArray a String associated with the id attribute of an Element.
* @return Array of strings that are not lines of whitespace, or null.
* @throws TypeError if the textArray param is not of type Array.
*/
function filterWhitespaceLines(textArray) 
{
    var filteredArray = [],
        whitespaceLine = /(?:^\s+$)/; // Non-capturing Regular Expression.

    if (!textArray instanceof Array) {
        throw new TypeError("The textArray parameter expects an instance of a Array.");
    }

    for (var i = 0; i < textArray.length; i++) {
        if (!whitespaceLine.test(textArray[i])) {  // If it is not a line of whitespace.
            filteredArray.push(textArray[i].trim());  // Trimming here is fine. 
        }
    }

    if (filteredArray.length > 0) {
        return filteredArray ; // Leave selecting and joining strings for a specific implementation. 
    }

    return null; // No text to return.
}

getTextById ()

/**
* Gets strings from text nodes. Robust.
* Generic, cross platform solution.
*
* @author Anthony Rutledge
* @version 6.0
* @param id A String associated with the id property of an Element.
* @return Array of strings, or null.
* @throws TypeError if the id param is not of type String.
* @throws TypeError if the id param cannot be used to find a node by id.
*/
function getTextById(id) 
{
    var textArray = null;             // The hopeful output.
    var idDatatype = typeof id;       // Only used in an TypeError message.
    var node;                         // The parent node being examined.

    try {
        if (idDatatype !== "string") {
            throw new TypeError("The id argument must be of type String! Got " + idDatatype);
        }

        node = document.getElementById(id);

        if (node === null) {
            throw new TypeError("No element found with the id: " + id);
        }

        textArray = getStringsFromChildren(node);

        if (textArray === null) {
            return null; // No text nodes found. Example: <element></element>
        }

        textArray = filterWhitespaceLines(textArray);

        if (textArray.length > 0) {
            return textArray; // Leave selecting and joining strings for a specific implementation. 
        }
    } catch (e) {
        console.log(e.message);
    }

    return null; // No text to return.
}

จากนั้นค่าส่งคืน (Array หรือ null) จะถูกส่งไปยังรหัสไคลเอ็นต์ที่ควรจัดการ หวังว่าอาร์เรย์ควรมีองค์ประกอบสตริงของข้อความจริงไม่ใช่บรรทัดของช่องว่าง

สตริงว่าง ( "") จะไม่ส่งคืนเนื่องจากคุณต้องการโหนดข้อความเพื่อบ่งชี้ว่ามีข้อความที่ถูกต้อง กลับ ( "") .nodeValueอาจจะทำให้การแสดงผลผิดว่าโหนดข้อความที่มีอยู่นำคนที่จะคิดว่าพวกเขาสามารถที่จะเปลี่ยนข้อความโดยการเปลี่ยนค่าของ นี่เป็นเท็จเนื่องจากไม่มีโหนดข้อความในกรณีของสตริงว่าง

ตัวอย่างที่ 1 :

<p id="bio"></p> <!-- There is no text node here. Return null. -->

ตัวอย่างที่ 2 :

<p id="bio">

</p> <!-- There are at least two text nodes ("\n"), here. -->

ปัญหาเกิดขึ้นเมื่อคุณต้องการทำให้ HTML ของคุณอ่านง่ายโดยเว้นระยะห่างออกไป ตอนนี้แม้ว่าจะไม่มีข้อความที่ถูกต้องที่มนุษย์สามารถอ่านได้ แต่ก็ยังมีโหนดข้อความที่มีอักขระขึ้นบรรทัดใหม่ ( "\n")อยู่ใน.nodeValueคุณสมบัติ

มนุษย์มองว่าตัวอย่างหนึ่งและสองเทียบเท่ากับฟังก์ชัน - องค์ประกอบว่างที่รอการเติม DOM แตกต่างจากการใช้เหตุผลของมนุษย์ นี่คือเหตุผลที่getStringsFromChildren()ฟังก์ชันต้องพิจารณาว่าโหนดข้อความมีอยู่หรือไม่และรวบรวม.nodeValueค่าลงในอาร์เรย์

for (var i = 0; i < length; i++) {
    if (nodeList[i].nodeType === Node.TEXT_NODE) {
            textNodes.push(nodeList[i].nodeValue);
    }
}

ในตัวอย่างที่สองมีโหนดข้อความสองโหนดและgetStringFromChildren()จะส่งคืนโหนด.nodeValueทั้งสอง ( "\n") อย่างไรก็ตามfilterWhitespaceLines()ใช้นิพจน์ทั่วไปเพื่อกรองบรรทัดของอักขระช่องว่างที่แท้จริง

การกลับมาnullแทนอักขระ newline ( "\n") เป็นรูปแบบของการโกหกไคลเอนต์ / รหัสการโทรหรือไม่ ในแง่ของมนุษย์ไม่ ในแง่ DOM ใช่ อย่างไรก็ตามปัญหาที่นี่คือการรับข้อความไม่ได้แก้ไข ไม่มีข้อความของมนุษย์ที่จะกลับไปยังรหัสการโทร

ไม่มีทางรู้ได้ว่ามีอักขระขึ้นบรรทัดใหม่กี่ตัวใน HTML ของใครบางคน การสร้างตัวนับที่มองหาอักขระขึ้นบรรทัดใหม่ "วินาที" นั้นไม่น่าเชื่อถือ มันอาจไม่มีอยู่จริง

แน่นอนยิ่งไปกว่านั้นปัญหาของการแก้ไขข้อความใน<p></p>องค์ประกอบว่างที่มีช่องว่างเพิ่มเติม (ตัวอย่างที่ 2) อาจหมายถึงการทำลาย (อาจจะข้าม) โหนดข้อความทั้งหมดยกเว้นหนึ่งโหนดระหว่างแท็กของย่อหน้าเพื่อให้แน่ใจว่าองค์ประกอบมีสิ่งที่เป็นอยู่ ควรจะแสดง

ไม่ว่าคุณจะทำสิ่งที่ไม่ธรรมดาคุณจะต้องมีวิธีพิจารณาว่า.nodeValueคุณสมบัติของโหนดข้อความใดมีข้อความที่เป็นจริงและอ่านได้ที่มนุษย์ต้องการแก้ไขยกเว้นในกรณีที่คุณต้องการแก้ไข filterWhitespaceLinesพาเราไปที่นั่นได้ครึ่งทาง

var whitespaceLine = /(?:^\s+$)/; // Non-capturing Regular Expression.

for (var i = 0; i < filteredTextArray.length; i++) {
    if (!whitespaceLine.test(textArray[i])) {  // If it is not a line of whitespace.
        filteredTextArray.push(textArray[i].trim());  // Trimming here is fine. 
    }
}

ณ จุดนี้คุณอาจมีผลลัพธ์ที่มีลักษณะดังนี้:

["Dealing with text nodes is fun.", "Some people just use jQuery."]

ไม่มีการรับประกันว่าสตริงทั้งสองนี้อยู่ติดกันใน DOM ดังนั้นการเชื่อมต่อเข้าด้วยกัน.join()อาจทำให้เกิดการผสมที่ผิดธรรมชาติ ในรหัสที่เรียกgetTextById()คุณจะต้องเลือกสตริงที่คุณต้องการใช้งานแทน

ทดสอบเอาต์พุต

try {
    var strings = getTextById("bio");

    if (strings === null) {
        // Do something.
    } else if (strings.length === 1) {
        // Do something with strings[0]
    } else { // Could be another else if
        // Do something. It all depends on the context.
    }
} catch (e) {
    console.log(e.message);
}

เราสามารถเพิ่ม.trim()ด้านในgetStringsFromChildren()เพื่อกำจัดช่องว่างที่นำหน้าและต่อท้าย (หรือเปลี่ยนช่องว่างจำนวนมากให้เป็นสตริงที่มีความยาวเป็นศูนย์ ( "") แต่คุณจะรู้เบื้องต้นได้อย่างไรว่าทุกแอปพลิเคชันอาจต้องเกิดขึ้นกับข้อความ (สตริง) เมื่อพบแล้วคุณไม่ทำดังนั้นปล่อยให้ใช้งานเฉพาะและปล่อยให้getStringsFromChildren()เป็นแบบทั่วไป

อาจมีบางครั้งที่targetไม่จำเป็นต้องมีความจำเพาะระดับนี้ ( และเช่นนั้น) เป็นสิ่งที่ดี. ใช้วิธีง่ายๆในกรณีเหล่านั้น อย่างไรก็ตามอัลกอริทึมทั่วไปช่วยให้คุณสามารถรองรับสถานการณ์ที่เรียบง่ายและซับซ้อนได้


8

ES6 เวอร์ชันที่ส่งคืนเนื้อหาโหนด #text แรก

const extract = (node) => {
  const text = [...node.childNodes].find(child => child.nodeType === Node.TEXT_NODE);
  return text && text.textContent.trim();
}

ฉันสงสัยเกี่ยวกับประสิทธิภาพและความยืดหยุ่น (1) การใช้.from()เพื่อสร้างอินสแตนซ์อาร์เรย์ที่คัดลอกแบบตื้น (2) การใช้.find()เพื่อทำการเปรียบเทียบสตริงโดยใช้.nodeName. ใช้node.NodeType === Node.TEXT_NODEจะดีกว่า (3) การส่งคืนสตริงว่างเมื่อไม่มีค่าnullจะเป็นจริงมากขึ้นหากไม่พบโหนดข้อความ หากไม่พบโหนดข้อความอาจต้องสร้างขึ้นมาใหม่! หากคุณส่งคืนสตริงว่าง""คุณอาจให้การแสดงผลที่ผิดพลาดว่ามีโหนดข้อความอยู่และสามารถจัดการได้ตามปกติ โดยพื้นฐานแล้วการส่งคืนสตริงว่างเปล่าเป็นเรื่องโกหกสีขาวและหลีกเลี่ยงได้ดีที่สุด
Anthony Rutledge

(4) หากมีโหนดข้อความมากกว่าหนึ่งโหนดใน nodeList ไม่มีวิธีใดที่จะระบุโหนดข้อความที่คุณต้องการได้ที่นี่ คุณอาจต้องการโหนดข้อความแรกแต่คุณอาจต้องการโหนดข้อความสุดท้าย
Anthony Rutledge

คุณแนะนำอะไรให้แทนที่ Array.from
jujule

@Snowman โปรดเพิ่มคำตอบของคุณเองสำหรับการเปลี่ยนแปลงที่สำคัญดังกล่าวหรือให้คำแนะนำสำหรับ OP เพื่อให้พวกเขามีโอกาสรวมเข้ากับคำตอบของพวกเขา
TylerH

@jujule - ดีกว่าที่จะใช้[...node.childNodes]ในการแปลงHTMLCollectionเป็น Arrays
vsync

5

.text() - for jquery

$('.title').clone()    //clone the element
.children() //select all the children
.remove()   //remove all the children
.end()  //again go back to selected element
.text();    //get the text of element

1
ฉันคิดว่าวิธีการสำหรับจาวาสคริปต์มาตรฐานต้องเป็น 'innerText'
Reporter

2
สิ่งนี้ไม่ได้ผลตามที่ OP ต้องการ แต่จะได้รับข้อความภายในaองค์ประกอบด้วย: jsfiddle.net/ekHJH
James Allardice

1
@James Allardice - ฉันทำ jquery solution แล้วตอนนี้จะได้ผล .................
Pranay Rana

เกือบจะใช้งานได้ แต่คุณพลาด.จุดเริ่มต้นของตัวเลือกซึ่งหมายความว่าคุณได้รับข้อความของtitleองค์ประกอบไม่ใช่องค์ประกอบด้วยclass="title"
James Allardice

@reporter .innerTextเป็นแบบแผนเก่าของ IE ที่เพิ่งนำมาใช้ ในแง่ของการเขียนสคริปต์ DOM มาตรฐานnode.nodeValueเป็นวิธีที่หนึ่งในการจับข้อความของโหนดข้อความ
Anthony Rutledge

2

สิ่งนี้จะไม่สนใจช่องว่างเช่นกันดังนั้นคุณไม่เคยมีรหัส Blank textNodes..code โดยใช้ Javascript หลัก

var oDiv = document.getElementById("MyDiv");
var firstText = "";
for (var i = 0; i < oDiv.childNodes.length; i++) {
    var curNode = oDiv.childNodes[i];
    whitespace = /^\s*$/;
    if (curNode.nodeName === "#text" && !(whitespace.test(curNode.nodeValue))) {
        firstText = curNode.nodeValue;
        break;
    }
}

ตรวจสอบได้ที่ jsfiddle: - http://jsfiddle.net/webx/ZhLep/


curNode.nodeType === Node.TEXT_NODEจะดีกว่า. การใช้การเปรียบเทียบสตริงและนิพจน์ทั่วไปภายในลูปเป็นวิธีแก้ปัญหาที่มีประสิทธิภาพต่ำโดยเฉพาะอย่างยิ่งเมื่อขนาดoDiv.childNodes.lengthเพิ่มขึ้น อัลกอริทึมนี้ช่วยแก้คำถามเฉพาะของ OP แต่อาจมีต้นทุนด้านประสิทธิภาพที่แย่มาก หากการจัดเรียงหรือจำนวนโหนดข้อความเปลี่ยนไปก็ไม่สามารถรับประกันว่าโซลูชันนี้จะส่งคืนผลลัพธ์ที่ถูกต้อง กล่าวคือคุณไม่สามารถกำหนดเป้าหมายโหนดข้อความที่คุณต้องการได้ คุณอยู่ในความเมตตาของโครงสร้าง HTML และการจัดเรียงข้อความในนั้น
Anthony Rutledge

1

คุณยังสามารถใช้การtext()ทดสอบโหนดของ XPath เพื่อรับโหนดข้อความเท่านั้น ตัวอย่างเช่น

var target = document.querySelector('div.title');
var iter = document.evaluate('text()', target, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
var node;
var want = '';

while (node = iter.iterateNext()) {
    want += node.data;
}

0

นี่คือวิธีการแก้ปัญหาของฉันใน ES6 เพื่อสร้างสตริง contraining ข้อความที่ตัดแบ่งของ childNodes ทั้งหมด (recursive) โปรดทราบว่ายังไปที่ shdowroot ของ childnodes

function text_from(node) {
    const extract = (node) => [...node.childNodes].reduce(
        (acc, childnode) => [
            ...acc,
            childnode.nodeType === Node.TEXT_NODE ? childnode.textContent.trim() : '',
            ...extract(childnode),
            ...(childnode.shadowRoot ? extract(childnode.shadowRoot) : [])],
        []);

    return extract(node).filter(text => text.length).join('\n');
}

การแก้ปัญหานี้ได้รับแรงบันดาลใจจากการแก้ปัญหาของhttps://stackoverflow.com/a/41051238./1300775

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.